Add live and soundtrack album types (#2406)
authorOzGav <gavnosp@hotmail.com>
Mon, 22 Sep 2025 14:28:41 +0000 (00:28 +1000)
committerGitHub <noreply@github.com>
Mon, 22 Sep 2025 14:28:41 +0000 (16:28 +0200)
music_assistant/constants.py
music_assistant/helpers/tags.py
music_assistant/helpers/util.py
music_assistant/providers/deezer/__init__.py
music_assistant/providers/spotify/parsers.py

index ec471dbd649be43cb8bd0823b16b7c92a79709df..a0bb0d41e240ba74d2acc8051386b09ce6401da1 100644 (file)
@@ -981,3 +981,22 @@ ATTR_ANNOUNCEMENT_IN_PROGRESS: Final[str] = "announcement_in_progress"
 ATTR_PREVIOUS_VOLUME: Final[str] = "previous_volume"
 ATTR_LAST_POLL: Final[str] = "last_poll"
 ATTR_GROUP_MEMBERS: Final[str] = "group_members"
+
+# Album type detection patterns
+LIVE_INDICATORS = [
+    r"\bunplugged\b",
+    r"\bin concert\b",
+    r"\bon stage\b",
+    r"\blive\b",
+]
+
+SOUNDTRACK_INDICATORS = [
+    r"\bsoundtrack\b",  # Catches all soundtrack variations
+    r"\bmusic from the motion picture\b",
+    r"\boriginal score\b",
+    r"\bthe score\b",
+    r"\bfilm score\b",
+    r"(^|\b)score:\s*",  # e.g., "Score: The Two Towers"
+    r"\bfrom the film\b",
+    r"\boriginal.*cast.*recording\b",
+]
index 2e8d2bbbaf5210b2178eec6fb1fb53d9280b49f0..75d911152466092c48dd17111dafe3b12bf180a2 100644 (file)
@@ -20,7 +20,7 @@ from music_assistant_models.errors import InvalidDataError
 from music_assistant.constants import MASS_LOGGER_NAME, UNKNOWN_ARTIST
 from music_assistant.helpers.json import json_loads
 from music_assistant.helpers.process import AsyncProcess
-from music_assistant.helpers.util import try_parse_int
+from music_assistant.helpers.util import infer_album_type, try_parse_int
 
 LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.tags")
 
@@ -314,25 +314,29 @@ class AudioTags:
         """Return albumtype tag if present."""
         if self.tags.get("compilation", "") == "1":
             return AlbumType.COMPILATION
+
         tag = (
             self.tags.get("musicbrainzalbumtype")
             or self.tags.get("albumtype")
             or self.tags.get("releasetype")
         )
-        if tag is None:
-            return AlbumType.UNKNOWN
-        # the album type tag is messy within id3 and may even contain multiple types
-        # try to parse one in order of preference
-        for album_type in (
-            AlbumType.COMPILATION,
-            AlbumType.EP,
-            AlbumType.SINGLE,
-            AlbumType.ALBUM,
-        ):
-            if album_type.value in tag.lower():
-                return album_type
-
-        return AlbumType.UNKNOWN
+
+        if tag is not None:
+            # try to parse one in order of preference
+            for album_type in (
+                AlbumType.LIVE,
+                AlbumType.SOUNDTRACK,
+                AlbumType.COMPILATION,
+                AlbumType.EP,
+                AlbumType.SINGLE,
+                AlbumType.ALBUM,
+            ):
+                if album_type.value in tag.lower():
+                    return album_type
+
+        # No valid tag found, try inference from album title
+        album_title = self.tags.get("album", "")
+        return infer_album_type(album_title, "")
 
     @property
     def isrc(self) -> tuple[str, ...]:
@@ -437,7 +441,9 @@ class AudioTags:
             if stream.get("codec_type") == "video":
                 continue
             for key, value in stream.get("tags", {}).items():
-                alt_key = key.lower().replace(" ", "").replace("_", "").replace("-", "")
+                alt_key = key.lower()
+                for char in [" ", "_", "-", "/"]:
+                    alt_key = alt_key.replace(char, "")
                 if alt_key in tags:
                     continue
                 tags[alt_key] = value
index e494e0f0c3f85051e586f5ab324f859688b15f37..c0e93388616b67b4fe2446a270d6c3c109f4ac26 100644 (file)
@@ -11,7 +11,6 @@ import re
 import shutil
 import socket
 import urllib.error
-import urllib.parse
 import urllib.request
 from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine
 from contextlib import suppress
@@ -24,9 +23,10 @@ from urllib.parse import urlparse
 
 import chardet
 import ifaddr
+from music_assistant_models.enums import AlbumType
 from zeroconf import IPVersion
 
-from music_assistant.constants import VERBOSE_LOG_LEVEL
+from music_assistant.constants import LIVE_INDICATORS, SOUNDTRACK_INDICATORS, VERBOSE_LOG_LEVEL
 from music_assistant.helpers.process import check_output
 
 if TYPE_CHECKING:
@@ -152,6 +152,18 @@ def parse_title_and_version(title: str, track_version: str | None = None) -> tup
     return title, version
 
 
+def infer_album_type(title: str, version: str) -> AlbumType:
+    """Infer album type by looking for live or soundtrack indicators."""
+    combined = f"{title} {version}".lower()
+    for pat in LIVE_INDICATORS:
+        if re.search(pat, combined):
+            return AlbumType.LIVE
+    for pat in SOUNDTRACK_INDICATORS:
+        if re.search(pat, combined):
+            return AlbumType.SOUNDTRACK
+    return AlbumType.UNKNOWN
+
+
 def strip_ads(line: str) -> str:
     """Strip Ads from line."""
     if ad_pattern.search(line):
index 330ca97ac95b5a7e01f4fe4617bd8aea4803e4df..a47a9d2c100e0588924befc9943257975d698259 100644 (file)
@@ -44,6 +44,7 @@ from music_assistant import MusicAssistant
 from music_assistant.helpers.app_vars import app_var
 from music_assistant.helpers.auth import AuthenticationHelper
 from music_assistant.helpers.datetime import utc_timestamp
+from music_assistant.helpers.util import infer_album_type
 from music_assistant.models import ProviderInstanceType
 from music_assistant.models.music_provider import MusicProvider
 
@@ -691,20 +692,26 @@ class DeezerProvider(MusicProvider):
 
     def get_album_type(self, album: deezer.Album) -> AlbumType:
         """Read and convert the Deezer album type."""
-        if not hasattr(album, "record_type"):
-            return AlbumType.UNKNOWN
-
-        match album.record_type:
-            case "album":
-                return AlbumType.ALBUM
-            case "single":
-                return AlbumType.SINGLE
-            case "ep":
-                return AlbumType.EP
-            case "compile":
-                return AlbumType.COMPILATION
-            case _:
-                return AlbumType.UNKNOWN
+        # Get provider's basic type first
+        provider_type = AlbumType.UNKNOWN
+        if hasattr(album, "record_type"):
+            match album.record_type:
+                case "album":
+                    provider_type = AlbumType.ALBUM
+                case "single":
+                    provider_type = AlbumType.SINGLE
+                case "ep":
+                    provider_type = AlbumType.EP
+                case "compile":
+                    provider_type = AlbumType.COMPILATION
+
+        # Try inference - override if it finds something more specific
+        inferred_type = infer_album_type(album.title, "")
+        if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE):
+            return inferred_type
+
+        # Otherwise use provider type
+        return provider_type
 
     ### SEARCH AND PARSE FUNCTIONS ###
     async def search_and_parse_tracks(
index 61e3b4c4de0cee7c2ed22de10073524a59b0eb02..7c7332e9d4fb9b7a63b1c13307941025b8258138 100644 (file)
@@ -20,7 +20,7 @@ from music_assistant_models.media_items import (
 )
 from music_assistant_models.unique_list import UniqueList
 
-from music_assistant.helpers.util import parse_title_and_version
+from music_assistant.helpers.util import infer_album_type, parse_title_and_version
 
 if TYPE_CHECKING:
     from .provider import SpotifyProvider
@@ -116,6 +116,11 @@ def parse_album(album_obj: dict[str, Any], provider: SpotifyProvider) -> Album:
     with contextlib.suppress(ValueError):
         album.album_type = AlbumType(album_obj["album_type"])
 
+    # Override with inferred type if version indicates it
+    inferred_type = infer_album_type(album.name, album.version)
+    if inferred_type in (AlbumType.LIVE, AlbumType.SOUNDTRACK):
+        album.album_type = inferred_type
+
     if "genres" in album_obj:
         album.metadata.genres = set(album_obj["genres"])