diff --git a/app/application/audio.py b/app/application/audio.py index 59aa7874e1..5402a81ee9 100644 --- a/app/application/audio.py +++ b/app/application/audio.py @@ -3,8 +3,10 @@ from uuid import UUID from mutagen import File as MutagenFile +from mutagen.apev2 import APEBinaryValue from mutagen.flac import FLAC, Picture from mutagen.id3 import APIC, SYLT, USLT +from mutagen.monkeysaudio import MonkeysAudio from mutagen.mp4 import MP4, MP4Cover from app.domain.context import MusicInfo, MusicLyrics @@ -54,8 +56,12 @@ def read_tags(cls, path: Path) -> Optional[MetaMusic]: return None tags = audio.tags or {} - track_number, total_tracks = cls._number_pair(cls._first(tags, "tracknumber")) - disc_number, total_discs = cls._number_pair(cls._first(tags, "discnumber")) + track_number, total_tracks = cls._number_pair( + cls._first_of(tags, "tracknumber", "track") + ) + disc_number, total_discs = cls._number_pair( + cls._first_of(tags, "discnumber", "disc") + ) musicbrainz_id = cls._normalize_musicbrainz_id( cls._first_of( tags, @@ -69,8 +75,8 @@ def read_tags(cls, path: Path) -> Optional[MetaMusic]: title=cls._first(tags, "title"), artists=cls._values(tags, "artist"), album=cls._first(tags, "album"), - album_artist=cls._first(tags, "albumartist"), - year=cls._year(cls._first(tags, "date") or cls._first(tags, "originaldate")), + album_artist=cls._first_of(tags, "albumartist", "album artist"), + year=cls._year(cls._first_of(tags, "date", "year", "originaldate")), disc_number=disc_number, track_number=track_number, total_discs=total_discs, @@ -269,8 +275,20 @@ def _write_cover( cover_mime: str, overwrite: bool, ) -> None: - """为 MP3、FLAC 和 MP4/M4A 写入内嵌封面,其它格式保留标签写入结果。""" + """为 MP3、FLAC、MP4/M4A 和 APE 写入内嵌封面,其它格式保留标签写入结果。""" audio = MutagenFile(path) + if isinstance(audio, MonkeysAudio): + if audio.tags is None: + audio.add_tags() + cover_key = "Cover Art (Front)" + if cover_key in audio.tags and not overwrite: + return + cover_filename = "cover.png" if cover_mime == "image/png" else "cover.jpg" + audio.tags[cover_key] = APEBinaryValue( + cover_filename.encode("ascii") + b"\x00" + cover_data + ) + audio.save() + return if isinstance(audio, FLAC): if audio.pictures and not overwrite: return diff --git a/app/runtime/config.py b/app/runtime/config.py index 78e8894616..9665989b64 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -402,6 +402,7 @@ class ConfigModel(BaseModel): ".alac", ".adif", ".adts", + ".ape", ".flac", ".midi", ".opus", diff --git a/tests/test_audio_metadata.py b/tests/test_audio_metadata.py index eed308c961..44b7001023 100644 --- a/tests/test_audio_metadata.py +++ b/tests/test_audio_metadata.py @@ -2,6 +2,9 @@ from types import SimpleNamespace from unittest.mock import Mock +from mutagen.apev2 import APEBinaryValue +from mutagen.monkeysaudio import MonkeysAudio + from app.chain.media import MediaChain from app.domain.context import MusicInfo from app.domain.meta.metamusic import ( @@ -182,6 +185,32 @@ def test_read_audio_tags_ignores_invalid_musicbrainz_id(monkeypatch): assert meta.media_id is None +def test_read_audio_tags_accepts_conventional_apev2_names(monkeypatch): + """APE 常见字段名应映射为标准专辑、曲序、碟号和年份。""" + audio = SimpleNamespace( + tags={ + "title": ["天下太平"], + "artist": ["陈奕迅", "张学友"], + "album": ["Solidays"], + "album artist": ["陈奕迅"], + "track": ["12/14"], + "disc": ["1/2"], + "year": ["2008"], + }, + info=SimpleNamespace(length=252), + ) + monkeypatch.setattr("app.application.audio.MutagenFile", lambda *_args, **_kwargs: audio) + + meta = AudioMetadataHelper.read_tags(Path("/music/天下太平.ape")) + + assert meta.album_artist == "陈奕迅" + assert meta.track_number == 12 + assert meta.total_tracks == 14 + assert meta.disc_number == 1 + assert meta.total_discs == 2 + assert meta.year == 2008 + + def test_remote_path_meta_parses_track_prefix_once(tmp_path): """远程或尚未落盘的音频路径应先剥离曲序,不能把 08 误识别成艺术家。""" audio_path = tmp_path / "Daft Punk - Random Access Memories (2013)" / "08 - Get Lucky.flac" @@ -358,3 +387,59 @@ def test_write_audio_metadata_can_embed_cover_without_rewriting_tags(monkeypatch cover_mime="image/jpeg", overwrite=False, ) + + +def test_write_audio_metadata_embeds_apev2_front_cover(monkeypatch): + """APE 封面应按 APEv2 约定写入文件名、空字节和图片数据。""" + class FakeMonkeysAudio(MonkeysAudio): + """记录 Monkey's Audio 封面写入结果。""" + + def __init__(self): + self.tags = {} + self.saved = False + + def save(self, *_args, **_kwargs): + self.saved = True + + audio = FakeMonkeysAudio() + monkeypatch.setattr("app.application.audio.MutagenFile", lambda *_args, **_kwargs: audio) + + AudioMetadataHelper._write_cover( + path=Path("/music/track.ape"), + cover_data=b"jpeg-data", + cover_mime="image/jpeg", + overwrite=True, + ) + + cover = audio.tags["Cover Art (Front)"] + assert isinstance(cover, APEBinaryValue) + assert bytes(cover) == b"cover.jpg\x00jpeg-data" + assert audio.saved is True + + +def test_write_audio_metadata_preserves_existing_apev2_cover(monkeypatch): + """关闭覆盖时应保留已有 APEv2 正面封面。""" + class FakeMonkeysAudio(MonkeysAudio): + """记录 Monkey's Audio 封面覆盖行为。""" + + def __init__(self): + self.tags = { + "Cover Art (Front)": APEBinaryValue(b"old.jpg\x00old-data") + } + self.saved = False + + def save(self, *_args, **_kwargs): + self.saved = True + + audio = FakeMonkeysAudio() + monkeypatch.setattr("app.application.audio.MutagenFile", lambda *_args, **_kwargs: audio) + + AudioMetadataHelper._write_cover( + path=Path("/music/track.ape"), + cover_data=b"new-data", + cover_mime="image/jpeg", + overwrite=False, + ) + + assert bytes(audio.tags["Cover Art (Front)"]) == b"old.jpg\x00old-data" + assert audio.saved is False diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index edb34f7369..d3ebc4029d 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -343,6 +343,17 @@ def test_metainfo_routes_audio_filename_to_music(): assert meta.apply_words == [] +def test_metainfo_routes_ape_filename_to_music(): + """Monkey's Audio 文件应使用默认音频扩展配置进入音乐识别分支。""" + meta = MetaInfo("陈奕迅 - 天下太平.ape") + + assert isinstance(meta, MetaMusic) + assert meta.type == MediaType.MUSIC + assert meta.title == "天下太平" + assert meta.artists == ["陈奕迅"] + assert meta.audio_format == "APE" + + def test_metainfo_routes_audio_path_to_music_without_parent_merge(): """音频路径应直接构造音乐元数据,不参与影视季集合并,并拆分歌手与曲名。""" meta = MetaInfoPath(Path("/music/叶惠美/周杰伦 - 晴天.flac"))