Fixes for Browse and Search features (#546)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 18 Mar 2023 22:10:28 +0000 (23:10 +0100)
committerGitHub <noreply@github.com>
Sat, 18 Mar 2023 22:10:28 +0000 (23:10 +0100)
Some backend changes to complement the Search and Browse features in the
frontend

music_assistant/common/helpers/uri.py
music_assistant/common/models/media_items.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/music.py
music_assistant/server/models/music_provider.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/json_rpc/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/ytmusic/__init__.py

index 89bef47af1446aac2b46b105febe715f46d5abeb..80ee643315e03b541ba4aa763d89ad0227ad1925 100644 (file)
@@ -9,44 +9,44 @@ from music_assistant.common.models.errors import MusicAssistantError
 def parse_uri(uri: str) -> tuple[MediaType, str, str]:
     """Try to parse URI to Mass identifiers.
 
-    Returns Tuple: MediaType, provider_domain, item_id
+    Returns Tuple: MediaType, provider_domain_or_instance_id, item_id
     """
     try:
         if uri.startswith("https://open."):
             # public share URL (e.g. Spotify or Qobuz, not sure about others)
             # https://open.spotify.com/playlist/5lH9NjOeJvctAO92ZrKQNB?si=04a63c8234ac413e
-            provider_domain = uri.split(".")[1]
+            provider_domain_or_instance_id = uri.split(".")[1]
             media_type_str = uri.split("/")[3]
             media_type = MediaType(media_type_str)
             item_id = uri.split("/")[4].split("?")[0]
         elif uri.startswith("http://") or uri.startswith("https://"):
             # Translate a plain URL to the URL provider
-            provider_domain = "url"
+            provider_domain_or_instance_id = "url"
             media_type = MediaType.UNKNOWN
             item_id = uri
         elif "://" in uri:
             # music assistant-style uri
             # provider://media_type/item_id
-            provider_domain = uri.split("://")[0]
+            provider_domain_or_instance_id = uri.split("://")[0]
             media_type_str = uri.split("/")[2]
             media_type = MediaType(media_type_str)
             item_id = uri.split(f"{media_type_str}/")[1]
         elif ":" in uri:
             # spotify new-style uri
-            provider_domain, media_type_str, item_id = uri.split(":")
+            provider_domain_or_instance_id, media_type_str, item_id = uri.split(":")
             media_type = MediaType(media_type_str)
         elif os.path.isfile(uri):
             # Translate a local file (which is not from file provider) to the URL provider
-            provider_domain = "url"
+            provider_domain_or_instance_id = "url"
             media_type = MediaType.TRACK
             item_id = uri
         else:
             raise KeyError
     except (TypeError, AttributeError, ValueError, KeyError) as err:
         raise MusicAssistantError(f"Not a valid Music Assistant uri: {uri}") from err
-    return (media_type, provider_domain, item_id)
+    return (media_type, provider_domain_or_instance_id, item_id)
 
 
-def create_uri(media_type: MediaType, provider_domain: str, item_id: str) -> str:
+def create_uri(media_type: MediaType, provider_domain_or_instance_id: str, item_id: str) -> str:
     """Create Music Assistant URI from MediaItem values."""
-    return f"{provider_domain}://{media_type.value}/{item_id}"
+    return f"{provider_domain_or_instance_id}://{media_type.value}/{item_id}"
index 87fd46aef50f64d7a176ef9489262fb422f867d1..c4a01926a3d4d7dcfe9e96cb80706cb11194afcd 100755 (executable)
@@ -418,6 +418,17 @@ class PagedItems(DataClassDictMixin):
     total: int | None = None
 
 
+@dataclass
+class SearchResults(DataClassDictMixin):
+    """Model for results from a search query."""
+
+    artists: list[Artist | ItemMapping] = field(default_factory=list)
+    albums: list[Album | ItemMapping] = field(default_factory=list)
+    tracks: list[Track | ItemMapping] = field(default_factory=list)
+    playlists: list[Playlist | ItemMapping] = field(default_factory=list)
+    radio: list[Radio | ItemMapping] = field(default_factory=list)
+
+
 def media_from_dict(media_item: dict) -> MediaItemType:
     """Return MediaItem from dict."""
     if media_item["media_type"] == "artist":
index 6464fd2773a2f0eafc8dd897f6e0a1222e8233c1..ff005a5739b71e384b5945b45f83cf2cbeda058b 100644 (file)
@@ -208,11 +208,21 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         if cache := await self.mass.cache.get(cache_key):
             return [media_from_dict(x) for x in cache]
         # no items in cache - get listing from provider
-        items = await prov.search(
+        searchresult = await prov.search(
             search_query,
             [self.media_type],
             limit,
         )
+        if self.media_type == MediaType.ARTIST:
+            items = searchresult.artists
+        elif self.media_type == MediaType.ALBUM:
+            items = searchresult.albums
+        elif self.media_type == MediaType.TRACK:
+            items = searchresult.tracks
+        elif self.media_type == MediaType.PLAYLIST:
+            items = searchresult.playlists
+        else:
+            items = searchresult.radio
         # store (serializable items) in cache
         if not prov.domain.startswith("filesystem"):  # do not cache filesystem results
             self.mass.create_task(
index 7f9b9fcd3faff4dd9a41181deea965ec51124313..9ecdcde0d57c7ee5c2f853ba3af5e4c44ed7e95f 100644 (file)
@@ -147,7 +147,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 # the file provider can handle uri's from all providers so simply add the uri
                 track_id_to_add = track_version.url or create_uri(
                     MediaType.TRACK,
-                    track_version.provider_domain,
+                    track_version.provider_instance,
                     track_version.item_id,
                 )
                 break
index ca112e4243914d3c53bb178f7fc71cc5ca42eb4b..daa31bc8cb9ccb533658fd252ee39599c38e9359 100755 (executable)
@@ -2,9 +2,9 @@
 from __future__ import annotations
 
 import asyncio
-import itertools
 import logging
 import statistics
+from itertools import zip_longest
 from typing import TYPE_CHECKING
 
 from music_assistant.common.helpers.datetime import utc_timestamp
@@ -15,7 +15,7 @@ from music_assistant.common.models.media_items import (
     BrowseFolder,
     MediaItem,
     MediaItemType,
-    media_from_dict,
+    SearchResults,
 )
 from music_assistant.common.models.provider import SyncTask
 from music_assistant.constants import (
@@ -111,7 +111,7 @@ class MusicController:
         search_query: str,
         media_types: list[MediaType] = MediaType.ALL,
         limit: int = 10,
-    ) -> list[MediaItemType]:
+    ) -> SearchResults:
         """Perform global search for media items on all providers.
 
         :param search_query: Search query.
@@ -120,21 +120,50 @@ class MusicController:
         """
         # include results from all music providers
         provider_instances = (item.instance_id for item in self.providers)
-        # TODO: sort by name and filter out duplicates ?
-        return list(
-            itertools.chain.from_iterable(
-                await asyncio.gather(
-                    *[
-                        self.search_provider(
-                            search_query,
-                            media_types,
-                            provider_instance=provider_instance,
-                            limit=limit,
-                        )
-                        for provider_instance in provider_instances
-                    ]
+        results_per_provider: list[SearchResults] = await asyncio.gather(
+            *[
+                self.search_provider(
+                    search_query,
+                    media_types,
+                    provider_instance=provider_instance,
+                    limit=limit,
                 )
-            )
+                for provider_instance in provider_instances
+            ]
+        )
+        # return result from all providers while keeping index
+        # so the result is sorted as each provider delivered
+        return SearchResults(
+            artists=[
+                item
+                for sublist in zip_longest(*[x.artists for x in results_per_provider])
+                for item in sublist
+                if item is not None
+            ],
+            albums=[
+                item
+                for sublist in zip_longest(*[x.albums for x in results_per_provider])
+                for item in sublist
+                if item is not None
+            ],
+            tracks=[
+                item
+                for sublist in zip_longest(*[x.tracks for x in results_per_provider])
+                for item in sublist
+                if item is not None
+            ],
+            playlists=[
+                item
+                for sublist in zip_longest(*[x.playlists for x in results_per_provider])
+                for item in sublist
+                if item is not None
+            ],
+            radio=[
+                item
+                for sublist in zip_longest(*[x.radio for x in results_per_provider])
+                for item in sublist
+                if item is not None
+            ],
         )
 
     async def search_provider(
@@ -144,7 +173,7 @@ class MusicController:
         provider_domain: str | None = None,
         provider_instance: str | None = None,
         limit: int = 10,
-    ) -> list[MediaItemType]:
+    ) -> SearchResults:
         """Perform search on given provider.
 
         :param search_query: Search query
@@ -157,30 +186,31 @@ class MusicController:
         try:
             prov = self.mass.get_provider(provider_instance or provider_domain)
         except ProviderUnavailableError:
-            return []
+            return SearchResults()
         if ProviderFeature.SEARCH not in prov.supported_features:
-            return []
+            return SearchResults()
 
         # create safe search string
         search_query = search_query.replace("/", " ").replace("'", "")
 
         # prefer cache items (if any)
-        cache_key = f"{prov.instance_id}.search.{search_query}.{limit}"
+        media_types_str = ",".join(media_types)
+        cache_key = f"{prov.instance_id}.search.{search_query}.{limit}.{media_types_str}"
         cache_key += "".join(x for x in media_types)
 
         if cache := await self.mass.cache.get(cache_key):
-            return [media_from_dict(x) for x in cache]
+            return SearchResults.from_dict(cache)
         # no items in cache - get listing from provider
-        items = await prov.search(
+        result = await prov.search(
             search_query,
             media_types,
             limit,
         )
         # store (serializable items) in cache
         self.mass.create_task(
-            self.mass.cache.set(cache_key, [x.to_dict() for x in items], expiration=86400 * 7)
+            self.mass.cache.set(cache_key, result.to_dict(), expiration=86400 * 7)
         )
-        return items
+        return result
 
     @api_command("music/browse")
     async def browse(self, path: str | None = None) -> BrowseFolder:
@@ -198,6 +228,7 @@ class MusicController:
                         item_id="root",
                         provider=prov.domain,
                         path=f"{prov.instance_id}://",
+                        uri=f"{prov.instance_id}://",
                         name=prov.name,
                     )
                     for prov in self.providers
@@ -214,11 +245,21 @@ class MusicController:
         self, uri: str, force_refresh: bool = False, lazy: bool = True
     ) -> MediaItemType:
         """Fetch MediaItem by uri."""
-        media_type, provider_domain, item_id = parse_uri(uri)
+        media_type, provider_domain_or_instance_id, item_id = parse_uri(uri)
+        for prov in self.providers:
+            if prov.instance_id == provider_domain_or_instance_id:
+                provider_instance = prov.instance_id
+                provider_domain = None
+                break
+        else:
+            provider_instance = None
+            provider_domain = provider_domain_or_instance_id
+
         return await self.get_item(
             media_type=media_type,
             item_id=item_id,
             provider_domain=provider_domain,
+            provider_instance=provider_instance,
             force_refresh=force_refresh,
             lazy=lazy,
         )
@@ -349,7 +390,18 @@ class MusicController:
         except MusicAssistantError:
             pass
 
-        for item in await self.search(media_item.name, [media_item.media_type], 20):
+        searchresult = await self.search(media_item.name, [media_item.media_type], 20)
+        if media_item.media_type == MediaType.ARTIST:
+            result = searchresult.artists
+        elif media_item.media_type == MediaType.ALBUM:
+            result = searchresult.albums
+        elif media_item.media_type == MediaType.TRACK:
+            result = searchresult.tracks
+        elif media_item.media_type == MediaType.PLAYLIST:
+            result = searchresult.playlists
+        else:
+            result = searchresult.radio
+        for item in result:
             if item.available:
                 await self.get_item(item.media_type, item.item_id, item.provider, lazy=False)
         return None
index e735df41be6630844ae434da274c34023a2169a2..9bacb694a7af938315de7efa3981bfd6778a4e86 100644 (file)
@@ -11,6 +11,7 @@ from music_assistant.common.models.media_items import (
     MediaItemType,
     Playlist,
     Radio,
+    SearchResults,
     StreamDetails,
     Track,
 )
@@ -31,7 +32,7 @@ class MusicProvider(Provider):
         search_query: str,
         media_types: list[MediaType] | None = None,
         limit: int = 5,
-    ) -> list[MediaItemType]:
+    ) -> SearchResults:
         """Perform search on musicprovider.
 
         :param search_query: Search query.
@@ -40,7 +41,7 @@ class MusicProvider(Provider):
         """
         if ProviderFeature.SEARCH in self.supported_features:
             raise NotImplementedError
-        return []
+        return SearchResults()
 
     async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
         """Retrieve library artists from the provider."""
index 4b8e74a4718ddedc8a7b027dc7762c6c9121b7df..beea8a3c0e4fd1f7b32c964bae6babc301573a68 100644 (file)
@@ -22,11 +22,11 @@ from music_assistant.common.models.media_items import (
     ContentType,
     ImageType,
     MediaItemImage,
-    MediaItemType,
     MediaType,
     Playlist,
     ProviderMapping,
     Radio,
+    SearchResults,
     StreamDetails,
     Track,
 )
@@ -146,9 +146,9 @@ class FileSystemProviderBase(MusicProvider):
 
     async def search(
         self, search_query: str, media_types=list[MediaType] | None, limit: int = 5  # noqa: ARG002
-    ) -> list[MediaItemType]:
+    ) -> SearchResults:
         """Perform search on this file based musicprovider."""
-        result: list[MediaItemType] = []
+        result = SearchResults()
         # searching the filesystem is slow and unreliable,
         # instead we make some (slow) freaking queries to the db ;-)
         params = {
@@ -158,20 +158,16 @@ class FileSystemProviderBase(MusicProvider):
         # ruff: noqa: E501
         if media_types is None or MediaType.TRACK in media_types:
             query = "SELECT * FROM tracks WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
-            tracks = await self.mass.music.tracks.get_db_items_by_query(query, params)
-            result += tracks
+            result.tracks = await self.mass.music.tracks.get_db_items_by_query(query, params)
         if media_types is None or MediaType.ALBUM in media_types:
             query = "SELECT * FROM albums WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
-            albums = await self.mass.music.albums.get_db_items_by_query(query, params)
-            result += albums
+            result.albums = await self.mass.music.albums.get_db_items_by_query(query, params)
         if media_types is None or MediaType.ARTIST in media_types:
             query = "SELECT * FROM artists WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
-            artists = await self.mass.music.artists.get_db_items_by_query(query, params)
-            result += artists
+            result.artists = await self.mass.music.artists.get_db_items_by_query(query, params)
         if media_types is None or MediaType.PLAYLIST in media_types:
             query = "SELECT * FROM playlists WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
-            playlists = await self.mass.music.playlists.get_db_items_by_query(query, params)
-            result += playlists
+            result.playlists = await self.mass.music.playlists.get_db_items_by_query(query, params)
         return result
 
     async def browse(self, path: str) -> BrowseFolder:
index a37712454005227412900b9709e5ef1d6c62fd76..e74072fb88d883ea5cd5ab52fa931ab372c8bb30 100644 (file)
@@ -203,6 +203,20 @@ class JSONRPCApi(PluginProvider):
         else:
             self.mass.create_task(self.mass.players.queues.seek, number)
 
+    def _handle_power(self, player_id: str, value: str | int) -> int | None:
+        """Handle player `time` command."""
+        # <playerid> power <0|1|?|>
+        # The "power" command turns the player on or off.
+        # Use 0 to turn off, 1 to turn on, ? to query and
+        # no parameter to toggle the power state of the player.
+        player = self.mass.players.get(player_id)
+        assert player is not None
+
+        if value == "?":
+            return int(player.powered)
+
+        self.mass.create_task(self.mass.players.cmd_power, player_id, bool(value))
+
     def _handle_playlist(
         self,
         player_id: str,
index aae4987a5729d76f733b86f08b9cccca77035d04..f56c0d5cb91136afb7620be057dabe44e95d595f 100644 (file)
@@ -20,10 +20,10 @@ from music_assistant.common.models.media_items import (
     ContentType,
     ImageType,
     MediaItemImage,
-    MediaItemType,
     MediaType,
     Playlist,
     ProviderMapping,
+    SearchResults,
     StreamDetails,
     Track,
 )
@@ -72,14 +72,14 @@ class QobuzProvider(MusicProvider):
 
     async def search(
         self, search_query: str, media_types=list[MediaType] | None, limit: int = 5
-    ) -> list[MediaItemType]:
+    ) -> SearchResults:
         """Perform search on musicprovider.
 
         :param search_query: Search query.
         :param media_types: A list of media_types to include. All types if None.
         :param limit: Number of items to return in the search (per type).
         """
-        result = []
+        result = SearchResults()
         params = {"query": search_query, "limit": limit}
         if len(media_types) == 1:
             # qobuz does not support multiple searchtypes, falls back to all if no type given
@@ -93,25 +93,25 @@ class QobuzProvider(MusicProvider):
                 params["type"] = "playlists"
         if searchresult := await self._get_data("catalog/search", **params):
             if "artists" in searchresult:
-                result += [
+                result.artists += [
                     await self._parse_artist(item)
                     for item in searchresult["artists"]["items"]
                     if (item and item["id"])
                 ]
             if "albums" in searchresult:
-                result += [
+                result.albums += [
                     await self._parse_album(item)
                     for item in searchresult["albums"]["items"]
                     if (item and item["id"])
                 ]
             if "tracks" in searchresult:
-                result += [
+                result.tracks += [
                     await self._parse_track(item)
                     for item in searchresult["tracks"]["items"]
                     if (item and item["id"])
                 ]
             if "playlists" in searchresult:
-                result += [
+                result.playlists += [
                     await self._parse_playlist(item)
                     for item in searchresult["playlists"]["items"]
                     if (item and item["id"])
index 9a918682c92d057ab0148eb8a1d0fd58b9e8a1f8..63805e35fc43c416a7e3b9dd5b86106724da5fe8 100644 (file)
@@ -23,10 +23,10 @@ from music_assistant.common.models.media_items import (
     ContentType,
     ImageType,
     MediaItemImage,
-    MediaItemType,
     MediaType,
     Playlist,
     ProviderMapping,
+    SearchResults,
     StreamDetails,
     Track,
 )
@@ -79,14 +79,14 @@ class SpotifyProvider(MusicProvider):
 
     async def search(
         self, search_query: str, media_types=list[MediaType] | None, limit: int = 5
-    ) -> list[MediaItemType]:
+    ) -> SearchResults:
         """Perform search on musicprovider.
 
         :param search_query: Search query.
         :param media_types: A list of media_types to include. All types if None.
         :param limit: Number of items to return in the search (per type).
         """
-        result = []
+        result = SearchResults()
         searchtypes = []
         if MediaType.ARTIST in media_types:
             searchtypes.append("artist")
@@ -102,25 +102,25 @@ class SpotifyProvider(MusicProvider):
             "search", q=search_query, type=searchtype, limit=limit
         ):
             if "artists" in searchresult:
-                result += [
+                result.artists += [
                     await self._parse_artist(item)
                     for item in searchresult["artists"]["items"]
                     if (item and item["id"])
                 ]
             if "albums" in searchresult:
-                result += [
+                result.albums += [
                     await self._parse_album(item)
                     for item in searchresult["albums"]["items"]
                     if (item and item["id"])
                 ]
             if "tracks" in searchresult:
-                result += [
+                result.tracks += [
                     await self._parse_track(item)
                     for item in searchresult["tracks"]["items"]
                     if (item and item["id"])
                 ]
             if "playlists" in searchresult:
-                result += [
+                result.playlists += [
                     await self._parse_playlist(item)
                     for item in searchresult["playlists"]["items"]
                     if (item and item["id"])
index 1dc9a408b7524a9282aa7c4672c2da9600cbad85..7b7f205423473c9e7af4eaf070a63b38eee80f82 100644 (file)
@@ -23,10 +23,10 @@ from music_assistant.common.models.media_items import (
     ContentType,
     ImageType,
     MediaItemImage,
-    MediaItemType,
     MediaType,
     Playlist,
     ProviderMapping,
+    SearchResults,
     StreamDetails,
     Track,
 )
@@ -97,7 +97,7 @@ class YoutubeMusicProvider(MusicProvider):
 
     async def search(
         self, search_query: str, media_types=list[MediaType] | None, limit: int = 5
-    ) -> list[MediaItemType]:
+    ) -> SearchResults:
         """Perform search on musicprovider.
 
         :param search_query: Search query.
@@ -116,17 +116,17 @@ class YoutubeMusicProvider(MusicProvider):
             if media_types[0] == MediaType.PLAYLIST:
                 ytm_filter = "playlists"
         results = await search(query=search_query, ytm_filter=ytm_filter, limit=limit)
-        parsed_results = []
+        parsed_results = SearchResults()
         for result in results:
             try:
                 if result["resultType"] == "artist":
-                    parsed_results.append(await self._parse_artist(result))
+                    parsed_results.artists.append(await self._parse_artist(result))
                 elif result["resultType"] == "album":
-                    parsed_results.append(await self._parse_album(result))
+                    parsed_results.albums.append(await self._parse_album(result))
                 elif result["resultType"] == "playlist":
-                    parsed_results.append(await self._parse_playlist(result))
+                    parsed_results.playlists.append(await self._parse_playlist(result))
                 elif result["resultType"] == "song" and (track := await self._parse_track(result)):
-                    parsed_results.append(track)
+                    parsed_results.tracks.append(track)
             except InvalidDataError:
                 pass  # ignore invalid item
         return parsed_results