Compare commits

...

2 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
abe04b28b2 feat(metadata-plugin): add local storage api 2025-05-02 13:52:07 +06:00
Kingkor Roy Tirtho
8ac30c0031 feat(metadata-plugin): add pagination support, feed and playlist CRUD endpoints 2025-05-02 11:36:09 +06:00
9 changed files with 1387 additions and 198 deletions

View File

@ -0,0 +1,14 @@
part of 'metadata.dart';
@freezed
class SpotubeFeedObject with _$SpotubeFeedObject {
factory SpotubeFeedObject({
required final String uid,
required final String name,
required final String externalUrl,
@Default([]) final List<SpotubeImageObject> images,
}) = _SpotubeFeedObject;
factory SpotubeFeedObject.fromJson(Map<String, dynamic> json) =>
_$SpotubeFeedObjectFromJson(json);
}

View File

@ -7,8 +7,12 @@ part 'metadata.freezed.dart';
part 'album.dart';
part 'artist.dart';
part 'feed.dart';
part 'image.dart';
part 'pagination.dart';
part 'playlist.dart';
part 'search.dart';
part 'track.dart';
part 'user.dart';
part 'plugin.dart';

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,27 @@ Map<String, dynamic> _$$SpotubeArtistObjectImplToJson(
'externalUrl': instance.externalUrl,
};
_$SpotubeFeedObjectImpl _$$SpotubeFeedObjectImplFromJson(Map json) =>
_$SpotubeFeedObjectImpl(
uid: json['uid'] as String,
name: json['name'] as String,
externalUrl: json['externalUrl'] as String,
images: (json['images'] as List<dynamic>?)
?.map((e) => SpotubeImageObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
const [],
);
Map<String, dynamic> _$$SpotubeFeedObjectImplToJson(
_$SpotubeFeedObjectImpl instance) =>
<String, dynamic>{
'uid': instance.uid,
'name': instance.name,
'externalUrl': instance.externalUrl,
'images': instance.images.map((e) => e.toJson()).toList(),
};
_$SpotubeImageObjectImpl _$$SpotubeImageObjectImplFromJson(Map json) =>
_$SpotubeImageObjectImpl(
url: json['url'] as String,
@ -75,6 +96,31 @@ Map<String, dynamic> _$$SpotubeImageObjectImplToJson(
'height': instance.height,
};
_$SpotubePaginationResponseObjectImpl<T>
_$$SpotubePaginationResponseObjectImplFromJson<T>(
Map json,
T Function(Object? json) fromJsonT,
) =>
_$SpotubePaginationResponseObjectImpl<T>(
total: (json['total'] as num).toInt(),
nextCursor: json['nextCursor'] as String?,
limit: json['limit'] as String,
hasMore: json['hasMore'] as bool,
items: (json['items'] as List<dynamic>).map(fromJsonT).toList(),
);
Map<String, dynamic> _$$SpotubePaginationResponseObjectImplToJson<T>(
_$SpotubePaginationResponseObjectImpl<T> instance,
Object? Function(T value) toJsonT,
) =>
<String, dynamic>{
'total': instance.total,
'nextCursor': instance.nextCursor,
'limit': instance.limit,
'hasMore': instance.hasMore,
'items': instance.items.map(toJsonT).toList(),
};
_$SpotubePlaylistObjectImpl _$$SpotubePlaylistObjectImplFromJson(Map json) =>
_$SpotubePlaylistObjectImpl(
uid: json['uid'] as String,
@ -110,35 +156,21 @@ Map<String, dynamic> _$$SpotubePlaylistObjectImplToJson(
_$SpotubeSearchResponseObjectImpl _$$SpotubeSearchResponseObjectImplFromJson(
Map json) =>
_$SpotubeSearchResponseObjectImpl(
tracks: (json['tracks'] as List<dynamic>?)
?.map((e) => SpotubeTrackObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
const [],
albums: (json['albums'] as List<dynamic>?)
?.map((e) => SpotubeAlbumObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
const [],
artists: (json['artists'] as List<dynamic>?)
?.map((e) => SpotubeArtistObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
const [],
playlists: (json['playlists'] as List<dynamic>?)
?.map((e) => SpotubePlaylistObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
const [],
tracks: _paginationTracksFromJson(json['tracks'] as Map<String, dynamic>),
albums: _paginationAlbumsFromJson(json['albums'] as Map<String, dynamic>),
artists:
_paginationArtistsFromJson(json['artists'] as Map<String, dynamic>),
playlists: _paginationPlaylistsFromJson(
json['playlists'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SpotubeSearchResponseObjectImplToJson(
_$SpotubeSearchResponseObjectImpl instance) =>
<String, dynamic>{
'tracks': instance.tracks.map((e) => e.toJson()).toList(),
'albums': instance.albums.map((e) => e.toJson()).toList(),
'artists': instance.artists.map((e) => e.toJson()).toList(),
'playlists': instance.playlists.map((e) => e.toJson()).toList(),
'tracks': _paginationToJson(instance.tracks),
'albums': _paginationToJson(instance.albums),
'artists': _paginationToJson(instance.artists),
'playlists': _paginationToJson(instance.playlists),
};
_$SpotubeTrackObjectImpl _$$SpotubeTrackObjectImplFromJson(Map json) =>
@ -191,3 +223,26 @@ Map<String, dynamic> _$$SpotubeUserObjectImplToJson(
'externalUrl': instance.externalUrl,
'displayName': instance.displayName,
};
_$PluginConfigurationImpl _$$PluginConfigurationImplFromJson(Map json) =>
_$PluginConfigurationImpl(
type: $enumDecode(_$PluginTypeEnumMap, json['type']),
name: json['name'] as String,
description: json['description'] as String,
version: json['version'] as String,
author: json['author'] as String,
);
Map<String, dynamic> _$$PluginConfigurationImplToJson(
_$PluginConfigurationImpl instance) =>
<String, dynamic>{
'type': _$PluginTypeEnumMap[instance.type]!,
'name': instance.name,
'description': instance.description,
'version': instance.version,
'author': instance.author,
};
const _$PluginTypeEnumMap = {
PluginType.metadata: 'metadata',
};

View File

@ -0,0 +1,22 @@
part of 'metadata.dart';
@Freezed(genericArgumentFactories: true)
class SpotubePaginationResponseObject<T>
with _$SpotubePaginationResponseObject {
factory SpotubePaginationResponseObject({
required int total,
required String? nextCursor,
required String limit,
required bool hasMore,
required List<T> items,
}) = _SpotubePaginationResponseObject;
factory SpotubePaginationResponseObject.fromJson(
Map<String, Object?> json,
T Function(Map<String, dynamic> json) fromJsonT,
) =>
_$SpotubePaginationResponseObjectFromJson(
json,
(json) => fromJsonT(json as Map<String, dynamic>),
);
}

View File

@ -0,0 +1,21 @@
part of 'metadata.dart';
enum PluginType { metadata }
@freezed
class PluginConfiguration with _$PluginConfiguration {
const PluginConfiguration._();
factory PluginConfiguration({
required PluginType type,
required String name,
required String description,
required String version,
required String author,
}) = _PluginConfiguration;
factory PluginConfiguration.fromJson(Map<String, dynamic> json) =>
_$PluginConfigurationFromJson(json);
String get slug => name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-');
}

View File

@ -1,12 +1,71 @@
part of 'metadata.dart';
SpotubePaginationResponseObject<SpotubeTrackObject> _paginationTracksFromJson(
Map<String, dynamic> json,
) {
return SpotubePaginationResponseObject<SpotubeTrackObject>.fromJson(
json,
(json) => SpotubeTrackObject.fromJson(json),
);
}
SpotubePaginationResponseObject<SpotubeAlbumObject> _paginationAlbumsFromJson(
Map<String, dynamic> json,
) {
return SpotubePaginationResponseObject<SpotubeAlbumObject>.fromJson(
json,
(json) => SpotubeAlbumObject.fromJson(json),
);
}
SpotubePaginationResponseObject<SpotubeArtistObject> _paginationArtistsFromJson(
Map<String, dynamic> json,
) {
return SpotubePaginationResponseObject<SpotubeArtistObject>.fromJson(
json,
(json) => SpotubeArtistObject.fromJson(json),
);
}
SpotubePaginationResponseObject<SpotubePlaylistObject>
_paginationPlaylistsFromJson(
Map<String, dynamic> json,
) {
return SpotubePaginationResponseObject<SpotubePlaylistObject>.fromJson(
json,
(json) => SpotubePlaylistObject.fromJson(json),
);
}
Map<String, dynamic>? _paginationToJson(
SpotubePaginationResponseObject? instance,
) {
return instance?.toJson((item) => item.toJson());
}
@freezed
class SpotubeSearchResponseObject with _$SpotubeSearchResponseObject {
factory SpotubeSearchResponseObject({
@Default([]) final List<SpotubeTrackObject> tracks,
@Default([]) final List<SpotubeAlbumObject> albums,
@Default([]) final List<SpotubeArtistObject> artists,
@Default([]) final List<SpotubePlaylistObject> playlists,
@JsonKey(
fromJson: _paginationTracksFromJson,
toJson: _paginationToJson,
)
final SpotubePaginationResponseObject<SpotubeTrackObject>? tracks,
@JsonKey(
fromJson: _paginationAlbumsFromJson,
toJson: _paginationToJson,
)
final SpotubePaginationResponseObject<SpotubeAlbumObject>? albums,
@JsonKey(
fromJson: _paginationArtistsFromJson,
toJson: _paginationToJson,
)
final SpotubePaginationResponseObject<SpotubeArtistObject>? artists,
@JsonKey(
fromJson: _paginationPlaylistsFromJson,
toJson: _paginationToJson,
)
final SpotubePaginationResponseObject<SpotubePlaylistObject>? playlists,
}) = _SpotubeSearchResponseObject;
factory SpotubeSearchResponseObject.fromJson(Map<String, dynamic> json) =>

View File

@ -0,0 +1,60 @@
import 'package:flutter_js/flutter_js.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PluginLocalStorageApi {
final JavascriptRuntime runtime;
final SharedPreferences sharedPreferences;
final String pluginName;
PluginLocalStorageApi({
required this.runtime,
required this.sharedPreferences,
required this.pluginName,
}) {
runtime.onMessage("LocalStorage.getItem", (args) {
final key = args[0];
final value = getItem(key);
runtime.evaluate(
"""
eventEmitter.emit('LocalStorage.getItem', ${value != null ? "'$value'" : "null"});
""",
);
});
runtime.onMessage("LocalStorage.setItem", (args) {
final map = args[0] as Map<String, dynamic>;
setItem(map["key"], map["value"]);
});
runtime.onMessage("LocalStorage.removeItem", (args) {
final map = args[0];
removeItem(map["key"]);
});
runtime.onMessage("LocalStorage.clear", (args) {
clear();
});
}
void setItem(String key, String value) async {
await sharedPreferences.setString("plugin.$pluginName.$key", value);
}
String? getItem(String key) {
return sharedPreferences.getString("plugin.$pluginName.$key");
}
void removeItem(String key) async {
await sharedPreferences.remove("plugin.$pluginName.$key");
}
void clear() async {
final keys = sharedPreferences.getKeys();
for (String key in keys) {
if (key.startsWith("plugin.$pluginName.")) {
await sharedPreferences.remove(key);
}
}
}
}

View File

@ -4,20 +4,23 @@ import 'dart:convert';
import 'package:flutter_js/extensions/fetch.dart';
import 'package:flutter_js/extensions/xhr.dart';
import 'package:flutter_js/flutter_js.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/metadata/apis/localstorage.dart';
const int defaultMetadataLimit = 20;
const int defaultMetadataOffset = 0;
const defaultMetadataLimit = "20";
/// Signature for metadata and related methods that will return Spotube native
/// objects e.g. SpotubeTrack, SpotubePlaylist, etc.
class MetadataApiSignature {
final JavascriptRuntime runtime;
final PluginLocalStorageApi localStorageApi;
MetadataApiSignature._(this.runtime);
MetadataApiSignature._(this.runtime, this.localStorageApi);
static Future<MetadataApiSignature> init(String libraryCode) async {
static Future<MetadataApiSignature> init(
String libraryCode, PluginConfiguration config) async {
final runtime = getJavascriptRuntime(xhr: true).enableXhr();
runtime.enableHandlePromises();
await runtime.enableFetch();
@ -42,7 +45,17 @@ class MetadataApiSignature {
);
}
return MetadataApiSignature._(runtime);
// Create all the PluginAPIs after library code is evaluated
final localStorageApi = PluginLocalStorageApi(
runtime: runtime,
sharedPreferences: await SharedPreferences.getInstance(),
pluginName: config.slug,
);
return MetadataApiSignature._(
runtime,
localStorageApi,
);
}
void dispose() {
@ -97,73 +110,91 @@ class MetadataApiSignature {
return SpotubeTrackObject.fromJson(result);
}
Future<List<SpotubeTrackObject>> listTracks({
Future<SpotubePaginationResponseObject<SpotubeTrackObject>> listTracks({
List<String>? ids,
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final result = await invoke(
"metadataApi.listTracks",
[
ids,
limit,
offset,
cursor,
],
);
return result.map(SpotubeTrackObject.fromJson).toList();
return SpotubePaginationResponseObject<SpotubeTrackObject>.fromJson(
result,
SpotubeTrackObject.fromJson,
);
}
Future<List<SpotubeTrackObject>> listTracksByAlbum(
Future<SpotubePaginationResponseObject<SpotubeTrackObject>> listTracksByAlbum(
String albumId, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listTracksByAlbum",
[albumId, limit, offset],
[albumId, limit, cursor],
);
return res.map(SpotubeTrackObject.fromJson).toList();
return SpotubePaginationResponseObject<SpotubeTrackObject>.fromJson(
res,
SpotubeTrackObject.fromJson,
);
}
Future<List<SpotubeTrackObject>> listTopTracksByArtist(
Future<SpotubePaginationResponseObject<SpotubeTrackObject>>
listTopTracksByArtist(
String artistId, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listTopTracksByArtist",
[artistId, limit, offset],
[artistId, limit, cursor],
);
return res.map(SpotubeTrackObject.fromJson).toList();
return SpotubePaginationResponseObject<SpotubeTrackObject>.fromJson(
res,
SpotubeTrackObject.fromJson,
);
}
Future<List<SpotubeTrackObject>> listTracksByPlaylist(
Future<SpotubePaginationResponseObject<SpotubeTrackObject>>
listTracksByPlaylist(
String playlistId, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listTracksByPlaylist",
[playlistId, limit, offset],
[playlistId, limit, cursor],
);
return res.map(SpotubeTrackObject.fromJson).toList();
return SpotubePaginationResponseObject<SpotubeTrackObject>.fromJson(
res,
SpotubeTrackObject.fromJson,
);
}
Future<List<SpotubeTrackObject>> listUserSavedTracks(
Future<SpotubePaginationResponseObject<SpotubeTrackObject>>
listUserSavedTracks(
String userId, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listUserSavedTracks",
[userId, limit, offset],
[userId, limit, cursor],
);
return res.map(SpotubeTrackObject.fromJson).toList();
return SpotubePaginationResponseObject<SpotubeTrackObject>.fromJson(
res,
SpotubeTrackObject.fromJson,
);
}
// ----- Album ------
@ -173,43 +204,54 @@ class MetadataApiSignature {
return SpotubeAlbumObject.fromJson(res);
}
Future<List<SpotubeAlbumObject>> listAlbums({
Future<SpotubePaginationResponseObject<SpotubeAlbumObject>> listAlbums({
List<String>? ids,
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listAlbums",
[ids, limit, offset],
[ids, limit, cursor],
);
return res.map(SpotubeAlbumObject.fromJson).toList();
return SpotubePaginationResponseObject<SpotubeAlbumObject>.fromJson(
res,
SpotubeAlbumObject.fromJson,
);
}
Future<List<SpotubeAlbumObject>> listAlbumsByArtist(
Future<SpotubePaginationResponseObject<SpotubeAlbumObject>>
listAlbumsByArtist(
String artistId, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listAlbumsByArtist",
[artistId, limit, offset],
[artistId, limit, cursor],
);
return res.map(SpotubeAlbumObject.fromJson).toList();
return SpotubePaginationResponseObject.fromJson(
res,
SpotubeAlbumObject.fromJson,
);
}
Future<List<SpotubeAlbumObject>> listUserSavedAlbums(
Future<SpotubePaginationResponseObject<SpotubeAlbumObject>>
listUserSavedAlbums(
String userId, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listUserSavedAlbums",
[userId, limit, offset],
[userId, limit, cursor],
);
return res.map(SpotubeAlbumObject.fromJson).toList();
return SpotubePaginationResponseObject.fromJson(
res,
SpotubeAlbumObject.fromJson,
);
}
// ----- Playlist ------
@ -219,30 +261,114 @@ class MetadataApiSignature {
return SpotubePlaylistObject.fromJson(res);
}
Future<List<SpotubePlaylistObject>> listPlaylists({
List<String>? ids,
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
Future<SpotubePaginationResponseObject<SpotubePlaylistObject>>
listFeedPlaylists(
String feedId, {
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listPlaylists",
[ids, limit, offset],
"metadataApi.listFeedPlaylists",
[feedId, limit, cursor],
);
return res.map(SpotubePlaylistObject.fromJson).toList();
return SpotubePaginationResponseObject.fromJson(
res,
SpotubePlaylistObject.fromJson,
);
}
Future<List<SpotubePlaylistObject>> listUserSavedPlaylists(
Future<SpotubePaginationResponseObject<SpotubePlaylistObject>>
listUserSavedPlaylists(
String userId, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listUserSavedPlaylists",
[userId, limit, offset],
[userId, limit, cursor],
);
return res.map(SpotubePlaylistObject.fromJson).toList();
return SpotubePaginationResponseObject.fromJson(
res,
SpotubePlaylistObject.fromJson,
);
}
Future<SpotubePlaylistObject> createPlaylist(
String userId,
String name, {
String? description,
bool? public,
bool? collaborative,
String? imageBase64,
}) async {
final res = await invoke(
"metadataApi.createPlaylist",
[
userId,
name,
description,
public,
collaborative,
imageBase64,
],
);
return SpotubePlaylistObject.fromJson(res);
}
Future<void> updatePlaylist(
String playlistId, {
String? name,
String? description,
bool? public,
bool? collaborative,
String? imageBase64,
}) async {
await invoke(
"metadataApi.updatePlaylist",
[
playlistId,
name,
description,
public,
collaborative,
imageBase64,
],
);
}
Future<void> deletePlaylist(String userId, String playlistId) async {
await unsavePlaylist(userId, playlistId);
}
Future<void> addTracksToPlaylist(
String playlistId,
List<String> trackIds, {
int? position,
}) async {
await invoke(
"metadataApi.addTracksToPlaylist",
[
playlistId,
trackIds,
position,
],
);
}
Future<void> removeTracksFromPlaylist(
String playlistId,
List<String> trackIds,
) async {
await invoke(
"metadataApi.removeTracksFromPlaylist",
[
playlistId,
trackIds,
],
);
}
// ----- Artist ------
@ -252,44 +378,70 @@ class MetadataApiSignature {
return SpotubeArtistObject.fromJson(res);
}
Future<List<SpotubeArtistObject>> listArtists({
Future<SpotubePaginationResponseObject<SpotubeArtistObject>> listArtists({
List<String>? ids,
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listArtists",
[ids, limit, offset],
[ids, limit, cursor],
);
return res.map(SpotubeArtistObject.fromJson).toList();
return SpotubePaginationResponseObject.fromJson(
res,
SpotubeArtistObject.fromJson,
);
}
Future<List<SpotubeArtistObject>> listUserSavedArtists(
Future<SpotubePaginationResponseObject<SpotubeArtistObject>>
listUserSavedArtists(
String userId, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.listUserSavedArtists",
[userId, limit, offset],
[userId, limit, cursor],
);
return res.map(SpotubeArtistObject.fromJson).toList();
return SpotubePaginationResponseObject.fromJson(
res,
SpotubeArtistObject.fromJson,
);
}
// ----- Search ------
Future<SpotubeSearchResponseObject> search(
String query, {
int limit = defaultMetadataLimit,
int offset = defaultMetadataOffset,
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke(
"metadataApi.search",
[query, limit, offset],
[query, limit, cursor],
);
return res.map(SpotubeSearchResponseObject.fromJson).toList();
return SpotubeSearchResponseObject.fromJson(res);
}
// ----- Feed ------
Future<SpotubeFeedObject> getFeed(String id) async {
final res = await invoke("metadataApi.getFeed", [id]);
return SpotubeFeedObject.fromJson(res);
}
Future<SpotubePaginationResponseObject<SpotubeFeedObject>> listFeeds({
String limit = defaultMetadataLimit,
String? cursor,
}) async {
final res = await invoke("metadataApi.listFeeds", [limit, cursor]);
return SpotubePaginationResponseObject.fromJson(
res,
SpotubeFeedObject.fromJson,
);
}
// ----- User ------