diff --git a/app/application/audio.py b/app/application/audio.py index 3d0ae481e..5d90f1015 100644 --- a/app/application/audio.py +++ b/app/application/audio.py @@ -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 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 63196c55c..bac31a866 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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 或有损/无损。 diff --git a/skills/moviepilot-api/api/music.md b/skills/moviepilot-api/api/music.md index 36d79ef0d..a2ddcc20a 100644 --- a/skills/moviepilot-api/api/music.md +++ b/skills/moviepilot-api/api/music.md @@ -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. diff --git a/tests/test_music_source_protection.py b/tests/test_music_source_protection.py index 657657dac..68afda68a 100644 --- a/tests/test_music_source_protection.py +++ b/tests/test_music_source_protection.py @@ -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"]