mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-06 07:09:09 +08:00
fix(music): make album years optional matching evidence (#7004)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
14
docs/music-recognition.md
Normal file
14
docs/music-recognition.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 音乐专辑识别
|
||||
|
||||
专辑匹配不要求提供年份。普通 `year`、原始发行年 `original_year` 用于候选排名,
|
||||
不单独否决名称、艺人、录音版本和曲目证据相符的专辑,以兼容首发、再版和数字发行的年份差异。
|
||||
明确提供的当前发行年 `release_year` 仍是版本约束;来源年份未知时不凭空判为冲突。
|
||||
MusicBrainz 具体发行 ID、发行组 ID,以及 Live、Remix 等录音版本的约束保持有效。
|
||||
|
||||
文件名或标签已提供歌手时,不带歌手、年份的纯专辑名目录也会补入缺失的专辑字段。
|
||||
例如 `周杰伦的床边故事/周杰伦 - 告白气球.flac` 会提供“周杰伦 / 告白气球 / 周杰伦的床边故事”
|
||||
三项检索线索。`Music`、`Downloads`、`音乐` 等通用目录不作为专辑名,已有标签专辑不被目录覆盖。
|
||||
|
||||
目录线索不等于在线身份确认。来源专辑仍须通过艺人、名称、版本和本地曲目对位校验;
|
||||
不同录音或不同内容的多个合理候选仍需人工选择,不按搜索返回顺序猜测。
|
||||
完整可信的本地标签可直接用于离线整理,无须先获得唯一的在线数据库 ID。
|
||||
149
tests/test_music_album_year.py
Normal file
149
tests/test_music_album_year.py
Normal file
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user