Add APEv2 tag parsing for WavPack/Musepack/Monkey's Audio (#3185)
authorOzGav <gavnosp@hotmail.com>
Thu, 19 Feb 2026 08:43:41 +0000 (18:43 +1000)
committerGitHub <noreply@github.com>
Thu, 19 Feb 2026 08:43:41 +0000 (09:43 +0100)
music_assistant/helpers/tags.py
tests/core/test_tags.py
tests/fixtures/MyArtist - MyTitle.wv [new file with mode: 0644]

index 4d6de1029f48b4d2f5bc824ffa3446b835bf4847..14be56ee470957601630cc7d2e398bf3df352173 100644 (file)
@@ -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)
 
index 2fbc766e54ea53cac109353300d801bf35963cac..9d6870e594798030016d8317d30a07101036a072 100644 (file)
@@ -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 (file)
index 0000000..23efef9
Binary files /dev/null and b/tests/fixtures/MyArtist - MyTitle.wv differ