From 92195a2b841052e1d4e98e9bb7eb465bfd55c3e6 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 4 Nov 2026 07:02:50 +0800 Subject: [PATCH] fix(subscribe): match explicit cumulative episode ranges (#7294) Refs #7289 --- app/application/torrent/download.py | 69 ++++++-- app/chain/search/result.py | 17 +- tests/test_subscribe_paged_search.py | 56 ++++++ tests/test_subscription_episode_numbering.py | 170 +++++++++++++++++++ 4 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 tests/test_subscription_episode_numbering.py diff --git a/app/application/torrent/download.py b/app/application/torrent/download.py index f232f62f6..a02e2d725 100644 --- a/app/application/torrent/download.py +++ b/app/application/torrent/download.py @@ -798,7 +798,13 @@ class TorrentHelper: self._invalid_torrents[url] = True @staticmethod - def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaBase, torrent: TorrentInfo) -> bool: + def match_torrent( + mediainfo: MediaInfo, + torrent_meta: MetaBase, + torrent: TorrentInfo, + *, + season_episodes: Optional[Dict[int, List[int]]] = None, + ) -> bool: """ 检查种子是否匹配媒体信息 @@ -807,6 +813,8 @@ class TorrentHelper: :param mediainfo: 需要匹配的媒体信息 :param torrent_meta: 种子识别信息 :param torrent: 种子信息 + :param season_episodes: 本次明确请求的季集号,可扩展默认季内集数上限;空集列表不扩展 + :return: 资源按显式媒体身份或名称、年份及季集范围匹配时返回 True """ # 显式标题标签与识别结果使用同一主身份比较。 torrent_identity = resolve_media_identity(media=torrent_meta) @@ -850,11 +858,12 @@ 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, + season_episodes=season_episodes, ): return False if meta_names.intersection(media_titles): @@ -886,9 +895,19 @@ class TorrentHelper: mediainfo: MediaInfo, torrent_meta: MetaBase, torrent: TorrentInfo, + season_episodes: Optional[Dict[int, List[int]]] = None, ) -> bool: """ - 用目标剧已知的季集范围排除无年份同名候选。 + 用目标剧已知的季集范围排除不可能的同名候选。 + + 显式请求的集号与订阅缺集计算使用同一编号体系,允许累计集号扩展默认上限; + 普通搜索沿用媒体源范围,季号检查仍使用媒体源事实。 + + :param mediainfo: 已确定身份的目标媒体,保留其原始季集数据 + :param torrent_meta: 从资源自身标题解析的季集号 + :param torrent: 资源信息,用于记录拒绝原因 + :param season_episodes: 本次明确请求的季集号;未指定或空集列表不扩展上限 + :return: 资源季集范围未超出媒体源与显式请求的已知上限时返回 True """ begin_season = getattr(torrent_meta, "begin_season", None) end_season = getattr(torrent_meta, "end_season", None) @@ -928,7 +947,38 @@ class TorrentHelper: if len(torrent_seasons) != 1: return True season = next(iter(torrent_seasons)) - target_episodes = (getattr(mediainfo, "seasons", None) or {}).get(season) or [] + episode_limit = TorrentHelper._tv_episode_limit( + mediainfo, season, known_seasons, (season_episodes or {}).get(season), + ) + torrent_episodes = getattr(torrent_meta, "episode_list", None) or [] + if episode_limit is not None and torrent_episodes and max(torrent_episodes) > episode_limit: + logger.debug( + f'{torrent.site_name} - {torrent.title} 最大集数 {max(torrent_episodes)} ' + f'超出目标媒体第 {season} 季集数上限 {episode_limit}' + ) + return False + return True + + @staticmethod + def _tv_episode_limit( + mediainfo: MediaInfo, + season: int, + known_seasons: set[int], + requested_episodes: Optional[List[int]], + ) -> Optional[int]: + """ + 从媒体源与显式请求计算目标季的已知集号上限,不修改原始媒体信息。 + + 未知上限时继续交给后续选集处理;已知上限只能被本轮明确请求的集号扩展, + 不能因缺集列表较短而缩小正常合集的允许范围。 + + :param mediainfo: 目标媒体的默认或剧集组季集信息 + :param season: 当前资源对应的单个季号 + :param known_seasons: 已知季号,只有单季媒体可使用全剧总集数作为回退 + :param requested_episodes: 本次明确请求的当前季集号;空列表不扩展上限 + :return: 可接受的最大集号;媒体源未提供上限时返回 None + """ + target_episodes: List[int] = (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) @@ -936,14 +986,9 @@ class TorrentHelper: 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 + if not target_episodes: + return None + return max(target_episodes + (requested_episodes or [])) @staticmethod def requires_identity_disambiguation( diff --git a/app/chain/search/result.py b/app/chain/search/result.py index 41e9c044e..ec2af0410 100644 --- a/app/chain/search/result.py +++ b/app/chain/search/result.py @@ -201,9 +201,12 @@ def _match_source( torrent_meta: MetaBase, mediainfo: MediaInfo, cache: DisambiguationCache, + season_episodes: Optional[Dict[int, List[int]]] = None, ) -> Optional[str]: """ 返回资源命中的身份依据,未匹配时返回 None。 + + :param season_episodes: 本次明确请求的季集号,仅用于兼容不同于默认季内集号的范围 """ torrent_source, torrent_media_id = resolve_media_identity(media=torrent) if torrent_source == MediaSource.IMDb and mediainfo.imdb_id and torrent_media_id == str(mediainfo.imdb_id): @@ -214,6 +217,7 @@ def _match_source( mediainfo=mediainfo, torrent_meta=torrent_meta, torrent=torrent, + season_episodes=season_episodes, ): return None if TorrentHelper.requires_identity_disambiguation( @@ -280,6 +284,7 @@ def _match_torrents( torrent_meta=meta, mediainfo=mediainfo, cache=cache, + season_episodes=season_episodes, ) if source: diagnostics["matched"] += 1 @@ -376,16 +381,26 @@ class SearchResultOwner(_SearchOwnerBase): ) -> tuple[list[SearchResourceEvidence], dict[str, Context]]: """ 过滤前识别新增资源;识别未知不会成为缺席证据或永久负缓存。 + + :param targets: 本轮已绑定目标媒体的缺集键,显式集号同时用于匹配累计编号资源 """ evidence = [] contexts = {} + season_episodes: Dict[int, List[int]] = {} + if mediainfo.type == MediaType.TV: + for target in targets: + season, episode = target.split(":", 1) + if episode != "season": + season_episodes.setdefault(int(season), []).append(int(episode)) for torrent in torrents: # 去重键含标题和描述,内容变化即为新资源;识别规则变化由检查点合同触发重搜。 key = SearchResultOwner._resource_identity(torrent) record = cache.get(key) if record is None or record["targets"] is None: meta = _torrent_meta(torrent=torrent, custom_words=custom_words, mediainfo=mediainfo) - source = _match_source(torrent, meta, mediainfo, disambiguation) if torrent.title else None + source = _match_source( + torrent, meta, mediainfo, disambiguation, season_episodes=season_episodes, + ) if torrent.title else None unknown = not torrent.title or (_disambiguation_key(meta) in disambiguation and disambiguation[_disambiguation_key(meta)] is None) matched_targets = _resource_targets(meta, targets, mediainfo.type) if source else set() diff --git a/tests/test_subscribe_paged_search.py b/tests/test_subscribe_paged_search.py index ffa6c3825..6d83c8c4e 100644 --- a/tests/test_subscribe_paged_search.py +++ b/tests/test_subscribe_paged_search.py @@ -83,6 +83,62 @@ def _missing(scope, item): subscribe=item, meta=build_subscribe_meta(item), mediainfo=scope.media, mediakey="tmdb:1")[1] +def test_cumulative_episode_subscription_reaches_download_submission(subscription, monkeypatch): + """ + 人工累计范围经真实缺集计算、分页识别和下载选集后提交 1180 集,且不提前完成订阅。 + """ + scope = subscription + subscribe = replace( + scope.initial, name="One Piece", year="1999", season=23, + start_episode=1156, total_episode=2000, note=list(range(1156, 1180)), + custom_words=r"(?i)(One[.\s]Piece.*?)S01E(?=\d{4}) => \1S23E", + ) + scope.current["value"] = subscribe + scope.media.title = scope.media.original_title = "One Piece" + scope.media.names = ["One Piece"] + scope.media.year = "1999" + scope.media.season = 23 + scope.media.season_years = {1: "1999", 23: "2026"} + scope.media.seasons = {1: list(range(1, 62)), 23: list(range(1, 26))} + scope.download.download_torrent.return_value = (b"torrent", None, [1180]) + target = SubscriptionSearchTarget( + subscribe, build_subscribe_meta(subscribe), scope.media, _missing(scope, subscribe), "tmdb:1", + ) + search = SearchChain() + monkeypatch.setattr(search, "_sync_indexers", lambda _sites: [{"id": 1, "name": "A"}]) + monkeypatch.setattr(search, "get_search_page_size", lambda **_params: 100) + monkeypatch.setattr(search, "search_plugin_torrents", lambda **_params: []) + monkeypatch.setattr(scope.chain, "get_sub_sites", lambda _subscribe: [1]) + calls = [] + + def request(**params): + """ + 仅回放站点原始累计资源,保留真实识别词和所有后续匹配逻辑。 + """ + calls.append(params["page"]) + items = [TorrentInfo( + site=1, site_name="A", title="One Piece 1999 S01E1180 2160p WEB-DL H265 AAC-PTerWEB", + enclosure="https://site.example/download?id=1180", pri_order=100, + )] if params["page"] == 0 else [] + report_site_search_outcome(attempted=True, outcome="success") + report_site_search_page(raw_count=len(items), has_more=False) + return items + + monkeypatch.setattr(search, "search_site_torrents", request) + + _process_paged_subscription( + scope.chain, subscribe, search, target, SystemConfigKey.SubscribeFilterRuleGroups, None, + ) + + assert calls == [0] + scope.download.download_single.assert_called_once() + assert 1180 in scope.current["value"].note + assert 1180 not in _missing(scope, scope.current["value"])["tmdb:1"][23].episodes + assert 1181 in _missing(scope, scope.current["value"])["tmdb:1"][23].episodes + assert scope.media.seasons[23] == list(range(1, 26)) + scope.finished.assert_not_called() + + def test_full_pack_candidate_without_torrent_cannot_submit_download(subscription, monkeypatch): """ 整包候选缺少种子信息时不比较优先级或提交下载,保留原始缺集范围。 diff --git a/tests/test_subscription_episode_numbering.py b/tests/test_subscription_episode_numbering.py new file mode 100644 index 000000000..04e922d35 --- /dev/null +++ b/tests/test_subscription_episode_numbering.py @@ -0,0 +1,170 @@ +""" +累计集号订阅与默认季内集号校验的兼容回归。 +""" + +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.application.subscription.contract import SubscriptionSnapshot, build_subscribe_meta +from app.chain.search.facade import SearchChain +from app.chain.search.result import SearchResultOwner, 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.mediaserver import NotExistMediaInfo +from app.schemas.types import MediaSource, MediaType + + +@pytest.fixture +def numbering(): + """ + 保留 Issue #7289 的媒体身份、累计范围、识别词与默认季内集数。 + """ + subscribe = SubscriptionSnapshot( + id=7289, name="航海王", year="1999", type=MediaType.TV.value, + media_source=MediaSource.TMDB, media_id="37854", season=23, + start_episode=1156, total_episode=2000, manual_total_episode=1, + note=list(range(1156, 1180)), state="R", + custom_words=r"(?i)(One[.\s]Piece.*?)S01E(?=\d{4}) => \1S23E", + ) + media = MediaInfo( + media_source=MediaSource.TMDB, media_id="37854", tmdb_id=37854, + title="航海王", original_title="ワンピース", names=["One Piece"], + type=MediaType.TV, year="1999", season=23, + season_years={1: "1999", 23: "2026"}, seasons={1: list(range(1, 62)), 23: list(range(1, 26))}, + ) + torrent = TorrentInfo( + site=1, site_name="测试站点", + title="One Piece 1999 S01E1180 2160p WEB-DL H265 AAC-PTerWEB", + category=MediaType.TV.value, + ) + missing = {"tmdb:37854": {23: NotExistMediaInfo( + season=23, episodes=list(range(1180, 2001)), start_episode=1156, total_episode=2000, + )}} + return SimpleNamespace(subscribe=subscribe, media=media, torrent=torrent, missing=missing) + + +@pytest.mark.parametrize("paged", [False, True]) +def test_search_accepts_explicit_cumulative_episode_scope(numbering, monkeypatch, paged): + """ + 普通结果解析和逐页身份识别均使用本次缺集范围,并保留原始媒体季内集数。 + """ + scope = numbering + words = scope.subscribe.custom_words.splitlines() + monkeypatch.setattr(TorrentHelper, "sort_torrents", staticmethod(lambda contexts: contexts)) + if paged: + evidence, contexts = SearchResultOwner._identify_page( + torrents=[scope.torrent], mediainfo=scope.media, + targets={f"23:{episode}" for episode in range(1180, 2001)}, + custom_words=words, cache={}, disambiguation={}, + ) + assert evidence[0].targets == frozenset({"23:1180"}) + result = list(contexts.values()) + else: + result = object.__new__(SearchChain)._parse_result( + torrents=[scope.torrent], mediainfo=scope.media, rule_groups=[], + season_episodes={23: scope.missing["tmdb:37854"][23].episodes}, custom_words=words, + ) + assert len(result) == 1 + assert result[0].meta_info.season_list == [23] + assert result[0].meta_info.episode_list == [1180] + assert scope.media.seasons[23] == list(range(1, 26)) + assert scope.torrent.title == "One Piece 1999 S01E1180 2160p WEB-DL H265 AAC-PTerWEB" + + +@pytest.mark.parametrize( + ("title", "episodes", "expected"), + [ + ("One Piece 1999 S23E1180", {23: [1180]}, True), + ("One Piece 1999 S23E1180", None, False), + ("One Piece 1999 S23E1180", {23: []}, False), + ("One Piece 1999 S23E1180", {1: [1180]}, False), + ("One Piece 1999 S23E2001", {23: list(range(1156, 2001))}, False), + ("One Piece 1999 S24E1180", {24: [1180]}, False), + ("One Piece 2000 S23E1180", {23: [1180]}, False), + ("Other Show 1999 S23E1180", {23: [1180]}, False), + ("One Piece S23E1180", None, False), + ("One Piece 1999 S23E01-E25", {23: [1]}, True), + ], +) +def test_explicit_scope_keeps_identity_and_topology_guards(numbering, title, episodes, expected): + """ + 显式集号只扩展目标季的集数上限,不放宽名称、年份和季号,也不缩小普通合集。 + """ + torrent = TorrentInfo(title=title, site_name="测试站点", category=MediaType.TV.value) + assert TorrentHelper.match_torrent( + numbering.media, MetaInfo(title), torrent, season_episodes=episodes, + ) is expected + + +@pytest.mark.parametrize("manual", [False, True]) +def test_rss_match_keeps_cumulative_subscription_scope(numbering, monkeypatch, manual): + """ + RSS 标题兜底继续按订阅缺集范围交付累计集号候选,人工标记不改变已有匹配行为。 + """ + scope = numbering + subscribe = replace(scope.subscribe, manual_total_episode=int(manual)) + candidate = Context(meta_info=MetaInfo(scope.torrent.title), torrent_info=scope.torrent) + torrents = {"site.example": [candidate]} + chain = object.__new__(SubscribeChain) + chain.check_and_handle_existing_media = Mock(return_value=(False, scope.missing)) + chain.get_params = Mock(return_value={}) + chain.filter_torrents = Mock(side_effect=lambda **params: params["torrent_list"]) + chain.finish_subscribe_or_not = Mock() + chain.subscription_repository = SimpleNamespace(get=Mock(return_value=subscribe)) + download = Mock(return_value=([], scope.missing)) + chain._SubscribeChain__download_best_version_with_full_pack_first = download + media_chain = SimpleNamespace( + supplement_media_info=lambda media: media, + recognize_by_meta=Mock(return_value=None), + ) + monkeypatch.setattr(subscribe_match, "MediaChain", lambda: media_chain) + monkeypatch.setattr(subscribe_match, "_prepare_subscription_match", Mock(return_value=( + build_subscribe_meta(subscribe), scope.media, torrents, [], [], + ))) + execution = SimpleNamespace(should_stop=lambda: False, report_phase=Mock()) + + assert chain._execute_match( + subscribe=subscribe, processed_torrents=torrents, candidate_index=None, + fresh_fact_lease=None, execution_context=execution, + ) == "completed" + + download.assert_called_once() + matched = download.call_args.kwargs["contexts"] + assert len(matched) == 1 + assert matched[0].meta_info.episode_list == [1180] + assert matched[0].meta_info.season_list == [23] + assert matched[0].media_info_is_target + assert not matched[0].candidate_recognized + assert candidate.meta_info.season_list == [1] + + +def test_requested_scope_preserves_packs_when_episode_count_is_unknown(numbering): + """ + 媒体源没有集数上限时,缺少一集不能把覆盖该集的正常合集当作越界资源。 + """ + numbering.media.seasons[23] = [] + torrent = TorrentInfo(title="One Piece 1999 S23E01-E30", site_name="测试站点") + assert TorrentHelper.match_torrent( + numbering.media, MetaInfo(torrent.title), torrent, season_episodes={23: [1]}, + ) + + +@pytest.mark.parametrize("count", [25, 0, None, "unknown"]) +@pytest.mark.parametrize("explicit", [False, True]) +def test_single_season_total_fallback_keeps_numbering_contract(numbering, count, explicit): + """ + 单季总集数回退只在已知有效上限时约束资源,并允许显式累计集号扩展上限。 + """ + media = numbering.media + media.seasons = {23: []} + media.season_years = {23: "1999"} + media.number_of_episodes = count + torrent = TorrentInfo(title="One Piece 1999 S23E1180", site_name="测试站点") + assert TorrentHelper.match_torrent( + media, MetaInfo(torrent.title), torrent, season_episodes={23: [1180]} if explicit else None, + ) is (explicit or count != 25)