From b51f93f60f9175fc2b2d1e4b2f5261c035a41770 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 23 Oct 2026 23:08:10 +0800 Subject: [PATCH] fix(subscribe): reconcile inferred media type conflicts (#7166) --- app/application/subscription/candidates.py | 19 +- app/application/torrent/download.py | 14 +- app/chain/subscribe/match.py | 21 +- .../architecture/complexity-baseline.json | 2 +- tests/test_subscribe_type_conflict.py | 310 ++++++++++++++++++ 5 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 tests/test_subscribe_type_conflict.py diff --git a/app/application/subscription/candidates.py b/app/application/subscription/candidates.py index 808c8fdec..20d737cff 100644 --- a/app/application/subscription/candidates.py +++ b/app/application/subscription/candidates.py @@ -76,6 +76,9 @@ class CandidateIndex: ) -> CandidateGroups: """ 保守路由可能命中的候选,并在外部事实查询前应用站点范围。 + + 无显式 ID 的候选识别类型可由后续严格复核纠正,不能据此提前排除; + 标题解析出的类型和季范围仍须符合订阅要求。 """ if subscribe.custom_words: positions = set(range(len(self._ordered))) @@ -102,7 +105,9 @@ class CandidateIndex: if site_ids and getattr(torrent_info, "site", None) not in site_ids: continue if not subscribe.custom_words and ( - not self.media_type_matches(context, subscribe) + not self.media_type_matches( + context, subscribe, include_recognized=position not in self._reconcilable + ) or not self.season_matches(context, subscribe) ): continue @@ -165,9 +170,17 @@ class CandidateIndex: ) @classmethod - def media_type_matches(cls, context: Context, subscribe: SubscriptionSnapshot) -> bool: + def media_type_matches( + cls, + context: Context, + subscribe: SubscriptionSnapshot, + *, + include_recognized: bool = True, + ) -> bool: """ 类型已知且冲突时拒绝,缺失类型保持保守候选。 + + :param include_recognized: 是否同时检查识别结果的类型;仅在路由可复核推断时忽略 """ subscribe_type = cls.normalize_media_type(subscribe.type) media_info = getattr(context, "media_info", None) @@ -175,7 +188,7 @@ class CandidateIndex: context_types = { cls.normalize_media_type(value) for value in ( - getattr(media_info, "type", None), + getattr(media_info, "type", None) if include_recognized else None, getattr(meta_info, "type", None), ) } diff --git a/app/application/torrent/download.py b/app/application/torrent/download.py index 6874cb0a1..c4140aee5 100644 --- a/app/application/torrent/download.py +++ b/app/application/torrent/download.py @@ -30,7 +30,7 @@ from app.foundation.url import UrlUtils from app.runtime.cache import FileCache, TTLCache from app.runtime.log import logger from app.schemas.media import resolve_media_identity -from app.schemas.types import MediaType, SystemConfigKey +from app.schemas.types import MediaSource, MediaType, SystemConfigKey _SIZE_UNIT = 1024 * 1024 @@ -912,10 +912,18 @@ class TorrentHelper: ) -> Tuple[bool, str]: """ 判断媒体 ID 冲突时是否存在足以覆盖候选识别的同作品证据。 + + TMDB 的电影与电视剧数字 ID 不可跨类型比较;类型冲突时仍需资源精确年份、 + 其他共同标识或一致的作品元数据,不能仅凭相同 TMDB ID 覆盖候选。 + + :return: 是否有同作品证据,以及用于诊断的证据或拒绝原因 """ target_identity = resolve_media_identity(media=target_mediainfo) candidate_identity = resolve_media_identity(media=candidate_mediainfo) - if all(target_identity) and target_identity == candidate_identity: + same_type = target_mediainfo.type == candidate_mediainfo.type + if all(target_identity) and target_identity == candidate_identity and ( + same_type or target_identity[0] != MediaSource.TMDB + ): return True, f"媒体身份 {target_identity[0]}:{target_identity[1]}" torrent_year = str(getattr(torrent_meta, "year", None) or "")[:4] @@ -931,6 +939,8 @@ class TorrentHelper: return True, f"资源年份 {torrent_year}" for field in ("tmdb_id", "imdb_id", "tvdb_id", "douban_id", "bangumi_id"): + if field == "tmdb_id" and not same_type: + continue target_id = getattr(target_mediainfo, field, None) candidate_id = getattr(candidate_mediainfo, field, None) if target_id and candidate_id and str(target_id) == str(candidate_id): diff --git a/app/chain/subscribe/match.py b/app/chain/subscribe/match.py index acce554eb..677f85176 100644 --- a/app/chain/subscribe/match.py +++ b/app/chain/subscribe/match.py @@ -192,15 +192,19 @@ def _reconcile_candidate_media( context: Context, ) -> Optional[MediaInfo]: """ - 在推断媒体 ID 冲突时,以订阅目标对候选标题做严格复核。 + 在推断媒体 ID 或类型冲突时,以订阅目标对候选标题做严格复核。 - 标题携带明确媒体身份或目标缺少同来源 ID 时保持严格拒绝;只有普通 - 标题推断产生冲突且标题、别名、类型和年份复核通过时才回填订阅目标。 + 标题携带明确媒体身份或目标缺少可比较身份时保持严格拒绝;只有普通 + 标题推断产生冲突且标题、别名、类型和同作品证据复核通过时才回填订阅目标。 + 电影和电视剧可能共用数字 ID,因此身份相同时仍须复核类型冲突。 """ candidate_source, candidate_id = resolve_media_identity(media=candidate_mediainfo) target_source, target_id = resolve_media_identity(media=target_mediainfo) + type_conflict = candidate_mediainfo.type != target_mediainfo.type conflicts = [] - if candidate_source and candidate_id and (candidate_source != target_source or candidate_id != target_id): + if candidate_source and candidate_id and ( + candidate_source != target_source or candidate_id != target_id or type_conflict + ): conflicts.append( ( str(candidate_source), @@ -215,6 +219,11 @@ def _reconcile_candidate_media( f"{source} {candidate_id} != {target_id if target_id is not None else '缺失'}" for source, candidate_id, target_id in conflicts ) + if type_conflict: + conflict_text = ( + f"媒体类型 {(candidate_mediainfo.type or MediaType.UNKNOWN).value} != " + f"{(target_mediainfo.type or MediaType.UNKNOWN).value};{conflict_text}" + ) if any(target_id is None for _, _, target_id in conflicts): logger.debug( f"{torrent_info.site_name} - {torrent_info.title} 候选媒体ID与订阅目标不可比较:{conflict_text}" @@ -615,6 +624,8 @@ class SubscribeMatchOwner(_SubscribeOwnerBase): ) -> _MatchOutcome: """ 在单条订阅准入期间完成匹配、下载和完成判定。 + + 候选推断的媒体类型与身份一并严格复核,复核通过后再应用目标季集范围。 """ if execution_context.should_stop(): return "skipped" @@ -735,8 +746,6 @@ class SubscribeMatchOwner(_SubscribeOwnerBase): _context.media_info_is_target = True if torrent_mediainfo and resolve_media_identity(media=torrent_mediainfo)[1]: - if torrent_mediainfo.type != mediainfo.type: - continue torrent_mediainfo = self._SubscribeChain__reconcile_candidate_media( target_mediainfo=mediainfo, candidate_mediainfo=cast(MediaInfo, torrent_mediainfo), diff --git a/tests/fixtures/architecture/complexity-baseline.json b/tests/fixtures/architecture/complexity-baseline.json index c4444dbc6..1f758a23f 100644 --- a/tests/fixtures/architecture/complexity-baseline.json +++ b/tests/fixtures/architecture/complexity-baseline.json @@ -53,7 +53,7 @@ "app/chain/scraping.py:ScrapingChain.scrape_music_metadata": 17, "app/chain/subscribe/completion.py:SubscribeCompletionOwner._SubscribeChain__get_subscribe_no_exits": 16, "app/chain/subscribe/completion.py:SubscribeCompletionOwner._SubscribeChain__record_subscribe_download_facts": 19, - "app/chain/subscribe/match.py:SubscribeMatchOwner._execute_match": 47, + "app/chain/subscribe/match.py:SubscribeMatchOwner._execute_match": 46, "app/chain/subscribe/query.py:SubscribeQueryOwner.follow": 19, "app/chain/subscribe/query.py:SubscribeQueryOwner.subscribe_files_info": 24, "app/chain/subscribe/refresh.py:SubscribeRefreshOwner.backfill_existing_episodes": 16, diff --git a/tests/test_subscribe_type_conflict.py b/tests/test_subscribe_type_conflict.py new file mode 100644 index 000000000..118441a5a --- /dev/null +++ b/tests/test_subscribe_type_conflict.py @@ -0,0 +1,310 @@ +""" +验证订阅候选被推断成其他媒体类型时的严格复核与缓存隔离。 +""" + +from collections.abc import Iterator +from dataclasses import replace +from types import SimpleNamespace +from typing import Optional +from unittest.mock import Mock + +import pytest + +from app.application.subscription.candidates import CandidateIndex +from app.application.subscription.contract import SubscriptionSnapshot +from app.application.subscription.execution import ( + SubscriptionExecutionAdmission, + SubscriptionExecutionContext, +) +from app.application.subscription.facts import FreshFactLease +from app.application.torrent.download import TorrentHelper +from app.chain.subscribe import match as subscribe_match +from app.chain.subscribe.facade import SubscribeChain +from app.domain.context import Context, MediaInfo, TorrentInfo +from app.domain.metainfo import MetaInfo +from app.schemas.types import MediaSource, MediaType + + +@pytest.fixture +def conflict_case(monkeypatch: pytest.MonkeyPatch) -> Iterator[SimpleNamespace]: + """ + 构造《空枪》资源被识别为《V星入侵》的离线订阅匹配场景。 + + 保留真实候选索引、标题解析和匹配逻辑,仅替换媒体查询、持久化和下载边界。 + """ + target = MediaInfo( + media_source=MediaSource.TMDB, + media_id="1567937", + tmdb_id=1567937, + type=MediaType.MOVIE, + title="空枪", + original_title="空枪", + year="2026", + ) + candidate = MediaInfo( + media_source=MediaSource.TMDB, + media_id="21494", + tmdb_id=21494, + type=MediaType.TV, + title="V星入侵", + original_title="V", + year="2009", + ) + torrent = TorrentInfo( + title="V 2026 1080p WEB-DL DDP5.1 H265 2Audio-HDSWEB", + description="空枪 | 主演: 朱一龙 檀健次 梁家辉 【国粤双语】", + category=MediaType.MOVIE.value, + site=1, + site_name="测试站点", + ) + meta = MetaInfo(title=torrent.title, subtitle=torrent.description) + context = Context( + meta_info=meta, + media_info=candidate, + torrent_info=torrent, + resource_source="rss", + match_source=MediaSource.TMDB.value, + candidate_recognized=True, + media_info_is_target=False, + ) + subscribe = SubscriptionSnapshot( + id=1, + name=target.title, + year=target.year, + type=target.type.value, + media_source=target.media_source, + media_id=target.media_id, + state="R", + ) + chain = object.__new__(SubscribeChain) + chain.subscription_repository = SimpleNamespace(get=lambda _subscription_id: None) + chain.get_sub_sites = Mock(return_value=[]) + chain.get_params = Mock(return_value={}) + chain.check_and_handle_existing_media = Mock(return_value=(False, {})) + chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"]) + chain.finish_subscribe_or_not = Mock() + download = Mock(return_value=([], {})) + chain._SubscribeChain__download_best_version_with_full_pack_first = download + media_chain = SimpleNamespace( + recognize_media=Mock(return_value=target), + supplement_media_info=lambda media: media, + ) + monkeypatch.setattr(subscribe_match, "MediaChain", lambda: media_chain) + monkeypatch.setattr( + subscribe_match, + "get_configured_system_config", + lambda: SimpleNamespace(get=lambda _key: []), + ) + monkeypatch.setattr( + subscribe_match, "runtime_stop_state", SimpleNamespace(is_system_stopped=False) + ) + admission = SubscriptionExecutionAdmission() + lease = admission.try_acquire(subscription_id=subscribe.id, operation="match", ttl_seconds=60) + assert lease is not None + yield SimpleNamespace( + target=target, + candidate=candidate, + torrent=torrent, + meta=meta, + context=context, + subscribe=subscribe, + chain=chain, + download=download, + execution=SubscriptionExecutionContext(lease=lease, admission=admission), + ) + assert admission.release(lease) + + +def _execute_match(case: SimpleNamespace) -> str: + """ + 经真实候选路由和单订阅匹配流程执行一次资源检查。 + """ + candidates = {"example.com": [case.context]} + return case.chain._execute_match( + subscribe=case.subscribe, + processed_torrents=candidates, + candidate_index=CandidateIndex(candidates), + fresh_fact_lease=FreshFactLease(), + execution_context=case.execution, + ) + + +def _reconcile(case: SimpleNamespace) -> Optional[MediaInfo]: + """ + 直接验证冲突复核边界,避免索引提前拒绝掩盖复核回归。 + """ + return SubscribeChain._SubscribeChain__reconcile_candidate_media( + target_mediainfo=case.target, + candidate_mediainfo=case.candidate, + torrent_meta=case.meta, + torrent_info=case.torrent, + context=case.context, + ) + + +def test_inferred_type_conflict_reaches_subscription_review(conflict_case: SimpleNamespace) -> None: + """ + 只由识别结果产生的类型冲突不能在候选路由阶段丢失。 + """ + case = conflict_case + index = CandidateIndex({"example.com": [case.context]}) + + assert index.route_for_match(case.subscribe) == {"example.com": [case.context]} + assert index.select_cache_candidates(case.subscribe, allow_title_match=True) == [] + + +@pytest.mark.parametrize("candidate_id", ["21494", "1567937"]) +def test_movie_subtitle_reconciles_inferred_tv_without_changing_cache( + conflict_case: SimpleNamespace, candidate_id: str +) -> None: + """ + 副标题及精确年份可纠正跨类型推断,相同数字 ID 也必须复核且不污染 RSS 缓存。 + """ + case = conflict_case + case.candidate.media_id = candidate_id + case.candidate.tmdb_id = int(candidate_id) + + assert _execute_match(case) == "completed" + + case.download.assert_called_once() + matched = case.download.call_args.kwargs["contexts"] + assert len(matched) == 1 + assert matched[0].media_info.media_id == case.target.media_id + assert matched[0].media_info.type == MediaType.MOVIE + assert matched[0].match_source == "title" + assert matched[0].candidate_recognized is False + assert matched[0].media_info_is_target is True + assert case.context.media_info is case.candidate + assert case.candidate.type == MediaType.TV + assert case.candidate.media_id == candidate_id + assert case.context.match_source == MediaSource.TMDB.value + assert case.context.candidate_recognized is True + assert case.context.media_info_is_target is False + + +@pytest.mark.parametrize("candidate_id", ["21494", "1567937"]) +def test_type_conflict_with_same_numeric_id_still_requires_title_review( + conflict_case: SimpleNamespace, candidate_id: str +) -> None: + """ + TMDB 的电影与电视剧数字 ID 相同时仍不能绕过标题校验。 + """ + case = conflict_case + case.candidate.media_id = candidate_id + case.candidate.tmdb_id = int(candidate_id) + case.torrent.description = "其他电影" + + assert _reconcile(case) is None + assert case.context.media_info is case.candidate + + +@pytest.mark.parametrize("constraint", ["year", "missing_year", "title_type", "site_type", "explicit_id"]) +@pytest.mark.parametrize("candidate_id", ["21494", "1567937"]) +def test_cross_type_review_preserves_resource_constraints( + conflict_case: SimpleNamespace, constraint: str, candidate_id: str +) -> None: + """ + 跨类型复核仍拒绝年份、季集类型、站点分类或明确媒体 ID 的冲突。 + """ + case = conflict_case + case.candidate.media_id = candidate_id + case.candidate.tmdb_id = int(candidate_id) + if constraint == "year": + case.meta.year = "2025" + elif constraint == "missing_year": + case.meta.year = None + elif constraint == "title_type": + case.meta.type = MediaType.TV + case.meta.begin_season = 1 + elif constraint == "site_type": + case.torrent.category = MediaType.TV.value + else: + case.meta.media_source = MediaSource.TMDB + case.meta.media_id = case.candidate.media_id + + assert _reconcile(case) is None + assert case.context.media_info is case.candidate + assert _execute_match(case) == "completed" + case.download.assert_not_called() + + +@pytest.mark.parametrize("candidate_id", ["21494", "1567937"]) +def test_no_year_tv_title_cannot_override_other_type_without_work_evidence( + conflict_case: SimpleNamespace, candidate_id: str +) -> None: + """ + 无年份剧集标题虽命中,仍需同作品证据才能覆盖错误类型。 + """ + case = conflict_case + case.target.type = MediaType.TV + case.target.season_years = {1: "2026"} + case.candidate.type = MediaType.MOVIE + case.candidate.media_id = candidate_id + case.candidate.tmdb_id = case.target.tmdb_id + case.torrent.title = "空枪 S01E01 1080p WEB-DL" + case.torrent.category = MediaType.TV.value + case.meta = MetaInfo(title=case.torrent.title) + case.context.meta_info = case.meta + case.subscribe = replace(case.subscribe, type=MediaType.TV.value, season=1) + + assert _reconcile(case) is None + assert _execute_match(case) == "completed" + case.download.assert_not_called() + + +def test_cross_type_rejection_logs_type_and_review_reason( + conflict_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + 冲突诊断须同时记录两种媒体类型和标题复核失败原因。 + """ + case = conflict_case + case.torrent.description = "其他电影" + messages = [] + monkeypatch.setattr(subscribe_match.logger, "debug", messages.append) + + assert _reconcile(case) is None + assert any("电视剧" in message and "电影" in message and "标题复核失败" in message for message in messages) + + +def test_tv_title_and_year_can_reconcile_inferred_movie(conflict_case: SimpleNamespace) -> None: + """ + 电视剧被误识别为电影时,同样可经标题及目标季年份复核后提交。 + """ + case = conflict_case + case.target.type = MediaType.TV + case.target.season_years = {1: "2026"} + case.candidate.type = MediaType.MOVIE + case.torrent.title = "空枪 2026 S01E01 1080p WEB-DL" + case.torrent.category = MediaType.TV.value + case.meta = MetaInfo(title=case.torrent.title) + case.context.meta_info = case.meta + case.subscribe = replace(case.subscribe, type=MediaType.TV.value, season=1) + + assert _execute_match(case) == "completed" + + case.download.assert_called_once() + matched = case.download.call_args.kwargs["contexts"][0] + assert matched.media_info.type == MediaType.TV + assert matched.media_info.media_id == case.target.media_id + assert matched.match_source == "title" + + +def test_global_identity_remains_valid_cross_type_evidence(conflict_case: SimpleNamespace) -> None: + """ + IMDb 这类全局媒体身份仍可证明同作品,不受 TMDB 的类型命名空间限制影响。 + """ + case = conflict_case + case.meta.year = None + for media in (case.target, case.candidate): + media.media_source = MediaSource.IMDb + media.media_id = "tt1234567" + + matched, evidence = TorrentHelper.match_same_work_evidence( + target_mediainfo=case.target, + candidate_mediainfo=case.candidate, + torrent_meta=case.meta, + ) + + assert matched is True + assert "tt1234567" in evidence