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
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.
"""
# 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)
-"""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:
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)
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"]