mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
fix(music): reject conflicting recording and album identities
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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` 再执行快照测试及一次
|
||||
|
||||
@@ -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 等基线均为零 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
187
tests/test_music_identity_conflicts.py
Normal file
187
tests/test_music_identity_conflicts.py
Normal 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)
|
||||
Reference in New Issue
Block a user