From 16840c6767b0535ce35334e35becde756ba4abcc Mon Sep 17 00:00:00 2001 From: Yang Feng <38039566+bgColorGray@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:55:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(subscribe):=20=E4=BF=AE=E5=A4=8D=20RSS=20?= =?UTF-8?q?=E5=AA=92=E4=BD=93=20ID=20=E8=AF=AF=E8=AF=86=E5=88=AB=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20(#6266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/subscribe.py | 96 ++++++++++- tests/test_subscribe_media_id_conflict.py | 184 ++++++++++++++++++++++ 2 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 tests/test_subscribe_media_id_conflict.py diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index b57d583db..1dcd96c63 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -2370,26 +2370,34 @@ class SubscribeChain(ChainBase): )[1]: if torrent_mediainfo.type != mediainfo.type: continue - if torrent_mediainfo.tmdb_id \ - and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id: + torrent_mediainfo = self.__reconcile_candidate_media( + target_mediainfo=mediainfo, + candidate_mediainfo=torrent_mediainfo, + torrent_meta=torrent_meta, + torrent_info=torrent_info, + context=_context, + ) + if not torrent_mediainfo: continue - if torrent_mediainfo.douban_id \ - and torrent_mediainfo.douban_id != mediainfo.douban_id: - continue - logger.info( - f'{mediainfo.title_year} 通过媒体ID匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}') match_source = _context.match_source if match_source == "title": # 标题兜底使用的是订阅目标 media_info,不能标记为候选自身识别结果。 _context.candidate_recognized = False _context.media_info_is_target = True + match_label = "标题复核" elif match_source == "unknown": _context.match_source = self.__get_media_id_match_source(torrent_mediainfo) _context.candidate_recognized = True _context.media_info_is_target = False + match_label = "媒体ID" else: _context.candidate_recognized = True _context.media_info_is_target = False + match_label = "媒体ID" + logger.info( + f'{mediainfo.title_year} 通过{match_label}匹配到可选资源:' + f'{torrent_info.site_name} - {torrent_info.title}' + ) else: continue @@ -4809,6 +4817,80 @@ class SubscribeChain(ChainBase): return "plugin" return "unknown" + @staticmethod + def __reconcile_candidate_media( + target_mediainfo: MediaInfo, + candidate_mediainfo: MediaInfo, + torrent_meta: MetaBase, + torrent_info: TorrentInfo, + context: Context, + ) -> Optional[MediaInfo]: + """ + 在推断媒体 ID 冲突时,以订阅目标对候选标题做严格复核。 + + 标题携带明确媒体身份或目标缺少同来源 ID 时保持严格拒绝;只有普通 + 标题推断产生冲突且标题、别名、类型和年份复核通过时才回填订阅目标。 + + :param target_mediainfo: 订阅目标媒体信息 + :param candidate_mediainfo: RSS 或首页候选的识别结果 + :param torrent_meta: 候选标题解析信息 + :param torrent_info: 候选种子信息 + :param context: 当前订阅使用的候选上下文副本 + :return: 可继续匹配的媒体信息,拒绝时返回 None + """ + identity_pairs = ( + ("TMDB", candidate_mediainfo.tmdb_id, target_mediainfo.tmdb_id), + ("豆瓣", candidate_mediainfo.douban_id, target_mediainfo.douban_id), + ) + conflicts = [ + (source, str(candidate_id), str(target_id) if target_id is not None else None) + for source, candidate_id, target_id in identity_pairs + if candidate_id is not None + and str(candidate_id) != (str(target_id) if target_id is not None else None) + ] + if not conflicts: + return candidate_mediainfo + + conflict_text = "、".join( + f"{source} {candidate_id} != {target_id if target_id is not None else '缺失'}" + for source, candidate_id, target_id in conflicts + ) + if any(target_id is None for _, _, target_id in conflicts): + logger.debug( + f'{torrent_info.site_name} - {torrent_info.title} 候选媒体ID与订阅目标不可比较:' + f'{conflict_text}' + ) + return None + + explicit_source, explicit_id = resolve_media_identity(media=torrent_meta) + if explicit_source and explicit_id: + logger.debug( + f'{torrent_info.site_name} - {torrent_info.title} 标题含明确媒体ID ' + f'{explicit_source}:{explicit_id},保持严格校验:{conflict_text}' + ) + return None + + if not TorrentHelper.match_torrent( + mediainfo=target_mediainfo, + torrent_meta=torrent_meta, + torrent=torrent_info, + ): + logger.debug( + f'{torrent_info.site_name} - {torrent_info.title} 候选媒体ID冲突且标题复核失败:' + f'{conflict_text}' + ) + return None + + context.media_info = target_mediainfo + context.match_source = "title" + context.candidate_recognized = False + context.media_info_is_target = True + logger.debug( + f'{target_mediainfo.title_year} 候选媒体ID冲突({conflict_text}),' + f'经标题或别名复核匹配到订阅目标:{torrent_info.site_name} - {torrent_info.title}' + ) + return target_mediainfo + @staticmethod def get_states_for_search(state: str) -> str: """ diff --git a/tests/test_subscribe_media_id_conflict.py b/tests/test_subscribe_media_id_conflict.py new file mode 100644 index 000000000..ecfba5d87 --- /dev/null +++ b/tests/test_subscribe_media_id_conflict.py @@ -0,0 +1,184 @@ +from types import SimpleNamespace + +from app.chain.subscribe import SubscribeChain +from app.core.context import Context, MediaInfo, TorrentInfo +from app.schemas.types import MediaType + + +def _target_media(tmdb_id=106449, douban_id=None) -> MediaInfo: + """构造订阅目标媒体。""" + return MediaInfo( + title="凡人修仙传", + original_title="凡人修仙传", + names=["A Record Of A Mortals Journey To Immortality"], + type=MediaType.TV, + year="2020", + season_years={1: "2020"}, + tmdb_id=tmdb_id, + douban_id=douban_id, + ) + + +def _candidate_media(tmdb_id=285479, douban_id=None) -> MediaInfo: + """构造由 RSS 标题推断出的候选媒体。""" + return MediaInfo( + title="凡人修仙传", + type=MediaType.TV, + year="2020", + tmdb_id=tmdb_id, + douban_id=douban_id, + ) + + +def _torrent_meta(*, en_name="A Record Of A Mortals Journey To Immortality", tmdbid=None): + """构造不触发外部识别的标题解析结果。""" + return SimpleNamespace( + tmdbid=tmdbid, + doubanid=None, + bangumiid=None, + anilistid=None, + media_source="themoviedb" if tmdbid else None, + media_id=str(tmdbid) if tmdbid else None, + cn_name="", + en_name=en_name, + type=MediaType.TV, + year=None, + org_string=f"{en_name} S01E186 2160p WEB-DL", + ) + + +def _context(meta, candidate, torrent) -> Context: + """构造 RSS 候选上下文。""" + return Context( + meta_info=meta, + media_info=candidate, + torrent_info=torrent, + resource_source="rss", + match_source="tmdbid", + candidate_recognized=True, + media_info_is_target=False, + ) + + +def _reconcile(target, candidate, meta, torrent, context): + """调用订阅链的候选媒体冲突复核。""" + return SubscribeChain._SubscribeChain__reconcile_candidate_media( + target_mediainfo=target, + candidate_mediainfo=candidate, + torrent_meta=meta, + torrent_info=torrent, + context=context, + ) + + +def test_inferred_tmdb_conflict_falls_back_to_strict_alias_match(): + """标题推断到重复 TMDB 条目时应允许订阅目标别名复核。""" + target = _target_media() + candidate = _candidate_media() + meta = _torrent_meta() + torrent = TorrentInfo( + title="A Record Of A Mortals Journey To Immortality S01E186 2160p WEB-DL", + site_name="测试站点", + category=MediaType.TV.value, + ) + context = _context(meta, candidate, torrent) + + result = _reconcile(target, candidate, meta, torrent, context) + + assert result is target + assert context.media_info is target + assert context.match_source == "title" + assert context.candidate_recognized is False + assert context.media_info_is_target is True + + +def test_inferred_tmdb_conflict_rejects_nonmatching_title(): + """ID 冲突且标题和别名不匹配时应继续拒绝候选。""" + target = _target_media() + candidate = _candidate_media() + meta = _torrent_meta(en_name="Different Series") + torrent = TorrentInfo( + title="Different Series S01E186 2160p WEB-DL", + site_name="测试站点", + category=MediaType.TV.value, + ) + context = _context(meta, candidate, torrent) + + assert _reconcile(target, candidate, meta, torrent, context) is None + assert context.media_info is candidate + assert context.match_source == "tmdbid" + assert context.candidate_recognized is True + assert context.media_info_is_target is False + + +def test_inferred_douban_conflict_falls_back_to_strict_alias_match(): + """豆瓣 ID 推断冲突时应使用同一套严格标题复核。""" + target = _target_media(tmdb_id=None, douban_id="30170816") + candidate = _candidate_media(tmdb_id=None, douban_id="36612345") + meta = _torrent_meta() + torrent = TorrentInfo( + title="A Record Of A Mortals Journey To Immortality S01E186 2160p WEB-DL", + site_name="测试站点", + category=MediaType.TV.value, + ) + context = _context(meta, candidate, torrent) + + result = _reconcile(target, candidate, meta, torrent, context) + + assert result is target + assert context.media_info is target + assert context.match_source == "title" + + +def test_explicit_tmdb_identity_keeps_strict_conflict_rejection(): + """标题显式携带媒体 ID 时不得被标题别名覆盖。""" + target = _target_media() + candidate = _candidate_media() + meta = _torrent_meta(tmdbid=285479) + torrent = TorrentInfo( + title="A Record Of A Mortals Journey To Immortality S01E186 {tmdbid=285479}", + site_name="测试站点", + category=MediaType.TV.value, + ) + context = _context(meta, candidate, torrent) + + assert _reconcile(target, candidate, meta, torrent, context) is None + assert context.media_info is candidate + assert context.match_source == "tmdbid" + + +def test_conflict_rejects_target_without_same_source_identity(): + """订阅目标缺少候选同来源 ID 时不得用标题跨来源放行。""" + target = _target_media(tmdb_id=None) + candidate = _candidate_media() + meta = _torrent_meta() + torrent = TorrentInfo( + title="A Record Of A Mortals Journey To Immortality S01E186", + site_name="测试站点", + category=MediaType.TV.value, + ) + context = _context(meta, candidate, torrent) + + assert _reconcile(target, candidate, meta, torrent, context) is None + assert context.media_info is candidate + + +def test_matching_media_id_preserves_candidate_identity(): + """媒体 ID 原本一致时应保持候选识别上下文。""" + target = _target_media() + candidate = _candidate_media(tmdb_id=106449) + meta = _torrent_meta() + torrent = TorrentInfo( + title="A Record Of A Mortals Journey To Immortality S01E186", + site_name="测试站点", + category=MediaType.TV.value, + ) + context = _context(meta, candidate, torrent) + + result = _reconcile(target, candidate, meta, torrent, context) + + assert result is candidate + assert context.media_info is candidate + assert context.match_source == "tmdbid" + assert context.candidate_recognized is True + assert context.media_info_is_target is False