fix(music): reject conflicting recording and album identities

This commit is contained in:
jxxghp
2026-09-30 16:18:09 +08:00
parent ca8662526e
commit 0678a17364
10 changed files with 282 additions and 32 deletions

View File

@@ -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)

View File

@@ -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)))

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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` 再执行快照测试及一次

View File

@@ -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 等基线均为零 |

View File

@@ -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",

View File

@@ -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)