import mutagen
from music_assistant_models.enums import AlbumType
from music_assistant_models.errors import InvalidDataError
+from mutagen._vorbis import VCommentDict
from mutagen.mp4 import MP4Tags
from music_assistant.constants import MASS_LOGGER_NAME, UNKNOWN_ARTIST
return result
+def _vorbis_get_single(tags: VCommentDict, key: str) -> str | None:
+ """Get single value from Vorbis comments (first item if multiple exist).
+
+ :param tags: VCommentDict from mutagen.
+ :param key: Tag name (case insensitive).
+ """
+ values = tags.get(key) # type: ignore[no-untyped-call]
+ return values[0] if values else None
+
+
+def _vorbis_get_multi(tags: VCommentDict, key: str) -> list[str] | None:
+ """Get all values from Vorbis comments as a list.
+
+ :param tags: VCommentDict from mutagen.
+ :param key: Tag name (case insensitive).
+ """
+ values = tags.get(key) # type: ignore[no-untyped-call]
+ return list(values) if values else None
+
+
+def _parse_vorbis_artist_tags(tags: VCommentDict, result: dict[str, Any]) -> None:
+ """Parse artist-related tags from Vorbis comments into result dict.
+
+ Handles multiple ARTIST/ALBUMARTIST fields per Vorbis spec, as well as
+ explicit ARTISTS tag which take precedence.
+
+ :param tags: VCommentDict from mutagen.
+ :param result: Dictionary to store parsed tags.
+ """
+ # Artist tags - check for multiple values (per Vorbis spec recommendation)
+ # Multiple ARTIST fields are treated the same as an ARTISTS tag
+ artist_values = _vorbis_get_multi(tags, "ARTIST")
+ if artist_values:
+ if len(artist_values) > 1:
+ # Multiple ARTIST fields - treat as authoritative list (like ARTISTS tag)
+ result["artists"] = artist_values
+ else:
+ # Single ARTIST field - use normal parsing logic
+ result["artist"] = artist_values[0]
+
+ # Album artist tags - same logic for multiple values
+ albumartist_values = _vorbis_get_multi(tags, "ALBUMARTIST")
+ if albumartist_values:
+ if len(albumartist_values) > 1:
+ # Multiple ALBUMARTIST fields - treat as authoritative list
+ result["albumartists"] = albumartist_values
+ else:
+ result["albumartist"] = albumartist_values[0]
+
+ # Explicit ARTISTS tag takes precedence if present
+ if artists := _vorbis_get_multi(tags, "ARTISTS"):
+ result["artists"] = artists
+
+
+def _parse_vorbis_tags(tags: VCommentDict) -> dict[str, Any]:
+ """Parse Vorbis comment tags (FLAC, OGG Vorbis, OGG Opus, etc.).
+
+ Vorbis comments support multiple values for the same field name per the spec.
+ For example, multiple ARTIST fields can be used instead of a single ARTISTS field.
+ See: https://xiph.org/vorbis/doc/v-comment.html
+
+ :param tags: VCommentDict from mutagen (FLAC, OGG, etc.).
+ """
+ result: dict[str, Any] = {}
+
+ # Basic tags
+ if title := _vorbis_get_single(tags, "TITLE"):
+ result["title"] = title
+ if album := _vorbis_get_single(tags, "ALBUM"):
+ result["album"] = album
+
+ # Artist tags (handles multiple ARTIST/ALBUMARTIST fields per Vorbis spec)
+ _parse_vorbis_artist_tags(tags, result)
+
+ # Genre (multi-value)
+ if genre := _vorbis_get_multi(tags, "GENRE"):
+ result["genre"] = genre
+
+ # MusicBrainz tags (single value)
+ if mb_album := _vorbis_get_single(tags, "MUSICBRAINZ_ALBUMID"):
+ result["musicbrainzalbumid"] = mb_album
+ if mb_rg := _vorbis_get_single(tags, "MUSICBRAINZ_RELEASEGROUPID"):
+ result["musicbrainzreleasegroupid"] = mb_rg
+ if mb_track := _vorbis_get_single(tags, "MUSICBRAINZ_TRACKID"):
+ result["musicbrainzrecordingid"] = mb_track
+ if mb_reltrack := _vorbis_get_single(tags, "MUSICBRAINZ_RELEASETRACKID"):
+ result["musicbrainztrackid"] = mb_reltrack
+
+ # MusicBrainz tags (multi-value)
+ if mb_aa_ids := _vorbis_get_multi(tags, "MUSICBRAINZ_ALBUMARTISTID"):
+ result["musicbrainzalbumartistid"] = mb_aa_ids
+ if mb_a_ids := _vorbis_get_multi(tags, "MUSICBRAINZ_ARTISTID"):
+ result["musicbrainzartistid"] = mb_a_ids
+
+ # Additional tags
+ if barcode := _vorbis_get_multi(tags, "BARCODE"):
+ result["barcode"] = barcode
+ if isrc := _vorbis_get_multi(tags, "ISRC"):
+ result["isrc"] = isrc
+
+ # Date
+ if date := _vorbis_get_single(tags, "DATE"):
+ result["date"] = date
+
+ # Lyrics
+ if lyrics := _vorbis_get_single(tags, "LYRICS"):
+ result["lyrics"] = lyrics
+
+ # Compilation flag
+ if compilation := _vorbis_get_single(tags, "COMPILATION"):
+ result["compilation"] = compilation
+
+ # Album type (MusicBrainz)
+ if albumtype := _vorbis_get_single(tags, "MUSICBRAINZ_ALBUMTYPE"):
+ result["musicbrainzalbumtype"] = albumtype
+ # Also check RELEASETYPE which is an alternative tag name
+ if not result.get("musicbrainzalbumtype"):
+ if releasetype := _vorbis_get_single(tags, "RELEASETYPE"):
+ result["musicbrainzalbumtype"] = releasetype
+
+ # ReplayGain tags
+ if rg_track := _vorbis_get_single(tags, "REPLAYGAIN_TRACK_GAIN"):
+ result["replaygaintrackgain"] = rg_track
+ if rg_album := _vorbis_get_single(tags, "REPLAYGAIN_ALBUM_GAIN"):
+ result["replaygainalbumgain"] = rg_album
+
+ # Sort tags
+ if artistsort := _vorbis_get_multi(tags, "ARTISTSORT"):
+ result["artistsort"] = artistsort
+ if albumartistsort := _vorbis_get_multi(tags, "ALBUMARTISTSORT"):
+ result["albumartistsort"] = albumartistsort
+ if titlesort := _vorbis_get_single(tags, "TITLESORT"):
+ result["titlesort"] = titlesort
+ if albumsort := _vorbis_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 ID3 tags (MP3) and MP4 tags (AAC/M4A/ALAC).
+ Supports Vorbis comments (FLAC, OGG), ID3 tags (MP3), and MP4 tags (AAC/M4A/ALAC).
:param input_file: Path to the audio file.
"""
# Check if MP4/M4A/AAC file (uses MP4Tags)
if isinstance(audio.tags, MP4Tags):
result = _parse_mp4_tags(audio.tags)
+ # Check if Vorbis comments (FLAC, OGG Vorbis, OGG Opus, etc.)
+ elif isinstance(audio.tags, VCommentDict):
+ result = _parse_vorbis_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.)."""
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 split_artists
+from music_assistant.helpers.tags import _parse_vorbis_tags, 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"))
async def test_parse_metadata_from_id3tags() -> None:
assert _tags.tags.get("albumartistsort") == ["MyAlbumArtist Sort"] # type: ignore[comparison-overlap]
+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 _tags.album == "Test Album"
+ assert _tags.title == "Test Track"
+ # Multiple ARTIST fields should be treated as authoritative list
+ assert _tags.artists == ("Artist One", "Artist Two", "Artist Three")
+ # Multiple ALBUMARTIST fields should be treated as authoritative list
+ assert _tags.album_artists == ("Album Artist 1", "Album Artist 2")
+ assert _tags.genres == ("Rock", "Pop")
+ assert _tags.year == 2024
+ # MusicBrainz IDs
+ assert _tags.musicbrainz_artistids == ("mb-artist-id-1", "mb-artist-id-2", "mb-artist-id-3")
+ assert _tags.musicbrainz_albumartistids == ("mb-albumartist-id-1", "mb-albumartist-id-2")
+ assert _tags.musicbrainz_recordingid == "mb-track-id"
+ # Track/disc from Vorbis comments
+ assert _tags.track == 5
+ assert _tags.disc == 1
+
+
async def test_parse_metadata_from_filename() -> None:
"""Test parsing of parsing metadata from filename."""
filename = str(RESOURCES_DIR.joinpath("MyArtist - MyTitle without Tags.mp3"))
# "with" SHOULD split when expected_count=2 indicates multiple artists
result = split_artists("Artist A with Artist B", expected_count=2)
assert result == ("Artist A", "Artist B")
+
+
+def _create_mock_vorbis_tags(tag_dict: dict[str, list[str]]) -> MagicMock:
+ """Create a mock VCommentDict with the given tags.
+
+ :param tag_dict: Dictionary mapping tag names to lists of values.
+ """
+ mock = MagicMock()
+ mock.get = lambda key: tag_dict.get(key.upper())
+ return mock
+
+
+def test_parse_vorbis_tags_multiple_artist_fields() -> None:
+ """Test that multiple ARTIST fields are treated as authoritative artist list."""
+ # Per Vorbis spec: multiple ARTIST fields should list all artists
+ mock_tags = _create_mock_vorbis_tags(
+ {
+ "TITLE": ["My Song"],
+ "ALBUM": ["My Album"],
+ "ARTIST": ["Artist 1", "Artist 2", "Artist 3"],
+ }
+ )
+
+ result = _parse_vorbis_tags(mock_tags)
+
+ # Multiple ARTIST fields should be stored as "artists" (plural)
+ assert result.get("artists") == ["Artist 1", "Artist 2", "Artist 3"]
+ # Single "artist" key should NOT be set when multiple artists are present
+ assert "artist" not in result
+ assert result.get("title") == "My Song"
+ assert result.get("album") == "My Album"
+
+
+def test_parse_vorbis_tags_single_artist_field() -> None:
+ """Test that a single ARTIST field is stored as singular artist."""
+ mock_tags = _create_mock_vorbis_tags(
+ {
+ "TITLE": ["My Song"],
+ "ARTIST": ["Single Artist"],
+ }
+ )
+
+ result = _parse_vorbis_tags(mock_tags)
+
+ # Single ARTIST should use singular key for normal parsing logic
+ assert result.get("artist") == "Single Artist"
+ assert "artists" not in result
+
+
+def test_parse_vorbis_tags_multiple_albumartist_fields() -> None:
+ """Test that multiple ALBUMARTIST fields are treated as authoritative list."""
+ mock_tags = _create_mock_vorbis_tags(
+ {
+ "ALBUMARTIST": ["Album Artist 1", "Album Artist 2"],
+ }
+ )
+
+ result = _parse_vorbis_tags(mock_tags)
+
+ # Multiple ALBUMARTIST fields should be stored as "albumartists" (plural)
+ assert result.get("albumartists") == ["Album Artist 1", "Album Artist 2"]
+ assert "albumartist" not in result
+
+
+def test_parse_vorbis_tags_single_albumartist_field() -> None:
+ """Test that a single ALBUMARTIST field is stored as singular."""
+ mock_tags = _create_mock_vorbis_tags(
+ {
+ "ALBUMARTIST": ["Single Album Artist"],
+ }
+ )
+
+ result = _parse_vorbis_tags(mock_tags)
+
+ assert result.get("albumartist") == "Single Album Artist"
+ assert "albumartists" not in result
+
+
+def test_parse_vorbis_tags_explicit_artists_tag_takes_precedence() -> None:
+ """Test that explicit ARTISTS tag takes precedence over multiple ARTIST fields."""
+ mock_tags = _create_mock_vorbis_tags(
+ {
+ "ARTIST": ["Artist A", "Artist B"], # Multiple ARTIST fields
+ "ARTISTS": [
+ "Explicit Artist 1",
+ "Explicit Artist 2",
+ "Explicit Artist 3",
+ ], # Explicit tag
+ }
+ )
+
+ result = _parse_vorbis_tags(mock_tags)
+
+ # ARTISTS tag should take precedence
+ assert result.get("artists") == ["Explicit Artist 1", "Explicit Artist 2", "Explicit Artist 3"]
+
+
+def test_parse_vorbis_tags_musicbrainz_ids() -> None:
+ """Test that MusicBrainz IDs are parsed correctly from Vorbis tags."""
+ mock_tags = _create_mock_vorbis_tags(
+ {
+ "ARTIST": ["Artist 1", "Artist 2"],
+ "MUSICBRAINZ_ARTISTID": ["mb-id-1", "mb-id-2"],
+ "MUSICBRAINZ_ALBUMID": ["mb-album-id"],
+ "MUSICBRAINZ_TRACKID": ["mb-track-id"],
+ }
+ )
+
+ result = _parse_vorbis_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"