"""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
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)
"""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
)
SYNCGROUP_PREFIX: Final[str] = "syncgroup_"
VERBOSE_LOG_LEVEL: Final[int] = 5
+PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz")
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
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
: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(
*[