Add support for using streamer provider publicly shareable URLs in search query ...
authorsprocket-9 <sprocketnumber9@gmail.com>
Thu, 11 Apr 2024 14:49:35 +0000 (15:49 +0100)
committerGitHub <noreply@github.com>
Thu, 11 Apr 2024 14:49:35 +0000 (16:49 +0200)
music_assistant/common/helpers/uri.py
music_assistant/common/models/errors.py
music_assistant/constants.py
music_assistant/server/controllers/music.py

index 11a3997cb8329d5586cfc47af39baa847ce529b7..5ed68e9c1b8c11d424305d9bc445300f99d0a02f 100644 (file)
@@ -1,12 +1,28 @@
 """Helpers for creating/parsing URI's."""
 
 import os
+import re
 
 from music_assistant.common.models.enums import MediaType
-from music_assistant.common.models.errors import MusicAssistantError
+from music_assistant.common.models.errors import InvalidProviderID, InvalidProviderURI
 
+base62_length22_id_pattern = re.compile(r"^[a-zA-Z0-9]{22}$")
 
-def parse_uri(uri: str) -> tuple[MediaType, str, str]:
+
+def valid_base62_length22(item_id) -> bool:
+    """Validate Spotify style ID."""
+    return bool(base62_length22_id_pattern.match(item_id))
+
+
+def valid_id(provider: str, item_id: str) -> bool:
+    """Validate Provider ID."""
+    if provider == "spotify":
+        return valid_base62_length22(item_id)
+    else:
+        return True
+
+
+def parse_uri(uri: str, validate_id: bool = False) -> tuple[MediaType, str, str]:
     """Try to parse URI to Mass identifiers.
 
     Returns Tuple: MediaType, provider_instance_id_or_domain, item_id
@@ -44,7 +60,10 @@ def parse_uri(uri: str) -> tuple[MediaType, str, str]:
             raise KeyError
     except (TypeError, AttributeError, ValueError, KeyError) as err:
         msg = f"Not a valid Music Assistant uri: {uri}"
-        raise MusicAssistantError(msg) from err
+        raise InvalidProviderURI(msg) from err
+    if validate_id and not valid_id(provider_instance_id_or_domain, item_id):
+        msg = f"Invalid {provider_instance_id_or_domain} ID: {item_id} found in URI: {uri}"
+        raise InvalidProviderID(msg)
     return (media_type, provider_instance_id_or_domain, item_id)
 
 
index 2c864e41f452e7a1a69703c93409ffd72a14ae15..ca8f3bfe753e4e634cdae4cfe31e748f6634d1eb 100644 (file)
@@ -92,3 +92,15 @@ class UnplayableMediaError(MusicAssistantError):
     """Error thrown when a MediaItem cannot be played properly."""
 
     error_code = 13
+
+
+class InvalidProviderURI(MusicAssistantError):
+    """Error thrown when a provider URI does not match a known format."""
+
+    error_code = 14
+
+
+class InvalidProviderID(MusicAssistantError):
+    """Error thrown when a provider media item identifier does not match a known format."""
+
+    error_code = 15
index 861d558e93c8f66be438e3532ffbee194ae86764..fde91282f10720743e4d4a40d4ae93737589202d 100644 (file)
@@ -97,3 +97,4 @@ CONFIGURABLE_CORE_CONTROLLERS = (
 )
 SYNCGROUP_PREFIX: Final[str] = "syncgroup_"
 VERBOSE_LOG_LEVEL: Final[int] = 5
+PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz")
index d5d2ac8f37463ab56b93bdfd4962350faa0c81af..49c8ff589271db7a69e058c5a0f6f7f765b7e075 100644 (file)
@@ -20,7 +20,12 @@ from music_assistant.common.models.enums import (
     ProviderFeature,
     ProviderType,
 )
-from music_assistant.common.models.errors import MediaNotFoundError, MusicAssistantError
+from music_assistant.common.models.errors import (
+    InvalidProviderID,
+    InvalidProviderURI,
+    MediaNotFoundError,
+    MusicAssistantError,
+)
 from music_assistant.common.models.media_items import BrowseFolder, MediaItemType, SearchResults
 from music_assistant.common.models.provider import SyncTask
 from music_assistant.common.models.streamdetails import LoudnessMeasurement
@@ -36,6 +41,7 @@ from music_assistant.constants import (
     DB_TABLE_SETTINGS,
     DB_TABLE_TRACK_LOUDNESS,
     DB_TABLE_TRACKS,
+    PROVIDERS_WITH_SHAREABLE_URLS,
 )
 from music_assistant.server.helpers.api import api_command
 from music_assistant.server.helpers.database import DatabaseConnection
@@ -157,6 +163,39 @@ class MusicController(CoreController):
         :param media_types: A list of media_types to include.
         :param limit: number of items to return in the search (per type).
         """
+        # Check if the search query is a streaming provider public shareable URL
+        try:
+            media_type, provider_instance_id_or_domain, item_id = parse_uri(
+                search_query, validate_id=True
+            )
+        except InvalidProviderURI:
+            pass
+        except InvalidProviderID as err:
+            self.logger.warning("%s", str(err))
+            return SearchResults()
+        else:
+            if provider_instance_id_or_domain in PROVIDERS_WITH_SHAREABLE_URLS:
+                try:
+                    item = await self.get_item(
+                        media_type=media_type,
+                        item_id=item_id,
+                        provider_instance_id_or_domain=provider_instance_id_or_domain,
+                    )
+                except MusicAssistantError as err:
+                    self.logger.warning("%s", str(err))
+                    return SearchResults()
+                else:
+                    if media_type == MediaType.ARTIST:
+                        return SearchResults(artists=[item])
+                    elif media_type == MediaType.ALBUM:
+                        return SearchResults(albums=[item])
+                    elif media_type == MediaType.TRACK:
+                        return SearchResults(tracks=[item])
+                    elif media_type == MediaType.PLAYLIST:
+                        return SearchResults(playlists=[item])
+                    else:
+                        return SearchResults()
+
         # include results from all (unique) music providers
         results_per_provider: list[SearchResults] = await asyncio.gather(
             *[