From 916619f2e25696ff659e53e1bd9c230e93155e29 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 6 Sep 2026 01:28:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E8=AF=8D=E5=B9=B6=E8=A1=A5=E9=BD=90=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=92=8C=E4=B8=93=E8=BE=91=E5=8C=B9=E9=85=8D=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/chain/_music.py | 35 +++++--- app/domain/meta/metamusic.py | 43 +++++++--- app/domain/metainfo.py | 27 ++++--- app/domain/music.py | 6 +- app/schemas/music.py | 1 + docs/architecture/media-search-design.md | 5 ++ docs/architecture/optimization-checklist.md | 2 +- docs/mcp-api.md | 3 + skills/moviepilot-api/SKILL.md | 4 + .../architecture/dependency-baseline.json | 6 +- .../fixtures/architecture/mypy-baseline.json | 2 +- tests/test_media_search_pipeline.py | 68 ++++++++++++++++ tests/test_music_matching.py | 80 +++++++++++++++++++ tests/test_music_subscribe.py | 37 +++++++++ 14 files changed, 280 insertions(+), 39 deletions(-) diff --git a/app/chain/_music.py b/app/chain/_music.py index 0f541264d..da64a93b2 100644 --- a/app/chain/_music.py +++ b/app/chain/_music.py @@ -20,10 +20,11 @@ from app.application.torrent.download import TorrentHelper from app.chain._contracts import MusicSubscribeMixinHost from app.chain.download import DownloadChain from app.chain.media import MediaChain -from app.chain.search.facade import SearchChain from app.domain.context import Context, MediaInfo, MusicInfo from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES from app.domain.meta.metamusic import MetaMusic +from app.domain.metainfo import MetaInfo +from app.domain.music import match_music_resource from app.runtime.log import logger from app.schemas.category import ClassificationSelection from app.schemas.common import JsonData @@ -113,12 +114,6 @@ def _finalize_music_subscribe_recognition( class MusicSubscribeMixin: - __mixin_host_protocol__ = MusicSubscribeMixinHost - subscription_repository: SubscriptionRepository - sync_subscription_mutation_scope: "SyncSubscriptionMutationScope" - _SubscribeChain__candidate_contract_changed: Callable[ - [SubscriptionSnapshot, SubscriptionSnapshot], bool - ] """ 音乐订阅功能域 mixin:单曲/专辑目标识别、实体快照同步、候选筛选、 择优下载与完成推进。 @@ -130,6 +125,13 @@ class MusicSubscribeMixin: SubscribeChain 主体形成双向模块依赖。 """ + __mixin_host_protocol__ = MusicSubscribeMixinHost + subscription_repository: SubscriptionRepository + sync_subscription_mutation_scope: "SyncSubscriptionMutationScope" + _SubscribeChain__candidate_contract_changed: Callable[ + [SubscriptionSnapshot, SubscriptionSnapshot], bool + ] + @staticmethod def _validate_music_subscribe_target( mediainfo: MediaInfo, @@ -376,6 +378,7 @@ class MusicSubscribeMixin: default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or [] + custom_words = subscribe.custom_words.split("\n") if subscribe.custom_words else [] torrent_helper = TorrentHelper() matched: List[Context] = [] for source_context in contexts or []: @@ -386,11 +389,16 @@ class MusicSubscribeMixin: torrent = copy.copy(source_torrent) if sites and torrent.site not in sites: continue - if not SearchChain.matches_music_resource( - mediainfo, - torrent.title, - torrent.description, - ): + meta = cast(MetaMusic, MetaInfo( + title=torrent.title, + subtitle=torrent.description, + custom_words=custom_words, + mtype=MediaType.MUSIC, + )) + match = match_music_resource( + mediainfo, torrent.title, torrent.description, torrent.category, meta=meta, + ) + if match.status != "exact": continue if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)): continue @@ -406,7 +414,6 @@ class MusicSubscribeMixin: context = copy.copy(source_context) context.torrent_info = torrent - meta = MetaMusic.parse_resource(torrent.title, torrent.description) if subscribe.best_version: # 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则 # 优先级时再回退到规范化音质分数,确保零配置也能自动升级。 @@ -423,6 +430,8 @@ class MusicSubscribeMixin: context.match_source = str(mediainfo.media_source or "title") context.candidate_recognized = False context.media_info_is_target = True + context.match_status = "exact" + context.match_reason = match.reason context.media_info = _apply_music_subscription_classification( context.media_info, subscribe, diff --git a/app/domain/meta/metamusic.py b/app/domain/meta/metamusic.py index 3965e0a34..46b6e311e 100644 --- a/app/domain/meta/metamusic.py +++ b/app/domain/meta/metamusic.py @@ -354,6 +354,12 @@ _MUSIC_DISC_DIR_RE = re.compile( _MUSIC_DIR_YEAR_RE = re.compile(r"[(\[]\s*(?P(?:19|20)\d{2})\s*[)\]]") # 目录名中的括号补充说明(格式、音质、厂牌等),如 [FLAC 24bit-96kHz] _MUSIC_BRACKET_RE = re.compile(r"\[[^\]]*\]|【[^】]*】|\([^)]*\)") +_MUSIC_RECORDING_VERSION_RE = re.compile( + r"[\[((【]([^\]))】]*(?:\blive\b|\bremix\b|\binstrumental\b|\bacoustic\b|" + r"\bunplugged\b|\bdemo\b|\bkaraoke\b|现场|現場|混音|伴奏|不插电|不插電)[^\]))】]*)[\]))】]", + re.IGNORECASE, +) +_MUSIC_VERSION_LABEL_RE = re.compile(r"^(?:录音版本|錄音版本|版本|version)\s*[::]\s*(.+)$", re.IGNORECASE) _MUSIC_SPACES_RE = re.compile(r"\s+") _MUSIC_COMPACT_RE = re.compile(r"[\W_]+", re.UNICODE) # 音乐视频/演唱会资源使用影视场景式命名,但标题语义仍属于音乐。 @@ -747,15 +753,24 @@ class MetaMusic(MetaBase): meta.year = secondary.year meta.apply_audio_quality(subtitle) if not meta.version: - version = re.search( - r"[\[((【]([^\]))】]*(?:\blive\b|\bremix\b|\binstrumental\b|\bacoustic\b|" - r"\bunplugged\b|\bdemo\b|\bkaraoke\b|现场|現場|混音|伴奏|不插电|不插電)[^\]))】]*)[\]))】]", - title, re.I, - ) - if version: - meta.version = version.group(1).strip() + meta.version = cls._resource_version(title, subtitle) return meta + @staticmethod + def _resource_version(title: str, subtitle: Optional[str]) -> Optional[str]: + """优先保留标题版本,副标题仅接受明确版本字段或独立版本标签,避免误读艺名。""" + version = _MUSIC_RECORDING_VERSION_RE.search(title) + if version: + return version.group(1).strip() + for field in re.split(r"[;;\n]", subtitle or ""): + labelled = _MUSIC_VERSION_LABEL_RE.fullmatch(field.strip()) + if labelled: + return labelled.group(1).strip().strip("[]()()【】").strip() or None + version = _MUSIC_RECORDING_VERSION_RE.fullmatch(field.strip()) + if version: + return version.group(1).strip() + return None + @classmethod def from_music_info(cls, info: Any) -> "MetaMusic": """把标准音乐信息转换为下载、整理和站点搜索使用的元数据。""" @@ -1574,14 +1589,20 @@ class MetaMusic(MetaBase): @property def apply_words(self) -> list[str]: - """音乐当前不应用影视自定义识别词。""" - return [] + """返回公共元数据工厂实际应用的识别词,兼容缺少该字段的旧缓存。""" + return getattr(self, "_apply_words", []) + + @apply_words.setter + def apply_words(self, value: Optional[list[str]]) -> None: + """独立保存识别词记录,避免调用方或其它音乐元数据共用可变列表。""" + self._apply_words = list(value or []) def to_dict(self) -> dict[str, Any]: """转换为可持久化和传输的字典,字段集与 schemas.MusicMeta 对齐。""" return { "type": self.type.value, "org_string": self.org_string, + "apply_words": list(self.apply_words), "title": self.title, "artists": list(self.artists), "artist": self.artist, @@ -1613,7 +1634,7 @@ class MetaMusic(MetaBase): raw_type = data.get("type") if raw_type not in (None, MediaType.MUSIC, MediaType.MUSIC.value, "music"): raise ValueError(f"不支持的音乐媒体类型:{raw_type}") - return cls( + meta = cls( org_string=data.get("org_string"), title=data.get("title"), artists=_string_list(data.get("artists") or data.get("artist")), @@ -1635,6 +1656,8 @@ class MetaMusic(MetaBase): media_source=data.get("media_source"), media_id=data.get("media_id"), ) + meta.apply_words = data.get("apply_words") + return meta def _build_name_result( diff --git a/app/domain/metainfo.py b/app/domain/metainfo.py index 0bddc5332..989f826c3 100644 --- a/app/domain/metainfo.py +++ b/app/domain/metainfo.py @@ -14,7 +14,7 @@ from app.domain.meta.infopath import ( ) from app.domain.meta.metaanime import MetaAnime from app.domain.meta.metabase import MetaBase -from app.domain.meta.metamusic import MetaMusic +from app.domain.meta.metamusic import MetaMusic, infer_audio_lossless, normalize_audio_format from app.domain.meta.metavideo import MetaVideo from app.domain.meta.releasegroup import ReleaseGroupsMatcher from app.domain.meta.runtime import ( @@ -502,17 +502,24 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] :param mtype: 已由调用方确定的媒体类型;音乐资源无文件后缀时也使用音乐解析器 :return: MetaAnime、MetaVideo、MetaMusic """ - # 音频文件名直接走音乐分支,避免进入影视季集解析,但影视附加音轨强制走视频解析 + # 媒体类型只选择解析器;音乐同样先应用用户识别词,影视附加音轨仍可强制按视频解析。 audio_suffix = Path(title).suffix.lower() if title else "" - if not force_video and mtype == MediaType.MUSIC: - return MetaMusic.parse_resource(title, subtitle) - if not force_video and mtype not in (MediaType.MOVIE, MediaType.TV) and audio_suffix in get_audio_extensions(): - return MetaMusic( - org_string=title, - title=Path(title).stem, - audio_format=audio_suffix.lstrip(".").upper() or None, - parse_title=True, + is_audio_file = audio_suffix in get_audio_extensions() + if not force_video and (mtype == MediaType.MUSIC or ( + mtype not in (MediaType.MOVIE, MediaType.TV) and is_audio_file + )): + parsed_title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words) + parsed_path = Path(parsed_title) + music_meta = MetaMusic.parse_resource( + parsed_path.stem if is_audio_file and parsed_path.suffix.lower() in get_audio_extensions() else parsed_title, + subtitle, ) + music_meta.org_string = parsed_title + music_meta.apply_words = apply_words + if is_audio_file: + music_meta.audio_format = normalize_audio_format(audio_suffix.lstrip(".")) + music_meta.audio_lossless = infer_audio_lossless(music_meta.audio_format) + return music_meta rust_meta = None accelerator = get_metainfo_accelerator() if accelerator and not _requires_python_metainfo(title, custom_words): diff --git a/app/domain/music.py b/app/domain/music.py index 9df0fbf4d..0c65a5502 100644 --- a/app/domain/music.py +++ b/app/domain/music.py @@ -174,7 +174,11 @@ def match_music_resource( if category not in (MediaType.MUSIC, MediaType.MUSIC.value): return MusicMatch("candidate", "category_unknown") if music.music_type == MUSIC_ENTITY_ALBUM: - if resource.track_number and resource.album: + # 所属专辑只说明单曲归属,不能代替资源主标题证明整专范围。 + primary_names = _resource_names(resource, artists) + if resource.track_number or (resource.album and not any( + music_text_key(music_base_title(item)) in primary_names for item in titles + )): return MusicMatch("candidate", "partial_album") if music.year and resource.year and str(music.year) != str(resource.year): return MusicMatch("candidate", "year_mismatch") diff --git a/app/schemas/music.py b/app/schemas/music.py index c10cfcf18..0cae3dcee 100644 --- a/app/schemas/music.py +++ b/app/schemas/music.py @@ -13,6 +13,7 @@ class MusicMeta(OptionalMediaIdentityMixin, BaseModel): type: Literal["音乐"] = "音乐" org_string: Optional[str] = None + apply_words: list[str] = Field(default_factory=list, description="资源解析时实际应用的自定义识别词") title: Optional[str] = None artists: list[str] = Field(default_factory=list) artist: Optional[str] = None diff --git a/docs/architecture/media-search-design.md b/docs/architecture/media-search-design.md index 64be80043..4c4108764 100644 --- a/docs/architecture/media-search-design.md +++ b/docs/architecture/media-search-design.md @@ -58,6 +58,11 @@ - `MetaMusic.parse_resource` 接受标题与副标题,复用现有 Python/Rust 标题解析, 补充明确艺术家/专辑字段、曲序结构和版本证据。缺少分隔且语义不明确时不猜测。 +- 音乐资源经 `MetaInfo` 工厂应用与影视相同的用户识别词,实际应用记录保存在 + `apply_words` 中;RSS 重新匹配也使用订阅识别词,不修改共享资源缓存。 + 音频文件的真实后缀仍优先于标题中的格式声明。 +- 副标题的明确版本字段或独立版本标签补充主标题缺少的录音版本;已有标题版本 + 不被副标题覆盖。单曲的所属专辑字段只证明归属,即使没有曲序也不代表整专资源。 - 展示繁转简前保留名称原文。作品、所属专辑与艺术家别名分开保存;单曲不使用 兼容 `names` 中混入的专辑名称。别名仅来自同一实体的来源数据。 - 完整名称及署名优先;短名称不能仅凭子串命中另一首作品。署名冲突、未知分类、 diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 1afe602e5..90c13af71 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,502 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| 全量 mypy 历史债务 | 9,501 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | | Ruff 历史诊断 | 542 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index cc7ec657f..fc8b06599 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -329,6 +329,9 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch 音乐与影视共用媒体搜索、资源查询、过滤、匹配和订阅搜索编排。资源 `meta_info` 来自 标题、副标题的实际解析,不用目标媒体回填证据;`title_aliases`、`album_aliases`、 `artist_aliases` 分别保留同一实体的可信别名及展示转简体前的原文。 +音乐资源解析同样应用全局或订阅自定义识别词,`MusicMeta.apply_words` 返回实际应用记录, +旧结果缺少该字段时按空列表处理。副标题的明确录音版本参与匹配;单曲所属专辑字段 +不能证明资源覆盖整张专辑,无曲序的单曲也会标记为 `partial_album` 待确认项。 音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true`, 额外返回待确认资源及关联专辑:`match_status=candidate`、`match_reason` 描述原因, diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 138c15703..aff152d95 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -163,6 +163,10 @@ Call the gateway with this shape: browse-only. Music recognition-cache operations are administrator-only; call `music.cache.get` before deleting one exact key, and clear all entries only after explicit confirmation. +- Music resource metadata records applied recognition rules in `apply_words`. + 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. ## Operation Catalog diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index bcd45dfac..70a34ddd6 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1090,7 +1090,7 @@ } }, "edge_count": 8259, - "edge_sha256": "f5f1d4cc3a4a6a659d1b3955995b605c8cc85be099cba76488ac3410935547cf", + "edge_sha256": "a380bbd37ee23f66e7195b0c143b4d5b2a25b17254c18946789b45174ec2eb92", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3849,13 +3849,13 @@ "app.chain._music -> app.chain._contracts", "app.chain._music -> app.chain.download", "app.chain._music -> app.chain.media", - "app.chain._music -> app.chain.search", - "app.chain._music -> app.chain.search.facade", "app.chain._music -> app.domain", "app.chain._music -> app.domain.context", "app.chain._music -> app.domain.media", "app.chain._music -> app.domain.meta", "app.chain._music -> app.domain.meta.metamusic", + "app.chain._music -> app.domain.metainfo", + "app.chain._music -> app.domain.music", "app.chain._music -> app.runtime", "app.chain._music -> app.runtime.log", "app.chain._music -> app.schemas", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index c3bff68fe..0693a97bd 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1288,7 +1288,7 @@ "arg-type": 4, "assignment": 7, "list-item": 1, - "override": 4 + "override": 3 }, "app/domain/meta/metavideo.py": { "arg-type": 2, diff --git a/tests/test_media_search_pipeline.py b/tests/test_media_search_pipeline.py index 79fb3edbe..5f7ae7591 100644 --- a/tests/test_media_search_pipeline.py +++ b/tests/test_media_search_pipeline.py @@ -83,3 +83,71 @@ def test_all_media_use_filtered_results_to_stop_keyword_search(monkeypatch, mtyp assert contexts[0].media_info.media_id == target.media_id assert contexts[0].meta_info.media_id is None assert isinstance(contexts[0].meta_info, MetaMusic) == (mtype == MediaType.MUSIC) + + +@pytest.mark.parametrize("mode", ["sync", "async", "stream"]) +@pytest.mark.parametrize("case", ["partial_album", "subtitle_version", "recognition_words"]) +def test_music_resource_evidence_controls_shared_search_stop(monkeypatch, mode, case): + """三种搜索入口均在完成识别词、版本与专辑范围验证后才允许停止换词。""" + chain = SearchChain() + chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False) + album_target = case == "partial_album" + target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="target", + music_type="album" if album_target else "recording", + title="叶惠美" if album_target else "晴天", artists=["周杰伦"]) + calls = [] + + def search(**kwargs): + """先返回待核验或需改名的资源,再提供正确身份的资源。""" + calls.append(kwargs["keyword"]) + if len(calls) > 1: + return [TorrentInfo(title=f"周杰伦 - {target.title} FLAC", category=MediaType.MUSIC.value)] + return [TorrentInfo( + title="周杰伦 - 错误曲名 FLAC" if case == "recognition_words" else "周杰伦 - 晴天 FLAC", + description="专辑:叶惠美" if album_target else "版本:Live" if case == "subtitle_version" else None, + category=MediaType.MUSIC.value, + )] + + async def async_search(**kwargs): + """用相同响应驱动异步站点入口。""" + return search(**kwargs) + + async def stream(**kwargs): + """用相同响应驱动站点流式入口。""" + yield {"items": search(**kwargs), "value": 100} + + async def supplement(mediainfo): + """目标已完整,不访问外部来源。""" + return mediainfo + + async def sleep(_delay): + """跳过关键词间的测试退避。""" + + monkeypatch.setattr(media, "MediaChain", lambda: SimpleNamespace( + supplement_media_info=lambda mediainfo: mediainfo, async_supplement_media_info=supplement, + )) + monkeypatch.setattr(execution.time, "sleep", lambda _delay: None) + monkeypatch.setattr(execution.asyncio, "sleep", sleep) + chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"]) + chain._SearchChain__search_all_sites = search + chain._SearchChain__async_search_all_sites = async_search + chain._SearchChain__async_search_all_sites_stream = stream + params = dict(mediainfo=target, rule_groups=[], include_candidates=True, + custom_words=["错误曲名 => 晴天"] if case == "recognition_words" else None) + if mode == "sync": + contexts = chain.process(**params) + elif mode == "async": + contexts = asyncio.run(chain.async_process(**params)) + else: + async def collect(): + """从完成事件读取权威结果,避免把流式预览当作精确命中。""" + return [event async for event in chain.async_process_stream(**params)] + + contexts = asyncio.run(collect())[-1]["contexts"] + assert calls == (["first"] if case == "recognition_words" else ["first", "second"]) + exact = [context for context in contexts if context.match_status == "exact"] + assert len(exact) == 1 + assert exact[0].meta_info.title == target.title + assert all(context.media_info is None for context in contexts if context.match_status == "candidate") + if case == "recognition_words": + assert exact[0].meta_info.apply_words == ["错误曲名 => 晴天"] diff --git a/tests/test_music_matching.py b/tests/test_music_matching.py index c607dddfe..69eb26b66 100644 --- a/tests/test_music_matching.py +++ b/tests/test_music_matching.py @@ -11,7 +11,10 @@ from app.chain.search import SearchChain 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 from app.domain.music import match_music_resource +from app.schemas.music import MusicMeta +from app.schemas.types import MediaType @pytest.mark.parametrize("title", ["U2 - One Tree Hill FLAC", "U2 - Someone FLAC", "U2 - One - Tree Hill FLAC"]) @@ -92,6 +95,77 @@ def test_music_album_field_cannot_match_target_recording(): assert match_music_resource(wanted, "Artist - Album - 01 - Other Song FLAC").status == "rejected" +@pytest.mark.parametrize("title", [ + "周杰伦 - 晴天 FLAC", + "周杰伦 - 03 - 晴天 FLAC", + "周杰伦 - 03 - 叶惠美 FLAC", +]) +def test_album_match_cannot_promote_track_from_subtitle(title): + """即使没有曲序,单曲所属专辑字段也不能证明资源是整张专辑。""" + album = MusicInfo(music_type="album", title="叶惠美", artists=["周杰伦"]) + match = match_music_resource(album, title, "专辑:叶惠美") + assert (match.status, match.reason) == ("candidate", "partial_album") + + +@pytest.mark.parametrize("title", ["周杰伦 - 叶惠美 FLAC", "周杰伦《叶惠美》FLAC", "Jay Chou - Ye Hui Mei FLAC"]) +def test_album_match_keeps_primary_album_names_and_aliases(title): + """整专主标题及可信别名仍可精确命中,不因副标题补充专辑字段而降级。""" + album = MusicInfo(music_type="album", title="叶惠美", title_aliases=["Ye Hui Mei"], + artists=["周杰伦"], artist_aliases=["Jay Chou"]) + assert match_music_resource(album, title, "专辑:叶惠美").status == "exact" + + +@pytest.mark.parametrize("subtitle", ["版本:Live", "錄音版本:Live", "Version: Live", "[Live]", "演唱:周杰伦;(Live)"]) +def test_resource_subtitle_version_is_matching_evidence(subtitle): + """明确的副标题版本只可匹配同一版本,不能自动绑定为普通录音。""" + title = "周杰伦 - 晴天 FLAC" + meta = MetaMusic.parse_resource(title, subtitle) + assert meta.version == "Live" + ordinary = MusicInfo(title="晴天", artists=["周杰伦"]) + assert match_music_resource(ordinary, title, subtitle).reason == "version_mismatch" + live = MusicInfo(title="晴天 (Live)", artists=["周杰伦"]) + assert match_music_resource(live, title, subtitle).status == "exact" + + +def test_resource_title_version_has_priority_over_subtitle(): + """标题版本已有明确证据时,冲突副标题不能覆盖;艺人字段也不是版本声明。""" + assert MetaMusic.parse_resource("U2 - One (Live) FLAC", "版本:Remix").version == "Live" + assert MetaMusic.parse_resource("Song FLAC", "艺术家:Live").version is None + + +@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): + """音乐名称复用影视识别词语法,音频后缀与显式类型入口行为一致。""" + words = ["错误曲名 => 晴天"] + if global_words: + monkeypatch.setattr("app.domain.meta.words.get_custom_words", lambda: words) + meta = MetaInfo(f"周杰伦 - 错误曲名{suffix}", "版本:Live", mtype=mtype, + custom_words=None if global_words else words) + assert (meta.title, meta.artists, meta.version) == ("晴天", ["周杰伦"], "Live") + assert meta.audio_format == "FLAC" + assert meta.apply_words == words + assert MetaMusic.from_dict(meta.to_dict()).apply_words == words + assert MusicMeta.model_validate(meta.to_dict()).apply_words == words + + +def test_legacy_music_metainfo_keeps_empty_recognition_words(): + """旧音乐缓存缺少识别词记录时仍能恢复,且实例之间不共享可变列表。""" + old = MetaMusic.from_dict({"title": "晴天"}) + assert old.apply_words == [] + changed = MetaMusic(title="晴天") + changed.apply_words = ["错误曲名 => 晴天"] + assert old.apply_words == [] + + +@pytest.mark.parametrize("suffix", ["mp2", "tta", "flac"]) +def test_music_recognition_words_keep_original_file_format(suffix): + """识别词可能删掉文件后缀,但不能丢失或覆盖文件原本声明的音频格式。""" + meta = MetaInfo(f"周杰伦 - 错误曲名.{suffix}", custom_words=[f"错误曲名\\.{suffix} => 晴天"]) + assert meta.title == "晴天" + assert meta.audio_format == suffix.upper() + + def test_music_artist_in_subtitle_cannot_override_conflicting_title_credit(): """另一位艺人的同名歌曲不能借副标题出现目标艺人而成为精确命中。""" music = MusicInfo(title="One", artists=["U2"]) @@ -146,6 +220,12 @@ def test_resource_evidence_matches_python_and_native_parser_paths(monkeypatch, p bracketed = MetaMusic.parse_resource("【永遠・是朋友】24bit/96kHz", "專輯藝人:周華健") assert bracketed.title and "永遠" in bracketed.title assert bracketed.artists == ["周華健"] + live = MetaInfo("周杰伦 - 错误曲名.flac", "版本:Live", mtype=MediaType.MUSIC, + custom_words=["错误曲名 => 晴天"]) + assert (live.title, live.version, live.audio_format) == ("晴天", "Live", "FLAC") + assert live.apply_words == ["错误曲名 => 晴天"] + album = MusicInfo(music_type="album", title="叶惠美", artists=["周杰伦"]) + assert match_music_resource(album, "周杰伦 - 晴天 FLAC", "专辑:叶惠美").reason == "partial_album" @pytest.mark.parametrize("factory", [MusicInfo, MusicAlbumInfo]) diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index 61a920a20..bbd9046d2 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -508,6 +508,43 @@ def test_music_subscribe_matches_artist_from_resource_description(): assert len(matched) == 1 +def test_music_rss_applies_subscription_words_without_mutating_cache(): + """RSS 与主动搜索使用同一订阅识别词,并保持缓存中的资源原文独立。""" + subscribe = _subscribe(custom_words="错误曲名 => 晴天") + torrent = TorrentInfo(title="周杰伦 - 错误曲名 FLAC", category=MediaType.MUSIC.value) + original_meta = MetaMusic.parse_resource(torrent.title) + context = Context(torrent_info=torrent, meta_info=original_meta) + chain = SubscribeChain() + chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"]) + + matched = chain._filter_music_subscribe_contexts(subscribe, _music_info(), [context]) + + assert len(matched) == 1 + assert matched[0].meta_info.title == "晴天" + assert matched[0].meta_info.apply_words == ["错误曲名 => 晴天"] + assert matched[0].meta_info is not original_meta + assert context.meta_info.title == "错误曲名" + assert context.torrent_info.title == "周杰伦 - 错误曲名 FLAC" + + +@pytest.mark.parametrize("album_target", [False, True]) +def test_music_rss_rejects_subtitle_version_or_partial_album(album_target): + """RSS 不能把副标题现场版或单曲所属专辑当作可自动下载的目标。""" + target = _music_info() + if album_target: + target.music_type = MUSIC_ENTITY_ALBUM + target.title = target.album + context = Context(torrent_info=TorrentInfo( + title="周杰伦 - 晴天 FLAC", + description="专辑:叶惠美" if album_target else "版本:Live", + category=MediaType.MUSIC.value, + )) + chain = SubscribeChain() + chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"]) + + assert chain._filter_music_subscribe_contexts(_subscribe(), target, [context]) == [] + + def test_album_best_version_requires_confirmed_full_coverage(): """最高优先级的非完整专辑不得写入洗版基线或完成订阅。""" subscribe = _subscribe(