From 391a6dbdaa4d7fcdd794ba63a4d3a3fe4ca941ad Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 12:51:05 +0800 Subject: [PATCH] fix: use normal rules for new episodes in best-version subscriptions --- app/chain/search/execution.py | 2 + app/chain/search/scan.py | 4 +- app/chain/subscribe/search.py | 110 +++++++++++++++++++++++++-- tests/test_subscribe_paged_search.py | 51 ++++++++++++- 4 files changed, 160 insertions(+), 7 deletions(-) diff --git a/app/chain/search/execution.py b/app/chain/search/execution.py index 736b7ccb6..f1e04e15a 100644 --- a/app/chain/search/execution.py +++ b/app/chain/search/execution.py @@ -33,6 +33,8 @@ class MediaSearchPlan: filter_params: Optional[Dict[str, str]] = None include_candidates: bool = False candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None + # 动态规则选择时用于检查点失效判断的稳定规则快照。 + rule_groups_signature: Optional[Any] = None @dataclass(frozen=True, slots=True) diff --git a/app/chain/search/scan.py b/app/chain/search/scan.py index abc74e171..5242d81a3 100644 --- a/app/chain/search/scan.py +++ b/app/chain/search/scan.py @@ -245,7 +245,9 @@ class SearchScan: media = self.media values = {"media": [media.media_source, media.media_id, media.type, media.title, media.season, media.episode_group], "keyword": self.plan.keyword, - "custom_words": self.plan.custom_words, "rules": self.plan.rule_groups, + "custom_words": self.plan.custom_words, + "rules": self.plan.rule_groups_signature + if self.plan.rule_groups_signature is not None else self.plan.rule_groups, "filter": self.plan.filter_params, "sites": sorted(self.sources.sites), "full": self.full, "target_scope": self.target_scope, "raw_title": False, "multiple_names": self.owner.runtime_config.search_multiple_name, diff --git a/app/chain/subscribe/search.py b/app/chain/subscribe/search.py index 0d59da19d..a802d4d36 100644 --- a/app/chain/subscribe/search.py +++ b/app/chain/subscribe/search.py @@ -40,6 +40,7 @@ from app.application.subscription.sitebudget import ( from app.chain.media import MediaChain from app.chain.search.execution import MediaSearchPlan from app.chain.search.facade import SearchChain +from app.chain.search.result import SearchResultOwner from app.chain.search.scan import SearchScan, search_targets from app.chain.subscribe.contract import _SubscribeOwnerBase from app.chain.subscribe.metadata import ( @@ -91,6 +92,89 @@ def _subscription_search_strategy(best_version: bool) -> str: return cast(str, strategy) if strategy in ("smart", "full", "single_page") else "smart" +def _subscription_rule_groups(subscribe: SubscriptionSnapshot) -> tuple[list[str], list[str]]: + """返回订阅普通搜索和洗版升级各自使用的规则组。""" + configuration = get_configured_system_config() + if subscribe.filter_groups: + groups = list(subscribe.filter_groups) + return groups, groups + return ( + configuration.get(SystemConfigKey.SubscribeFilterRuleGroups) or [], + configuration.get(SystemConfigKey.BestVersionFilterRuleGroups) or [], + ) + + +def _best_version_target_episodes( + subscribe: SubscriptionSnapshot, + target: SubscriptionSearchTarget, +) -> set[int]: + """返回本轮洗版搜索覆盖的目标集数,用于区分首次下载与升级候选。""" + season_info = (target.missing.get(target.media_key) or {}).get(subscribe.season) + if season_info and season_info.episodes: + return set(season_info.episodes) + start_episode = subscribe.start_episode or 1 + total_episode = subscribe.total_episode or 0 + return set(range(start_episode, total_episode + 1)) if total_episode >= start_episode else set() + + +def _build_subscription_candidate_filter( + owner: _SubscribeOwnerBase, + searchchain: SearchChain, + subscribe: SubscriptionSnapshot, + target: SubscriptionSearchTarget, +) -> Callable[[list[Context]], list[Context]]: + """按资源覆盖范围选择普通规则或洗版规则,再执行订阅候选校验。""" + normal_groups, best_groups = _subscription_rule_groups(subscribe) + if not subscribe.best_version or target.media.type != MediaType.TV: + return partial(owner._filter_search_contexts, subscribe) + + target_episodes = _best_version_target_episodes(subscribe, target) + downloaded_episodes = set( + owner._SubscribeChain__get_downloaded_best_version_episodes( + subscribe, total_episode=subscribe.total_episode, + ) + ) + + def filter_contexts(contexts: list[Context]) -> list[Context]: + """分别过滤首次下载候选和已有集数升级候选。""" + initial_downloads: list[Context] = [] + upgrades: list[Context] = [] + for context in contexts: + episodes = set(context.meta_info.episode_list if context.meta_info else []) + if not episodes: + episodes = set(target_episodes) + if episodes and episodes <= downloaded_episodes: + upgrades.append(context) + else: + initial_downloads.append(context) + + filtered: list[Context] = [] + for candidates, groups, apply_best_version in ( + (initial_downloads, normal_groups, False), + (upgrades, best_groups, True), + ): + if not candidates: + continue + candidates = SearchResultOwner._filter_identified_contexts( + searchchain, + candidates, + target.media, + groups, + {}, + None, + ) + filtered.extend( + owner._filter_search_contexts( + subscribe, + candidates, + apply_best_version=apply_best_version, + ) + ) + return filtered + + return filter_contexts + + def _process_paged_subscription(owner: _SubscribeOwnerBase, subscribe: SubscriptionSnapshot, searchchain: SearchChain, target: SubscriptionSearchTarget, rule_key: SystemConfigKey, @@ -98,13 +182,26 @@ def _process_paged_subscription(owner: _SubscribeOwnerBase, subscribe: Subscript """影视订阅在每页处理后分批提交,保留当前任务的持久恢复身份。""" if not isinstance(target.media, MediaInfo): raise ValueError("增量搜索仅适用于影视订阅") + dynamic_rule_groups = subscribe.best_version and target.media.type == MediaType.TV + candidate_filter: Callable[[list[Context]], list[Context]] = partial( + cast(Any, owner)._filter_search_contexts, + subscribe, + ) + rule_groups = subscribe.filter_groups or get_configured_system_config().get(rule_key) or [] + rule_groups_signature = None + if dynamic_rule_groups: + rule_groups = [] + normal_groups, best_groups = _subscription_rule_groups(subscribe) + rule_groups_signature = {"normal": normal_groups, "best": best_groups} + candidate_filter = _build_subscription_candidate_filter(owner, searchchain, subscribe, target) plan = MediaSearchPlan( mediainfo=target.media, keyword=subscribe.keyword, no_exists=target.missing, sites=owner.get_sub_sites(subscribe), - rule_groups=subscribe.filter_groups or get_configured_system_config().get(rule_key) or [], + rule_groups=rule_groups, area="imdbid" if subscribe.search_imdbid and target.media.imdb_id else "title", custom_words=subscribe.custom_words.split("\n") if subscribe.custom_words else None, - filter_params=owner.get_params(subscribe), candidate_filter=partial(cast(Any, owner)._filter_search_contexts, subscribe), + filter_params=owner.get_params(subscribe), candidate_filter=candidate_filter, + rule_groups_signature=rule_groups_signature, ) repository = getattr(getattr(owner, "subscription_search_repository", None), "sessions", None) if execution_context else None snapshot = repository.get(task_id=execution_context.task_id) if repository and execution_context and execution_context.task_id else None @@ -944,8 +1041,10 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner): self, subscribe: SubscriptionSnapshot, contexts: list[Any], + *, + apply_best_version: bool = True, ) -> list[Any]: - """过滤不满足洗版范围或优先级的搜索候选,并释放原结果列表。""" + """过滤不满足订阅范围或洗版优先级的候选,并释放原结果列表。""" matched = [] activity = current_activity_run() try: @@ -961,7 +1060,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner): continue if isinstance(media, MusicInfo) and subscribe.best_version: torrent_info.pri_order = torrent_info.pri_order or torrent_meta.audio_quality_score - if subscribe.best_version and media.type == MediaType.TV: + if apply_best_version and subscribe.best_version and media.type == MediaType.TV: if not self._SubscribeChain__is_full_season_best_version_resource(torrent_meta, subscribe): logger.debug(f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源") continue @@ -976,7 +1075,8 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner): logger.debug(f"{subscribe.name} 正在洗版,{torrent_info.title} 优先级未达到当前模式的升级条件") continue if ( - subscribe.best_version + apply_best_version + and subscribe.best_version and media.type != MediaType.TV and subscribe.current_priority and torrent_info.pri_order <= subscribe.current_priority diff --git a/tests/test_subscribe_paged_search.py b/tests/test_subscribe_paged_search.py index 5ad48af7f..824a4bcb8 100644 --- a/tests/test_subscribe_paged_search.py +++ b/tests/test_subscribe_paged_search.py @@ -20,9 +20,14 @@ from app.application.subscription.priority import ( ) from app.chain.download import DownloadChain from app.chain.search.facade import SearchChain +from app.chain.search.result import SearchResultOwner from app.chain.subscribe.facade import SubscribeChain from app.chain.subscribe.metadata import SubscriptionSearchTarget -from app.chain.subscribe.search import _finish_paged_subscription, _process_paged_subscription +from app.chain.subscribe.search import ( + _build_subscription_candidate_filter, + _finish_paged_subscription, + _process_paged_subscription, +) from app.domain.context import Context, MediaInfo, TorrentInfo from app.domain.metainfo import MetaInfo from app.schemas.mediaserver import ExistMediaInfo, NotExistMediaInfo @@ -107,6 +112,50 @@ def test_paged_search_keeps_new_missing_episodes_in_completion(subscription, mon assert call.kwargs["no_exists"]["tmdb:1"][1].episodes == [2] +def test_best_version_search_uses_normal_rules_for_never_downloaded_episodes(subscription, monkeypatch): + """洗版候选同时包含缺集和升级集时,缺集使用普通订阅规则组。""" + scope = subscription + subscribe = replace( + scope.initial, + best_version=1, + total_episode=2, + episode_priority={"1": 80}, + ) + target = SubscriptionSearchTarget( + subscribe, + build_subscribe_meta(subscribe), + scope.media, + {"tmdb:1": {1: NotExistMediaInfo(season=1, episodes=[1, 2], total_episode=2)}}, + "tmdb:1", + ) + contexts = [ + Context(meta_info=MetaInfo("Example Show S01E01"), media_info=scope.media, + torrent_info=TorrentInfo(site=1, title="Example Show S01E01")), + Context(meta_info=MetaInfo("Example Show S01E02"), media_info=scope.media, + torrent_info=TorrentInfo(site=1, title="Example Show S01E02")), + ] + monkeypatch.setattr( + subscribe_search, + "get_configured_system_config", + lambda: SimpleNamespace(get=lambda key: { + SystemConfigKey.SubscribeFilterRuleGroups: ["normal"], + SystemConfigKey.BestVersionFilterRuleGroups: ["best"], + }.get(key)), + ) + monkeypatch.setattr(SearchResultOwner, "_filter_identified_contexts", lambda _owner, items, *_args: items) + calls = [] + scope.chain._filter_search_contexts = lambda _subscribe, items, *, apply_best_version=True: ( + calls.append((items, apply_best_version)) or items + ) + + candidate_filter = _build_subscription_candidate_filter(scope.chain, SearchChain(), subscribe, target) + assert {item.meta_info.episode_list[0] for item in candidate_filter(contexts)} == {1, 2} + assert [(items[0].meta_info.episode_list, apply_best) for items, apply_best in calls] == [ + ([2], False), + ([1], True), + ] + + @pytest.mark.parametrize("explicit_missing", [False, True]) @pytest.mark.parametrize("strategy", ["smart", "full", "single_page"]) def test_whole_season_downloads_late_complete_pack_before_early_parts(subscription, monkeypatch, explicit_missing, strategy):