Add base logic for recommendations (#2033)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 12 Mar 2025 23:32:15 +0000 (00:32 +0100)
committerGitHub <noreply@github.com>
Wed, 12 Mar 2025 23:32:15 +0000 (00:32 +0100)
.vscode/settings.json
music_assistant/controllers/music.py
music_assistant/models/music_provider.py
music_assistant/providers/_template_music_provider/__init__.py
music_assistant/providers/deezer/__init__.py
music_assistant/providers/radiobrowser/__init__.py
pyproject.toml
requirements_all.txt
tests/providers/jellyfin/__snapshots__/test_parsers.ambr
tests/providers/opensubsonic/__snapshots__/test_parsers.ambr

index 8e26bf7cc0a107404795571bee43a814d55b8c7f..ad2bcf4faef778cbd31a0f8b82e41d3b82bf7bdb 100644 (file)
   "[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
+    }
+  ]
 }
index 61c40ae37e3b6fbb18212bfca92e52c8bb4fa3f5..f6c91322cfa1e726ec948682d2fee5f7a0c16d0a 100644 (file)
@@ -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,
index 3c23c4aa7443a6911bdc90a8b4a731f203c88211..d521af50cbbfbd3bf10c30b05e5b88fe22c19659 100644 (file)
@@ -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
index 9ea6436fa8e7b46700b0f39f4744abdd61714631..e00cde9d647750c2e8ef500d51af24fb8d133dfc 100644 (file)
@@ -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.
index a7a5ae3ea578e92ab673c164ad859e90e9b87628..37dd768a9e42d72bc61ccb1e631aeb4a881e655d 100644 (file)
@@ -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:
index c8902da0ea1ba94b4a31d95e963217864b3199b8..bd3bb5a34c3c762ee70646f328535671e9f1edf9 100644 (file)
@@ -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
index b3852cc6fca610acd41b71fc3fe069517a2aa94a..dc6046a028f6cdf91dc4aa9f6d35a30f66030609 100644 (file)
@@ -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",
index 88659f33c053eca0de83ade7eea99ec366723822..ae77fe9d4563540408dc5c8f637d78718278540e 100644 (file)
@@ -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
index b7d1d15f63099b954dec86a06a2465786da9adb6..9a1e7ce55fa6e42808dc6f7748abe3cacaef4e8e 100644 (file)
@@ -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,
         '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': '',
       }),
       }),
     ]),
     'sort_name': 'this is christmas',
+    'translation_key': None,
     'uri': 'jellyfin://album/32ed6a0091733dcff57eae67010f3d4b',
     'version': '',
     'year': 2011,
         'name': '[unknown]',
         'provider': 'jellyfin',
         'sort_name': 'unknown]',
+        'translation_key': None,
         'uri': 'jellyfin://artist/[unknown]',
         'version': '',
       }),
       }),
     ]),
     'sort_name': 'yesterday when i was mad [disc 0000000002]',
+    'translation_key': None,
     'uri': 'jellyfin://album/7c8d0bd55291c7fc0451d17ebef30017',
     'version': '',
     'year': None,
       }),
     ]),
     'sort_name': 'ash',
+    'translation_key': None,
     'uri': 'jellyfin://artist/dd954bbf54398e247d803186d3585b79',
     'version': '',
   })
       'name': 'AM',
       'provider': 'xx-instance-id-xx',
       'sort_name': 'am',
+      'translation_key': None,
       'uri': 'xx-instance-id-xx://album/d42d74e134693184e7adc73106238e89',
       'version': '',
     }),
         'name': 'Arctic Monkeys',
         'provider': 'xx-instance-id-xx',
         'sort_name': 'arctic monkeys',
+        'translation_key': None,
         'uri': 'xx-instance-id-xx://artist/cc940aeb8a99149f159fe9794f136071',
         'version': '',
       }),
     ]),
     'sort_name': 'do i wanna know?',
     'track_number': 1,
+    'translation_key': None,
     'uri': 'xx-instance-id-xx://track/da9c458e425584680765ddc3a89cbc0c',
     'version': '',
   })
       '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': '',
     }),
         'name': '[unknown]',
         'provider': 'jellyfin',
         'sort_name': 'unknown]',
+        'translation_key': None,
         'uri': 'jellyfin://artist/[unknown]',
         'version': '',
       }),
     ]),
     'sort_name': '11 thrown away',
     'track_number': 0,
+    'translation_key': None,
     'uri': 'xx-instance-id-xx://track/b5319fb11cde39fca2023184fcfa9862',
     'version': '',
   })
         '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': '',
       }),
     ]),
     'sort_name': 'where the bands are (2018 version)',
     'track_number': 1,
+    'translation_key': None,
     'uri': 'xx-instance-id-xx://track/54918f75ee8f6c8b8dc5efd680644f29',
     'version': '',
   })
       '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': '',
     }),
         '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': '',
       }),
         'name': 'Tim Wheeler',
         'provider': 'xx-instance-id-xx',
         'sort_name': 'tim wheeler',
+        'translation_key': None,
         'uri': 'xx-instance-id-xx://artist/1952db245ddef4e41dcd016475379190',
         'version': '',
       }),
     ]),
     'sort_name': 'zombie christmas',
     'track_number': 8,
+    'translation_key': None,
     'uri': 'xx-instance-id-xx://track/fb12a77f49616a9fc56a6fab2b94174c',
     'version': '',
   })
index b3946ed50576e28bb65ce88bbfe50a002c73f032..99e1bb543e3ab6a87a11f413bcf3d56b18e7237e 100644 (file)
@@ -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': '',
       }),
       }),
     ]),
     'sort_name': '8-bit lagerfeuer',
+    'translation_key': None,
     'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35',
     'version': '',
     'year': 2007,
       }),
     ]),
     'sort_name': '2 mello',
+    'translation_key': None,
     'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51',
     'version': '',
   })
       }),
     ]),
     'sort_name': '2 mello',
+    'translation_key': None,
     'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51',
     'version': '',
   })
       }),
     ]),
     'sort_name': 'synthetic',
+    'translation_key': None,
     'uri': 'opensubsonic://artist/100000002',
     'version': '',
   })
       }),
     ]),
     'sort_name': 'synthetic',
+    'translation_key': None,
     'uri': 'opensubsonic://artist/100000002',
     'version': '',
   })