diff --git a/app/chain/transfer/filter.py b/app/chain/transfer/filter.py index 8f8fc34c6..a7a913f1c 100644 --- a/app/chain/transfer/filter.py +++ b/app/chain/transfer/filter.py @@ -32,9 +32,9 @@ from app.domain.meta.metamusic import MetaMusic from app.domain.music import ( MusicDirectoryMatch, music_album_matches, + music_album_year_matches, music_artist_matches, music_cover_priority, - music_release_year_matches, music_title_matches, music_track_title_is_weak, music_version_matches, @@ -244,7 +244,7 @@ class _MusicFileFilterBase(_TransferOwnerBase): and (weak_title or music_title_matches(info, file_meta.title)) and (not file_meta.album or not info.album or music_album_matches(info, file_meta.album)) and ((weak_title and not file_meta.version) or music_version_matches(info, file_meta)) - and music_release_year_matches(info, file_meta) + and music_album_year_matches(info, file_meta) ) if not evidence_matches: logger.warning( diff --git a/app/domain/meta/metamusic.py b/app/domain/meta/metamusic.py index 312311a3f..a325efbdc 100644 --- a/app/domain/meta/metamusic.py +++ b/app/domain/meta/metamusic.py @@ -1879,6 +1879,14 @@ class MetaMusic(MetaBase): """移除大小写、空白与标点,生成比对使用的紧凑文本。""" return _MUSIC_COMPACT_RE.sub("", str(value or "").casefold()) + @classmethod + def is_generic_album_name(cls, value: Optional[str]) -> bool: + """通用收件目录不能凭文件名中的歌手升级为专辑线索;真实标签由调用方优先保留。""" + return cls.compact_text(value) in { + "music", "musics", "audio", "downloads", "download", "inbox", "unknown", "untitled", + "various", "variousartists", "va", "音乐", "音樂", "下载", "下載", "未分类", "未分類", + } + @staticmethod def _clean_text(value: Any) -> str: """压缩多余空白,返回可用于匹配和展示的文本。""" @@ -2056,8 +2064,9 @@ class MetaMusic(MetaBase): album_dir = parent.parent dir_info = self.parse_album_dir(album_dir.name) if dir_info: - # 目录名同时带歌手或年份才视为有意的专辑命名,避免把监控根目录误当专辑 - if dir_info.get("artist") or dir_info.get("year"): + # 文件已明确歌手时,纯专辑名目录也可补足检索线索;通用监控根目录仍须排除。 + if (dir_info.get("artist") or dir_info.get("year") + or (self.artists and not self.is_generic_album_name(dir_info.get("album")))): if not self.album and dir_info.get("album"): self.album = dir_info["album"] self.field_sources["album"] = "directory" diff --git a/app/domain/music.py b/app/domain/music.py index 9ad1594bd..38b7c3a04 100644 --- a/app/domain/music.py +++ b/app/domain/music.py @@ -234,7 +234,7 @@ def music_completed_album_keys(metas: list[MetaMusic], target: MusicInfo) -> lis def music_album_candidate_matches(album: MusicAlbumInfo, meta: MetaMusic, tracks: list[MetaMusic]) -> bool: - """来源专辑必须满足名称、署名、版本与完整本地对位;弱专辑名需要额外曲目证据。""" + """核验名称、署名、版本与本地对位;普通年份可缺省,弱专辑名需要额外曲目证据。""" if not tracks or not album.tracks or not music_album_has_consistent_tracks(album): return False info = album.to_music_info() @@ -245,7 +245,7 @@ def music_album_candidate_matches(album: MusicAlbumInfo, meta: MetaMusic, tracks if (artists or any(music_credit_values(meta).values())) and not music_album_artist_evidence_matches(album, meta): return False evidence = MetaMusic(title=meta.album or meta.title, version=meta.version) - if not music_version_matches(info, evidence) or not music_release_year_matches(info, meta): + if not music_version_matches(info, evidence) or not music_album_year_matches(info, meta): return False aligned = align_music_tracks(tracks, album.tracks) if len(aligned) != len(tracks): @@ -448,10 +448,7 @@ def music_album_title_is_weak(meta: MetaMusic) -> bool: source = meta.field_sources.get("album" if meta.album else "title") if source in {"tag", "album_tags", "cue", "torrent", "manual", "remote"}: return False - return music_text_key(title) in { - "music", "musics", "audio", "downloads", "download", "inbox", "unknown", "untitled", - "various", "variousartists", "va", "音乐", "音樂", "下载", "下載", "未分类", "未分類", - } + return MetaMusic.is_generic_album_name(title) def expand_music_tracks(metas: list[MetaMusic]) -> tuple[list[MetaMusic], list[int]]: @@ -749,7 +746,10 @@ def music_artist_alias_targets( title = (meta.album or meta.title) if info.music_type == MUSIC_ENTITY_ALBUM else meta.title if not music_title_matches(info, title, preserve_editions=True) or not music_version_matches(info, meta): continue - if not music_release_year_matches(info, meta): + year_matches = (music_album_year_matches + if isinstance(candidate, MusicAlbumInfo) or info.music_type == MUSIC_ENTITY_ALBUM + else music_release_year_matches) + if not year_matches(info, meta): continue for artist_id in candidate.artist_ids: if not artist_id or (artist_id not in targets and len(targets) >= 3): @@ -821,6 +821,11 @@ def music_year_matches(music: MusicInfo, meta: MetaMusic) -> bool: return str(music.year).strip() == str(meta.year).strip() +def music_album_year_matches(music: MusicInfo, meta: MetaMusic) -> bool: + """普通年份仅作排名线索;双方明确的发行年才约束版本,来源首发年不能冒充发行年。""" + return not meta.release_year or not music.release_year or meta.release_year == music.release_year + + def music_release_year_matches(music: MusicInfo, meta: MetaMusic) -> bool: """具体发行按当前发行年核验;已分离的原始年不能再冒充当前年制造再版冲突。""" if meta.release_year: diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index a13e76685..57fee071b 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -39,6 +39,7 @@ from app.domain.music import ( music_album_artist_evidence_matches, music_album_matches, music_album_title_is_weak, + music_album_year_matches, music_artist_affix_matches, music_artist_evidence_matches, music_base_title, @@ -1266,7 +1267,7 @@ class MusicBrainzModule(_ModuleBase): expected = getattr(meta, field) if expected and expected != getattr(album, field): return 0.0 - if meta.release_year and album.year and meta.release_year != album.year: + if not music_album_year_matches(album.to_music_info(), meta): return 0.0 known_title = not music_album_title_is_weak(meta) title_sim = cls._text_similarity(meta.album or meta.title, album.title) if known_title else 0.0 @@ -1728,7 +1729,7 @@ class MusicBrainzModule(_ModuleBase): meta_volume = cls._volume_number(clean_title) ranked: list[tuple[bool, int, MusicInfo]] = [] for album in albums: - if music_credit_conflicts(album, meta): + if music_credit_conflicts(album, meta) or not music_album_year_matches(album, meta): continue score = 0 album_title = album.title or album.album diff --git a/app/modules/theaudiodb/__init__.py b/app/modules/theaudiodb/__init__.py index fb8521b6d..92d0578ff 100644 --- a/app/modules/theaudiodb/__init__.py +++ b/app/modules/theaudiodb/__init__.py @@ -18,6 +18,7 @@ from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic, music_credit_values from app.domain.music import ( music_album_artist_evidence_matches, + music_album_year_matches, music_artist_evidence_matches, music_credit_conflicts, music_isrc_matches, @@ -582,7 +583,7 @@ class TheAudioDbModule(_ModuleBase): meta: MetaMusic, candidates: list[MusicAlbumInfo], ) -> Optional[MusicAlbumInfo]: - """将来源专辑投影到统一音乐模型,按专辑名、专辑署名与版本确认。""" + """按专辑名、署名和版本确认;普通年份可缺省,明确发行年仍须核验。""" expected_title = meta.album or meta.title expected_artists = [meta.album_artist] if meta.album_artist else meta.artists album_meta = MetaMusic(title=expected_title, version=meta.version) @@ -593,7 +594,7 @@ class TheAudioDbModule(_ModuleBase): continue if (expected_artists or any(music_credit_values(meta).values())) and not music_album_artist_evidence_matches(candidate, meta): continue - if not music_release_year_matches(music, meta): + if not music_album_year_matches(music, meta): continue matches.append(candidate) return unique_music_match(matches) diff --git a/docs/music-recognition.md b/docs/music-recognition.md new file mode 100644 index 000000000..dad7fd987 --- /dev/null +++ b/docs/music-recognition.md @@ -0,0 +1,14 @@ +# 音乐专辑识别 + +专辑匹配不要求提供年份。普通 `year`、原始发行年 `original_year` 用于候选排名, +不单独否决名称、艺人、录音版本和曲目证据相符的专辑,以兼容首发、再版和数字发行的年份差异。 +明确提供的当前发行年 `release_year` 仍是版本约束;来源年份未知时不凭空判为冲突。 +MusicBrainz 具体发行 ID、发行组 ID,以及 Live、Remix 等录音版本的约束保持有效。 + +文件名或标签已提供歌手时,不带歌手、年份的纯专辑名目录也会补入缺失的专辑字段。 +例如 `周杰伦的床边故事/周杰伦 - 告白气球.flac` 会提供“周杰伦 / 告白气球 / 周杰伦的床边故事” +三项检索线索。`Music`、`Downloads`、`音乐` 等通用目录不作为专辑名,已有标签专辑不被目录覆盖。 + +目录线索不等于在线身份确认。来源专辑仍须通过艺人、名称、版本和本地曲目对位校验; +不同录音或不同内容的多个合理候选仍需人工选择,不按搜索返回顺序猜测。 +完整可信的本地标签可直接用于离线整理,无须先获得唯一的在线数据库 ID。 diff --git a/tests/test_music_album_year.py b/tests/test_music_album_year.py new file mode 100644 index 000000000..a68da576f --- /dev/null +++ b/tests/test_music_album_year.py @@ -0,0 +1,149 @@ +"""专辑年份只作辅助证据,明确发行年份和真实内容冲突仍须拒绝。""" + +from copy import deepcopy +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from app.application.audio import AudioMetadataHelper +from app.chain.media import MediaChain +from app.chain.transfer.filter import _MusicFileFilterBase +from app.domain.context import MusicInfo +from app.domain.meta.metamusic import MetaMusic +from app.domain.music import music_album_candidate_matches, music_album_year_matches, music_artist_alias_targets +from app.modules.musicbrainz import MusicBrainzModule +from app.modules.theaudiodb import TheAudioDbModule +from app.schemas.file import FileItem +from app.schemas.types import MediaSource +from tests.test_music_album_match import ALBUM_TRACKS, _local_tracks, _release_detail + + +@pytest.fixture +def album(): + """构造专辑及录音身份一致、可完整对位的再版候选。""" + detail = _release_detail("release-2020", "七里香", "周杰伦", ALBUM_TRACKS) + detail["date"] = "2020-01-01" + return MusicBrainzModule._release_to_album(detail) + + +@pytest.mark.parametrize("year", [None, 2004, 2020]) +@pytest.mark.parametrize("source", [MediaSource.MusicBrainz, MediaSource.TheAudioDB, MediaSource.DoubanMusic]) +def test_album_track_verification_does_not_require_matching_general_year(album, year, source): + """专辑名称、艺人与曲目吻合时,未知或不同普通年份不阻断来源核验。""" + album.media_source = source + for track in album.tracks: + track.media_source = source + meta = MetaMusic(album="七里香", artists=["周杰伦"], year=year) + + assert music_album_candidate_matches(album, meta, _local_tracks()) + + +@pytest.mark.parametrize("year,expected", [(None, True), (2004, False), (2020, True)]) +def test_album_track_verification_preserves_explicit_release_year(album, year, expected): + """用户指定具体发行年份时,普通曲目吻合不能抵消发行年的明确冲突。""" + meta = MetaMusic(album="七里香", artists=["周杰伦"], release_year=year) + + assert music_album_candidate_matches(album, meta, _local_tracks()) is expected + + +def test_catalog_first_release_year_is_not_a_known_edition_year(): + """次级专辑目录只有首发年时,不把它当成已知再版年份制造冲突。""" + info = MusicInfo(music_type="album", year=2004, original_year=2004) + + assert music_album_year_matches(info, MetaMusic(release_year=2020)) + + +@pytest.mark.parametrize("source", [MediaSource.MusicBrainz, MediaSource.TheAudioDB]) +@pytest.mark.parametrize("year", [None, 2004, 2020]) +def test_album_source_selection_accepts_optional_general_year(album, source, year): + """两个内置来源的专辑候选采用同一可选年份规则。""" + album.media_source = source + meta = MetaMusic(title="七里香", album="七里香", artists=["周杰伦"], year=year) + + result = (MusicBrainzModule._select_album_candidate(meta, [album.to_music_info()]) + if source == MediaSource.MusicBrainz else TheAudioDbModule._select_album(meta, [album])) + + assert result is not None + + +@pytest.mark.parametrize("source", [MediaSource.MusicBrainz, MediaSource.TheAudioDB]) +def test_album_source_selection_rejects_explicit_release_year_conflict(album, source): + """专辑搜索不能绕过明确发行年份,再把错误结果交给整理。""" + album.media_source = source + meta = MetaMusic(title="七里香", album="七里香", artists=["周杰伦"], release_year=2004) + + result = (MusicBrainzModule._select_album_candidate(meta, [album.to_music_info()]) + if source == MediaSource.MusicBrainz else TheAudioDbModule._select_album(meta, [album])) + + assert result is None + + +def test_album_year_difference_does_not_prevent_artist_alias_verification(album): + """普通年份差异不能阻止候选按已知Artist ID补充真实别名。""" + meta = MetaMusic(album="七里香", artists=["ZhouJieLun"], year=2004) + + assert music_artist_alias_targets([(meta, album)], MediaSource.MusicBrainz) == {"artist-1": [album]} + + +@pytest.mark.parametrize("release_year,matched", [(None, True), (2004, False), (2020, True)]) +def test_transfer_album_recheck_uses_optional_year(album, monkeypatch, tmp_path, release_year, matched): + """目录已完整匹配后,整理复核不能重新用普通年份否决同一候选。""" + path = tmp_path / "我的地盘.flac" + info = album.tracks[0] + monkeypatch.setattr(MediaChain, "recognize_music_album_directory", Mock(return_value={str(path.resolve()): info})) + meta = MetaMusic(title="我的地盘", album="七里香", artists=["周杰伦"], year=2004, release_year=release_year) + + _meta, result = _MusicFileFilterBase._match_music_album_context(FileItem(storage="local", path=str(path)), path, meta) + + assert (result is not None) is matched + + +@pytest.mark.parametrize("conflict", ["artist", "title", "version", "tracks"]) +def test_optional_year_does_not_accept_conflicting_album_content(album, conflict): + """放宽普通年份不能接受不同艺人、作品、录音版本或无法对位的曲目。""" + candidate = deepcopy(album) + meta = MetaMusic(album="七里香", artists=["周杰伦"], year=2004) + if conflict == "artist": + candidate.artists = ["另一位歌手"] + candidate.artist_aliases = [] + elif conflict == "title": + candidate.title = "另一张专辑" + elif conflict == "version": + meta.version = "Live" + else: + candidate.tracks[0].title = "另一首歌曲" + + assert not music_album_candidate_matches(candidate, meta, _local_tracks()) + + +def test_confession_balloon_path_retains_album_without_year(): + """真实报告中的纯专辑名目录无需年份,即可补足单曲检索的专辑线索。""" + path = Path("/音乐/周杰伦 - 全部专辑(无损或320K)/周杰伦的床边故事/周杰伦 - 告白气球.flac") + + meta = AudioMetadataHelper.read_filename(path) + + assert (meta.title, meta.artists, meta.album, meta.year) == ("告白气球", ["周杰伦"], "周杰伦的床边故事", None) + assert meta.field_sources["album"] == "directory" + + +@pytest.mark.parametrize("directory", ["Music", "Downloads", "inbox", "音乐", "未分類"]) +def test_generic_directory_does_not_become_album(directory): + """文件名已有歌手也不能把通用监控根目录补成专辑名。""" + meta = AudioMetadataHelper.read_filename(Path("/data") / directory / "周杰伦 - 告白气球.flac") + + assert meta.album is None + + +def test_yearless_directory_is_only_candidate_evidence(): + """明确专辑线索排除同名曲的其他专辑,仍保留不同录音ID的歧义保护。""" + path = Path("/音乐/周杰伦的床边故事/周杰伦 - 告白气球.flac") + meta = AudioMetadataHelper.read_filename(path) + correct = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="recording-one", title="告白气球", + artists=["周杰伦"], album="周杰伦的床边故事") + other = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="recording-two", title="告白气球", + artists=["周杰伦"], album="另一张专辑") + + assert MusicBrainzModule._select_candidate(meta, [other, correct], MediaSource.MusicBrainz) is correct + other.album = correct.album + assert MusicBrainzModule._select_candidate(meta, [other, correct], MediaSource.MusicBrainz) is None diff --git a/tests/test_transfer_music_classification.py b/tests/test_transfer_music_classification.py index 5c651a8ea..7d5bcade0 100644 --- a/tests/test_transfer_music_classification.py +++ b/tests/test_transfer_music_classification.py @@ -133,13 +133,14 @@ def test_album_context_rejects_candidate_conflicting_with_file_tags( tmp_path, monkeypatch, ) -> None: - """目录级匹配也必须核验逐文件标签,不能绕过曲目层的发行证据门禁。""" + """目录级匹配仍须核验逐文件明确的发行年份,不能用普通年份规则绕过冲突。""" file_path = tmp_path / "Speak Now" / "02 - Sparks Fly.flac" local_meta = MetaMusic( title="Sparks Fly", artists=["Taylor Swift"], album="Speak Now", year=2010, + release_year=2010, ) wrong_release = MusicInfo( media_source="musicbrainz", @@ -148,6 +149,7 @@ def test_album_context_rejects_candidate_conflicting_with_file_tags( artists=["Taylor Swift"], album="Speak Now", year=2025, + release_year=2025, ) file_item = FileItem( storage="local",