fix: use normal rules for new episodes in best-version subscriptions

This commit is contained in:
jxxghp
2026-10-17 12:51:05 +08:00
parent 3075efba1c
commit 391a6dbdaa
4 changed files with 160 additions and 7 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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):