Add Infer album type to streaming providers (#2420)
authorOzGav <gavnosp@hotmail.com>
Tue, 23 Sep 2025 12:24:07 +0000 (22:24 +1000)
committerGitHub <noreply@github.com>
Tue, 23 Sep 2025 12:24:07 +0000 (14:24 +0200)
music_assistant/providers/apple_music/__init__.py
music_assistant/providers/ibroadcast/__init__.py
music_assistant/providers/nugs/__init__.py
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/tidal/__init__.py
music_assistant/providers/ytmusic/__init__.py

index d1505fe4f70712934e37c24be24ec7d720c7f0a2..5740aefed40b86229297f5995c209206351b6901 100644 (file)
@@ -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_typeslist[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"):
index 1783f175a321e4ef476337eaf9c9af72df04bc44..106028675553d08ecb499c34095259c0391a8a2e 100644 (file)
@@ -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:
index 414441fa14d0a89dc11d76dae849f0a17ff337a0..53284ea191d242620b5f3bbb95172e3f30fe1a36 100644 (file)
@@ -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:
index 8342cbce8f65fafc045d1aa2c7c6d2bc6d975274..bfbaec5ca944d7cfcc1cd97a1eebc9e717aafc4d 100644 (file)
@@ -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_typeslist[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"):
index 1cd48d9b3c35eb51503469c3a3c27f6aae9c05c3..e4159419c36ea44810a723589715c59a736a2bc8 100644 (file)
@@ -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:
index 7e19836636e1374718358a98640d7077c5d791d5..91f334ce5de55eb88c67e2438545996873e35571 100644 (file)
@@ -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: