From: OzGav Date: Thu, 19 Feb 2026 08:43:41 +0000 (+1000) Subject: Add APEv2 tag parsing for WavPack/Musepack/Monkey's Audio (#3185) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=28bcf0fb69ad5df3cedc73b843dd17ab4132809b;p=music-assistant-server.git Add APEv2 tag parsing for WavPack/Musepack/Monkey's Audio (#3185) --- diff --git a/music_assistant/helpers/tags.py b/music_assistant/helpers/tags.py index 4d6de102..14be56ee 100644 --- a/music_assistant/helpers/tags.py +++ b/music_assistant/helpers/tags.py @@ -17,6 +17,7 @@ import mutagen from music_assistant_models.enums import AlbumType from music_assistant_models.errors import InvalidDataError from mutagen._vorbis import VCommentDict +from mutagen.apev2 import APEv2 from mutagen.mp4 import MP4Tags from music_assistant.constants import MASS_LOGGER_NAME, UNKNOWN_ARTIST @@ -1065,10 +1066,138 @@ def _parse_vorbis_tags(tags: VCommentDict) -> dict[str, Any]: return result +def _apev2_get_values(tags: APEv2, key: str) -> list[str]: + """Get values from an APEv2 tag, splitting on null bytes for multi-value fields. + + :param tags: APEv2 tags object. + :param key: Tag key (case-insensitive in APEv2). + """ + if key not in tags: + return [] + val = str(tags[key]) + # APEv2 uses null byte as separator for multiple values + if "\x00" in val: + return [v.strip() for v in val.split("\x00") if v.strip()] + return [val] if val else [] + + +def _apev2_get_single(tags: APEv2, key: str) -> str | None: + """Get a single value from an APEv2 tag. + + :param tags: APEv2 tags object. + :param key: Tag key. + """ + values = _apev2_get_values(tags, key) + return values[0] if values else None + + +def _apev2_get_multi(tags: APEv2, key: str) -> list[str] | None: + """Get multiple values from an APEv2 tag. + + :param tags: APEv2 tags object. + :param key: Tag key. + """ + values = _apev2_get_values(tags, key) + return values if values else None + + +def _parse_apev2_tags(tags: APEv2) -> dict[str, Any]: # noqa: PLR0915 + r"""Parse APEv2 tags into a normalized dictionary. + + APEv2 tags are used by WavPack, Musepack, Monkey's Audio, OptimFROG, and TAK. + Multi-value fields use null byte (\x00) as separator. + + :param tags: APEv2 tags object from mutagen. + """ + result: dict[str, Any] = {} + + # Basic text tags + if title := _apev2_get_single(tags, "Title"): + result["title"] = title + if artist := _apev2_get_single(tags, "Artist"): + result["artist"] = artist + if albumartist := _apev2_get_single(tags, "Album Artist"): + result["albumartist"] = albumartist + if album := _apev2_get_single(tags, "Album"): + result["album"] = album + + # Genre (can be multi-value) + if genre := _apev2_get_multi(tags, "Genre"): + result["genre"] = genre + + # Multi-artist support (ARTISTS tag) + if artists := _apev2_get_multi(tags, "Artists"): + result["artists"] = artists + + # MusicBrainz IDs - single value + if mb_albumid := _apev2_get_single(tags, "MUSICBRAINZ_ALBUMID"): + result["musicbrainzalbumid"] = mb_albumid + if mb_releasegroupid := _apev2_get_single(tags, "MUSICBRAINZ_RELEASEGROUPID"): + result["musicbrainzreleasegroupid"] = mb_releasegroupid + if mb_trackid := _apev2_get_single(tags, "MUSICBRAINZ_TRACKID"): + # MUSICBRAINZ_TRACKID in APEv2 is actually the recording ID + result["musicbrainzrecordingid"] = mb_trackid + if mb_releasetrackid := _apev2_get_single(tags, "MUSICBRAINZ_RELEASETRACKID"): + result["musicbrainztrackid"] = mb_releasetrackid + + # MusicBrainz IDs - multi-value (can have multiple artist IDs) + if mb_artistid := _apev2_get_multi(tags, "MUSICBRAINZ_ARTISTID"): + result["musicbrainzartistid"] = mb_artistid + if mb_albumartistid := _apev2_get_multi(tags, "MUSICBRAINZ_ALBUMARTISTID"): + result["musicbrainzalbumartistid"] = mb_albumartistid + + # Additional tags + if barcode := _apev2_get_single(tags, "Barcode"): + result["barcode"] = barcode + if isrc := _apev2_get_multi(tags, "ISRC"): + result["isrc"] = isrc + + # Track and disc numbers + if track := _apev2_get_single(tags, "Track"): + result["track"] = track + if disc := _apev2_get_single(tags, "Disc"): + result["disc"] = disc + + # Date + if date := _apev2_get_single(tags, "Year"): + result["date"] = date + + # Lyrics + if lyrics := _apev2_get_single(tags, "Lyrics"): + result["lyrics"] = lyrics + + # Compilation + if compilation := _apev2_get_single(tags, "Compilation"): + result["compilation"] = compilation + + # Album type + if albumtype := _apev2_get_single(tags, "MUSICBRAINZ_ALBUMTYPE"): + result["musicbrainzalbumtype"] = albumtype + + # ReplayGain tags + if rg_track := _apev2_get_single(tags, "REPLAYGAIN_TRACK_GAIN"): + result["replaygaintrackgain"] = rg_track + if rg_album := _apev2_get_single(tags, "REPLAYGAIN_ALBUM_GAIN"): + result["replaygainalbumgain"] = rg_album + + # Sort tags + if artistsort := _apev2_get_multi(tags, "ARTISTSORT"): + result["artistsort"] = artistsort + if albumartistsort := _apev2_get_multi(tags, "ALBUMARTISTSORT"): + result["albumartistsort"] = albumartistsort + if titlesort := _apev2_get_single(tags, "TITLESORT"): + result["titlesort"] = titlesort + if albumsort := _apev2_get_single(tags, "ALBUMSORT"): + result["albumsort"] = albumsort + + return result + + def parse_tags_mutagen(input_file: str) -> dict[str, Any]: """Parse tags from an audio file using Mutagen. - Supports Vorbis comments (FLAC, OGG), ID3 tags (MP3), and MP4 tags (AAC/M4A/ALAC). + Supports Vorbis comments (FLAC, OGG), ID3 tags (MP3), MP4 tags (AAC/M4A/ALAC), + and APEv2 tags (WavPack, Musepack, Monkey's Audio). :param input_file: Path to the audio file. """ @@ -1084,10 +1213,11 @@ def parse_tags_mutagen(input_file: str) -> dict[str, Any]: # Check if Vorbis comments (FLAC, OGG Vorbis, OGG Opus, etc.) elif isinstance(audio.tags, VCommentDict): result = _parse_vorbis_tags(audio.tags) + # Check if APEv2 tags (WavPack, Musepack, Monkey's Audio, etc.) + elif isinstance(audio.tags, APEv2): + result = _parse_apev2_tags(audio.tags) else: # ID3 tags (MP3) and other formats - # TODO: Add _parse_apev2_tags() for WavPack/Musepack/Monkey's Audio to extract - # MusicBrainz IDs and multi-artist tags (APEv2 uses different tag names than ID3). tags_dict = dict(audio.tags) result = _parse_id3_tags(tags_dict) diff --git a/tests/core/test_tags.py b/tests/core/test_tags.py index 2fbc766e..9d6870e5 100644 --- a/tests/core/test_tags.py +++ b/tests/core/test_tags.py @@ -1,17 +1,23 @@ -"""Tests for parsing audio file tags (ID3, MP4/AAC, etc.).""" +"""Tests for parsing audio file tags (ID3, MP4/AAC, Vorbis, APEv2, etc.).""" import pathlib from unittest.mock import MagicMock from music_assistant.constants import UNKNOWN_ARTIST from music_assistant.helpers import tags -from music_assistant.helpers.tags import _parse_vorbis_tags, split_artists +from music_assistant.helpers.tags import ( + _parse_apev2_tags, + _parse_vorbis_tags, + parse_tags_mutagen, + split_artists, +) RESOURCES_DIR = pathlib.Path(__file__).parent.parent.resolve().joinpath("fixtures") FILE_MP3 = str(RESOURCES_DIR.joinpath("MyArtist - MyTitle.mp3")) FILE_M4A = str(RESOURCES_DIR.joinpath("MyArtist - MyTitle.m4a")) FILE_FLAC = str(RESOURCES_DIR.joinpath("MultipleArtists.flac")) +FILE_WV = str(RESOURCES_DIR.joinpath("MyArtist - MyTitle.wv")) async def test_parse_metadata_from_id3tags() -> None: @@ -76,6 +82,35 @@ async def test_parse_metadata_from_mp4tags() -> None: assert _tags.tags.get("albumartistsort") == ["MyAlbumArtist Sort"] # type: ignore[comparison-overlap] +def test_parse_metadata_from_apev2tags() -> None: + """Test parsing of metadata from APEv2 tags (WavPack). + + Uses parse_tags_mutagen directly since the minimal WavPack fixture + does not contain valid audio data for ffprobe to parse. + """ + result = parse_tags_mutagen(FILE_WV) + assert result.get("album") == "MyAlbum" + assert result.get("title") == "MyTitle" + assert result.get("albumartist") == "MyArtist" + assert result.get("artist") == "MyArtist" + assert result.get("artists") == ["MyArtist", "MyArtist2"] + assert result.get("genre") == ["Genre1", "Genre2"] + assert result.get("musicbrainzalbumartistid") == ["abcdefg"] + assert result.get("musicbrainzartistid") == ["abcdefg"] + assert result.get("musicbrainzreleasegroupid") == "abcdefg" + assert result.get("musicbrainzrecordingid") == "abcdefg" + # test track/disc (APEv2 uses "5/12" format like ID3) + assert result.get("track") == "5/12" + assert result.get("disc") == "1/2" + # test year + assert result.get("date") == "2022" + # test sort tags (artistsort/albumartistsort returned as lists to match ID3 behavior) + assert result.get("titlesort") == "MyTitle Sort" + assert result.get("artistsort") == ["MyArtist Sort"] + assert result.get("albumsort") == "MyAlbum Sort" + assert result.get("albumartistsort") == ["MyAlbumArtist Sort"] + + async def test_parse_metadata_from_flac_with_multiple_artist_fields() -> None: """Test parsing of FLAC file with multiple ARTIST fields (per Vorbis spec).""" _tags = await tags.async_parse_tags(FILE_FLAC) @@ -321,3 +356,66 @@ def test_parse_vorbis_tags_musicbrainz_ids() -> None: assert result.get("musicbrainzartistid") == ["mb-id-1", "mb-id-2"] assert result.get("musicbrainzalbumid") == "mb-album-id" assert result.get("musicbrainzrecordingid") == "mb-track-id" + + +def _create_mock_apev2_tags(tag_dict: dict[str, str]) -> MagicMock: + r"""Create a mock APEv2 tags object. + + :param tag_dict: Dictionary mapping tag names to values (use \x00 for multi-value). + """ + mock = MagicMock() + mock.__contains__ = lambda _, key: key in tag_dict + mock.__getitem__ = lambda _, key: tag_dict[key] + mock.keys = lambda: tag_dict.keys() + return mock + + +def test_parse_apev2_tags_multi_value_artists() -> None: + """Test that APEv2 multi-value fields (null-separated) are parsed correctly.""" + mock_tags = _create_mock_apev2_tags( + { + "Title": "My Song", + "Album": "My Album", + "Artist": "Single Artist", + "Artists": "Artist 1\x00Artist 2\x00Artist 3", # Null-separated + } + ) + + result = _parse_apev2_tags(mock_tags) + + assert result.get("title") == "My Song" + assert result.get("album") == "My Album" + assert result.get("artist") == "Single Artist" + assert result.get("artists") == ["Artist 1", "Artist 2", "Artist 3"] + + +def test_parse_apev2_tags_musicbrainz_ids() -> None: + """Test that MusicBrainz IDs are parsed correctly from APEv2 tags.""" + mock_tags = _create_mock_apev2_tags( + { + "MUSICBRAINZ_ARTISTID": "mb-id-1\x00mb-id-2", # Multi-value + "MUSICBRAINZ_ALBUMID": "mb-album-id", + "MUSICBRAINZ_TRACKID": "mb-track-id", # Recording ID in APEv2 + "MUSICBRAINZ_RELEASEGROUPID": "mb-rg-id", + } + ) + + result = _parse_apev2_tags(mock_tags) + + assert result.get("musicbrainzartistid") == ["mb-id-1", "mb-id-2"] + assert result.get("musicbrainzalbumid") == "mb-album-id" + assert result.get("musicbrainzrecordingid") == "mb-track-id" + assert result.get("musicbrainzreleasegroupid") == "mb-rg-id" + + +def test_parse_apev2_tags_genre_multi_value() -> None: + """Test that APEv2 genre with multiple values is parsed correctly.""" + mock_tags = _create_mock_apev2_tags( + { + "Genre": "Rock\x00Pop\x00Jazz", + } + ) + + result = _parse_apev2_tags(mock_tags) + + assert result.get("genre") == ["Rock", "Pop", "Jazz"] diff --git a/tests/fixtures/MyArtist - MyTitle.wv b/tests/fixtures/MyArtist - MyTitle.wv new file mode 100644 index 00000000..23efef95 Binary files /dev/null and b/tests/fixtures/MyArtist - MyTitle.wv differ