Handle user (music)provider filter
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 28 Nov 2025 23:33:31 +0000 (00:33 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 28 Nov 2025 23:33:31 +0000 (00:33 +0100)
music_assistant/controllers/config.py
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/podcasts.py
music_assistant/controllers/media/tracks.py
music_assistant/mass.py

index 6eae6b1672c2e185b13e0d008c9e6d9956ac5d20..042d698ab34557c33e0b290240ee3c811d59be0a 100644 (file)
@@ -65,7 +65,6 @@ from music_assistant.constants import (
     DEFAULT_PROVIDER_CONFIG_ENTRIES,
     ENCRYPT_SUFFIX,
 )
-from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, async_json_dumps, async_json_loads
 from music_assistant.helpers.util import load_provider_module
@@ -193,7 +192,7 @@ class ConfigController:
 
         self.save()
 
-    @api_command("config/providers")
+    @api_command("config/providers", required_role="admin")
     async def get_provider_configs(
         self,
         provider_type: ProviderType | None = None,
@@ -201,9 +200,6 @@ class ConfigController:
         include_values: bool = False,
     ) -> list[ProviderConfig]:
         """Return all known provider configurations, optionally filtered by ProviderType."""
-        user = get_current_user()
-        user_provider_filter = user.provider_filter if user else None
-
         raw_values = self.get(CONF_PROVIDERS, {})
         prov_entries = {x.domain for x in self.mass.get_provider_manifests()}
         return [
@@ -215,11 +211,9 @@ class ConfigController:
             and (provider_domain is None or prov_conf["domain"] == provider_domain)
             # guard for deleted providers
             and prov_conf["domain"] in prov_entries
-            # filter by user's provider_filter if set
-            and (not user_provider_filter or prov_conf["instance_id"] in user_provider_filter)
         ]
 
-    @api_command("config/providers/get")
+    @api_command("config/providers/get", required_role="admin")
     async def get_provider_config(self, instance_id: str) -> ProviderConfig:
         """Return configuration for a single provider."""
         if raw_conf := self.get(f"{CONF_PROVIDERS}/{instance_id}", {}):
index 2674d4b603c3037a52aa393f92838254da841f36..a773fd4b815c1ab87bb17cce994721f56b1c210b 100644 (file)
@@ -108,12 +108,23 @@ class AlbumsController(MediaControllerBase[Album]):
         limit: int = 500,
         offset: int = 0,
         order_by: str = "sort_name",
-        provider: str | None = None,
+        provider: str | list[str] | None = None,
         extra_query: str | None = None,
         extra_query_params: dict[str, Any] | None = None,
         album_types: list[AlbumType] | None = None,
     ) -> list[Album]:
-        """Get in-database albums."""
+        """Get in-database albums.
+
+        :param favorite: Filter by favorite status.
+        :param search: Filter by search query.
+        :param limit: Maximum number of items to return.
+        :param offset: Number of items to skip.
+        :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
+        :param provider: Filter by provider instance ID or domain (single string or list).
+        :param extra_query: Additional SQL query string.
+        :param extra_query_params: Additional query parameters.
+        :param album_types: Filter by album types.
+        """
         extra_query_params = extra_query_params or {}
         extra_query_parts: list[str] = [extra_query] if extra_query else []
         extra_join_parts: list[str] = []
index b9ceeeeb3a6c0c5b44251a98229d4e55dc6c4376..53c68df0da1d1f1621b9c38b2a6aac246de3daf6 100644 (file)
@@ -70,12 +70,23 @@ class ArtistsController(MediaControllerBase[Artist]):
         limit: int = 500,
         offset: int = 0,
         order_by: str = "sort_name",
-        provider: str | None = None,
+        provider: str | list[str] | None = None,
         extra_query: str | None = None,
         extra_query_params: dict[str, Any] | None = None,
         album_artists_only: bool = False,
     ) -> list[Artist]:
-        """Get in-database (album) artists."""
+        """Get in-database (album) artists.
+
+        :param favorite: Filter by favorite status.
+        :param search: Filter by search query.
+        :param limit: Maximum number of items to return.
+        :param offset: Number of items to skip.
+        :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
+        :param provider: Filter by provider instance ID or domain (single string or list).
+        :param extra_query: Additional SQL query string.
+        :param extra_query_params: Additional query parameters.
+        :param album_artists_only: Only return artists that have albums.
+        """
         extra_query_params = extra_query_params or {}
         extra_query_parts: list[str] = [extra_query] if extra_query else []
         if album_artists_only:
index 51ccda5da2030c0481ca928d44319b5d470ca144..2ff44baf2b86478aba93e29ccac906b5d82616dd 100644 (file)
@@ -66,11 +66,21 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
         limit: int = 500,
         offset: int = 0,
         order_by: str = "sort_name",
-        provider: str | None = None,
+        provider: str | list[str] | None = None,
         extra_query: str | None = None,
         extra_query_params: dict[str, Any] | None = None,
     ) -> list[Audiobook]:
-        """Get in-database audiobooks."""
+        """Get in-database audiobooks.
+
+        :param favorite: Filter by favorite status.
+        :param search: Filter by search query.
+        :param limit: Maximum number of items to return.
+        :param offset: Number of items to skip.
+        :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
+        :param provider: Filter by provider instance ID or domain (single string or list).
+        :param extra_query: Additional SQL query string.
+        :param extra_query_params: Additional query parameters.
+        """
         extra_query_params = extra_query_params or {}
         extra_query_parts: list[str] = [extra_query] if extra_query else []
         result = await self._get_library_items_by_query(
index ffe6fb8b55b2fa9aefd2ce78890dee98ec2ac002..ef6c1ef2e6daaca8828bbb920c247cc8ee7bb2ce 100644 (file)
@@ -14,6 +14,7 @@ from music_assistant_models.errors import MediaNotFoundError, ProviderUnavailabl
 from music_assistant_models.media_items import ItemMapping, MediaItemType, ProviderMapping, Track
 
 from music_assistant.constants import DB_TABLE_PLAYLOG, DB_TABLE_PROVIDER_MAPPINGS, MASS_LOGGER_NAME
+from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
 from music_assistant.helpers.compare import compare_media_item, create_safe_string
 from music_assistant.helpers.json import json_loads, serialize_to_json
 from music_assistant.helpers.util import guard_single_request
@@ -230,7 +231,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         limit: int = 500,
         offset: int = 0,
         order_by: str = "sort_name",
-        provider: str | None = None,
+        provider: str | list[str] | None = None,
         extra_query: str | None = None,
         extra_query_params: dict[str, Any] | None = None,
     ) -> list[ItemCls]:
@@ -251,7 +252,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         favorite: bool | None = None,
         search: str | None = None,
         order_by: str = "sort_name",
-        provider: str | None = None,
+        provider: str | list[str] | None = None,
         extra_query: str | None = None,
         extra_query_params: dict[str, Any] | None = None,
     ) -> AsyncGenerator[ItemCls, None]:
@@ -704,7 +705,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         limit: int = 500,
         offset: int = 0,
         order_by: str | None = None,
-        provider: str | None = None,
+        provider: str | list[str] | None = None,
         extra_query_parts: list[str] | None = None,
         extra_query_params: dict[str, Any] | None = None,
         extra_join_parts: list[str] | None = None,
@@ -753,7 +754,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         join_parts: list[str],
         favorite: bool | None,
         search: str | None,
-        provider: str | None,
+        provider: str | list[str] | None,
         limit: int,
     ) -> None:
         """Build a fast random subquery with all filters applied."""
@@ -789,9 +790,14 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         join_parts: list[str],
         favorite: bool | None,
         search: str | None,
-        provider: str | None,
+        provider: str | list[str] | None,
     ) -> None:
-        """Apply search, favorite, and provider filters."""
+        """Apply search, favorite, and provider filters.
+
+        If the current user has a provider_filter set, it will be applied automatically.
+        If an explicit provider filter is provided, it will be validated against the user's
+        allowed providers.
+        """
         # handle search
         if search:
             query_parts.append(f"{self.db_table}.search_name LIKE :search")
@@ -801,14 +807,47 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             query_parts.append(f"{self.db_table}.favorite = :favorite")
             query_params["favorite"] = favorite
 
-        # handle provider filter
-        if provider:
+        # Apply user provider filter
+        user = get_current_user()
+        user_provider_filter = user.provider_filter if user and user.provider_filter else None
+
+        # Determine final provider filter
+        final_provider_filter: list[str] | None = None
+
+        if user_provider_filter:
+            # User has a provider filter set
+            if provider:
+                # Explicit provider filter provided - validate against user's allowed providers
+                requested_providers = [provider] if isinstance(provider, str) else provider
+                # Only include providers that are in both the user's filter and the requested list
+                final_provider_filter = [
+                    p for p in requested_providers if p in user_provider_filter
+                ]
+                if not final_provider_filter:
+                    # No overlap - user requested providers they don't have access to
+                    # Return empty results by adding impossible condition
+                    query_parts.append("1 = 0")
+                    return
+            else:
+                # No explicit filter - use user's provider filter
+                final_provider_filter = user_provider_filter
+        elif provider:
+            # No user filter, but explicit provider filter provided
+            final_provider_filter = [provider] if isinstance(provider, str) else provider
+
+        # Apply the final provider filter
+        if final_provider_filter:
+            provider_conditions = []
+            for prov in final_provider_filter:
+                provider_conditions.append(
+                    f"provider_mappings.provider_instance = '{prov}' "
+                    f"OR provider_mappings.provider_domain = '{prov}'"
+                )
             join_parts.append(
                 f"JOIN provider_mappings ON provider_mappings.item_id = {self.db_table}.item_id "
                 f"AND provider_mappings.media_type = '{self.media_type.value}' "
                 "AND provider_mappings.in_library = 1 "
-                f"AND (provider_mappings.provider_instance = '{provider}' "
-                f"OR provider_mappings.provider_domain = '{provider}')"
+                f"AND ({' OR '.join(provider_conditions)})"
             )
 
     def _build_final_query(
index eaba273e61bc7355ef47c7ba5909be9122ae2515..46a5d7830cdcf3e47721403d242135a3911aa73c 100644 (file)
@@ -49,11 +49,21 @@ class PodcastsController(MediaControllerBase[Podcast]):
         limit: int = 500,
         offset: int = 0,
         order_by: str = "sort_name",
-        provider: str | None = None,
+        provider: str | list[str] | None = None,
         extra_query: str | None = None,
         extra_query_params: dict[str, Any] | None = None,
     ) -> list[Podcast]:
-        """Get in-database podcasts."""
+        """Get in-database podcasts.
+
+        :param favorite: Filter by favorite status.
+        :param search: Filter by search query.
+        :param limit: Maximum number of items to return.
+        :param offset: Number of items to skip.
+        :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
+        :param provider: Filter by provider instance ID or domain (single string or list).
+        :param extra_query: Additional SQL query string.
+        :param extra_query_params: Additional query parameters.
+        """
         extra_query_params = extra_query_params or {}
         extra_query_parts: list[str] = [extra_query] if extra_query else []
         result = await self._get_library_items_by_query(
index a720a14c885e699e60c49db8966a218bc9450530..e1c953a380d808b5d04365266bb5e5fb1448583c 100644 (file)
@@ -164,11 +164,21 @@ class TracksController(MediaControllerBase[Track]):
         limit: int = 500,
         offset: int = 0,
         order_by: str = "sort_name",
-        provider: str | None = None,
+        provider: str | list[str] | None = None,
         extra_query: str | None = None,
         extra_query_params: dict[str, Any] | None = None,
     ) -> list[Track]:
-        """Get in-database tracks."""
+        """Get in-database tracks.
+
+        :param favorite: Filter by favorite status.
+        :param search: Filter by search query.
+        :param limit: Maximum number of items to return.
+        :param offset: Number of items to skip.
+        :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
+        :param provider: Filter by provider instance ID or domain (single string or list).
+        :param extra_query: Additional SQL query string.
+        :param extra_query_params: Additional query parameters.
+        """
         extra_query_params = extra_query_params or {}
         extra_query_parts: list[str] = [extra_query] if extra_query else []
         extra_join_parts: list[str] = []
index 68d5dcd06fda68e995342a18d988be009849fc91..3b04bc89d09b976eb23ca54b23dcbedd74419833 100644 (file)
@@ -284,7 +284,12 @@ class MusicAssistant:
             x
             for x in self._providers.values()
             if (provider_type is None or provider_type == x.type)
-            and (not user_provider_filter or x.instance_id in user_provider_filter)
+            # handle optional user (music) provider filter
+            and (
+                not user_provider_filter
+                or x.instance_id in user_provider_filter
+                or x.type != ProviderType.MUSIC
+            )
         ]
 
     @api_command("logging/get")