From: OzGav Date: Tue, 23 Sep 2025 12:24:07 +0000 (+1000) Subject: Add Infer album type to streaming providers (#2420) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=403907d3a79e4bf24409a67d872be1d2c71c0ed4;p=music-assistant-server.git Add Infer album type to streaming providers (#2420) --- diff --git a/music_assistant/providers/apple_music/__init__.py b/music_assistant/providers/apple_music/__init__.py index d1505fe4..5740aefe 100644 --- a/music_assistant/providers/apple_music/__init__.py +++ b/music_assistant/providers/apple_music/__init__.py @@ -33,6 +33,7 @@ from music_assistant_models.enums import ( ContentType, ExternalID, ImageType, + MediaType, ProviderFeature, StreamType, ) @@ -49,11 +50,11 @@ from music_assistant_models.media_items import ( ItemMapping, MediaItemImage, MediaItemType, - MediaType, Playlist, ProviderMapping, SearchResults, Track, + UniqueList, ) from music_assistant_models.streamdetails import StreamDetails from pywidevine import PSSH, Cdm, Device, DeviceTypes @@ -64,6 +65,7 @@ from music_assistant.helpers.auth import AuthenticationHelper from music_assistant.helpers.json import json_loads from music_assistant.helpers.playlists import fetch_playlist from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries +from music_assistant.helpers.util import infer_album_type from music_assistant.models.music_provider import MusicProvider if TYPE_CHECKING: @@ -278,7 +280,7 @@ class AppleMusicProvider(MusicProvider): self._decrypt_private_key = await _file.read() async def search( - self, search_query: str, media_types=list[MediaType] | None, limit: int = 5 + self, search_query: str, media_types: list[MediaType] | None, limit: int = 5 ) -> SearchResults: """Perform search on musicprovider. @@ -519,7 +521,7 @@ class AppleMusicProvider(MusicProvider): enable_cache=True, ) - def _parse_artist(self, artist_obj): + def _parse_artist(self, artist_obj: dict[str, Any]) -> Artist: """Parse artist object to generic layout.""" relationships = artist_obj.get("relationships", {}) if ( @@ -555,14 +557,14 @@ class AppleMusicProvider(MusicProvider): }, ) if artwork := attributes.get("artwork"): - artist.metadata.images = [ + artist.metadata.add_image( MediaItemImage( + provider=self.lookup_key, type=ImageType.THUMB, path=artwork["url"].format(w=artwork["width"], h=artwork["height"]), - provider=self.lookup_key, remotely_accessible=True, ) - ] + ) if genres := attributes.get("genreNames"): artist.metadata.genres = set(genres) if notes := attributes.get("editorialNotes"): @@ -614,29 +616,31 @@ class AppleMusicProvider(MusicProvider): }, ) if artists := relationships.get("artists"): - album.artists = [self._parse_artist(artist) for artist in artists["data"]] + album.artists = UniqueList([self._parse_artist(artist) for artist in artists["data"]]) elif artist_name := attributes.get("artistName"): - album.artists = [ - ItemMapping( - media_type=MediaType.ARTIST, - provider=self.lookup_key, - item_id=artist_name, - name=artist_name, - ) - ] + album.artists = UniqueList( + [ + ItemMapping( + media_type=MediaType.ARTIST, + provider=self.lookup_key, + item_id=artist_name, + name=artist_name, + ) + ] + ) if release_date := attributes.get("releaseDate"): album.year = int(release_date.split("-")[0]) if genres := attributes.get("genreNames"): album.metadata.genres = set(genres) if artwork := attributes.get("artwork"): - album.metadata.images = [ + album.metadata.add_image( MediaItemImage( + provider=self.lookup_key, type=ImageType.THUMB, path=artwork["url"].format(w=artwork["width"], h=artwork["height"]), - provider=self.lookup_key, remotely_accessible=True, ) - ] + ) if album_copyright := attributes.get("copyright"): album.metadata.copyright = album_copyright if record_label := attributes.get("recordLabel"): @@ -653,6 +657,13 @@ class AppleMusicProvider(MusicProvider): elif attributes.get("isCompilation"): album_type = AlbumType.COMPILATION album.album_type = album_type + + # Try inference - override if it finds something more specific + # Apple Music doesn't seem to have version field + inferred_type = infer_album_type(album.name, "") + if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE): + album.album_type = inferred_type + return album def _parse_track( @@ -709,14 +720,14 @@ class AppleMusicProvider(MusicProvider): if "data" in albums and len(albums["data"]) > 0: track.album = self._parse_album(albums["data"][0]) if artwork := attributes.get("artwork"): - track.metadata.images = [ + track.metadata.add_image( MediaItemImage( + provider=self.lookup_key, type=ImageType.THUMB, path=artwork["url"].format(w=artwork["width"], h=artwork["height"]), - provider=self.lookup_key, remotely_accessible=True, ) - ] + ) if genres := attributes.get("genreNames"): track.metadata.genres = set(genres) if composers := attributes.get("composerName"): @@ -725,7 +736,7 @@ class AppleMusicProvider(MusicProvider): track.external_ids.add((ExternalID.ISRC, isrc)) return track - def _parse_playlist(self, playlist_obj) -> Playlist: + def _parse_playlist(self, playlist_obj: dict[str, Any]) -> Playlist: """Parse Apple Music playlist object to generic layout.""" attributes = playlist_obj["attributes"] playlist_id = attributes["playParams"].get("globalId") or playlist_obj["id"] @@ -749,14 +760,14 @@ class AppleMusicProvider(MusicProvider): url = artwork["url"] if artwork["width"] and artwork["height"]: url = url.format(w=artwork["width"], h=artwork["height"]) - playlist.metadata.images = [ + playlist.metadata.add_image( MediaItemImage( + provider=self.lookup_key, type=ImageType.THUMB, path=url, - provider=self.lookup_key, remotely_accessible=True, ) - ] + ) if description := attributes.get("description"): playlist.metadata.description = description.get("standard") if checksum := attributes.get("lastModifiedDate"): diff --git a/music_assistant/providers/ibroadcast/__init__.py b/music_assistant/providers/ibroadcast/__init__.py index 1783f175..10602867 100644 --- a/music_assistant/providers/ibroadcast/__init__.py +++ b/music_assistant/providers/ibroadcast/__init__.py @@ -8,7 +8,6 @@ from aiohttp import ClientSession from ibroadcastaio import IBroadcastClient from music_assistant_models.config_entries import ConfigEntry, ConfigValueType from music_assistant_models.enums import ( - AlbumType, ConfigEntryType, ContentType, ImageType, @@ -37,7 +36,7 @@ from music_assistant.constants import ( VARIOUS_ARTISTS_MBID, VARIOUS_ARTISTS_NAME, ) -from music_assistant.helpers.util import parse_title_and_version +from music_assistant.helpers.util import infer_album_type, parse_title_and_version from music_assistant.models.music_provider import MusicProvider SUPPORTED_FEATURES = { @@ -333,8 +332,9 @@ class IBroadcastProvider(MusicProvider): if "rating" in album_obj and album_obj["rating"] == 5: album.favorite = True - # iBroadcast doesn't seem to know album type - album.album_type = AlbumType.UNKNOWN + # iBroadcast doesn't seem to know album type - try inference + album.album_type = infer_album_type(name, version) + # There is only an artwork in the tracks, lets get the first track one artwork_url = await self._client.get_album_artwork_url(album_id) if artwork_url: diff --git a/music_assistant/providers/nugs/__init__.py b/music_assistant/providers/nugs/__init__.py index 414441fa..53284ea1 100644 --- a/music_assistant/providers/nugs/__init__.py +++ b/music_assistant/providers/nugs/__init__.py @@ -39,6 +39,7 @@ from music_assistant_models.streamdetails import StreamDetails from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME from music_assistant.helpers.json import json_loads +from music_assistant.helpers.util import infer_album_type from music_assistant.models.music_provider import MusicProvider if TYPE_CHECKING: @@ -276,6 +277,9 @@ class NugsProvider(MusicProvider): if year: album.year = int(year) + # No album type info in this provider so try and infer it + album.album_type = infer_album_type(album.name, "") + return album def _parse_playlist(self, playlist_obj: dict[str, Any]) -> Playlist: diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz/__init__.py index 8342cbce..bfbaec5c 100644 --- a/music_assistant/providers/qobuz/__init__.py +++ b/music_assistant/providers/qobuz/__init__.py @@ -6,7 +6,7 @@ import datetime import hashlib import time from contextlib import suppress -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from aiohttp import client_exceptions from music_assistant_models.config_entries import ConfigEntry, ConfigValueType @@ -48,7 +48,12 @@ from music_assistant.constants import ( from music_assistant.helpers.app_vars import app_var from music_assistant.helpers.json import json_loads from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries -from music_assistant.helpers.util import lock, parse_title_and_version, try_parse_int +from music_assistant.helpers.util import ( + infer_album_type, + lock, + parse_title_and_version, + try_parse_int, +) from music_assistant.models.music_provider import MusicProvider if TYPE_CHECKING: @@ -137,7 +142,7 @@ class QobuzProvider(MusicProvider): raise LoginFailed(msg) async def search( - self, search_query: str, media_types=list[MediaType], limit: int = 5 + self, search_query: str, media_types: list[MediaType], limit: int = 5 ) -> SearchResults: """Perform search on musicprovider. @@ -513,7 +518,9 @@ class QobuzProvider(MusicProvider): artist.metadata.description = artist_obj["biography"].get("content") return artist - async def _parse_album(self, album_obj: dict, artist_obj: dict | None = None): + async def parse_album( + self, album_obj: dict[str, Any], artist_obj: dict[str, Any] | None = None + ) -> Album: """Parse qobuz album object to generic layout.""" if not artist_obj and "artist" not in album_obj: # artist missing in album info, return full abum instead @@ -555,17 +562,23 @@ class QobuzProvider(MusicProvider): or album_obj.get("release_type", "") == "album" ): album.album_type = AlbumType.ALBUM + + # Try inference - override if it finds something more specific + inferred_type = infer_album_type(name, version) + if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE): + album.album_type = inferred_type + if "genre" in album_obj: album.metadata.genres = {album_obj["genre"]["name"]} if img := self.__get_image(album_obj): - album.metadata.images = [ + album.metadata.add_image( MediaItemImage( + provider=self.lookup_key, type=ImageType.THUMB, path=img, - provider=self.lookup_key, remotely_accessible=True, ) - ] + ) if "label" in album_obj: album.metadata.label = album_obj["label"]["name"] if released_at := album_obj.get("released_at"): diff --git a/music_assistant/providers/tidal/__init__.py b/music_assistant/providers/tidal/__init__.py index 1cd48d9b..e4159419 100644 --- a/music_assistant/providers/tidal/__init__.py +++ b/music_assistant/providers/tidal/__init__.py @@ -52,6 +52,7 @@ from music_assistant_models.streamdetails import StreamDetails from music_assistant.constants import CACHE_CATEGORY_DEFAULT, CACHE_CATEGORY_RECOMMENDATIONS from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries +from music_assistant.helpers.util import infer_album_type from music_assistant.models.music_provider import MusicProvider from .auth_manager import ManualAuthenticationHelper, TidalAuthManager @@ -1727,6 +1728,11 @@ class TidalProvider(MusicProvider): elif album_type == "SINGLE": album.album_type = AlbumType.SINGLE + # Try inference - override if it finds something more specific + inferred_type = infer_album_type(name, version) + if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE): + album.album_type = inferred_type + # Safely parse year if release_date := album_obj.get("releaseDate", ""): try: diff --git a/music_assistant/providers/ytmusic/__init__.py b/music_assistant/providers/ytmusic/__init__.py index 7e198366..91f334ce 100644 --- a/music_assistant/providers/ytmusic/__init__.py +++ b/music_assistant/providers/ytmusic/__init__.py @@ -45,6 +45,7 @@ from music_assistant_models.media_items import ( RecommendationFolder, SearchResults, Track, + UniqueList, ) from music_assistant_models.streamdetails import StreamDetails from ytmusicapi.constants import SUPPORTED_LANGUAGES @@ -53,7 +54,7 @@ from ytmusicapi.helpers import get_authorization, sapisid_from_cookie from music_assistant.constants import CONF_USERNAME, VERBOSE_LOG_LEVEL from music_assistant.controllers.cache import use_cache -from music_assistant.helpers.util import install_package +from music_assistant.helpers.util import infer_album_type, install_package from music_assistant.models.music_provider import MusicProvider from .helpers import ( @@ -685,6 +686,10 @@ class YoutubeMusicProvider(MusicProvider): def _parse_album(self, album_obj: dict, album_id: str | None = None) -> Album: """Parse a YT Album response to an Album model object.""" album_id = album_id or album_obj.get("id") or album_obj.get("browseId") + + if not album_id: + raise InvalidDataError("Album ID is required but not found") + if "title" in album_obj: name = album_obj["title"] elif "name" in album_obj: @@ -705,19 +710,21 @@ class YoutubeMusicProvider(MusicProvider): if album_obj.get("year") and album_obj["year"].isdigit(): album.year = album_obj["year"] if "thumbnails" in album_obj: - album.metadata.images = self._parse_thumbnails(album_obj["thumbnails"]) + album.metadata.images = UniqueList(self._parse_thumbnails(album_obj["thumbnails"])) if description := album_obj.get("description"): album.metadata.description = unquote(description) if "isExplicit" in album_obj: album.metadata.explicit = album_obj["isExplicit"] if "artists" in album_obj: - album.artists = [ - self._get_artist_item_mapping(artist) - for artist in album_obj["artists"] - if artist.get("id") - or artist.get("channelId") - or artist.get("name") == "Various Artists" - ] + album.artists = UniqueList( + [ + self._get_artist_item_mapping(artist) + for artist in album_obj["artists"] + if artist.get("id") + or artist.get("channelId") + or artist.get("name") == "Various Artists" + ] + ) if "type" in album_obj: if album_obj["type"] == "Single": album_type = AlbumType.SINGLE @@ -728,6 +735,12 @@ class YoutubeMusicProvider(MusicProvider): else: album_type = AlbumType.UNKNOWN album.album_type = album_type + + # Try inference - override if it finds something more specific + inferred_type = infer_album_type(name, "") # YouTube doesn't seem to have version field + if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE): + album.album_type = inferred_type + return album def _parse_artist(self, artist_obj: dict) -> Artist: