From 2d982f08fabc3145f82e31220c3b41dde2bcab13 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 22 Aug 2026 11:35:01 +0800 Subject: [PATCH] fix(search): disambiguate yearless same-title resources --- app/application/torrent.py | 169 ++++++++++++++++++ app/chain/search.py | 33 ++++ app/chain/subscribe.py | 24 ++- tests/test_metainfo.py | 24 +++ tests/test_search_same_name_disambiguation.py | 104 +++++++++++ tests/test_subscribe_media_id_conflict.py | 93 ++++++++-- 6 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 tests/test_search_same_name_disambiguation.py diff --git a/app/application/torrent.py b/app/application/torrent.py index 6978f3801..c2069ee02 100644 --- a/app/application/torrent.py +++ b/app/application/torrent.py @@ -482,6 +482,13 @@ class TorrentHelper: str(int(mediainfo.year) + 1)]: logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}') return False + # 无年份同名剧仅靠标题无法消歧,先用目标剧已知的季集拓扑排除不可能候选。 + if mediainfo.type == MediaType.TV and not TorrentHelper._match_tv_topology( + mediainfo=mediainfo, + torrent_meta=torrent_meta, + torrent=torrent, + ): + return False # 比对标题和原语种标题 if meta_names.intersection(media_titles): logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}') @@ -515,6 +522,168 @@ class TorrentHelper: logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}') return False + @staticmethod + def _match_tv_topology( + mediainfo: MediaInfo, + torrent_meta: MetaBase, + torrent: TorrentInfo, + ) -> bool: + """用目标剧已知的季集范围排除无年份同名候选。""" + begin_season = getattr(torrent_meta, "begin_season", None) + end_season = getattr(torrent_meta, "end_season", None) + if begin_season is None: + torrent_seasons = set() + elif end_season is None: + torrent_seasons = {begin_season} + else: + start, end = sorted((begin_season, end_season)) + torrent_seasons = set(range(start, end + 1)) + + known_seasons = set() + for season_map in ( + getattr(mediainfo, "seasons", None), + getattr(mediainfo, "season_years", None), + ): + for season in (season_map or {}).keys(): + try: + known_seasons.add(int(season)) + except (TypeError, ValueError): + continue + if not known_seasons: + try: + season_count = int(getattr(mediainfo, "number_of_seasons", None) or 0) + except (TypeError, ValueError): + season_count = 0 + if season_count: + known_seasons = set(range(1, season_count + 1)) + + if torrent_seasons and known_seasons and not torrent_seasons.issubset(known_seasons): + logger.debug( + f'{torrent.site_name} - {torrent.title} 季范围 {sorted(torrent_seasons)} ' + f'超出目标媒体季范围 {sorted(known_seasons)}' + ) + return False + + if len(torrent_seasons) != 1: + return True + season = next(iter(torrent_seasons)) + target_episodes = (getattr(mediainfo, "seasons", None) or {}).get(season) or [] + if not target_episodes and len(known_seasons) == 1: + try: + episode_count = int(getattr(mediainfo, "number_of_episodes", None) or 0) + except (TypeError, ValueError): + episode_count = 0 + if episode_count: + target_episodes = list(range(1, episode_count + 1)) + torrent_episodes = getattr(torrent_meta, "episode_list", None) or [] + if target_episodes and torrent_episodes and max(torrent_episodes) > max(target_episodes): + logger.debug( + f'{torrent.site_name} - {torrent.title} 最大集数 {max(torrent_episodes)} ' + f'超出目标媒体第 {season} 季总集数 {max(target_episodes)}' + ) + return False + return True + + @staticmethod + def requires_identity_disambiguation( + mediainfo: MediaInfo, + torrent_meta: MetaBase, + ) -> bool: + """判断无年份标题是否只通过目标别名命中,需进一步核验媒体身份。""" + if ( + mediainfo.type != MediaType.TV + or getattr(torrent_meta, "year", None) + or not getattr(mediainfo, "year", None) + ): + return False + media_titles = { + text_tools.normalize_upper(value) + for value in ( + getattr(mediainfo, "title", None), + getattr(mediainfo, "original_title", None), + ) + if value + } + media_names = { + text_tools.normalize_upper(value) + for value in (getattr(mediainfo, "names", None) or []) + if value + } + meta_names = { + text_tools.normalize_upper(value) + for value in ( + getattr(torrent_meta, "cn_name", None), + getattr(torrent_meta, "en_name", None), + ) + if value + } + return bool(meta_names.intersection(media_names)) and not bool( + meta_names.intersection(media_titles) + ) + + @staticmethod + def match_same_work_evidence( + target_mediainfo: MediaInfo, + candidate_mediainfo: MediaInfo, + torrent_meta: MetaBase, + ) -> Tuple[bool, str]: + """判断媒体 ID 冲突时是否存在足以覆盖候选识别的同作品证据。""" + 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: + return True, f"媒体身份 {target_identity[0]}:{target_identity[1]}" + + torrent_year = str(getattr(torrent_meta, "year", None) or "")[:4] + target_years = { + str(year)[:4] + for year in ( + [getattr(target_mediainfo, "year", None)] + + list((getattr(target_mediainfo, "season_years", None) or {}).values()) + ) + if year + } + if torrent_year and torrent_year in target_years: + return True, f"资源年份 {torrent_year}" + + for field in ("imdb_id", "tvdb_id", "douban_id", "bangumi_id"): + 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): + return True, f"共同 {field}" + + target_year = str(getattr(target_mediainfo, "year", None) or "")[:4] + candidate_year = str(getattr(candidate_mediainfo, "year", None) or "")[:4] + if target_year and candidate_year and target_year != candidate_year: + return False, f"首播年份冲突 {candidate_year} != {target_year}" + + target_original_title = text_tools.normalize_upper( + getattr(target_mediainfo, "original_title", None) or "" + ) + candidate_original_title = text_tools.normalize_upper( + getattr(candidate_mediainfo, "original_title", None) or "" + ) + if ( + target_original_title + and candidate_original_title + and target_original_title != candidate_original_title + ): + return False, "原始标题冲突" + + target_language = str( + getattr(target_mediainfo, "original_language", None) or "" + ).casefold() + candidate_language = str( + getattr(candidate_mediainfo, "original_language", None) or "" + ).casefold() + if target_language and candidate_language and target_language != candidate_language: + return False, f"原始语言冲突 {candidate_language} != {target_language}" + + if target_year and candidate_year: + return True, f"共同首播年份 {target_year}" + if target_original_title and candidate_original_title: + return True, "共同原始标题" + return False, "资源无年份,候选与目标也没有可核验的共同元数据" + @staticmethod def filter_torrent(torrent_info: TorrentInfo, filter_params: Dict[str, Any]) -> bool: diff --git a/app/chain/search.py b/app/chain/search.py index 5de00e2d0..7f4dbfc17 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -1331,6 +1331,7 @@ class SearchChain(ChainBase): # 开始匹配 _match_torrents = [] + disambiguation_cache: Dict[Tuple[str, str, str], Optional[MediaInfo]] = {} try: # 英文标题应该在别名/原标题中,不需要再匹配 logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}") @@ -1370,6 +1371,38 @@ class SearchChain(ChainBase): if TorrentHelper.match_torrent(mediainfo=mediainfo, torrent_meta=torrent_meta, torrent=torrent): + if TorrentHelper.requires_identity_disambiguation( + mediainfo=mediainfo, + torrent_meta=torrent_meta, + ): + disambiguation_key = ( + torrent_meta.cn_name or "", + torrent_meta.en_name or "", + torrent_meta.year or "", + ) + if disambiguation_key not in disambiguation_cache: + disambiguation_cache[disambiguation_key] = MediaChain().recognize_by_meta( + torrent_meta, + obtain_images=False, + ) + candidate_mediainfo = disambiguation_cache[disambiguation_key] + if not candidate_mediainfo: + logger.info( + f'{torrent.site_name} - {torrent.title} ' + f'仅通过无年份别名命中且候选媒体身份无法确认,已跳过' + ) + continue + evidence_matched, evidence = TorrentHelper.match_same_work_evidence( + target_mediainfo=mediainfo, + candidate_mediainfo=candidate_mediainfo, + torrent_meta=torrent_meta, + ) + if not evidence_matched: + logger.info( + f'{torrent.site_name} - {torrent.title} ' + f'无年份同名候选未通过消歧:{evidence}' + ) + continue # 匹配成功 _match_torrents.append((torrent, torrent_meta, "title")) continue diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 906be8219..362409165 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -2030,6 +2030,15 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if TorrentHelper.match_torrent(mediainfo=mediainfo, torrent_meta=torrent_meta, torrent=torrent_info): + if TorrentHelper.requires_identity_disambiguation( + mediainfo=mediainfo, + torrent_meta=torrent_meta, + ): + logger.info( + f'{torrent_info.site_name} - {torrent_info.title} ' + f'仅通过无年份别名命中且候选媒体身份无法确认,已跳过' + ) + continue # 匹配成功 logger.info( f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}') @@ -3839,13 +3848,26 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): ) return None + evidence_matched, evidence = TorrentHelper.match_same_work_evidence( + target_mediainfo=target_mediainfo, + candidate_mediainfo=candidate_mediainfo, + torrent_meta=torrent_meta, + ) + if not evidence_matched: + logger.debug( + f'{torrent_info.site_name} - {torrent_info.title} 候选媒体ID冲突且缺少同作品证据:' + f'{evidence};{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}' + f'经标题或别名及{evidence}复核匹配到订阅目标:' + f'{torrent_info.site_name} - {torrent_info.title}' ) return target_mediainfo diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index 5cca89cbc..725363416 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from app.domain.context import MediaInfo from app.domain.metainfo import MetaInfo, MetaInfoPath, find_metainfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic @@ -160,6 +161,29 @@ def test_torrent_title_match_ignores_question_mark_variants(): ) +def test_torrent_title_match_rejects_season_absent_from_target_series(): + """无年份同名剧的资源季超出目标剧季范围时应拒绝。""" + mediainfo = MediaInfo( + title="家族计划", + original_title="가족계획", + names=["Family Matters"], + type=MediaType.TV, + year="2024", + number_of_seasons=1, + seasons={1: list(range(1, 7))}, + season_years={1: "2024"}, + ) + torrent_meta = MetaInfo("Family Matters S02 1080p WEBRip DD2.0 x264-TrollHD") + torrent = SimpleNamespace( + site_name="测试站点", + title=torrent_meta.org_string, + category=MediaType.TV.value, + description=None, + ) + + assert not TorrentHelper.match_torrent(mediainfo, torrent_meta, torrent) + + def test_python_metainfo_fallback_preserves_xxx_movie_title(): """Python 兜底解析不应删除合法 xXx 片名。""" with patch("app.adapters.system.rust.parse_metainfo", return_value=None): diff --git a/tests/test_search_same_name_disambiguation.py b/tests/test_search_same_name_disambiguation.py new file mode 100644 index 000000000..9d2d6ecac --- /dev/null +++ b/tests/test_search_same_name_disambiguation.py @@ -0,0 +1,104 @@ +from unittest.mock import Mock + +from app.chain import search as search_module +from app.chain.search import SearchChain +from app.domain.context import MediaInfo, TorrentInfo +from app.schemas.types import MediaSource, MediaType + + +def test_exact_search_rejects_no_year_alias_recognized_as_different_work(monkeypatch): + """精确搜索中的无年份别名候选识别为不同作品时应拒绝。""" + target = MediaInfo( + media_source=MediaSource.TMDB, + media_id="236356", + tmdb_id=236356, + title="家族计划", + original_title="가족계획", + names=["Family Matters"], + type=MediaType.TV, + year="2024", + original_language="ko", + season_years={1: "2024"}, + ) + candidate = MediaInfo( + media_source=MediaSource.TMDB, + media_id="30161", + tmdb_id=30161, + title="Family Matters", + original_title="Family Matters", + type=MediaType.TV, + year="2008", + original_language="en", + ) + torrent = TorrentInfo( + title="Family Matters S01 1080p WEBRip DD2.0 x264-TrollHD", + site_name="测试站点", + category=MediaType.TV.value, + ) + media_chain = Mock() + media_chain.recognize_by_meta.return_value = candidate + monkeypatch.setattr(search_module, "MediaChain", Mock(return_value=media_chain)) + monkeypatch.setattr( + search_module.TorrentHelper, + "sort_torrents", + staticmethod(lambda contexts: contexts), + ) + chain = object.__new__(SearchChain) + + contexts = chain._SearchChain__parse_result( + torrents=[torrent], + mediainfo=target, + rule_groups=[], + ) + + assert contexts == [] + media_chain.recognize_by_meta.assert_called_once() + + +def test_exact_search_reuses_disambiguation_for_same_parsed_title(monkeypatch): + """同一解析标题的多条资源应复用一次候选识别,避免重复外部查询。""" + target = MediaInfo( + media_source=MediaSource.TMDB, + media_id="236356", + tmdb_id=236356, + title="家族计划", + original_title="가족계획", + names=["Family Matters"], + type=MediaType.TV, + year="2024", + season_years={1: "2024"}, + ) + candidate = MediaInfo( + media_source=MediaSource.TMDB, + media_id="236356", + tmdb_id=236356, + title="家族计划", + type=MediaType.TV, + year="2024", + ) + torrents = [ + TorrentInfo( + title=f"Family Matters S01 1080p WEB-DL {group}", + site_name=f"测试站点{index}", + category=MediaType.TV.value, + ) + for index, group in enumerate(("GROUP-A", "GROUP-B"), start=1) + ] + media_chain = Mock() + media_chain.recognize_by_meta.return_value = candidate + monkeypatch.setattr(search_module, "MediaChain", Mock(return_value=media_chain)) + monkeypatch.setattr( + search_module.TorrentHelper, + "sort_torrents", + staticmethod(lambda contexts: contexts), + ) + chain = object.__new__(SearchChain) + + contexts = chain._SearchChain__parse_result( + torrents=torrents, + mediainfo=target, + rule_groups=[], + ) + + assert len(contexts) == 2 + media_chain.recognize_by_meta.assert_called_once() diff --git a/tests/test_subscribe_media_id_conflict.py b/tests/test_subscribe_media_id_conflict.py index f41f13136..f6d9651d7 100644 --- a/tests/test_subscribe_media_id_conflict.py +++ b/tests/test_subscribe_media_id_conflict.py @@ -5,36 +5,44 @@ from app.domain.context import Context, MediaInfo, TorrentInfo from app.schemas.types import MediaSource, MediaType -def _target_media(tmdb_id=106449, douban_id=None) -> MediaInfo: +def _target_media(tmdb_id=106449, douban_id=None, **kwargs) -> MediaInfo: """构造订阅目标媒体。""" media_source = MediaSource.TMDB if tmdb_id is not None else MediaSource.Douban media_id = str(tmdb_id) if tmdb_id is not None else douban_id + defaults = { + "title": "凡人修仙传", + "original_title": "凡人修仙传", + "names": ["A Record Of A Mortals Journey To Immortality"], + "type": MediaType.TV, + "year": "2020", + "season_years": {1: "2020"}, + } + defaults.update(kwargs) return MediaInfo( media_source=media_source if media_id is not None else None, media_id=media_id, - 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, + **defaults, ) -def _candidate_media(tmdb_id=285479, douban_id=None) -> MediaInfo: +def _candidate_media(tmdb_id=285479, douban_id=None, **kwargs) -> MediaInfo: """构造由 RSS 标题推断出的候选媒体。""" media_source = MediaSource.TMDB if tmdb_id is not None else MediaSource.Douban media_id = str(tmdb_id) if tmdb_id is not None else douban_id + defaults = { + "title": "凡人修仙传", + "type": MediaType.TV, + "year": "2020", + } + defaults.update(kwargs) return MediaInfo( media_source=media_source if media_id is not None else None, media_id=media_id, - title="凡人修仙传", - type=MediaType.TV, - year="2020", tmdb_id=tmdb_id, douban_id=douban_id, + **defaults, ) @@ -96,6 +104,69 @@ def test_inferred_tmdb_conflict_falls_back_to_strict_alias_match(): assert context.media_info_is_target is True +def test_no_year_alias_rejects_candidate_with_different_first_air_year(): + """无年份别名命中另一个首播年份的同名作品时应拒绝。""" + target = _target_media( + tmdb_id=236356, + title="家族计划", + original_title="가족계획", + names=["Family Matters"], + year="2024", + original_language="ko", + season_years={1: "2024"}, + ) + candidate = _candidate_media( + tmdb_id=30161, + title="Family Matters", + original_title="Family Matters", + year="2008", + original_language="en", + ) + meta = _torrent_meta(en_name="Family Matters") + torrent = TorrentInfo( + title="Family Matters S01 1080p WEBRip DD2.0 x264-TrollHD", + 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_explicit_target_year_can_override_wrong_same_name_candidate(): + """资源明确携带目标年份时应允许纠正同名候选的错误识别。""" + target = _target_media( + tmdb_id=236356, + title="家族计划", + original_title="가족계획", + names=["Family Matters"], + year="2024", + original_language="ko", + season_years={1: "2024"}, + ) + candidate = _candidate_media( + tmdb_id=30161, + title="Family Matters", + original_title="Family Matters", + year="2008", + original_language="en", + ) + meta = _torrent_meta(en_name="Family Matters") + meta.year = "2024" + torrent = TorrentInfo( + title="Family Matters 2024 S01 1080p WEB-DL", + site_name="测试站点", + category=MediaType.TV.value, + ) + context = _context(meta, candidate, torrent) + + assert _reconcile(target, candidate, meta, torrent, context) is target + assert context.media_info is target + assert context.match_source == "title" + + def test_inferred_tmdb_conflict_rejects_nonmatching_title(): """ID 冲突且标题和别名不匹配时应继续拒绝候选。""" target = _target_media()