fix(music): preserve native APE identities and precise tag dates

This commit is contained in:
jxxghp
2026-09-30 08:32:22 +08:00
parent 75ae1743f4
commit 264fb6e1db
4 changed files with 57 additions and 15 deletions

View File

@@ -603,13 +603,20 @@ class AudioMetadataHelper:
@classmethod
def _needs_tag_write(cls, audio: Any, music: Union[MetaMusic, MusicInfo], overwrite: bool) -> bool:
"""先只读比较已有标签,完整元数据不应触发大音频文件的副本创建。"""
return bool(cls._tag_updates(audio, music, overwrite))
@classmethod
def _tag_updates(cls, audio: Any, music: Union[MetaMusic, MusicInfo], overwrite: bool) -> dict[str, list[str]]:
"""预检和实际写入共用字段差异,年份模型不能抹掉标签中同年的完整日期。"""
tags = cls._readable_tags(audio.tags)
aliases = {"albumartist": ("albumartist", "album artist"), "date": ("date", "year"),
"originaldate": ("originaldate", "originalyear"),
"tracknumber": ("tracknumber", "track"), "discnumber": ("discnumber", "disc"),
"musicbrainz_trackid": ("musicbrainz_trackid", "musicbrainz_track_id"),
"musicbrainz_albumid": ("musicbrainz_albumid", "musicbrainz_album_id"),
"musicbrainz_releasegroupid": ("musicbrainz_releasegroupid", "musicbrainz_release_group_id"),
"musicbrainz_releasetrackid": ("musicbrainz_releasetrackid", "musicbrainz_release_track_id")}
updates = {}
for key, value in cls._tag_values(music).items():
if value in (None, "", []):
continue
@@ -618,9 +625,11 @@ class AudioMetadataHelper:
if current and key in {"tracknumber", "discnumber"} and cls._number_pair(current[0]) == cls._number_pair(expected[0]):
# MP4用(1, 0)表达未知总数,与文本标签的1具有相同位置语义。
continue
if key in {"date", "originaldate"} and current and len(expected[0]) == 4 and current[0].startswith(f"{expected[0]}-"):
continue
if not current or (overwrite and current != expected):
return True
return False
updates[key] = expected
return updates
@staticmethod
def _has_cover(audio: Any) -> bool:
@@ -641,8 +650,7 @@ class AudioMetadataHelper:
"""按标签策略保存可支持的字段;空标签音频仍然是可写的有效容器。"""
if audio.tags is None:
audio.add_tags()
values = {key: value if isinstance(value, list) else [str(value)]
for key, value in cls._tag_values(music).items() if value not in (None, "", [])}
values = cls._tag_updates(audio, music, overwrite)
if isinstance(audio.tags, ID3):
changed = cls._write_id3_tags(audio.tags, values, overwrite)
elif isinstance(audio.tags, MP4Tags):
@@ -655,9 +663,9 @@ class AudioMetadataHelper:
@classmethod
def _write_text_tags(cls, audio: Any, values: dict[str, list[str]], overwrite: bool, path: Path) -> bool:
"""写入Vorbis/APEv2文本字段,并使用APE播放器通用的字段名。"""
ape_fields = {"albumartist": "Album Artist", "date": "Year", "tracknumber": "Track", "discnumber": "Disc",
"musicbrainz_trackid": "MusicBrainz Track Id", "musicbrainz_albumid": "MusicBrainz Album Id",
"musicbrainz_releasegroupid": "MusicBrainz Release Group Id", "musicbrainz_releasetrackid": "MusicBrainz Release Track Id"}
# APEv2的MusicBrainz字段沿用下划线名称,不能套用ID3 TXXX描述中的空格。
ape_fields = {"albumartist": "Album Artist", "date": "Year", "originaldate": "Originalyear",
"tracknumber": "Track", "discnumber": "Disc"}
changed = False
for name, value in values.items():
key = ape_fields.get(name, name) if isinstance(audio.tags, APEv2) else name

View File

@@ -545,7 +545,9 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身
写入失败或目标在处理期间改变时,丢弃临时副本,不覆盖原目标。LRC 与 Lyricsfile 旁挂也按目录项原子替换。
WAV、DSF 和 MP3 使用原生 ID3 帧,MP4/M4A 使用标准 atom 及文本 freeform;无标签音频可直接补写,
错误扩展名按实际容器处理。Recording、Release、Release Group、Release Track ID 分别写入专用标签;
仅有首发年份时只写 `ORIGINALDATE`,不能据此生成当前发行日期。
仅有首发年份时只写原始年份标签,不能据此生成当前发行日期;已有同年的完整日期不因模型只携带年份而截断。
APEv2 按[标准标签映射](https://picard-docs.musicbrainz.org/en/latest/appendices/tag_mapping.html)
使用下划线形式的 MusicBrainz 字段和 `Originalyear`,不套用 ID3 的带空格描述;读取仍兼容旧字段别名。
音乐整理按每个文件的 `storage` 决定证据来源:远端仅使用名称、目录和原始种子线索,
不读取本机同路径的标签、时长或 CUE。仅有 `.m4a` 扩展名时不声明 AAC/ALAC 或有损/无损。

View File

@@ -64,7 +64,9 @@ Music recognition, exploration, album and artist navigation, and recognition-cac
Untagged files can be written, and incorrect extensions use the actual
container. Native ID3 and MP4/freeform writers keep recording/release/group/
release-track identities separate. An original year alone writes
`ORIGINALDATE`, never a fabricated current-release date.
the original-year tag (`ORIGINALDATE` or its native equivalent), never a
fabricated current-release date. Preserve more precise existing dates in the
same year. APEv2 MusicBrainz keys use underscores, not ID3's spaced descriptions.
- Organization uses each file's `storage`: remote items use names and original
torrent evidence, never local tags, duration, or CUE at an identical path.
An `.m4a` suffix alone does not establish AAC/ALAC or lossless status.

View File

@@ -12,7 +12,7 @@ import pytest
from mutagen import File as MutagenFile
from mutagen.apev2 import APEv2
from mutagen.flac import FLAC
from mutagen.id3 import ID3
from mutagen.id3 import ID3, TDOR, TDRC
from mutagen.mp4 import MP4
from app.application.audio import AudioMetadataHelper
@@ -268,14 +268,44 @@ def test_native_ape_tags_keep_standard_names_and_identity_namespaces(tmp_path):
path = tmp_path / "native-ape-tags"
path.touch()
info = MusicInfo(title="Song", album_artist="Artist", year=2020, track_number=2, total_tracks=8,
info = MusicInfo(title="Song", album_artist="Artist", year=2020, original_year=2003, track_number=2, total_tracks=8,
media_source="musicbrainz", media_id=_RECORDING, musicbrainz_release_id=_RELEASE,
musicbrainz_release_group_id=_GROUP, musicbrainz_release_track_id=_TRACK)
AudioMetadataHelper._write_tag_values(ApeTagFile(), info, True, path)
tags = APEv2(path)
assert str(tags["Album Artist"]) == "Artist" and str(tags["Track"]) == "2/8"
assert str(tags["Originalyear"]) == "2003"
assert "MusicBrainz Track Id" not in tags
readable = AudioMetadataHelper._readable_tags(tags)
assert AudioMetadataHelper._values(readable, "musicbrainz_track_id") == [_RECORDING]
assert AudioMetadataHelper._values(readable, "musicbrainz_album_id") == [_RELEASE]
assert AudioMetadataHelper._values(readable, "musicbrainz_release_group_id") == [_GROUP]
assert AudioMetadataHelper._values(readable, "musicbrainz_release_track_id") == [_TRACK]
assert AudioMetadataHelper._values(readable, "musicbrainz_trackid") == [_RECORDING]
assert AudioMetadataHelper._values(readable, "musicbrainz_albumid") == [_RELEASE]
assert AudioMetadataHelper._values(readable, "musicbrainz_releasegroupid") == [_GROUP]
assert AudioMetadataHelper._values(readable, "musicbrainz_releasetrackid") == [_TRACK]
reloaded = ApeTagFile()
reloaded.tags = tags
assert not AudioMetadataHelper._needs_tag_write(reloaded, info, True)
@pytest.mark.parametrize("container", ["wav", "dsf", "flac", "mp3", "m4a"])
def test_writing_other_fields_preserves_more_precise_tag_dates(tmp_path, container):
"""改曲名时不能把已有完整日期截断成年份;各容器都保留同年日期的精度。"""
path = _audio_file(tmp_path, container)
assert AudioMetadataHelper.write(path, MusicInfo(title="Old"))
audio = MutagenFile(path)
if isinstance(audio.tags, ID3):
audio.tags.add(TDRC(encoding=3, text=["2020-06-01"]))
audio.tags.add(TDOR(encoding=3, text=["2003-05-02"]))
elif isinstance(audio, MP4):
audio.tags["\xa9day"] = ["2020-06-01"]
audio.tags["----:com.apple.iTunes:ORIGINALDATE"] = [b"2003-05-02"]
else:
audio["date"] = ["2020-06-01"]
audio["originaldate"] = ["2003-05-02"]
audio.save()
assert AudioMetadataHelper.write(path, MusicInfo(title="New", year=2020, release_year=2020, original_year=2003))
tags = AudioMetadataHelper._readable_tags(MutagenFile(path).tags)
assert AudioMetadataHelper._values(tags, "title") == ["New"]
assert AudioMetadataHelper._values(tags, "date") == ["2020-06-01"]
assert AudioMetadataHelper._values(tags, "originaldate") == ["2003-05-02"]