Use preferred provider steering also for radio mode
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 14 Dec 2025 23:43:40 +0000 (00:43 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 14 Dec 2025 23:43:40 +0000 (00:43 +0100)
music_assistant/controllers/media/albums.py
music_assistant/controllers/media/artists.py
music_assistant/controllers/media/audiobooks.py
music_assistant/controllers/media/base.py
music_assistant/controllers/media/genres.py
music_assistant/controllers/media/playlists.py
music_assistant/controllers/media/podcasts.py
music_assistant/controllers/media/radio.py
music_assistant/controllers/media/tracks.py
music_assistant/controllers/player_queues.py

index 1a764788308db7541e98c8bc8c79dc05060c1f23..a7a4f785f0e58e63d7e2c300971fbe9b6b87efb7 100644 (file)
@@ -454,11 +454,16 @@ class AlbumsController(MediaControllerBase[Album]):
 
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
+        item: Album,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
-        return await self.tracks(item_id, provider_instance_id_or_domain, in_library_only=False)
+        """
+        Get the list of base tracks from the controller used to calculate the dynamic radio.
+
+        :param item: The Album to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+        """
+        return await self.tracks(item.item_id, item.provider, in_library_only=False)
 
     async def _set_album_artists(
         self,
index 4042d22b2ef7abb2e815e0fb41501b24690cd07f..d9acdd9ee80798030c6650b81443624e10b5efab 100644 (file)
@@ -368,13 +368,18 @@ class ArtistsController(MediaControllerBase[Artist]):
 
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
+        item: Artist,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
+        """
+        Get the list of base tracks from the controller used to calculate the dynamic radio.
+
+        :param item: The Artist to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+        """
         return await self.tracks(
-            item_id,
-            provider_instance_id_or_domain,
+            item.item_id,
+            item.provider,
             in_library_only=False,
         )
 
index 5403c0339d2012b037125a4a484dd792052848b5..54d37657dab05b2c874e4266e947ae10c34cc184 100644 (file)
@@ -204,12 +204,16 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
 
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
-        limit: int = 25,
+        item: Audiobook,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
-        msg = "Dynamic tracks not supported for Radio MediaItem"
+        """
+        Get the list of base tracks from the controller used to calculate the dynamic radio.
+
+        :param item: The Audiobook to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+        """
+        msg = "Dynamic tracks not supported for Audiobook MediaItem"
         raise NotImplementedError(msg)
 
     async def match_provider(
index 2e44ecc99bab1f3149f9f15c91749ffb15fc5637..a60d9f7955547b1a622a68827597d9842d89a3ee 100644 (file)
@@ -736,10 +736,16 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
     @abstractmethod
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
+        item: ItemCls,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
+        """
+        Get the list of base tracks from the controller used to calculate the dynamic radio.
+
+        :param item: The MediaItem to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+            When provided, these providers will be tried first before falling back to others.
+        """
 
     @final
     async def _get_library_items_by_query(
index 0ea6f2f869be4cc45c6b41e4866262b126c38919..bb940dd7396bff01c10c467219d95add54b1bcef 100644 (file)
@@ -48,11 +48,15 @@ class GenreController(MediaControllerBase[Genre]):
 
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
-        limit: int = 25,
+        item: Genre,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller - stub implementation."""
+        """
+        Get the list of base tracks from the controller - stub implementation.
+
+        :param item: The Genre to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+        """
         raise NotImplementedError("Genre support is not yet implemented")
 
     async def match_providers(self, db_item: Genre) -> None:
index 80b27192cde4b775fc9dacb087c4176a3c3e3713..b4ec18a71750a2dc9719aa3a6092260038039546 100644 (file)
@@ -440,13 +440,18 @@ class PlaylistController(MediaControllerBase[Playlist]):
 
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
+        item: Playlist,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
+        """
+        Get the list of base tracks from the controller used to calculate the dynamic radio.
+
+        :param item: The Playlist to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+        """
         return [
             x
-            async for x in self.tracks(item_id, provider_instance_id_or_domain)
+            async for x in self.tracks(item.item_id, item.provider)
             # filter out unavailable tracks
             if x.available
         ]
index d8138ee4ed17327f8f858c6388557fd8c77167dd..7530b95894176e92a4ec12f0bfd94154a52804b7 100644 (file)
@@ -243,11 +243,15 @@ class PodcastsController(MediaControllerBase[Podcast]):
 
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
-        limit: int = 25,
+        item: Podcast,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
+        """
+        Get the list of base tracks from the controller used to calculate the dynamic radio.
+
+        :param item: The Podcast to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+        """
         msg = "Dynamic tracks not supported for Podcast MediaItem"
         raise NotImplementedError(msg)
 
index cad66626a60c2d776f91d1ce7fe1c9c0ca07b588..d5e902a8d2dee9192751e72c718de95e9d75eb5b 100644 (file)
@@ -124,11 +124,15 @@ class RadioController(MediaControllerBase[Radio]):
 
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
-        limit: int = 25,
+        item: Radio,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
+        """
+        Get the list of base tracks from the controller used to calculate the dynamic radio.
+
+        :param item: The Radio to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+        """
         msg = "Dynamic tracks not supported for Radio MediaItem"
         raise NotImplementedError(msg)
 
index 47bc97d610b6483325f8477619b22dd6a93bac21..7a9e555f5eed01924d7a0764641ae084b7bd989f 100644 (file)
@@ -6,7 +6,7 @@ import urllib.parse
 from collections.abc import Iterable
 from typing import TYPE_CHECKING, Any
 
-from music_assistant_models.enums import MediaType, ProviderFeature, ProviderType
+from music_assistant_models.enums import MediaType, ProviderFeature
 from music_assistant_models.errors import (
     InvalidDataError,
     MusicAssistantError,
@@ -305,36 +305,79 @@ class TracksController(MediaControllerBase[Track]):
         provider_instance_id_or_domain: str,
         limit: int = 25,
         allow_lookup: bool = False,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get a list of similar tracks for the given track."""
+        """
+        Get a list of similar tracks for the given track.
+
+        :param item_id: The item ID of the track.
+        :param provider_instance_id_or_domain: The provider instance ID or domain.
+        :param limit: Maximum number of similar tracks to return.
+        :param allow_lookup: Allow lookup on other providers if not found.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+            When provided, these providers will be tried first before falling back to others.
+        """
         ref_item = await self.get(item_id, provider_instance_id_or_domain)
-        for prov_mapping in ref_item.provider_mappings:
-            prov = self.mass.get_provider(prov_mapping.provider_instance)
-            if prov is None:
-                continue
-            if not isinstance(prov, MusicProvider):
-                continue
-            if ProviderFeature.SIMILAR_TRACKS not in prov.supported_features:
-                continue
-            # Grab similar tracks from the music provider
-            return await prov.get_similar_tracks(prov_track_id=prov_mapping.item_id, limit=limit)
+
+        # Sort provider mappings to prefer user's provider instances
+        def sort_key(mapping: ProviderMapping) -> tuple[int, int]:
+            # Primary sort: preferred providers first (0), then others (1)
+            preferred = (
+                0
+                if preferred_provider_instances
+                and mapping.provider_instance in preferred_provider_instances
+                else 1
+            )
+            # Secondary sort: by quality (higher is better, so negate)
+            quality = -(mapping.quality or 0)
+            return (preferred, quality)
+
+        sorted_mappings = sorted(ref_item.provider_mappings, key=sort_key)
+
+        # Try preferred providers first, then fall back to others
+        for allow_other_provider in (False, True):
+            for prov_mapping in sorted_mappings:
+                if (
+                    not allow_other_provider
+                    and preferred_provider_instances
+                    and prov_mapping.provider_instance not in preferred_provider_instances
+                ):
+                    continue
+                prov = self.mass.get_provider(prov_mapping.provider_instance)
+                if prov is None:
+                    continue
+                if not isinstance(prov, MusicProvider):
+                    continue
+                if ProviderFeature.SIMILAR_TRACKS not in prov.supported_features:
+                    continue
+                # Grab similar tracks from the music provider
+                return await prov.get_similar_tracks(
+                    prov_track_id=prov_mapping.item_id, limit=limit
+                )
+
         if not allow_lookup:
             return []
 
         # check if we have any provider that supports dynamic tracks
         # TODO: query metadata provider(s) (such as lastfm?)
         # to get similar tracks (or tracks from similar artists)
-        for prov in self.mass.get_providers(ProviderType.MUSIC):
+        music_prov: MusicProvider | None = None
+        for prov in self.mass.music.providers:
             if ProviderFeature.SIMILAR_TRACKS in prov.supported_features:
+                music_prov = prov
                 break
-        else:
+        if music_prov is None:
             msg = "No Music Provider found that supports requesting similar tracks."
             raise UnsupportedFeaturedException(msg)
 
-        if ref_item.provider == "library":
-            await self.mass.metadata.update_metadata(ref_item)
-        else:
-            await self.match_providers(ref_item)
+        if mappings := await self.match_provider(ref_item, music_prov):
+            if ref_item.provider == "library":
+                # update database with new provider mappings
+                await self.add_provider_mappings(ref_item.item_id, mappings)
+            ref_item.provider_mappings.update(mappings)
+            return await music_prov.get_similar_tracks(
+                prov_track_id=mappings[0].item_id, limit=limit
+            )
 
         return []
 
@@ -375,30 +418,30 @@ class TracksController(MediaControllerBase[Track]):
 
     async def match_provider(
         self,
-        db_track: Track,
+        base_track: Track,
         provider: MusicProvider,
         strict: bool = True,
         ref_albums: list[Album] | None = None,
     ) -> list[ProviderMapping]:
         """
-        Try to find match on (streaming) provider for the provided (database) track.
+        Try to find match on (streaming) provider for the provided track.
 
         This is used to link objects of different providers/qualities together.
         """
         if ref_albums is None:
-            ref_albums = await self.albums(db_track.item_id, db_track.provider)
-        self.logger.debug("Trying to match track %s on provider %s", db_track.name, provider.name)
+            ref_albums = await self.albums(base_track.item_id, base_track.provider)
+        self.logger.debug("Trying to match track %s on provider %s", base_track.name, provider.name)
         matches: list[ProviderMapping] = []
-        for artist in db_track.artists:
+        for artist in base_track.artists:
             if matches:
                 break
-            search_str = f"{artist.name} - {db_track.name}"
+            search_str = f"{artist.name} - {base_track.name}"
             search_result = await self.search(search_str, provider.domain)
             for search_result_item in search_result:
                 if not search_result_item.available:
                     continue
                 # do a basic compare first
-                if not compare_media_item(db_track, search_result_item, strict=False):
+                if not compare_media_item(base_track, search_result_item, strict=False):
                     continue
                 # we must fetch the full version, search results can be simplified objects
                 prov_track = await self.get_provider_item(
@@ -406,19 +449,20 @@ class TracksController(MediaControllerBase[Track]):
                     search_result_item.provider,
                     fallback=search_result_item,
                 )
-                if compare_track(db_track, prov_track, strict=strict, track_albums=ref_albums):
+                if compare_track(base_track, prov_track, strict=strict, track_albums=ref_albums):
                     matches.extend(search_result_item.provider_mappings)
 
         if not matches:
             self.logger.debug(
                 "Could not find match for Track %s on provider %s",
-                db_track.name,
+                base_track.name,
                 provider.name,
             )
         return matches
 
     async def match_providers(self, db_track: Track) -> None:
-        """Try to find matching track on all providers for the provided (database) track_id.
+        """
+        Try to find matching track on all providers for the provided (database) track_id.
 
         This is used to link objects of different providers/qualities together.
         """
@@ -447,11 +491,16 @@ class TracksController(MediaControllerBase[Track]):
 
     async def radio_mode_base_tracks(
         self,
-        item_id: str,
-        provider_instance_id_or_domain: str,
+        item: Track,
+        preferred_provider_instances: list[str] | None = None,
     ) -> list[Track]:
-        """Get the list of base tracks from the controller used to calculate the dynamic radio."""
-        return [await self.get(item_id, provider_instance_id_or_domain)]
+        """
+        Get the list of base tracks from the controller used to calculate the dynamic radio.
+
+        :param item: The Track to get base tracks for.
+        :param preferred_provider_instances: List of preferred provider instance IDs to use.
+        """
+        return [item]
 
     async def _add_library_item(self, item: Track, overwrite_existing: bool = False) -> int:
         """Add a new item record to the database."""
index a843709be79c1bdfc2356d7be3c79fd829292ec1..9499243a77a70956fc4ae305d31f1f618d163378 100644 (file)
@@ -1832,6 +1832,16 @@ class PlayerQueuesController(CoreController):
             queue.display_name,
             ", ".join([x.name for x in queue.radio_source]),
         )
+
+        # Get user's preferred provider instances for steering provider selection
+        preferred_provider_instances: list[str] | None = None
+        if (
+            queue.userid
+            and (playback_user := await self.mass.webserver.auth.get_user(queue.userid))
+            and playback_user.provider_filter
+        ):
+            preferred_provider_instances = playback_user.provider_filter
+
         available_base_tracks: list[Track] = []
         base_track_sample_size = 5
         # Some providers have very deterministic similar track algorithms when providing
@@ -1852,7 +1862,8 @@ class PlayerQueuesController(CoreController):
                     available_base_tracks += [
                         track
                         for track in await ctrl.radio_mode_base_tracks(
-                            radio_item.item_id, radio_item.provider
+                            radio_item,  # type: ignore[arg-type]
+                            preferred_provider_instances,
                         )
                         # Avoid duplicate base tracks
                         if track not in available_base_tracks
@@ -1883,6 +1894,7 @@ class PlayerQueuesController(CoreController):
                         base_track.item_id,
                         base_track.provider,
                         allow_lookup=allow_lookup,
+                        preferred_provider_instances=preferred_provider_instances,
                     )
                 except MediaNotFoundError:
                     # Some providers don't have similar tracks for all items. For example,