From 0678a173644eacbe81af600c0e72d6eae0276899 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 30 Sep 2026 16:18:09 +0800 Subject: [PATCH] fix(music): reject conflicting recording and album identities --- app/chain/media/album.py | 3 +- app/chain/media/path.py | 11 +- app/chain/musicbrainz.py | 5 +- app/domain/music.py | 74 +++++-- app/modules/musicbrainz/__init__.py | 14 +- app/modules/musicbrainz/cache.py | 6 +- docs/architecture-overview.md | 6 +- docs/refactor/optimization-checklist.md | 2 +- .../architecture/dependency-baseline.json | 6 +- tests/test_music_identity_conflicts.py | 187 ++++++++++++++++++ 10 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 tests/test_music_identity_conflicts.py diff --git a/app/chain/media/album.py b/app/chain/media/album.py index 20370e80c..259cfd8e5 100644 --- a/app/chain/media/album.py +++ b/app/chain/media/album.py @@ -25,6 +25,7 @@ from app.domain.music import ( align_music_tracks, expand_music_tracks, music_album_candidate_matches, + music_album_has_consistent_tracks, music_album_lookup_plan, music_album_title_is_weak, music_text_key, @@ -347,7 +348,7 @@ class MediaAlbumOwner(_MediaOwnerBase): allow_title_override: bool = False, ) -> dict[str, MusicInfo]: """逻辑曲目全部对位后才输出对应物理文件,整轨始终保持 Album 身份。""" - if len(files) != len(metas): + if len(files) != len(metas) or not music_album_has_consistent_tracks(album): return {} logical, owners = expand_music_tracks(metas) aligned = align_music_tracks(logical, album.tracks, allow_title_override=allow_title_override) diff --git a/app/chain/media/path.py b/app/chain/media/path.py index 0e04375ff..c657ef451 100644 --- a/app/chain/media/path.py +++ b/app/chain/media/path.py @@ -142,7 +142,7 @@ def _merge_music_path_evidence( meta: MetaMusic, evidence: Optional[MetaMusic], ) -> MetaMusic: - """把已确认的标签证据补入文件名搜索,但不传播远程身份。""" + """把同一文件的标签证据补入文件名搜索,保留ISRC约束但不传播来源实体ID。""" if not meta or not evidence: return meta merged = MetaMusic.from_dict(meta.to_dict()) @@ -156,6 +156,8 @@ def _merge_music_path_evidence( merged.year = evidence.year if not merged.version and evidence.version: merged.version = evidence.version + if not merged.isrc and evidence.isrc: + merged.isrc = evidence.isrc return merged @@ -238,6 +240,7 @@ def _fingerprint_info_matches_evidence( music_album_matches, music_artist_evidence_matches, music_credit_conflicts, + music_isrc_conflicts, music_text_key, music_usable_artists, music_version_matches, @@ -247,7 +250,7 @@ def _fingerprint_info_matches_evidence( if not _has_remote_music_identity(info): return False primary = tag_meta if tag_meta and not music_track_title_is_weak(tag_meta) else filename_meta or tag_meta - if not primary: + if not primary or music_isrc_conflicts(info, tag_meta or primary): return False weak_title = music_track_title_is_weak(primary) local_duration = fingerprint_duration or (tag_meta.duration if tag_meta else None) or primary.duration @@ -521,10 +524,11 @@ def _music_info_matches_text_evidence( info: Optional[MusicInfo], meta: Optional[MetaMusic], ) -> bool: - """统一校验各识别层的版本、发行年份和演奏证据,插件结果也不能绕过角色冲突。""" + """统一校验版本、年份、ISRC和演奏证据,插件结果也不能绕过明确冲突。""" from app.domain.music import ( # pylint: disable=import-outside-toplevel music_artist_evidence_matches, music_credit_conflicts, + music_isrc_conflicts, music_isrc_matches, music_version_matches, music_year_matches, @@ -534,6 +538,7 @@ def _music_info_matches_text_evidence( return False return bool(music_version_matches(info, meta) and music_year_matches(info, meta) and not music_credit_conflicts(info, meta) + and not music_isrc_conflicts(info, meta) and (not any(music_credit_values(meta).values()) or music_isrc_matches(info, meta) or music_artist_evidence_matches(info, meta))) diff --git a/app/chain/musicbrainz.py b/app/chain/musicbrainz.py index ed1f0de2d..52f7a628d 100644 --- a/app/chain/musicbrainz.py +++ b/app/chain/musicbrainz.py @@ -3,6 +3,7 @@ from typing import Any, Iterable, Optional from app.chain.base import ChainBase from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo from app.domain.meta.metamusic import MetaMusic +from app.domain.music import music_album_has_consistent_tracks from app.schemas.types import MediaSource, MediaType, MusicEntityType @@ -253,7 +254,7 @@ class MusicMetadataSourceChain(ChainBase): media_id: Optional[str] = None, musicbrainz_release_id: Optional[str] = None, ) -> Optional[MusicAlbumInfo]: - """将模块或插件结果统一转换为专辑详情。""" + """转换专辑详情并拒绝曲目混源或明确发行身份冲突,手选版另要求身份完整。""" if isinstance(result, MusicAlbumInfo): album = result elif isinstance(result, dict): @@ -262,6 +263,8 @@ class MusicMetadataSourceChain(ChainBase): return None if album.media_source != self.source: return None + if not music_album_has_consistent_tracks(album): + return None if media_id and album.media_id != media_id: return None if musicbrainz_release_id is not None: diff --git a/app/domain/music.py b/app/domain/music.py index f25bd2353..a90240a77 100644 --- a/app/domain/music.py +++ b/app/domain/music.py @@ -144,9 +144,29 @@ def music_album_with_artist_aliases(album: MusicAlbumInfo, aliases: dict[str, li return result +def music_album_has_consistent_tracks(album: MusicAlbumInfo) -> bool: + """专辑与曲目须处于同一来源和发行身份中;缺失的可选身份不构成冲突。""" + source = album.media_source or next((track.media_source for track in album.tracks if track.media_source), None) + identities = { + "album_id": album.media_id, + "musicbrainz_release_id": album.musicbrainz_release_id, + "musicbrainz_release_group_id": album.musicbrainz_release_group_id, + } + if source == MediaSource.MusicBrainz: + identities["musicbrainz_release_group_id"] = album.musicbrainz_release_group_id or album.media_id + if album.media_id and album.musicbrainz_release_group_id and album.media_id != album.musicbrainz_release_group_id: + return False + return all( + track.media_source == source and all( + not expected or not getattr(track, key) or getattr(track, key) == expected + for key, expected in identities.items() + ) for track in album.tracks + ) + + def music_album_candidate_matches(album: MusicAlbumInfo, meta: MetaMusic, tracks: list[MetaMusic]) -> bool: """来源专辑必须满足名称、署名、版本与完整本地对位;弱专辑名需要额外曲目证据。""" - if not tracks or not album.tracks: + if not tracks or not album.tracks or not music_album_has_consistent_tracks(album): return False info = album.to_music_info() known_title = not music_album_title_is_weak(meta) @@ -413,24 +433,34 @@ def _alignment_title_key(title: Optional[str]) -> str: return music_text_key(title) or re.sub(r"\s+", "", normalize("NFKC", str(title or ""))).casefold() +def _music_track_identity_match(meta: MetaMusic, track: MusicInfo, allow_title_override: bool) -> Optional[bool]: + """返回明确录音身份的命中状态;None表示冲突,手选纠正允许覆盖原标签身份。""" + if not allow_title_override and music_isrc_conflicts(track, meta): + return None + identity_match = music_isrc_matches(track, meta) + for field in ("musicbrainz_release_id", "musicbrainz_release_track_id"): + local_id, remote_id = getattr(meta, field), getattr(track, field) + if local_id and remote_id: + if local_id != remote_id and not allow_title_override: + return None + if field == "musicbrainz_release_track_id" and local_id == remote_id: + identity_match = True + if meta.media_id and track.media_id and meta.media_source == track.media_source and meta.music_type != MUSIC_ENTITY_ALBUM: + if meta.media_id != track.media_id and not allow_title_override: + return None + identity_match = identity_match or meta.media_id == track.media_id + return identity_match + + def _music_track_pair_score(meta: MetaMusic, track: MusicInfo, allow_title_override: bool) -> float: """评估一条文件与发行曲目的证据;明确身份或时长冲突不能被其它分数抵消。""" if not allow_title_override and music_credit_conflicts(track, meta): return 0.0 if not allow_title_override and any(music_credit_values(meta).values()) and not music_artist_evidence_matches(track, meta): return 0.0 - identity_match = False - for field in ("musicbrainz_release_id", "musicbrainz_release_track_id"): - local_id, remote_id = getattr(meta, field), getattr(track, field) - if local_id and remote_id: - if local_id != remote_id and not allow_title_override: - return 0.0 - if field == "musicbrainz_release_track_id" and local_id == remote_id: - identity_match = True - if meta.media_id and track.media_id and meta.media_source == track.media_source and meta.music_type != MUSIC_ENTITY_ALBUM: - if meta.media_id != track.media_id and not allow_title_override: - return 0.0 - identity_match = identity_match or meta.media_id == track.media_id + identity_match = _music_track_identity_match(meta, track, allow_title_override) + if identity_match is None: + return 0.0 artists = music_usable_artists(meta.artists) if (not allow_title_override and artists and track.artists and meta.field_sources.get("artists") not in {"directory", "torrent", "album_tags"} @@ -739,10 +769,24 @@ def _isrc_key(value: Optional[str]) -> Optional[str]: return code.upper() if _ISRC.fullmatch(code) else None +def music_isrc_codes(music: MusicInfo) -> set[str]: + """保留同一MusicBrainz录音实际返回的多个ISRC,兼容旧缓存中的原始响应。""" + values = music.raw_data.get("isrcs") if music.media_source == MediaSource.MusicBrainz else None + values = values if isinstance(values, (list, tuple)) else [] + return {key for value in (music.isrc, *values) if (key := _isrc_key(value))} + + def music_isrc_matches(music: MusicInfo, meta: MetaMusic) -> bool: - """只有格式有效且相同的 ISRC 才能作为优先于文本匹配的录音身份。""" + """只有格式有效且属于同一录音的ISRC才能作为优先于文本匹配的身份。""" expected = _isrc_key(meta.isrc) - return bool(expected and expected == _isrc_key(music.isrc)) + return bool(expected and expected in music_isrc_codes(music)) + + +def music_isrc_conflicts(music: MusicInfo, meta: MetaMusic) -> bool: + """双方均有有效ISRC且没有交集才构成冲突,缺失或无效标签继续按其它证据核验。""" + expected = _isrc_key(meta.isrc) + actual = music_isrc_codes(music) + return bool(expected and actual and expected not in actual) def _artist_match_text(text: str) -> str: diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index fb58a9217..a13e76685 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -43,6 +43,7 @@ from app.domain.music import ( music_artist_evidence_matches, music_base_title, music_credit_conflicts, + music_isrc_conflicts, music_isrc_matches, music_query_artists, music_text_key, @@ -1194,7 +1195,7 @@ class MusicBrainzModule(_ModuleBase): # Release lookup 默认只返回 Release Group 的最小引用, # 不包含 primary-type / secondary-types。目录级专辑识别 # 后续需要这些字段执行音乐分类,因此必须显式展开。 - "inc": "recordings+media+artist-credits+release-groups" + _RELEASE_RELATIONS, + "inc": "recordings+media+artist-credits+release-groups+isrcs" + _RELEASE_RELATIONS, "fmt": "json", }, ) @@ -1525,7 +1526,8 @@ class MusicBrainzModule(_ModuleBase): and (not meta.title or music_title_matches(cached_info, meta.title)) and music_version_matches(cached_info, meta) ) - if music_credit_conflicts(cached_info, meta) or not release_matches or (not music_isrc_matches(cached_info, meta) and not identity_matches): + if (music_credit_conflicts(cached_info, meta) or music_isrc_conflicts(cached_info, meta) or not release_matches + or (not music_isrc_matches(cached_info, meta) and not identity_matches)): return None if cached_info.media_id: logger.info(f"{meta.title} 使用音乐识别缓存:{cached_info.title}") @@ -1664,7 +1666,7 @@ class MusicBrainzModule(_ModuleBase): for candidate in candidates: if normalized_source and str(candidate.media_source or "").casefold() != normalized_source: continue - if music_credit_conflicts(candidate, meta): + if music_credit_conflicts(candidate, meta) or music_isrc_conflicts(candidate, meta): continue album_matches = not meta.album or not candidate.album or music_album_matches(candidate, meta.album) release_matches = album_matches and music_year_matches(candidate, meta) @@ -2403,7 +2405,7 @@ class MusicBrainzModule(_ModuleBase): return None payload = cls._request_json( f"/release/{release['id']}", - params={"inc": "recordings+artist-credits" + _RELEASE_RELATIONS + params={"inc": "recordings+artist-credits+isrcs" + _RELEASE_RELATIONS + ("+release-groups" if release_id is not None else ""), "fmt": "json"}, ) return cls._validate_album_release(payload, release_id=release_id, group_id=group_id) @@ -2423,7 +2425,7 @@ class MusicBrainzModule(_ModuleBase): return None payload = await cls._async_request_json( f"/release/{release['id']}", - params={"inc": "recordings+artist-credits" + _RELEASE_RELATIONS + params={"inc": "recordings+artist-credits+isrcs" + _RELEASE_RELATIONS + ("+release-groups" if release_id is not None else ""), "fmt": "json"}, ) return cls._validate_album_release(payload, release_id=release_id, group_id=group_id) @@ -2524,6 +2526,7 @@ class MusicBrainzModule(_ModuleBase): track_number=cls._optional_int(track.get("position")), total_tracks=cls._optional_int(medium.get("track-count")), duration=cls._duration_seconds(track.get("length") or recording.get("length")), + isrc=next(iter(recording.get("isrcs") or []), None), cover_url=album.cover_url, version=recording.get("disambiguation") or None, secondary_types=list(album.secondary_types), @@ -2534,6 +2537,7 @@ class MusicBrainzModule(_ModuleBase): release_status=album.release_status, names=[str(title)], detail_link=f"{cls._detail_url}/{media_id}", + raw_data={"isrcs": list(recording.get("isrcs") or [])}, )) @classmethod diff --git a/app/modules/musicbrainz/cache.py b/app/modules/musicbrainz/cache.py index 82f4db8ce..b7bb49388 100644 --- a/app/modules/musicbrainz/cache.py +++ b/app/modules/musicbrainz/cache.py @@ -9,6 +9,7 @@ from typing import Optional from app.domain.context import MusicInfo from app.domain.meta.metamusic import MetaMusic, music_credit_values +from app.domain.music import music_isrc_codes from app.foundation.singleton import WeakSingleton from app.runtime.cache import FileCache, TTLCache from app.runtime.log import logger @@ -184,8 +185,11 @@ class MusicBrainzCache(metaclass=WeakSingleton): return key = self.__get_key(meta, music_type) cache_data = info.to_dict() - # 上游原始响应体积大且不参与身份恢复,不入缓存 + # 原始响应不入缓存,只保留核验录音身份必需的有效ISRC集合。 cache_data.pop("raw_data", None) + codes = music_isrc_codes(info) + if codes: + cache_data["raw_data"] = {"isrcs": sorted(codes)} with lock: self._set(key, cache_data) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 0cff78f5e..ad1afd208 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -762,8 +762,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1060 | -| 内部导入边 | 9,060 | +| Python 模块 | 1061 | +| 内部导入边 | 9,062 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 48(债务已清零,48 条精确 containment) | @@ -773,7 +773,7 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,060,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。当前 `app.startup.lifecycle` 为 576、`app.factory` 为 588、`app.main` 为 590。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,062,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。当前 `app.startup.lifecycle` 为 576、`app.factory` 为 588、`app.main` 为 590。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index e1d7dbdc7..fc9595438 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -96,7 +96,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1061 / 9,060 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | +| 宿主 Python 模块 / 内部依赖边 | 1061 / 9,062 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index ea2b1cc7b..f605db586 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -930,8 +930,8 @@ "runtime_only": true } }, - "edge_count": 9060, - "edge_sha256": "cb948f057f3a8075ddf419370773b879f73aa13f6a13b1c0d9690f8c49f7f8a8", + "edge_count": 9062, + "edge_sha256": "27870b9cb98a47174fa1dcad7e44ecb1fa9dab8f6ba23c0952e2ca31b23f8abd", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -4723,6 +4723,7 @@ "app.chain.musicbrainz -> app.domain.context", "app.chain.musicbrainz -> app.domain.meta", "app.chain.musicbrainz -> app.domain.meta.metamusic", + "app.chain.musicbrainz -> app.domain.music", "app.chain.musicbrainz -> app.schemas", "app.chain.musicbrainz -> app.schemas.types", "app.chain.notification -> app.chain", @@ -7477,6 +7478,7 @@ "app.modules.musicbrainz.cache -> app.domain.context", "app.modules.musicbrainz.cache -> app.domain.meta", "app.modules.musicbrainz.cache -> app.domain.meta.metamusic", + "app.modules.musicbrainz.cache -> app.domain.music", "app.modules.musicbrainz.cache -> app.foundation", "app.modules.musicbrainz.cache -> app.foundation.singleton", "app.modules.musicbrainz.cache -> app.runtime", diff --git a/tests/test_music_identity_conflicts.py b/tests/test_music_identity_conflicts.py new file mode 100644 index 000000000..cea6029cf --- /dev/null +++ b/tests/test_music_identity_conflicts.py @@ -0,0 +1,187 @@ +"""录音标识与发行来源的一致性必须贯穿自动匹配、缓存及文件名回退。""" + +import asyncio +import shutil +from copy import deepcopy +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +import pytest +from mutagen.flac import FLAC + +from app.application.audio import AudioMetadataHelper +from app.chain.acoustid import AcoustIdChain +from app.chain.media import MediaChain +from app.chain.media.path import _fingerprint_info_matches_evidence +from app.chain.musicbrainz import MusicBrainzChain +from app.domain.context import MusicInfo +from app.domain.meta.metamusic import MetaMusic +from app.domain.music import align_music_tracks, music_isrc_conflicts, music_isrc_matches +from app.modules.musicbrainz import MusicBrainzModule +from app.schemas.types import MediaSource +from tests.test_music_album_match import ALBUM_TRACKS, _local_tracks, _release_detail +from tests.test_music_recognize_cache import _build_module_with_cache, _build_music_cache +from tests.test_music_source_fallback import _album, _backend, _directory_setup + +ISRC = "USABC2400001" +OTHER_ISRC = "USABC2400002" + + +def _recording(isrc=OTHER_ISRC, **kwargs): + """构造同名歌曲以保证测试只由身份约束决定结果。""" + return MusicInfo(title="Song", artists=["Artist"], media_source=MediaSource.MusicBrainz, + media_id="recording", isrc=isrc, track_number=1, **kwargs) + + +@pytest.mark.parametrize("local,remote,extras,matched,conflict", [ + (ISRC, OTHER_ISRC, [], False, True), + ("ISRC: US-ABC-24-00001", "usabc2400001", [], True, False), + (ISRC, OTHER_ISRC, [OTHER_ISRC, ISRC], True, False), + (ISRC, None, ["unknown", ISRC], True, False), + (ISRC, None, [], False, False), + (None, OTHER_ISRC, [], False, False), + ("Unknown", OTHER_ISRC, [], False, False), + (ISRC, "Unknown", [], False, False), + (ISRC, None, {"isrc": ISRC}, False, False), +]) +def test_isrc_evidence_validates_all_recording_codes(local, remote, extras, matched, conflict): + """有效列表、展示分隔符和缺失值有不同语义,异常列表不得当成已验证身份。""" + info = _recording(remote, raw_data={"isrcs": extras}) + meta = MetaMusic(title="Song", isrc=local) + assert music_isrc_matches(info, meta) is matched + assert music_isrc_conflicts(info, meta) is conflict + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_recording_candidates_cannot_outvote_explicit_isrc(reverse): + """明确冲突的唯一同名候选被拒绝,完整ISRC列表命中不受列表顺序干扰。""" + meta = MetaMusic(title="Song", artists=["Artist"], isrc=ISRC) + wrong = _recording() + assert MusicBrainzModule._select_candidate(meta, [wrong], MediaSource.MusicBrainz) is None + right = _recording(raw_data={"isrcs": [OTHER_ISRC, ISRC]}) + right.media_id, right.title = "right-recording", "Different Display Title" + candidates = [wrong, right] if reverse else [right, wrong] + assert MusicBrainzModule._select_candidate(meta, candidates, MediaSource.MusicBrainz) is right + + +def test_album_alignment_uses_isrc_and_preserves_manual_correction(): + """自动对位拒绝冲突;人工选版可纠正错误旧标签,但仍不能覆盖严重时长冲突。""" + local = MetaMusic(title="Song", track_number=1, isrc=ISRC) + wrong = _recording() + assert align_music_tracks([local], [wrong]) == {} + assert align_music_tracks([local], [wrong], allow_title_override=True) == {0: 0} + right = _recording(raw_data={"isrcs": [ISRC]}) + right.title, right.track_number = "Different Display Title", 2 + assert align_music_tracks([local], [wrong, right]) == {0: 1} + local.duration, right.duration = 180, 300 + assert align_music_tracks([local], [right], allow_title_override=True) == {} + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("same_recording", [False, True]) +def test_cache_rechecks_isrc_including_secondary_codes(monkeypatch, asynchronous, same_recording): + """旧缓存冲突须重新查询,同一录音的第二个ISRC则可直接复用缓存。""" + meta = MetaMusic(title="Song", artists=["Artist"], isrc=ISRC) + cache = _build_music_cache({}) + module = _build_module_with_cache(cache) + cached = _recording(raw_data={"isrcs": [OTHER_ISRC, ISRC] if same_recording else [OTHER_ISRC]}) + cached.raw_data["unrelated_payload"] = "must not persist" + cache.update(meta, cached, music_type="recording") + assert set(cache.get(meta, music_type="recording").raw_data) == {"isrcs"} + fresh = _recording(ISRC) + fresh.media_id = "fresh-recording" + lookup = AsyncMock(return_value=[fresh]) if asynchronous else Mock(return_value=[fresh]) + monkeypatch.setattr(module, "_async_search_recordings" if asynchronous else "_search_recordings", lookup) + result = asyncio.run(module.async_recognize_media(meta=meta, music_type="recording")) if asynchronous else ( + module.recognize_media(meta=meta, music_type="recording")) + assert result.media_id == (cached.media_id if same_recording else fresh.media_id) + assert lookup.call_count == (0 if same_recording else 1) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("same_recording", [False, True]) +def test_release_lookup_preserves_isrc_for_track_alignment(monkeypatch, asynchronous, same_recording): + """真正的Release响应投影后仍保留全部ISRC,自动整专不能接受错误录音。""" + module = MusicBrainzModule() + detail = _release_detail("release", "七里香", "周杰伦", ALBUM_TRACKS) + codes = [OTHER_ISRC, ISRC] if same_recording else [OTHER_ISRC] + detail["media"][0]["tracks"][0]["recording"]["isrcs"] = codes + local = _local_tracks() + local[0].isrc = ISRC + request = AsyncMock(return_value=detail) if asynchronous else Mock(return_value=detail) + monkeypatch.setattr(module, "_async_request_json" if asynchronous else "_request_json", request) + meta = MetaMusic(album="七里香", artists=["周杰伦"], musicbrainz_release_id="release") + result = asyncio.run(module.async_match_music_album(meta, local)) if asynchronous else module.match_music_album(meta, local) + assert (result is not None) is same_recording + assert "isrcs" in request.call_args.kwargs["params"]["inc"].split("+") + if result: + assert music_isrc_matches(result.tracks[0], local[0]) + assert result.tracks[0].raw_data["isrcs"] == codes + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("field,value", [ + ("media_source", "theaudiodb"), ("album_id", "other-group"), + ("musicbrainz_release_id", "other-release"), ("musicbrainz_release_group_id", "other-group"), +]) +def test_automatic_album_source_and_directory_reject_conflicting_tracks(tmp_path, monkeypatch, asynchronous, field, value): + """来源链和整专编排均拒绝混源或混版曲目;不依赖用户显式指定发行ID才核验。""" + album = MusicBrainzModule._release_to_album(_release_detail("release", "七里香", "周杰伦", ALBUM_TRACKS)) + setattr(album.tracks[1], field, value) + source = MusicBrainzChain() + monkeypatch.setattr(source, "async_run_module" if asynchronous else "run_module", + AsyncMock(return_value=album) if asynchronous else Mock(return_value=album)) + meta = MetaMusic(album="七里香", artists=["周杰伦"]) + result = asyncio.run(source.async_match_music_album(meta, _local_tracks())) if asynchronous else source.match_music_album(meta, _local_tracks()) + assert result is None + files = [tmp_path / f"{index}.flac" for index in range(3)] + monkeypatch.setattr(AudioMetadataHelper, "read_many", Mock(return_value=_local_tracks())) + monkeypatch.setattr(source, "async_match_music_album" if asynchronous else "match_music_album", + AsyncMock(return_value=album) if asynchronous else Mock(return_value=album)) + monkeypatch.setattr("app.chain.media.album.MusicBrainzChain", lambda: source) + chain = MediaChain() + method = chain._async_match_music_album_directory if asynchronous else chain._match_music_album_directory + result = asyncio.run(method(tmp_path, files)) if asynchronous else method(tmp_path, files) + assert not result and result.recognition["status"] == "conflict" + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_secondary_album_cannot_mix_source_namespaces(tmp_path, monkeypatch, asynchronous): + """次级来源的专辑壳也不能让其它来源的同名曲目取得当前来源身份。""" + directory, chain, _config, _primary = _directory_setup(tmp_path, monkeypatch, "theaudiodb") + album = _album(MediaSource.TheAudioDB) + album.tracks[0].media_source = MediaSource.DoubanMusic + monkeypatch.setattr(chain, "_music_source_chain", lambda _source: _backend([album])) + result = asyncio.run(chain.async_recognize_music_album_directory(directory)) if asynchronous else chain.recognize_music_album_directory(directory) + assert not result + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_real_tags_keep_isrc_constraint_through_filename_fallback(tmp_path, monkeypatch, asynchronous): + """读取真实FLAC后,标签层被拒绝不能通过文件名层丢弃ISRC约束后重新误命中。""" + path = tmp_path / "01 - Artist - Song.flac" + shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.flac", path) + audio = FLAC(path) + audio.update(title="Song", artist="Artist", isrc=ISRC) + audio.save() + chain = object.__new__(MediaChain) + lookup = AsyncMock(side_effect=lambda **_kwargs: deepcopy(_recording())) if asynchronous else Mock(side_effect=lambda **_kwargs: deepcopy(_recording())) + monkeypatch.setattr(chain, "async_recognize_media" if asynchronous else "recognize_media", lookup) + monkeypatch.setattr(AcoustIdChain, "async_identify_music_by_fingerprint" if asynchronous else "identify_music_by_fingerprint", + AsyncMock(return_value=None) if asynchronous else Mock(return_value=None)) + monkeypatch.setattr(chain, "_async_music_album_dir_fallback" if asynchronous else "_music_album_dir_fallback", + AsyncMock(return_value=None) if asynchronous else Mock(return_value=None)) + monkeypatch.setattr(chain, "_async_finalize_recognition_result" if asynchronous else "_finalize_recognition_result", + AsyncMock(side_effect=lambda info, **_kwargs: info) if asynchronous else lambda info, **_kwargs: info) + _meta, info = asyncio.run(chain.async_recognize_music_by_path(path)) if asynchronous else chain.recognize_music_by_path(path) + assert info.media_id is None + assert lookup.call_count == 2 + assert all(call.kwargs["meta"].isrc == ISRC for call in lookup.call_args_list) + + +@pytest.mark.parametrize("title", ["Song", "Track 01"]) +def test_high_fingerprint_score_cannot_hide_isrc_conflict(title): + """强指纹也须与实际标签的明确录音身份相容,占位曲名不能使ISRC被忽略。""" + tags = MetaMusic(title=title, artists=["Artist"], isrc=ISRC, duration=180) + info = _recording(duration=180) + assert not _fingerprint_info_matches_evidence(info, tags, MetaMusic(title="Song"), fingerprint_score=.99)