diff --git a/lib/models/metadata/artist.dart b/lib/models/metadata/artist.dart index 89c59082..24d8f55c 100644 --- a/lib/models/metadata/artist.dart +++ b/lib/models/metadata/artist.dart @@ -21,6 +21,7 @@ class SpotubeSimpleArtistObject with _$SpotubeSimpleArtistObject { required String id, required String name, required String externalUri, + List? images, }) = _SpotubeSimpleArtistObject; factory SpotubeSimpleArtistObject.fromJson(Map json) => diff --git a/lib/models/metadata/metadata.freezed.dart b/lib/models/metadata/metadata.freezed.dart index 2bf50c6c..d01743a4 100644 --- a/lib/models/metadata/metadata.freezed.dart +++ b/lib/models/metadata/metadata.freezed.dart @@ -994,6 +994,7 @@ mixin _$SpotubeSimpleArtistObject { String get id => throw _privateConstructorUsedError; String get name => throw _privateConstructorUsedError; String get externalUri => throw _privateConstructorUsedError; + List? get images => throw _privateConstructorUsedError; /// Serializes this SpotubeSimpleArtistObject to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -1011,7 +1012,11 @@ abstract class $SpotubeSimpleArtistObjectCopyWith<$Res> { $Res Function(SpotubeSimpleArtistObject) then) = _$SpotubeSimpleArtistObjectCopyWithImpl<$Res, SpotubeSimpleArtistObject>; @useResult - $Res call({String id, String name, String externalUri}); + $Res call( + {String id, + String name, + String externalUri, + List? images}); } /// @nodoc @@ -1033,6 +1038,7 @@ class _$SpotubeSimpleArtistObjectCopyWithImpl<$Res, Object? id = null, Object? name = null, Object? externalUri = null, + Object? images = freezed, }) { return _then(_value.copyWith( id: null == id @@ -1047,6 +1053,10 @@ class _$SpotubeSimpleArtistObjectCopyWithImpl<$Res, ? _value.externalUri : externalUri // ignore: cast_nullable_to_non_nullable as String, + images: freezed == images + ? _value.images + : images // ignore: cast_nullable_to_non_nullable + as List?, ) as $Val); } } @@ -1060,7 +1070,11 @@ abstract class _$$SpotubeSimpleArtistObjectImplCopyWith<$Res> __$$SpotubeSimpleArtistObjectImplCopyWithImpl<$Res>; @override @useResult - $Res call({String id, String name, String externalUri}); + $Res call( + {String id, + String name, + String externalUri, + List? images}); } /// @nodoc @@ -1081,6 +1095,7 @@ class __$$SpotubeSimpleArtistObjectImplCopyWithImpl<$Res> Object? id = null, Object? name = null, Object? externalUri = null, + Object? images = freezed, }) { return _then(_$SpotubeSimpleArtistObjectImpl( id: null == id @@ -1095,6 +1110,10 @@ class __$$SpotubeSimpleArtistObjectImplCopyWithImpl<$Res> ? _value.externalUri : externalUri // ignore: cast_nullable_to_non_nullable as String, + images: freezed == images + ? _value._images + : images // ignore: cast_nullable_to_non_nullable + as List?, )); } } @@ -1103,7 +1122,11 @@ class __$$SpotubeSimpleArtistObjectImplCopyWithImpl<$Res> @JsonSerializable() class _$SpotubeSimpleArtistObjectImpl implements _SpotubeSimpleArtistObject { _$SpotubeSimpleArtistObjectImpl( - {required this.id, required this.name, required this.externalUri}); + {required this.id, + required this.name, + required this.externalUri, + final List? images}) + : _images = images; factory _$SpotubeSimpleArtistObjectImpl.fromJson(Map json) => _$$SpotubeSimpleArtistObjectImplFromJson(json); @@ -1114,10 +1137,19 @@ class _$SpotubeSimpleArtistObjectImpl implements _SpotubeSimpleArtistObject { final String name; @override final String externalUri; + final List? _images; + @override + List? get images { + final value = _images; + if (value == null) return null; + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } @override String toString() { - return 'SpotubeSimpleArtistObject(id: $id, name: $name, externalUri: $externalUri)'; + return 'SpotubeSimpleArtistObject(id: $id, name: $name, externalUri: $externalUri, images: $images)'; } @override @@ -1128,12 +1160,14 @@ class _$SpotubeSimpleArtistObjectImpl implements _SpotubeSimpleArtistObject { (identical(other.id, id) || other.id == id) && (identical(other.name, name) || other.name == name) && (identical(other.externalUri, externalUri) || - other.externalUri == externalUri)); + other.externalUri == externalUri) && + const DeepCollectionEquality().equals(other._images, _images)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, id, name, externalUri); + int get hashCode => Object.hash(runtimeType, id, name, externalUri, + const DeepCollectionEquality().hash(_images)); /// Create a copy of SpotubeSimpleArtistObject /// with the given fields replaced by the non-null parameter values. @@ -1154,9 +1188,11 @@ class _$SpotubeSimpleArtistObjectImpl implements _SpotubeSimpleArtistObject { abstract class _SpotubeSimpleArtistObject implements SpotubeSimpleArtistObject { factory _SpotubeSimpleArtistObject( - {required final String id, - required final String name, - required final String externalUri}) = _$SpotubeSimpleArtistObjectImpl; + {required final String id, + required final String name, + required final String externalUri, + final List? images}) = + _$SpotubeSimpleArtistObjectImpl; factory _SpotubeSimpleArtistObject.fromJson(Map json) = _$SpotubeSimpleArtistObjectImpl.fromJson; @@ -1167,6 +1203,8 @@ abstract class _SpotubeSimpleArtistObject implements SpotubeSimpleArtistObject { String get name; @override String get externalUri; + @override + List? get images; /// Create a copy of SpotubeSimpleArtistObject /// with the given fields replaced by the non-null parameter values. @@ -2529,7 +2567,7 @@ SpotubeSearchResponseObject _$SpotubeSearchResponseObjectFromJson( mixin _$SpotubeSearchResponseObject { List get albums => throw _privateConstructorUsedError; - List get artists => + List get artists => throw _privateConstructorUsedError; List get playlists => throw _privateConstructorUsedError; @@ -2556,7 +2594,7 @@ abstract class $SpotubeSearchResponseObjectCopyWith<$Res> { @useResult $Res call( {List albums, - List artists, + List artists, List playlists, List tracks}); } @@ -2590,7 +2628,7 @@ class _$SpotubeSearchResponseObjectCopyWithImpl<$Res, artists: null == artists ? _value.artists : artists // ignore: cast_nullable_to_non_nullable - as List, + as List, playlists: null == playlists ? _value.playlists : playlists // ignore: cast_nullable_to_non_nullable @@ -2614,7 +2652,7 @@ abstract class _$$SpotubeSearchResponseObjectImplCopyWith<$Res> @useResult $Res call( {List albums, - List artists, + List artists, List playlists, List tracks}); } @@ -2647,7 +2685,7 @@ class __$$SpotubeSearchResponseObjectImplCopyWithImpl<$Res> artists: null == artists ? _value._artists : artists // ignore: cast_nullable_to_non_nullable - as List, + as List, playlists: null == playlists ? _value._playlists : playlists // ignore: cast_nullable_to_non_nullable @@ -2666,7 +2704,7 @@ class _$SpotubeSearchResponseObjectImpl implements _SpotubeSearchResponseObject { _$SpotubeSearchResponseObjectImpl( {required final List albums, - required final List artists, + required final List artists, required final List playlists, required final List tracks}) : _albums = albums, @@ -2686,9 +2724,9 @@ class _$SpotubeSearchResponseObjectImpl return EqualUnmodifiableListView(_albums); } - final List _artists; + final List _artists; @override - List get artists { + List get artists { if (_artists is EqualUnmodifiableListView) return _artists; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_artists); @@ -2757,7 +2795,7 @@ abstract class _SpotubeSearchResponseObject implements SpotubeSearchResponseObject { factory _SpotubeSearchResponseObject( {required final List albums, - required final List artists, + required final List artists, required final List playlists, required final List tracks}) = _$SpotubeSearchResponseObjectImpl; @@ -2768,7 +2806,7 @@ abstract class _SpotubeSearchResponseObject @override List get albums; @override - List get artists; + List get artists; @override List get playlists; @override diff --git a/lib/models/metadata/metadata.g.dart b/lib/models/metadata/metadata.g.dart index 52001504..ff263819 100644 --- a/lib/models/metadata/metadata.g.dart +++ b/lib/models/metadata/metadata.g.dart @@ -113,6 +113,10 @@ _$SpotubeSimpleArtistObjectImpl _$$SpotubeSimpleArtistObjectImplFromJson( id: json['id'] as String, name: json['name'] as String, externalUri: json['externalUri'] as String, + images: (json['images'] as List?) + ?.map((e) => + SpotubeImageObject.fromJson(Map.from(e as Map))) + .toList(), ); Map _$$SpotubeSimpleArtistObjectImplToJson( @@ -121,6 +125,7 @@ Map _$$SpotubeSimpleArtistObjectImplToJson( 'id': instance.id, 'name': instance.name, 'externalUri': instance.externalUri, + 'images': instance.images?.map((e) => e.toJson()).toList(), }; _$SpotubeBrowseSectionObjectImpl @@ -260,7 +265,7 @@ _$SpotubeSearchResponseObjectImpl _$$SpotubeSearchResponseObjectImplFromJson( Map.from(e as Map))) .toList(), artists: (json['artists'] as List) - .map((e) => SpotubeSimpleArtistObject.fromJson( + .map((e) => SpotubeFullArtistObject.fromJson( Map.from(e as Map))) .toList(), playlists: (json['playlists'] as List) diff --git a/lib/models/metadata/search.dart b/lib/models/metadata/search.dart index e655d892..4918c898 100644 --- a/lib/models/metadata/search.dart +++ b/lib/models/metadata/search.dart @@ -4,7 +4,7 @@ part of 'metadata.dart'; class SpotubeSearchResponseObject with _$SpotubeSearchResponseObject { factory SpotubeSearchResponseObject({ required List albums, - required List artists, + required List artists, required List playlists, required List tracks, }) = _SpotubeSearchResponseObject; diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 25fb046a..19043f42 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -2,7 +2,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotify/spotify.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -18,8 +17,8 @@ import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dar import 'package:spotube/pages/search/sections/albums.dart'; import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; -import 'package:spotube/pages/search/sections/tracks.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/search/all.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:auto_route/auto_route.dart'; @@ -39,17 +38,11 @@ class SearchPage extends HookConsumerWidget { final controller = useShadcnTextEditingController(); final focusNode = useFocusNode(); - final auth = ref.watch(authenticationProvider); + final authenticated = ref.watch(metadataPluginAuthenticatedProvider); final searchTerm = ref.watch(searchTermStateProvider); - final searchTrack = ref.watch(searchProvider(SearchType.track)); - final searchAlbum = ref.watch(searchProvider(SearchType.album)); - final searchPlaylist = ref.watch(searchProvider(SearchType.playlist)); - final searchArtist = ref.watch(searchProvider(SearchType.artist)); - - final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist]; - - final isFetching = queries.every((s) => s.isLoading); + final searchSnapshot = + ref.watch(metadataPluginSearchAllProvider(searchTerm)); useEffect(() { controller.text = searchTerm; @@ -82,7 +75,7 @@ class SearchPage extends HookConsumerWidget { if (kTitlebarVisible) const TitleBar(automaticallyImplyLeading: false, height: 30) ], - child: auth.asData?.value == null + child: authenticated.asData?.value != true ? const AnonymousFallback() : Column( children: [ @@ -174,7 +167,10 @@ class SearchPage extends HookConsumerWidget { Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: switch ((searchTerm.isEmpty, isFetching)) { + child: switch (( + searchTerm.isEmpty, + searchSnapshot.isLoading + )) { (true, false) => Column( children: [ SizedBox( @@ -228,7 +224,7 @@ class SearchPage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ - SearchTracksSection(), + // SearchTracksSection(), SearchPlaylistsSection(), Gap(20), SearchArtistsSection(), diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 105c23d5..249e0e6d 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -1,11 +1,9 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/metadata_plugin/search/all.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class SearchAlbumsSection extends HookConsumerWidget { @@ -15,23 +13,15 @@ class SearchAlbumsSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final query = ref.watch(searchProvider(SearchType.album)); - final notifier = ref.watch(searchProvider(SearchType.album).notifier); - final albums = useMemoized( - () => - query.asData?.value.items - .cast() - .map((e) => e.toAlbum()) - .toList() ?? - [], - [query.asData?.value], - ); + final searchTerm = ref.watch(searchTermStateProvider); + final search = ref.watch(metadataPluginSearchAllProvider(searchTerm)); + final albums = search.asData?.value.albums ?? []; return HorizontalPlaybuttonCardView( - isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.asData?.value.hasMore == true, + isLoadingNextPage: false, + hasNextPage: false, items: albums, - onFetchMore: notifier.fetchMore, + onFetchMore: () {}, title: Text(context.l10n.albums), ); } diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart index 9a94b3c1..ac009cf4 100644 --- a/lib/pages/search/sections/artists.dart +++ b/lib/pages/search/sections/artists.dart @@ -1,9 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/metadata_plugin/search/all.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class SearchArtistsSection extends HookConsumerWidget { @@ -13,16 +13,16 @@ class SearchArtistsSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final query = ref.watch(searchProvider(SearchType.artist)); - final notifier = ref.watch(searchProvider(SearchType.artist).notifier); + final searchTerm = ref.watch(searchTermStateProvider); + final search = ref.watch(metadataPluginSearchAllProvider(searchTerm)); - final artists = query.asData?.value.items.cast() ?? []; + final artists = search.asData?.value.artists ?? []; - return HorizontalPlaybuttonCardView( - isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.asData?.value.hasMore == true, + return HorizontalPlaybuttonCardView( + isLoadingNextPage: false, + hasNextPage: false, items: artists, - onFetchMore: notifier.fetchMore, + onFetchMore: () {}, title: Text(context.l10n.artists), ); } diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart index 17bf4849..2c387f28 100644 --- a/lib/pages/search/sections/playlists.dart +++ b/lib/pages/search/sections/playlists.dart @@ -1,8 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/metadata_plugin/search/all.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class SearchPlaylistsSection extends HookConsumerWidget { @@ -12,17 +12,16 @@ class SearchPlaylistsSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlistsQuery = ref.watch(searchProvider(SearchType.playlist)); - final playlistsQueryNotifier = - ref.watch(searchProvider(SearchType.playlist).notifier); - final playlists = - playlistsQuery.asData?.value.items.cast() ?? []; + final searchTerm = ref.watch(searchTermStateProvider); + final playlistsQuery = + ref.watch(metadataPluginSearchAllProvider(searchTerm)); + final playlists = playlistsQuery.asData?.value.playlists ?? []; return HorizontalPlaybuttonCardView( - isLoadingNextPage: playlistsQuery.isLoadingNextPage, - hasNextPage: playlistsQuery.asData?.value.hasMore == true, + isLoadingNextPage: false, + hasNextPage: false, items: playlists, - onFetchMore: playlistsQueryNotifier.fetchMore, + onFetchMore: () {}, title: Text(context.l10n.playlists), ); } diff --git a/lib/provider/metadata_plugin/search/all.dart b/lib/provider/metadata_plugin/search/all.dart new file mode 100644 index 00000000..53c79f7c --- /dev/null +++ b/lib/provider/metadata_plugin/search/all.dart @@ -0,0 +1,19 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/metadata/metadata.dart'; +import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; +import 'package:spotube/services/metadata/endpoints/error.dart'; + +final metadataPluginSearchAllProvider = + FutureProvider.autoDispose.family( + (ref, query) async { + final metadataPlugin = await ref.watch(metadataPluginProvider.future); + + if (metadataPlugin == null) { + throw MetadataPluginException.noDefaultPlugin( + "No default metadata plugin found", + ); + } + + return metadataPlugin.search.all(query); + }, +); diff --git a/lib/services/metadata/endpoints/search.dart b/lib/services/metadata/endpoints/search.dart index e69de29b..070628c2 100644 --- a/lib/services/metadata/endpoints/search.dart +++ b/lib/services/metadata/endpoints/search.dart @@ -0,0 +1,156 @@ +import 'package:hetu_script/hetu_script.dart'; +import 'package:hetu_script/values.dart'; +import 'package:spotube/models/metadata/metadata.dart'; + +class MetadataPluginSearchEndpoint { + final Hetu hetu; + MetadataPluginSearchEndpoint(this.hetu); + + HTInstance get hetuMetadataSearch => + (hetu.fetch("metadataPlugin") as HTInstance).memberGet("search") + as HTInstance; + + Future all(String query) async { + if (query.isEmpty) { + return SpotubeSearchResponseObject( + albums: [], + artists: [], + playlists: [], + tracks: [], + ); + } + + final raw = await hetuMetadataSearch.invoke( + "all", + positionalArgs: [query], + ) as Map; + + return SpotubeSearchResponseObject.fromJson(raw.cast()); + } + + Future> albums( + String query, { + int? limit, + int? offset, + }) async { + if (query.isEmpty) { + return SpotubePaginationResponseObject( + items: [], + total: 0, + limit: limit ?? 20, + hasMore: false, + nextOffset: null, + ); + } + + final raw = await hetuMetadataSearch.invoke( + "albums", + positionalArgs: [query], + namedArgs: { + "limit": limit, + "offset": offset, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (json) => SpotubeSimpleAlbumObject.fromJson(json.cast()), + ); + } + + Future> artists( + String query, { + int? limit, + int? offset, + }) async { + if (query.isEmpty) { + return SpotubePaginationResponseObject( + items: [], + total: 0, + limit: limit ?? 20, + hasMore: false, + nextOffset: null, + ); + } + + final raw = await hetuMetadataSearch.invoke( + "artists", + positionalArgs: [query], + namedArgs: { + "limit": limit, + "offset": offset, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (json) => SpotubeFullArtistObject.fromJson( + json.cast(), + ), + ); + } + + Future> + playlists( + String query, { + int? limit, + int? offset, + }) async { + if (query.isEmpty) { + return SpotubePaginationResponseObject( + items: [], + total: 0, + limit: limit ?? 20, + hasMore: false, + nextOffset: null, + ); + } + + final raw = await hetuMetadataSearch.invoke( + "playlists", + positionalArgs: [query], + namedArgs: { + "limit": limit, + "offset": offset, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject< + SpotubeSimplePlaylistObject>.fromJson( + raw.cast(), + (json) => SpotubeSimplePlaylistObject.fromJson( + json.cast(), + ), + ); + } + + Future> tracks( + String query, { + int? limit, + int? offset, + }) async { + if (query.isEmpty) { + return SpotubePaginationResponseObject( + items: [], + total: 0, + limit: limit ?? 20, + hasMore: false, + nextOffset: null, + ); + } + + final raw = await hetuMetadataSearch.invoke( + "tracks", + positionalArgs: [query], + namedArgs: { + "limit": limit, + "offset": offset, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (json) => SpotubeSimpleTrackObject.fromJson(json.cast()), + ); + } +} diff --git a/lib/services/metadata/metadata.dart b/lib/services/metadata/metadata.dart index 034459a9..1d1eaf7d 100644 --- a/lib/services/metadata/metadata.dart +++ b/lib/services/metadata/metadata.dart @@ -16,6 +16,7 @@ import 'package:spotube/services/metadata/endpoints/artist.dart'; import 'package:spotube/services/metadata/endpoints/auth.dart'; import 'package:spotube/services/metadata/endpoints/browse.dart'; import 'package:spotube/services/metadata/endpoints/playlist.dart'; +import 'package:spotube/services/metadata/endpoints/search.dart'; import 'package:spotube/services/metadata/endpoints/user.dart'; const defaultMetadataLimit = "20"; @@ -80,6 +81,7 @@ class MetadataPlugin { late final MetadataPluginAlbumEndpoint album; late final MetadataPluginArtistEndpoint artist; late final MetadataPluginBrowseEndpoint browse; + late final MetadataPluginSearchEndpoint search; late final MetadataPluginPlaylistEndpoint playlist; late final MetadataPluginUserEndpoint user; @@ -89,6 +91,7 @@ class MetadataPlugin { artist = MetadataPluginArtistEndpoint(hetu); album = MetadataPluginAlbumEndpoint(hetu); browse = MetadataPluginBrowseEndpoint(hetu); + search = MetadataPluginSearchEndpoint(hetu); playlist = MetadataPluginPlaylistEndpoint(hetu); user = MetadataPluginUserEndpoint(hetu); }