From c047c26932f0332d1c0ad8ca056c158a7a4a618b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 13 Mar 2025 00:32:15 +0100 Subject: [PATCH] Add base logic for recommendations (#2033) --- .vscode/settings.json | 8 ++- music_assistant/controllers/music.py | 71 +++++++++++++++++++ music_assistant/models/music_provider.py | 39 +++++----- .../_template_music_provider/__init__.py | 11 +-- music_assistant/providers/deezer/__init__.py | 14 +++- .../providers/radiobrowser/__init__.py | 20 +++--- pyproject.toml | 2 +- requirements_all.txt | 2 +- .../jellyfin/__snapshots__/test_parsers.ambr | 19 +++++ .../__snapshots__/test_parsers.ambr | 8 +++ 10 files changed, 152 insertions(+), 42 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8e26bf7c..ad2bcf4f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,11 @@ "[github-actions-workflow]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "python.analysis.extraPaths": ["../aiosonos/"] + "python.analysis.extraPaths": ["../models/"], + "python.analysis.packageIndexDepths": [ + { + "name": "music_assistant_models", + "depth": 2 + } + ] } diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index 61c40ae3..f6c91322 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -33,6 +33,7 @@ from music_assistant_models.media_items import ( ItemMapping, MediaItemType, MediaItemTypeOrItemMapping, + RecommendationFolder, SearchResults, ) from music_assistant_models.provider import SyncTask @@ -549,6 +550,76 @@ class MusicController(CoreController): provider_instance_id_or_domain=provider_instance_id_or_domain, ) + @api_command("music/recommendations") + async def recommendations(self) -> list[RecommendationFolder]: + """Get all recommendations.""" + recommendation_providers = [ + x for x in self.providers if ProviderFeature.RECOMMENDATIONS in x.supported_features + ] + results_per_provider: list[list[RecommendationFolder]] = await asyncio.gather( + self._get_default_recommendations(), + *[ + provider_instance.recommendations() + for provider_instance in recommendation_providers + ], + ) + # return result from all providers while keeping index + # so the result is sorted as each provider delivered + return [item for sublist in zip_longest(*results_per_provider) for item in sublist] + + async def _get_default_recommendations(self) -> list[RecommendationFolder]: + """Return default recommendations.""" + return [ + RecommendationFolder( + item_id="in_progress", + provider="library", + name="In progress", + translation_key="in_progress_items", + icon="mdi-motion-play", + items=await self.in_progress_items(limit=10), + ), + RecommendationFolder( + item_id="recently_played", + provider="library", + name="Recently played", + translation_key="recently_played", + icon="mdi-motion-play", + items=await self.recently_played(limit=10), + ), + RecommendationFolder( + item_id="random_artists", + provider="library", + name="Random artists", + translation_key="random_artists", + icon="mdi-account-music", + items=await self.artists.library_items(limit=10, order_by="random"), + ), + RecommendationFolder( + item_id="random_albums", + provider="library", + name="Random albums", + translation_key="random_albums", + icon="mdi-album", + items=await self.albums.library_items(limit=10, order_by="random"), + ), + RecommendationFolder( + item_id="random_tracks", + provider="library", + name="Random tracks", + translation_key="random_tracks", + icon="mdi-file-music", + items=await self.tracks.library_items(limit=10, order_by="random"), + ), + RecommendationFolder( + item_id="random_playlists", + provider="library", + name="Random playlists", + translation_key="random_playlists", + icon="mdi-playlist-music", + items=await self.playlists.library_items(limit=10, order_by="random"), + ), + ] + @api_command("music/item") async def get_item( self, diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index 3c23c4aa..d521af50 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -23,6 +23,7 @@ from music_assistant_models.media_items import ( Podcast, PodcastEpisode, Radio, + RecommendationFolder, SearchResults, Track, ) @@ -535,8 +536,8 @@ class MusicProvider(Provider): item_id="artists", provider=self.instance_id, path=path + "artists", - name="", - label="artists", + name="Artists", + translation_key="artists", is_playable=True, ) ) @@ -546,8 +547,8 @@ class MusicProvider(Provider): item_id="albums", provider=self.instance_id, path=path + "albums", - name="", - label="albums", + name="Albums", + translation_key="albums", is_playable=True, ) ) @@ -557,8 +558,8 @@ class MusicProvider(Provider): item_id="tracks", provider=self.domain, path=path + "tracks", - name="", - label="tracks", + name="Tracks", + translation_key="tracks", is_playable=True, ) ) @@ -568,8 +569,8 @@ class MusicProvider(Provider): item_id="playlists", provider=self.instance_id, path=path + "playlists", - name="", - label="playlists", + name="Playlists", + translation_key="playlists", is_playable=True, ) ) @@ -579,8 +580,8 @@ class MusicProvider(Provider): item_id="radios", provider=self.instance_id, path=path + "radios", - name="", - label="radios", + name="Radio", + translation_key="radios", ) ) if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features: @@ -589,8 +590,8 @@ class MusicProvider(Provider): item_id="audiobooks", provider=self.instance_id, path=path + "audiobooks", - name="", - label="audiobooks", + name="Audiobooks", + translation_key="audiobooks", ) ) if ProviderFeature.LIBRARY_PODCASTS in self.supported_features: @@ -599,8 +600,8 @@ class MusicProvider(Provider): item_id="podcasts", provider=self.instance_id, path=path + "podcasts", - name="", - label="podcasts", + name="Podcasts", + translation_key="podcasts", ) ) if len(items) == 1: @@ -608,12 +609,12 @@ class MusicProvider(Provider): return await self.browse(items[0].path) return items - async def recommendations(self) -> list[MediaItemType]: - """Get this provider's recommendations. + async def recommendations(self) -> list[RecommendationFolder]: + """ + Get this provider's recommendations. - Returns a actual and personalised list of Media items with recommendations - form this provider for the user/account. It may return nested levels with - BrowseFolder items. + Returns an actual (and often personalised) list of recommendations + from this provider for the user/account. """ if ProviderFeature.RECOMMENDATIONS in self.supported_features: raise NotImplementedError diff --git a/music_assistant/providers/_template_music_provider/__init__.py b/music_assistant/providers/_template_music_provider/__init__.py index 9ea6436f..e00cde9d 100644 --- a/music_assistant/providers/_template_music_provider/__init__.py +++ b/music_assistant/providers/_template_music_provider/__init__.py @@ -50,6 +50,7 @@ from music_assistant_models.media_items import ( Playlist, ProviderMapping, Radio, + RecommendationFolder, SearchResults, Track, ) @@ -503,12 +504,12 @@ class MyDemoMusicprovider(MusicProvider): # that will call the get_library_* methods if you did not override it. return [] - async def recommendations(self) -> list[MediaItemType]: - """Get this provider's recommendations. + async def recommendations(self) -> list[RecommendationFolder]: + """ + Get this provider's recommendations. - Returns a actual and personalised list of Media items with recommendations - form this provider for the user/account. It may return nested levels with - BrowseFolder items. + Returns an actual (and often personalised) list of recommendations + from this provider for the user/account. """ # Get this provider's recommendations. # This is only called if you reported the RECOMMENDATIONS feature in the supported_features. diff --git a/music_assistant/providers/deezer/__init__.py b/music_assistant/providers/deezer/__init__.py index a7a5ae3e..37dd768a 100644 --- a/music_assistant/providers/deezer/__init__.py +++ b/music_assistant/providers/deezer/__init__.py @@ -33,6 +33,7 @@ from music_assistant_models.media_items import ( MediaItemType, Playlist, ProviderMapping, + RecommendationFolder, SearchResults, Track, ) @@ -388,11 +389,18 @@ class DeezerProvider(MusicProvider): raise NotImplementedError return result - async def recommendations(self) -> list[Track]: + async def recommendations(self) -> list[RecommendationFolder]: """Get deezer's recommendations.""" return [ - self.parse_track(track=track, user_country=self.gw_client.user_country) - for track in await self.client.get_user_recommended_tracks() + RecommendationFolder( + item_id="recommended_tracks", + name="Recommended tracks", + translation_key="recommended_tracks", + items=[ + self.parse_track(track=track, user_country=self.gw_client.user_country) + for track in await self.client.get_user_recommended_tracks() + ], + ) ] async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None: diff --git a/music_assistant/providers/radiobrowser/__init__.py b/music_assistant/providers/radiobrowser/__init__.py index c8902da0..bd3bb5a3 100644 --- a/music_assistant/providers/radiobrowser/__init__.py +++ b/music_assistant/providers/radiobrowser/__init__.py @@ -152,21 +152,21 @@ class RadioBrowserProvider(MusicProvider): provider=self.domain, path=path + "popular", name="", - label="radiobrowser_by_popularity", + translation_key="radiobrowser_by_popularity", ), BrowseFolder( item_id="country", provider=self.domain, path=path + "country", name="", - label="radiobrowser_by_country", + translation_key="radiobrowser_by_country", ), BrowseFolder( item_id="tag", provider=self.domain, path=path + "tag", name="", - label="radiobrowser_by_tag", + translation_key="radiobrowser_by_tag", ), ] @@ -252,15 +252,11 @@ class RadioBrowserProvider(MusicProvider): path=base_path + "/" + country.code.lower(), name=country.name, ) - folder.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=country.favicon, - provider=self.lookup_key, - remotely_accessible=True, - ) - ] + folder.image = MediaItemImage( + type=ImageType.THUMB, + path=country.favicon, + provider=self.lookup_key, + remotely_accessible=True, ) items.append(folder) return items diff --git a/pyproject.toml b/pyproject.toml index b3852cc6..dc6046a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "ifaddr==0.2.0", "mashumaro==3.15", "music-assistant-frontend==2.13.1", - "music-assistant-models==1.1.36", + "music-assistant-models==1.1.37", "mutagen==1.47.0", "orjson==3.10.15", "pillow==11.1.0", diff --git a/requirements_all.txt b/requirements_all.txt index 88659f33..ae77fe9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -27,7 +27,7 @@ ifaddr==0.2.0 liblistenbrainz==0.5.6 mashumaro==3.15 music-assistant-frontend==2.13.1 -music-assistant-models==1.1.36 +music-assistant-models==1.1.37 mutagen==1.47.0 orjson==3.10.15 pillow==11.1.0 diff --git a/tests/providers/jellyfin/__snapshots__/test_parsers.ambr b/tests/providers/jellyfin/__snapshots__/test_parsers.ambr index b7d1d15f..9a1e7ce5 100644 --- a/tests/providers/jellyfin/__snapshots__/test_parsers.ambr +++ b/tests/providers/jellyfin/__snapshots__/test_parsers.ambr @@ -14,6 +14,7 @@ 'name': 'Papa Roach', 'provider': 'xx-instance-id-xx', 'sort_name': 'papa roach', + 'translation_key': None, 'uri': 'xx-instance-id-xx://artist/e439648e08ade14e27d5de48fa97c88e', 'version': '', }), @@ -82,6 +83,7 @@ }), ]), 'sort_name': 'infest', + 'translation_key': None, 'uri': 'jellyfin://album/70b7288088b42d318f75dbcc41fd0091', 'version': '', 'year': 2000, @@ -102,6 +104,7 @@ 'name': 'Emmy the Great & Tim Wheeler', 'provider': 'xx-instance-id-xx', 'sort_name': 'emmy the great & tim wheeler', + 'translation_key': None, 'uri': 'xx-instance-id-xx://artist/555b36f7d310d1b7405557a8775c6878', 'version': '', }), @@ -170,6 +173,7 @@ }), ]), 'sort_name': 'this is christmas', + 'translation_key': None, 'uri': 'jellyfin://album/32ed6a0091733dcff57eae67010f3d4b', 'version': '', 'year': 2011, @@ -190,6 +194,7 @@ 'name': '[unknown]', 'provider': 'jellyfin', 'sort_name': 'unknown]', + 'translation_key': None, 'uri': 'jellyfin://artist/[unknown]', 'version': '', }), @@ -244,6 +249,7 @@ }), ]), 'sort_name': 'yesterday when i was mad [disc 0000000002]', + 'translation_key': None, 'uri': 'jellyfin://album/7c8d0bd55291c7fc0451d17ebef30017', 'version': '', 'year': None, @@ -323,6 +329,7 @@ }), ]), 'sort_name': 'ash', + 'translation_key': None, 'uri': 'jellyfin://artist/dd954bbf54398e247d803186d3585b79', 'version': '', }) @@ -340,6 +347,7 @@ 'name': 'AM', 'provider': 'xx-instance-id-xx', 'sort_name': 'am', + 'translation_key': None, 'uri': 'xx-instance-id-xx://album/d42d74e134693184e7adc73106238e89', 'version': '', }), @@ -355,6 +363,7 @@ 'name': 'Arctic Monkeys', 'provider': 'xx-instance-id-xx', 'sort_name': 'arctic monkeys', + 'translation_key': None, 'uri': 'xx-instance-id-xx://artist/cc940aeb8a99149f159fe9794f136071', 'version': '', }), @@ -418,6 +427,7 @@ ]), 'sort_name': 'do i wanna know?', 'track_number': 1, + 'translation_key': None, 'uri': 'xx-instance-id-xx://track/da9c458e425584680765ddc3a89cbc0c', 'version': '', }) @@ -435,6 +445,7 @@ 'name': 'Unknown Album (70b7288088b42d318f75dbcc41fd0091)', 'provider': 'xx-instance-id-xx', 'sort_name': 'unknown album (70b7288088b42d318f75dbcc41fd0091)', + 'translation_key': None, 'uri': 'xx-instance-id-xx://album/70b7288088b42d318f75dbcc41fd0091', 'version': '', }), @@ -450,6 +461,7 @@ 'name': '[unknown]', 'provider': 'jellyfin', 'sort_name': 'unknown]', + 'translation_key': None, 'uri': 'jellyfin://artist/[unknown]', 'version': '', }), @@ -507,6 +519,7 @@ ]), 'sort_name': '11 thrown away', 'track_number': 0, + 'translation_key': None, 'uri': 'xx-instance-id-xx://track/b5319fb11cde39fca2023184fcfa9862', 'version': '', }) @@ -526,6 +539,7 @@ 'name': 'Dead Like Harry', 'provider': 'xx-instance-id-xx', 'sort_name': 'dead like harry', + 'translation_key': None, 'uri': 'xx-instance-id-xx://artist/94875b0dd58cbf5245a135982133651a', 'version': '', }), @@ -589,6 +603,7 @@ ]), 'sort_name': 'where the bands are (2018 version)', 'track_number': 1, + 'translation_key': None, 'uri': 'xx-instance-id-xx://track/54918f75ee8f6c8b8dc5efd680644f29', 'version': '', }) @@ -606,6 +621,7 @@ 'name': 'This Is Christmas', 'provider': 'xx-instance-id-xx', 'sort_name': 'this is christmas', + 'translation_key': None, 'uri': 'xx-instance-id-xx://album/32ed6a0091733dcff57eae67010f3d4b', 'version': '', }), @@ -621,6 +637,7 @@ 'name': 'Emmy the Great', 'provider': 'xx-instance-id-xx', 'sort_name': 'emmy the great', + 'translation_key': None, 'uri': 'xx-instance-id-xx://artist/a0c459294295710546c81c20a8d9abfc', 'version': '', }), @@ -635,6 +652,7 @@ 'name': 'Tim Wheeler', 'provider': 'xx-instance-id-xx', 'sort_name': 'tim wheeler', + 'translation_key': None, 'uri': 'xx-instance-id-xx://artist/1952db245ddef4e41dcd016475379190', 'version': '', }), @@ -702,6 +720,7 @@ ]), 'sort_name': 'zombie christmas', 'track_number': 8, + 'translation_key': None, 'uri': 'xx-instance-id-xx://track/fb12a77f49616a9fc56a6fab2b94174c', 'version': '', }) diff --git a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr index b3946ed5..99e1bb54 100644 --- a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr +++ b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr @@ -14,6 +14,7 @@ 'name': 'pornophonique', 'provider': 'xx-instance-id-xx', 'sort_name': 'pornophonique', + 'translation_key': None, 'uri': 'xx-instance-id-xx://artist/91c3901ac465b9efc439e4be4270c2b6', 'version': '', }), @@ -74,6 +75,7 @@ }), ]), 'sort_name': '8-bit lagerfeuer', + 'translation_key': None, 'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35', 'version': '', 'year': 2007, @@ -94,6 +96,7 @@ 'name': 'pornophonique', 'provider': 'xx-instance-id-xx', 'sort_name': 'pornophonique', + 'translation_key': None, 'uri': 'xx-instance-id-xx://artist/91c3901ac465b9efc439e4be4270c2b6', 'version': '', }), @@ -160,6 +163,7 @@ }), ]), 'sort_name': '8-bit lagerfeuer', + 'translation_key': None, 'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35', 'version': '', 'year': 2007, @@ -223,6 +227,7 @@ }), ]), 'sort_name': '2 mello', + 'translation_key': None, 'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51', 'version': '', }) @@ -291,6 +296,7 @@ }), ]), 'sort_name': '2 mello', + 'translation_key': None, 'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51', 'version': '', }) @@ -353,6 +359,7 @@ }), ]), 'sort_name': 'synthetic', + 'translation_key': None, 'uri': 'opensubsonic://artist/100000002', 'version': '', }) @@ -421,6 +428,7 @@ }), ]), 'sort_name': 'synthetic', + 'translation_key': None, 'uri': 'opensubsonic://artist/100000002', 'version': '', }) -- 2.34.1