Feat: Subsonic: Add configurable recommendations (#2226)
authorEric Munson <eric@munsonfam.org>
Sat, 14 Jun 2025 14:01:25 +0000 (10:01 -0400)
committerGitHub <noreply@github.com>
Sat, 14 Jun 2025 14:01:25 +0000 (16:01 +0200)
The provider has the ability to ask for things like newest albums, most
played albums, and anything that is starred by a user. These could be
used to fill out the recommendation rows in the home page if the user
wants. Add configuration for what kinds (if any) of these
recommendations are desired and the size of the list.

Signed-off-by: Eric B Munson <eric@munsonfam.org>
music_assistant/providers/opensubsonic/__init__.py
music_assistant/providers/opensubsonic/sonic_provider.py

index 12ec28064f64c54b498f88cb3aeebb023c3cdc36..faa4f362f62161fa52eaa72815f4eca4bfb46ed3 100644 (file)
@@ -13,7 +13,11 @@ from .sonic_provider import (
     CONF_BASE_URL,
     CONF_ENABLE_LEGACY_AUTH,
     CONF_ENABLE_PODCASTS,
+    CONF_NEW_ALBUMS,
     CONF_OVERRIDE_OFFSET,
+    CONF_PLAYED_ALBUMS,
+    CONF_RECO_FAVES,
+    CONF_RECO_SIZE,
     OpenSonicProvider,
 )
 
@@ -86,7 +90,7 @@ async def get_config_entries(
         ConfigEntry(
             key=CONF_ENABLE_LEGACY_AUTH,
             type=ConfigEntryType.BOOLEAN,
-            label="Enable legacy auth",
+            label="Enable Legacy Auth",
             required=True,
             description='Enable OpenSubsonic "legacy" auth support',
             default_value=False,
@@ -94,10 +98,42 @@ async def get_config_entries(
         ConfigEntry(
             key=CONF_OVERRIDE_OFFSET,
             type=ConfigEntryType.BOOLEAN,
-            label="Force player provider seek",
+            label="Force Player Provider Seek",
             required=True,
             description="Some Subsonic implementations advertise that they support seeking when "
             "they do not always. If seeking does not work for you, enable this.",
             default_value=False,
         ),
+        ConfigEntry(
+            key=CONF_RECO_FAVES,
+            type=ConfigEntryType.BOOLEAN,
+            label="Recommend Favorites",
+            required=True,
+            description="Should favorited (starred) items be included as recommendations.",
+            default_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_NEW_ALBUMS,
+            type=ConfigEntryType.BOOLEAN,
+            label="Recommend New Albums",
+            required=True,
+            description="Should new albums be included as recommendations.",
+            default_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_PLAYED_ALBUMS,
+            type=ConfigEntryType.BOOLEAN,
+            label="Recommend Most Played",
+            required=True,
+            description="Should most played albums be included as recommendations.",
+            default_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_RECO_SIZE,
+            type=ConfigEntryType.INTEGER,
+            label="Recommendation Limit",
+            required=True,
+            description="How many recommendations from each enabled type should be included.",
+            default_value=10,
+        ),
     )
index d4cef92a95f1ee132f50e1cc7ae004f5d065e7ca..1ffb9a2d4917e2c869e77b72b41050f4796cc1e5 100644 (file)
@@ -30,6 +30,7 @@ from music_assistant_models.media_items import (
     Podcast,
     PodcastEpisode,
     ProviderMapping,
+    RecommendationFolder,
     SearchResults,
     Track,
 )
@@ -72,6 +73,10 @@ CONF_BASE_URL = "baseURL"
 CONF_ENABLE_PODCASTS = "enable_podcasts"
 CONF_ENABLE_LEGACY_AUTH = "enable_legacy_auth"
 CONF_OVERRIDE_OFFSET = "override_transcode_offest"
+CONF_RECO_FAVES = "recommend_favorites"
+CONF_NEW_ALBUMS = "recommend_new"
+CONF_PLAYED_ALBUMS = "recommend_played"
+CONF_RECO_SIZE = "recommendation_count"
 
 
 Param = ParamSpec("Param")
@@ -85,6 +90,10 @@ class OpenSonicProvider(MusicProvider):
     _enable_podcasts: bool = True
     _seek_support: bool = False
     _ignore_offset: bool = False
+    _show_faves: bool = True
+    _show_new: bool = True
+    _show_played: bool = True
+    _reco_limit: int = 10
 
     async def handle_async_init(self) -> None:
         """Set up the music provider and test the connection."""
@@ -123,6 +132,10 @@ class OpenSonicProvider(MusicProvider):
                     break
         except OSError:
             self.logger.info("Server does not support transcodeOffset, seeking in player provider")
+        self._show_faves = bool(self.config.get_value(CONF_RECO_FAVES))
+        self._show_new = bool(self.config.get_value(CONF_NEW_ALBUMS))
+        self._show_played = bool(self.config.get_value(CONF_PLAYED_ALBUMS))
+        self._reco_limit = int(str(self.config.get_value(CONF_RECO_SIZE)))
 
     @property
     def supported_features(self) -> set[ProviderFeature]:
@@ -135,6 +148,7 @@ class OpenSonicProvider(MusicProvider):
             ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
             ProviderFeature.BROWSE,
             ProviderFeature.SEARCH,
+            ProviderFeature.RECOMMENDATIONS,
             ProviderFeature.ARTIST_ALBUMS,
             ProviderFeature.ARTIST_TOPTRACKS,
             ProviderFeature.SIMILAR_TRACKS,
@@ -760,3 +774,53 @@ class OpenSonicProvider(MusicProvider):
                 streamer_task.cancel()
 
         self.logger.debug("Done streaming %s", streamdetails.item_id)
+
+    async def recommendations(self) -> list[RecommendationFolder]:
+        """Provide recommendations.
+
+        These can provide favorited items, recently added albums, and most played albums.
+        What is included is configured with the provider.
+        """
+        recos: list[RecommendationFolder] = []
+        if self._show_faves:
+            faves: RecommendationFolder = RecommendationFolder(
+                item_id="subsonic_starred_albums", provider=self.domain, name="Starred Items"
+            )
+            starred = await self._run_async(self.conn.get_starred2)
+            if starred.album:
+                for sonic_album in starred.album[: self._reco_limit]:
+                    faves.items.append(parse_album(self.logger, self.instance_id, sonic_album))
+            if starred.artist:
+                for sonic_artist in starred.artist[: self._reco_limit]:
+                    faves.items.append(parse_artist(self.instance_id, sonic_artist))
+            if starred.song:
+                for sonic_song in starred.song[: self._reco_limit]:
+                    faves.items.append(parse_track(self.logger, self.instance_id, sonic_song))
+
+            recos.append(faves)
+
+        if self._show_new:
+            new_stuff: RecommendationFolder = RecommendationFolder(
+                item_id="subsonic_new_albums", provider=self.domain, name="New Albums"
+            )
+            new_albums = await self._run_async(
+                self.conn.get_album_list2, ltype="newest", size=self._reco_limit
+            )
+            for sonic_album in new_albums:
+                new_stuff.items.append(parse_album(self.logger, self.instance_id, sonic_album))
+
+            recos.append(new_stuff)
+
+        if self._show_played:
+            recent: RecommendationFolder = RecommendationFolder(
+                item_id="subsonic_most_played", provider=self.domain, name="Most Played Albums"
+            )
+            albums = await self._run_async(
+                self.conn.get_album_list2, ltype="frequent", size=self._reco_limit
+            )
+            for sonic_album in albums:
+                recent.items.append(parse_album(self.logger, self.instance_id, sonic_album))
+
+            recos.append(recent)
+
+        return recos