From 0a891a438b0f21d07974395df044ed960707bfe9 Mon Sep 17 00:00:00 2001 From: OzGav Date: Tue, 23 Sep 2025 00:28:41 +1000 Subject: [PATCH] Add live and soundtrack album types (#2406) --- music_assistant/constants.py | 19 ++++++++++ music_assistant/helpers/tags.py | 38 +++++++++++--------- music_assistant/helpers/util.py | 16 +++++++-- music_assistant/providers/deezer/__init__.py | 35 ++++++++++-------- music_assistant/providers/spotify/parsers.py | 7 +++- 5 files changed, 82 insertions(+), 33 deletions(-) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index ec471dbd..a0bb0d41 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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", +] diff --git a/music_assistant/helpers/tags.py b/music_assistant/helpers/tags.py index 2e8d2bbb..75d91115 100644 --- a/music_assistant/helpers/tags.py +++ b/music_assistant/helpers/tags.py @@ -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 diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py index e494e0f0..c0e93388 100644 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py @@ -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): diff --git a/music_assistant/providers/deezer/__init__.py b/music_assistant/providers/deezer/__init__.py index 330ca97a..a47a9d2c 100644 --- a/music_assistant/providers/deezer/__init__.py +++ b/music_assistant/providers/deezer/__init__.py @@ -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( diff --git a/music_assistant/providers/spotify/parsers.py b/music_assistant/providers/spotify/parsers.py index 61e3b4c4..7c7332e9 100644 --- a/music_assistant/providers/spotify/parsers.py +++ b/music_assistant/providers/spotify/parsers.py @@ -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"]) -- 2.34.1