Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions app/application/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -269,8 +275,20 @@ def _write_cover(
cover_mime: str,
overwrite: bool,
) -> None:
"""为 MP3、FLACMP4/M4A 写入内嵌封面,其它格式保留标签写入结果。"""
"""为 MP3、FLACMP4/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
Expand Down
1 change: 1 addition & 0 deletions app/runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ class ConfigModel(BaseModel):
".alac",
".adif",
".adts",
".ape",
".flac",
".midi",
".opus",
Expand Down
85 changes: 85 additions & 0 deletions tests/test_audio_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions tests/test_metainfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down