From a01a941c118acb3e03f4122df612bd67799270a2 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 6 Sep 2026 04:06:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E5=A4=87=E9=80=89=E6=9D=A5=E6=BA=90=E5=8C=B9=E9=85=8D=E5=B9=B6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=BD=95=E9=9F=B3=E7=89=88=E6=9C=AC=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/domain/music.py | 72 ++++++- app/modules/douban/__init__.py | 42 ++--- app/modules/musicbrainz/__init__.py | 8 +- app/modules/theaudiodb/__init__.py | 43 ++--- docs/architecture-overview.md | 2 +- docs/architecture/media-search-design.md | 10 + docs/architecture/optimization-checklist.md | 2 +- docs/mcp-api.md | 3 + skills/moviepilot-api/SKILL.md | 4 + .../architecture/dependency-baseline.json | 6 +- tests/test_music_matching.py | 41 +++- tests/test_music_metadata_sources.py | 177 +++++++++++++++++- tests/test_music_recognize_cache.py | 22 +++ 13 files changed, 365 insertions(+), 67 deletions(-) diff --git a/app/domain/music.py b/app/domain/music.py index c2e677e77..f396074cd 100644 --- a/app/domain/music.py +++ b/app/domain/music.py @@ -2,6 +2,7 @@ import re from dataclasses import dataclass +from datetime import date from typing import Iterable, Literal, Optional from unicodedata import combining, normalize @@ -33,6 +34,9 @@ _BARE_VERSION_SUFFIX = re.compile( ) _TITLE_LABEL = re.compile(r"^(?:专辑(?:名|名称)?|專輯(?:名|名稱)?|曲名|歌曲|album|title)\s*[::]\s*", re.I) _COLLECTIVE_ARTISTS = ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人") +_VERSION_YEAR = re.compile(r"(? boo return bool(keys & {music_text_key(artist) for artist in artists}) -def music_base_title(value: Optional[str]) -> str: - """仅剥离已知发行版本后缀,保留未知括号和属于作品本身的文字。""" - text = _VERSION_SUFFIX.sub("", _EDITION.sub("", str(value or ""))) - return _BARE_VERSION_SUFFIX.sub("", text).strip() +def music_base_title(value: Optional[str], *, preserve_editions: bool = False) -> str: + """剥离已知版本后缀;数据源可保留发行版标签,不改变未知括号中的作品名。""" + text = str(value or "") + if not preserve_editions: + text = _EDITION.sub("", text) + + def strip_version(match: re.Match[str]) -> str: + """保留发行版声明,避免被包含它的录音版本标签一并删除。""" + return match.group(0) if preserve_editions and _EDITION.search(match.group(0)) else "" + + return _BARE_VERSION_SUFFIX.sub(strip_version, _VERSION_SUFFIX.sub(strip_version, text)).strip() + + +def music_title_matches(music: MusicInfo, title: Optional[str], *, preserve_editions: bool = False) -> bool: + """统一全半角后比较完整名称及别名,允许数据源保持既有发行版本边界。""" + expected = music_text_key(music_base_title(normalize("NFKC", str(title or "")), preserve_editions=preserve_editions)) + return bool(expected and any( + expected == music_text_key(music_base_title(normalize("NFKC", name), preserve_editions=preserve_editions)) + for name in music_titles(music) + )) + + +def _isrc_key(value: Optional[str]) -> Optional[str]: + """校验 12 位 ISRC 结构,兼容展示前缀、空白和分隔符,不接受占位值。""" + code = re.sub(r"[\s-]+", "", str(value or "")) + if not _ISRC.fullmatch(code) and code[:4].lower() == "isrc": + code = code[4:].lstrip(":") + return code.upper() if _ISRC.fullmatch(code) else None + + +def music_isrc_matches(music: MusicInfo, meta: MetaMusic) -> bool: + """只有格式有效且相同的 ISRC 才能作为优先于文本匹配的录音身份。""" + expected = _isrc_key(meta.isrc) + return bool(expected and expected == _isrc_key(music.isrc)) def _contains_artist(text: str, artist: str) -> bool: @@ -141,7 +175,24 @@ def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = Fal def _version_markers(text: str) -> set[str]: """识别会改变录音身份的版本标记,普通发行后缀单独处理。""" - return {name for name, pattern in _VERSIONS.items() if re.search(pattern, text, re.I)} + normalized = normalize("NFKC", text) + return {name for name, pattern in _VERSIONS.items() if re.search(pattern, normalized, re.I)} + + +def _version_dates(title: Optional[str], version: Optional[str]) -> tuple[set[int], set[date]]: + """只提取明确版本字段及版本后缀中的日期,不把数字作品名或发行年份当作录制日期。""" + title_text = normalize("NFKC", str(title or "")) + text = normalize("NFKC", " ".join([ + str(version or ""), *_VERSION_SUFFIX.findall(title_text), *_BARE_VERSION_SUFFIX.findall(title_text), + ])) + years = {int(year) for year in _VERSION_YEAR.findall(text)} + dates: set[date] = set() + for match in _VERSION_DATE.finditer(text): + try: + dates.add(date(*(int(value) for value in match.groups()))) + except ValueError: + continue + return years, dates def music_version_matches(music: MusicInfo, meta: MetaMusic) -> bool: @@ -150,7 +201,14 @@ def music_version_matches(music: MusicInfo, meta: MetaMusic) -> bool: # 专辑类型描述整专版本,但单曲的所属专辑类型不能代替该录音自身的版本。 album_versions = " ".join(music.secondary_types or []) if music.music_type == MUSIC_ENTITY_ALBUM else "" expected = _version_markers(f"{target_title or ''} {music.version or ''} {album_versions}") - return expected == _version_markers(f"{meta.title or ''} {meta.version or ''}") + if expected != _version_markers(f"{meta.title or ''} {meta.version or ''}"): + return False + expected_years, expected_dates = _version_dates(target_title, music.version) + actual_years, actual_dates = _version_dates(meta.title, meta.version) + # 多个时间值可能描述区间或重发记录,不能当作唯一录制时间互斥比较。 + if len(expected_dates) == len(actual_dates) == 1 and expected_dates != actual_dates: + return False + return not (len(expected_years) == len(actual_years) == 1 and expected_years != actual_years) def match_music_resource( @@ -171,7 +229,7 @@ def match_music_resource( names = _resource_names(resource, artists, album=music.music_type == MUSIC_ENTITY_ALBUM, album_suffixes=albums if music.music_type != MUSIC_ENTITY_ALBUM else None) titles = music_titles(music) - title_matched = any(music_text_key(music_base_title(item)) in names for item in titles) + title_matched = any(music_title_matches(music, name) for name in names) content = f"{title} {description}" artist_matched = music_artist_matches(music, resource.artists) if resource.artists \ else any(_contains_artist(content, artist) for artist in artists) diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 65c6f9563..c86fd6c6f 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -14,6 +14,7 @@ from app.domain.media import is_media_source_enabled, is_media_source_selected from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo +from app.domain.music import music_artist_matches, music_title_matches, music_version_matches from app.foundation.text import convert as zhconv_convert from app.modules import _ModuleBase from app.modules._base.media import MediaAuxiliaryProviderMixin @@ -294,7 +295,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): direct_result = self._direct_music_candidate(plan, candidate) if direct_result is not None: return direct_result - if meta.album and meta.title: + if plan.music_type != MUSIC_ENTITY_ALBUM and meta.album and meta.title: info = self.doubanapi.music_detail(subject_id=str(candidate.media_id)) album = self._douban_music_to_album(info) if info else None matched_track = self._select_douban_music_track(meta, album) @@ -331,7 +332,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): direct_result = self._direct_music_candidate(plan, candidate) if direct_result is not None: return direct_result - if meta.album and meta.title: + if plan.music_type != MUSIC_ENTITY_ALBUM and meta.album and meta.title: info = await self.doubanapi.async_music_detail( subject_id=str(candidate.media_id) ) @@ -408,20 +409,17 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): meta: MetaMusic, candidates: List[MusicInfo], ) -> List[MusicInfo]: - """按专辑标题和可用艺术家线索过滤豆瓣音乐候选。""" + """按专辑名称与专辑署名筛选父级候选,录音版本留待实际目标确认。""" expected_title = meta.album or meta.title + expected_artists = [meta.album_artist] if meta.album_artist else meta.artists return [ candidate for candidate in candidates - if cls._same_music_text(expected_title, candidate.title) + if music_title_matches(candidate, expected_title, preserve_editions=True) and ( - not meta.artists + not expected_artists or not candidate.artists - or any( - cls._same_music_text(expected, actual) - for expected in meta.artists - for actual in candidate.artists - ) + or music_artist_matches(candidate, expected_artists) ) ] @@ -432,13 +430,12 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): ) -> Optional[MusicInfo]: """决定候选可直接返回,还是必须继续读取专辑曲目。""" meta = plan.require_meta() - if plan.music_type == MUSIC_ENTITY_ALBUM: - return candidate - if meta.album and meta.title: + if plan.music_type != MUSIC_ENTITY_ALBUM and ( + (meta.album and meta.title) or plan.music_type == MUSIC_ENTITY_RECORDING + ): return None - if plan.music_type == MUSIC_ENTITY_RECORDING: - return None - return candidate + album_meta = MetaMusic(title=meta.album or meta.title, version=meta.version) + return candidate if music_version_matches(candidate, album_meta) else None @classmethod def _select_douban_music_track( @@ -451,16 +448,12 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): return None candidates = [ track for track in album.tracks - if cls._same_music_text(meta.title, track.title) + if music_title_matches(track, meta.title, preserve_editions=True) and music_version_matches(track, meta) ] if meta.artists: candidates = [ track for track in candidates - if any( - cls._same_music_text(expected, actual) - for expected in meta.artists - for actual in track.artists - ) + if music_artist_matches(track, meta.artists) ] if not candidates: return None @@ -785,11 +778,6 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): text = cls._douban_music_text(value) return int(text[:4]) if text and text[:4].isdigit() else None - @staticmethod - def _same_music_text(left: Optional[str], right: Optional[str]) -> bool: - """使用音乐元数据紧凑文本规则比较豆瓣候选。""" - return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right)) - @staticmethod def _prepare_search_names(meta: MetaBase) -> List[str]: """ diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index db96191a8..75c4dabf0 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -21,7 +21,9 @@ from app.domain.meta.metamusic import MetaMusic from app.domain.music import ( music_artist_matches, music_base_title, + music_isrc_matches, music_text_key, + music_title_matches, music_titles, music_version_matches, unique_music_texts, @@ -1092,6 +1094,8 @@ class MusicBrainzModule(_ModuleBase): return None if plan.music_type and cached_info.music_type != plan.music_type: return None + if cached_info.media_id and not music_isrc_matches(cached_info, meta) and not music_version_matches(cached_info, meta): + return None if cached_info.media_id: logger.info(f"{meta.title} 使用音乐识别缓存:{cached_info.title}") else: @@ -1223,7 +1227,7 @@ class MusicBrainzModule(_ModuleBase): for candidate in candidates: if normalized_source and str(candidate.media_source or "").casefold() != normalized_source: continue - if meta.isrc and cls._same_text(meta.isrc, candidate.isrc): + if music_isrc_matches(candidate, meta): # 相同 ISRC 是明确录音身份,不能被另一条纯标题命中的得分压过。 return candidate score = 0 @@ -1237,7 +1241,7 @@ class MusicBrainzModule(_ModuleBase): elif ( bare_title and artist_match - and any(cls._same_text(bare_title, music_base_title(title)) for title in titles) + and music_title_matches(candidate, clean_title) ): score += 2 title_match = True diff --git a/app/modules/theaudiodb/__init__.py b/app/modules/theaudiodb/__init__.py index d3168e466..4b557a733 100644 --- a/app/modules/theaudiodb/__init__.py +++ b/app/modules/theaudiodb/__init__.py @@ -10,6 +10,7 @@ from app.domain.context import ( from app.domain.media import is_media_source_selected from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic +from app.domain.music import music_artist_matches, music_isrc_matches, music_title_matches, music_version_matches from app.modules import _ModuleBase from app.runtime.cache import cached from app.runtime.log import logger @@ -483,9 +484,9 @@ class TheAudioDbModule(_ModuleBase): @staticmethod def _album_search_params(meta: MetaMusic) -> Optional[dict[str, str]]: - """从音乐元数据归一化专辑搜索参数。""" + """专辑查询优先使用专辑署名,不把合辑中的单曲表演者当作专辑艺人。""" album_name = meta.album or meta.title - artist = meta.artists[0] if meta.artists else meta.album_artist + artist = meta.album_artist or (meta.artists[0] if meta.artists else None) if not album_name or not artist: return None return {"a": album_name, "s": artist} @@ -511,41 +512,37 @@ class TheAudioDbModule(_ModuleBase): for item in self._entities(payload, "artists", "artist") ] - @classmethod + @staticmethod def _select_track( - cls, meta: MetaMusic, candidates: list[MusicInfo], ) -> Optional[MusicInfo]: - """按曲名和可用艺术家线索选择可信单曲候选。""" + """复用统一音乐证据确认单曲,明确 ISRC 优先于名称候选。""" + identity = next((candidate for candidate in candidates if music_isrc_matches(candidate, meta)), None) + if identity is not None: + return identity for candidate in candidates: - if not cls._same_text(meta.title, candidate.title): + if not music_title_matches(candidate, meta.title, preserve_editions=True) or not music_version_matches(candidate, meta): continue - if meta.artists and not any( - cls._same_text(expected, actual) - for expected in meta.artists - for actual in candidate.artists - ): + if meta.artists and not music_artist_matches(candidate, meta.artists): continue return candidate return None - @classmethod + @staticmethod def _select_album( - cls, 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) for candidate in candidates: - if not cls._same_text(expected_title, candidate.title): + music = candidate.to_music_info() + if not music_title_matches(music, expected_title, preserve_editions=True) or not music_version_matches(music, album_meta): continue - if meta.artists and not any( - cls._same_text(expected, actual) - for expected in meta.artists - for actual in candidate.artists - ): + if expected_artists and not music_artist_matches(music, expected_artists): continue return candidate return None @@ -589,6 +586,7 @@ class TheAudioDbModule(_ModuleBase): metadata_category=" / ".join(genres), genres=genres, names=cls._unique_texts([title, item.get("strTrackAlternate")]), + title_aliases=cls._unique_texts([item.get("strTrackAlternate")]), detail_link=f"{cls._detail_url}/track/{media_id}", raw_data={ "musicbrainz_id": cls._text(item.get("strMusicBrainzID")), @@ -874,8 +872,3 @@ class TheAudioDbModule(_ModuleBase): seen.add(identity) results.append(text) return results - - @staticmethod - def _same_text(left: Optional[str], right: Optional[str]) -> bool: - """使用音乐元数据紧凑文本规则比较标题和艺术家。""" - return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right)) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 5803fc654..d55332523 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -755,7 +755,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 973 | -| 内部导入边 | 8,259 | +| 内部导入边 | 8,261 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/media-search-design.md b/docs/architecture/media-search-design.md index 8eaccf1f7..46801f9c6 100644 --- a/docs/architecture/media-search-design.md +++ b/docs/architecture/media-search-design.md @@ -78,6 +78,16 @@ - MusicBrainz 的候选确认与资源匹配复用繁简、变音符、可信别名、完整署名和录音版本规则。 单曲不凭首词、包含关系或任意括号剥离认定同一作品;已返回的同一 ISRC 优先于名称打分。 专辑的 `secondary_types` 可提供整专版本证据,但不能反向覆盖其中单曲的录音版本。 +- TheAudioDB 与豆瓣音乐也复用名称、署名和版本规则;TheAudioDB 的 `strTrackAlternate` + 投影为曲名别名,不能混入专辑名称。专辑查询及父专辑确认优先使用 `album_artist`, + 单曲仍按自己的表演者确认,不因合辑署名不同而跳过其曲目。 + 备选来源保留既有发行版名称约束,不因录音版本后缀归一化而将豪华版合并成普通版。 +- 录音版本双方各自提供唯一有效年份或日期且冲突时,不确认成同一版本;支持全半角、 + 数字分隔日期及中文日期。普通数字曲名、缺失日期、无效日期与多时间值不被臆断为冲突。 + 已缓存的无显式 ID 名称识别结果也重新检查版本,已核验的相同 ISRC 保持身份优先级。 +- ISRC 优先级仅适用于符合 [ISRC 官方结构](https://isrc.ifpi.org/isrc-standard/isrc-structure) + 的代码,兼容大小写、展示前缀及分隔符,不能把占位文本相等当成身份相等。 + 代码内的年份是分配参考年,不用于推断录制日期。 - 自动识别在某个检索式返回的候选全部不匹配时继续尝试后续检索式;手动元数据目录 浏览仍保留原始候选。Python/Rust 标题解析进入共同包装层时均保留 `[Live]` 等版本证据。 - 音乐识别缓存按请求实体与来源身份或完整标题、署名、专辑、年份、版本、ISRC 编码, diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 5750ded77..4a362f263 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 976 / 8,259 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 976 / 8,261 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 95fb5312c..d781636de 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -332,6 +332,9 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch 音乐资源解析同样应用全局或订阅自定义识别词,`MusicMeta.apply_words` 返回实际应用记录, 旧结果缺少该字段时按空列表处理。副标题的明确录音版本参与匹配;单曲所属专辑字段 不能证明资源覆盖整张专辑,无曲序的单曲也会标记为 `partial_album` 待确认项。 +这些名称、艺名和版本规则也适用于显式选择的 TheAudioDB 与豆瓣音乐;查询专辑或确认 +单曲所属专辑时优先使用专辑艺人,不能覆盖单曲的表演者。版本字段中双方明确且唯一的 +年份、录制日期冲突会使资源进入 `version_mismatch` 待确认项,缺少日期不单独造成淘汰。 音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true`, 额外返回待确认资源及关联专辑:`match_status=candidate`、`match_reason` 描述原因, diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 0895ef31e..e03e37ff7 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -170,6 +170,10 @@ Call the gateway with this shape: Explicit subtitle versions participate in matching. A track's `album` field does not prove whole-album coverage, even without a track number; keep `partial_album` candidates out of automatic downloads. +- TheAudioDB and Douban Music use the same name, artist, and version evidence + rules. Album lookup uses the album credit without replacing the track's + performer. Conflicting explicit recording dates are version mismatches; + missing dates alone do not reject a candidate. ## Operation Catalog diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 70a34ddd6..8c9321230 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8259, - "edge_sha256": "a380bbd37ee23f66e7195b0c143b4d5b2a25b17254c18946789b45174ec2eb92", + "edge_count": 8261, + "edge_sha256": "062c4385a99cd29502ce65387bda0f768399c824f293db9b2d90e108f8c340b3", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -6214,6 +6214,7 @@ "app.modules.douban -> app.domain.meta.metabase", "app.modules.douban -> app.domain.meta.metamusic", "app.modules.douban -> app.domain.metainfo", + "app.modules.douban -> app.domain.music", "app.modules.douban -> app.foundation", "app.modules.douban -> app.foundation.text", "app.modules.douban -> app.modules", @@ -7228,6 +7229,7 @@ "app.modules.theaudiodb -> app.domain.meta", "app.modules.theaudiodb -> app.domain.meta.metabase", "app.modules.theaudiodb -> app.domain.meta.metamusic", + "app.modules.theaudiodb -> app.domain.music", "app.modules.theaudiodb -> app.modules", "app.modules.theaudiodb -> app.runtime", "app.modules.theaudiodb -> app.runtime.cache", diff --git a/tests/test_music_matching.py b/tests/test_music_matching.py index 8166b6723..d4545678b 100644 --- a/tests/test_music_matching.py +++ b/tests/test_music_matching.py @@ -13,7 +13,7 @@ from app.domain.context import Context, MusicAlbumInfo, MusicInfo from app.domain.meta.metamusic import MetaMusic from app.domain.meta.runtime import get_metainfo_accelerator from app.domain.metainfo import MetaInfo, MetaInfoPath -from app.domain.music import match_music_resource +from app.domain.music import match_music_resource, music_isrc_matches, music_version_matches from app.schemas.music import MusicMeta from app.schemas.types import MediaType @@ -134,6 +134,45 @@ def test_resource_title_version_has_priority_over_subtitle(): assert MetaMusic.parse_resource("Song FLAC", "艺术家:Live").version is None +@pytest.mark.parametrize("expected,actual,matched", [ + ("Live 1999", "Live 2000", False), + ("Live 1999-01-02", "Live 1999.01.03", False), + ("Live 1999-01-02", "Live 1999/1/2", True), + ("Live 1999", "Live 1999-01-02", True), + ("Live", "Live 1999", True), + ("Live 1999", "Live", True), + ("mix 1999", "mix 2000", False), + ("Live 1999-02-31", "Live 1999-03-01", True), + ("Live 1999-01-02", "Live 1999-01-02", True), + ("Live 1999年1月2日", "Live 1999-01-03", False), + ("Live 1999-2001", "Live 2000", True), + ("Live 1999-01-01 to 1999-01-03", "Live 1999-01-02", True), +]) +def test_music_version_checks_only_explicit_conflicting_dates(expected, actual, matched): + """同类录音的明确日期或年份冲突仍须排除;缺失、部分日期或非法日期不能凭空补全。""" + target = MusicInfo(title="1999", artists=["Artist"], version=expected) + meta = MetaMusic(title="1999", artists=["Artist"], version=actual) + assert music_version_matches(target, meta) is matched + + +def test_music_resource_rejects_different_dated_live_recording(): + """版本日期可来自标题括号,不将两场同名现场录音自动绑定成同一作品。""" + target = MusicInfo(title="Song (Live 2001-05-02)", artists=["Artist"]) + assert match_music_resource(target, "Artist - Song (Live 2001-05-03) FLAC").reason == "version_mismatch" + + +@pytest.mark.parametrize("code", [None, "", "Unknown", "N/A", "0", "---", "USABC260001", "UŚABC2600001"]) +def test_invalid_isrc_cannot_be_strong_identity(code): + """空值、占位值与格式错误不能因为文本相同而绕过名称和版本匹配。""" + assert music_isrc_matches(MusicInfo(isrc=code), MetaMusic(isrc=code)) is False + + +@pytest.mark.parametrize("code", ["USABC2600001", "us-abc-26-00001", "ISRC US-ABC-26-00001"]) +def test_valid_isrc_accepts_standard_display_format(code): + """标准代码、大小写和用于展示的前缀分隔格式应指向同一录音身份。""" + assert music_isrc_matches(MusicInfo(isrc="USABC2600001"), MetaMusic(isrc=code)) is True + + @pytest.mark.parametrize("mtype,suffix", [(MediaType.MUSIC, " FLAC"), (None, ".flac")]) @pytest.mark.parametrize("global_words", [False, True]) def test_music_metainfo_applies_shared_recognition_words(monkeypatch, mtype, suffix, global_words): diff --git a/tests/test_music_metadata_sources.py b/tests/test_music_metadata_sources.py index 5134deaab..39eb76517 100644 --- a/tests/test_music_metadata_sources.py +++ b/tests/test_music_metadata_sources.py @@ -6,8 +6,9 @@ import pytest from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain -from app.domain.context import MUSIC_ENTITY_ALBUM, MusicInfo +from app.domain.context import MUSIC_ENTITY_ALBUM, MusicAlbumInfo, MusicInfo from app.domain.meta.metamusic import MetaMusic +from app.domain.music import match_music_resource from app.modules.douban import DoubanModule from app.modules.theaudiodb import TheAudioDbModule from app.schemas.types import MediaRecognizeType, MediaSource, MediaType @@ -48,6 +49,157 @@ def test_theaudiodb_module_maps_track_and_album(monkeypatch): assert module.get_subtype() == MediaRecognizeType.TheAudioDB +def _selected_music_candidate(source, music_type, meta, candidate): + """使用各来源真实确认入口,保留专辑与曲目各自的数据结构。""" + if source == MediaSource.TheAudioDB: + if music_type == "recording": + return TheAudioDbModule._select_track(meta, [candidate]) + album = MusicAlbumInfo.from_dict(candidate.to_dict()) + return TheAudioDbModule._select_album(meta, [album]) + if music_type == "recording": + return DoubanModule._select_douban_music_track(meta, MusicAlbumInfo(tracks=[candidate])) + candidates = DoubanModule._matching_music_candidates(meta, [candidate]) + plan = DoubanModule._music_recognition_plan(meta, source, None, "album") + return DoubanModule._direct_music_candidate(plan, candidates[0]) if candidates else None + + +@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic]) +@pytest.mark.parametrize("music_type", ["recording", "album"]) +@pytest.mark.parametrize("title,artists,candidate_title,candidate_artists", [ + ("永遠是朋友", ["周華健"], "永远是朋友", ["周华健"]), + ("Example Work", ["AC", "DC"], "Example Work", ["AC/DC"]), + ("Example Work", ["Beyonce"], "Example Work", ["Beyoncé"]), + ("Fine Day", ["Jay Chou"], "晴天", ["周杰倫"]), +]) +def test_alternate_sources_share_music_name_and_artist_rules(source, music_type, title, artists, + candidate_title, candidate_artists): + """备选来源确认同样支持繁简、变音符、复合艺名和来自同一实体的可信别名。""" + meta = MetaMusic(title=title, artists=artists) + candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type, + title=candidate_title, artists=candidate_artists, + title_aliases=["Fine Day"], artist_aliases=["Jay Chou"]) + result = _selected_music_candidate(source, music_type, meta, candidate) + assert result and result.media_id == "candidate" + + +@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic]) +@pytest.mark.parametrize("music_type", ["recording", "album"]) +@pytest.mark.parametrize("candidate_title,matched", [ + ("Example Work", False), ("Example Work (Live)", True), ("Example Work(Live)", True), +]) +def test_alternate_sources_verify_recording_versions(source, music_type, candidate_title, matched): + """标题中的版本与独立版本字段可以互认,但现场版不能被确认成普通录音。""" + meta = MetaMusic(title="Example Work", artists=["Artist"], version="Live") + candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type, + title=candidate_title, artists=["Artist"]) + assert bool(_selected_music_candidate(source, music_type, meta, candidate)) is matched + + +@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic]) +@pytest.mark.parametrize("music_type", ["recording", "album"]) +@pytest.mark.parametrize("requested,candidate_title", [ + ("Example Work (Deluxe Edition)", "Example Work"), + ("Example Work", "Example Work (Deluxe Edition)"), + ("Example Work (Live Deluxe Edition)", "Example Work (Live)"), +]) +def test_alternate_sources_keep_existing_edition_boundaries(source, music_type, requested, candidate_title): + """共用录音版本规则时不能放宽备选来源原有的发行版名称约束。""" + candidate = MusicInfo(media_source=source, media_id="candidate", music_type=music_type, + title=candidate_title, artists=["Artist"]) + meta = MetaMusic(title=requested, artists=["Artist"]) + assert _selected_music_candidate(source, music_type, meta, candidate) is None + + +def test_theaudiodb_preserves_source_track_alias_for_all_matching(): + """TheAudioDB 已提供的曲名别名应同时用于元数据确认和资源搜索匹配。""" + candidate = TheAudioDbModule._track_to_info({ + "idTrack": "1", "strTrack": "晴天", "strTrackAlternate": "Fine Day", "strArtist": "Artist", + }) + assert candidate.title_aliases == ["Fine Day"] + assert TheAudioDbModule._select_track(MetaMusic(title="Fine Day", artists=["Artist"]), [candidate]) is candidate + assert match_music_resource(candidate, "Artist - Fine Day FLAC").status == "exact" + + +def test_theaudiodb_album_query_and_matching_use_album_artist(): + """合辑的专辑艺人用于专辑检索和确认,单曲查询则保持独立表演者署名。""" + meta = MetaMusic(title="Song", artists=["Performer"], album="Sampler", album_artist="Various Artists") + album = MusicAlbumInfo(title="Sampler", artists=["Various Artists"]) + assert TheAudioDbModule._track_search_params(meta) == {"t": "Song", "s": "Performer"} + assert TheAudioDbModule._album_search_params(meta) == {"a": "Sampler", "s": "Various Artists"} + assert TheAudioDbModule._select_album(meta, [album]) is album + + +def test_theaudiodb_isrc_identity_precedes_title_matches(): + """明确的 ISRC 录音身份必须优先于首条同名候选。""" + meta = MetaMusic(title="Song", artists=["Artist"], isrc="USABC2600001") + first = MusicInfo(title="Song", artists=["Artist"], media_source="theaudiodb", media_id="wrong") + exact = MusicInfo(title="Alternate Name", artists=["Other Credit"], media_source="theaudiodb", + media_id="correct", isrc="USABC2600001") + assert TheAudioDbModule._select_track(meta, [first, exact]) is exact + + +def test_theaudiodb_placeholder_isrc_does_not_override_music_evidence(): + """外部数据中的相同 ISRC 占位值不能让另一首作品抢占正确候选。""" + meta = MetaMusic(title="Song", artists=["Artist"], isrc="N/A") + first = MusicInfo(title="Other Song", artists=["Other Artist"], isrc="N/A") + correct = MusicInfo(title="Song", artists=["Artist"]) + assert TheAudioDbModule._select_track(meta, [first, correct]) is correct + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", [MediaSource.TheAudioDB, MediaSource.DoubanMusic]) +@pytest.mark.parametrize("music_type", ["recording", "album"]) +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_alternate_source_entrypoints_select_the_matching_version(source, music_type, async_mode): + """同步异步入口都跳过首条普通版本,并从真实响应投影中确认后续现场版本。""" + meta = MetaMusic(title="Example Work", artists=["Artist"], version="Live") + if music_type == "album": + meta.album = meta.title + if source == MediaSource.TheAudioDB: + module = TheAudioDbModule() + payload = { + "track": [{"idTrack": "wrong", "strTrack": "Example Work", "strArtist": "Artist"}, + {"idTrack": "right", "strTrack": "Example Work (Live)", "strArtist": "Artist"}], + "album": [{"idAlbum": "wrong", "strAlbum": "Example Work", "strArtist": "Artist"}, + {"idAlbum": "right", "strAlbum": "Example Work (Live)", "strArtist": "Artist"}], + } + module._request_json = Mock(return_value=payload) + module._async_request_json = AsyncMock(return_value=payload) + expected_id = "right" + else: + module = DoubanModule() + if music_type == "recording": + meta.album = "Sampler" + search = {"items": [{"id": "parent", "title": "Sampler", "artists": [{"name": "Artist"}]}]} + expected_id = "parent:2" + else: + search = {"items": [ + {"id": "wrong", "title": "Example Work", "artists": [{"name": "Artist"}]}, + {"id": "right", "title": "Example Work (Live)", "artists": [{"name": "Artist"}]}, + ]} + expected_id = "right" + detail = {"id": "parent", "title": "Sampler", "artists": [{"name": "Artist"}], + "songs": [{"title": "Example Work"}, {"title": "Example Work (Live)"}]} + module.doubanapi = Mock( + music_search=Mock(return_value=search), music_detail=Mock(return_value=detail), + async_music_search=AsyncMock(return_value=search), async_music_detail=AsyncMock(return_value=detail), + ) + if async_mode: + result = await module.async_recognize_media(meta=meta, media_source=source, music_type=music_type) + else: + result = module.recognize_media(meta=meta, media_source=source, music_type=music_type) + assert result and result.media_id == expected_id + assert result.music_type == music_type + if source == MediaSource.TheAudioDB: + assert module._request_json.call_count == (0 if async_mode else 1) + assert module._async_request_json.await_count == (1 if async_mode else 0) + else: + assert module.doubanapi.music_search.call_count == (0 if async_mode else 1) + assert module.doubanapi.async_music_search.await_count == (1 if async_mode else 0) + assert module.doubanapi.music_detail.call_count == (1 if not async_mode and music_type == "recording" else 0) + assert module.doubanapi.async_music_detail.await_count == (1 if async_mode and music_type == "recording" else 0) + + def test_theaudiodb_module_ignores_other_sources(monkeypatch): """显式选择其它来源时 TheAudioDB 不得发起请求或占用识别结果。""" module = TheAudioDbModule() @@ -376,6 +528,29 @@ def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch): assert result.album == "范特西" +@pytest.mark.asyncio +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_douban_compilation_lookup_preserves_performer_evidence(async_mode): + """先按合辑署名确认所属专辑,再按实际表演者确认曲目,不用合辑署名覆盖单曲。""" + module = DoubanModule() + search_result = {"items": [{"id": "1", "title": "Sampler", "artists": [{"name": "Various Artists"}]}]} + detail = {"id": "1", "title": "Sampler", "artists": [{"name": "Various Artists"}], + "songs": [{"title": "Song", "artist_names": ["Performer"]}]} + module.doubanapi = Mock( + music_search=Mock(return_value=search_result), music_detail=Mock(return_value=detail), + async_music_search=AsyncMock(return_value=search_result), async_music_detail=AsyncMock(return_value=detail), + ) + meta = MetaMusic(title="Song", artists=["Performer"], album="Sampler", album_artist="Various Artists") + if async_mode: + result = await module.async_recognize_media(meta=meta, media_source=MediaSource.DoubanMusic, + music_type="recording") + else: + result = module.recognize_media(meta=meta, media_source=MediaSource.DoubanMusic, music_type="recording") + assert result and result.media_id == "1:1" + assert result.artists == ["Performer"] + assert meta.artists == ["Performer"] + + def test_douban_music_mapping_keeps_legacy_attrs_tracks(): """豆瓣旧响应中的 attrs.singer 与 attrs.tracks 仍应保持兼容。""" album = DoubanModule._douban_music_to_album({ diff --git a/tests/test_music_recognize_cache.py b/tests/test_music_recognize_cache.py index dfa3c0205..9aa8cb4eb 100644 --- a/tests/test_music_recognize_cache.py +++ b/tests/test_music_recognize_cache.py @@ -531,3 +531,25 @@ def test_shared_recognition_replaces_entity_scoped_negative_cache(async_mode): assert result is True assert cache.get(meta, music_type="recording").media_id == "rec-1" assert cache.get(meta).media_id == "rec-1" + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("isrc_identity", [False, True]) +def test_cached_recording_rechecks_explicit_version_conflicts(monkeypatch, async_mode, isrc_identity): + """旧正缓存不能绕过版本冲突确认;已核验的同一 ISRC 则保留身份优先级。""" + cache = _build_music_cache({}) + module = _build_module_with_cache(cache) + isrc = "USABC2600001" if isrc_identity else None + meta = MetaMusic(title="Example Work", artists=["Artist"], version="Live 2001-05-02", isrc=isrc) + cached = _music_info(title="Example Work", artists=["Artist"], version="Live 2001-05-03", isrc=isrc) + fresh = _music_info(title="Example Work", artists=["Artist"], version=meta.version, media_id="fresh") + cache.update(meta, cached, music_type="recording") + monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[fresh])) + monkeypatch.setattr(module, "_async_search_recordings", AsyncMock(return_value=[fresh])) + if async_mode: + result = asyncio.run(module.async_recognize_media(meta=meta, music_type="recording")) + assert module._async_search_recordings.await_count == (0 if isrc_identity else 1) + else: + result = module.recognize_media(meta=meta, music_type="recording") + assert module._search_recordings.call_count == (0 if isrc_identity else 1) + assert result.media_id == (cached.media_id if isrc_identity else fresh.media_id)