feat(music): support APE metadata and cover art

This commit is contained in:
yanjunpu
2026-09-04 05:42:56 +08:00
parent b8fd1b9958
commit 16e5a161b4
4 changed files with 120 additions and 5 deletions
+23 -5
View File
@@ -3,8 +3,10 @@ from typing import Any, Optional, Union
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 @@ class AudioMetadataHelper:
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 @@ class AudioMetadataHelper:
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 @@ class AudioMetadataHelper:
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
+1
View File
@@ -402,6 +402,7 @@ class ConfigModel(BaseModel):
".alac",
".adif",
".adts",
".ape",
".flac",
".midi",
".opus",
+85
View File
@@ -2,6 +2,9 @@ from pathlib import Path
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
+11
View File
@@ -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"))