diff --git a/app/application/subscription/priority.py b/app/application/subscription/priority.py index 83df19870..8bc56083d 100644 --- a/app/application/subscription/priority.py +++ b/app/application/subscription/priority.py @@ -553,7 +553,7 @@ def should_prefer_full_pack_for_episode_best_version( priority: int, ) -> bool: """ - 判断分集洗版是否应优先下载整包。 + 判断分集洗版或首次下载的整包候选是否通过按集优先级门槛。 整包优先级必须严格高于每个目标集;否则交回按集路径,只下载能提升质量的集。 """ @@ -573,14 +573,38 @@ def should_prefer_full_pack_for_episode_best_version( return all(resource_priority > episode_priority.get(str(episode), 0) for episode in target_episodes) +def should_prefer_full_pack_for_initial_download( + subscribe: SubscriptionSnapshot, + mediakey: Union[int, str], + no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]], +) -> bool: + """普通电视剧尚无下载记录且媒体库缺少全部目标集时,允许首轮优先整包。 + + 缺集事实优先于缓存的 lack_episode;已有任意下载记录或媒体库已有目标集时保留补集行为。 + """ + if subscribe.best_version or subscribe.type != MediaType.TV.value or subscribe.note or subscribe.season is None: + return False + if any(priority > 0 for priority in normalize_episode_priority(subscribe.episode_priority).values()): + return False + target_episodes = get_best_version_target_episodes(subscribe) + missing = (no_exists or {}).get(mediakey, {}).get(subscribe.season) + if not target_episodes or missing is None: + return False + missing_episodes = missing.episodes or list(range(missing.start_episode or 1, (missing.total_episode or 0) + 1)) + return set(target_episodes) == set(missing_episodes) + + def build_full_pack_first_no_exists( subscribe: SubscriptionSnapshot, mediakey: Union[int, str], + no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None, ) -> Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]: """ - 构造分集洗版优先全集时使用的整季缺失范围。 + 构造分集洗版或普通电视剧首次下载优先全集时使用的完整目标范围。 """ - if not subscribe.best_version or is_full_best_version_enabled(subscribe) or subscribe.type != MediaType.TV.value: + if is_full_best_version_enabled(subscribe) or subscribe.type != MediaType.TV.value: + return None + if not subscribe.best_version and not should_prefer_full_pack_for_initial_download(subscribe, mediakey, no_exists): return None target_episodes = get_best_version_target_episodes(subscribe) diff --git a/app/chain/subscribe/policy.py b/app/chain/subscribe/policy.py index 023c85bfb..50bf721e7 100644 --- a/app/chain/subscribe/policy.py +++ b/app/chain/subscribe/policy.py @@ -273,7 +273,7 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner): subscribe: SubscriptionSnapshot, priority: int, ) -> bool: - """判断分集洗版是否应优先下载整包。""" + """判断分集洗版或首次下载的整包候选是否通过按集优先级门槛。""" result: bool = _priority.should_prefer_full_pack_for_episode_best_version(subscribe, priority) return result @@ -282,10 +282,11 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner): cls, subscribe: SubscriptionSnapshot, mediakey: Union[int, str], + no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None, ) -> Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]: - """构造分集洗版优先全集时使用的整季缺失范围。""" + """构造分集洗版或普通电视剧首次下载优先全集时使用的完整目标范围。""" result: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = ( - _priority.build_full_pack_first_no_exists(subscribe, mediakey) + _priority.build_full_pack_first_no_exists(subscribe, mediakey, no_exists) ) return result @@ -311,7 +312,7 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner): eligible_targets: Optional[set[str]] = None, ) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]: """ - TV 分集洗版先尝试覆盖目标范围的全集资源,失败后回退到按集下载。 + TV 分集洗版及尚无已有剧集的普通订阅先尝试完整包,失败后回退到按集下载。 """ activity = current_activity_run() governance: Optional[SubscriptionDownloadGovernance] = None @@ -368,7 +369,7 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner): no_exists = _prepare_batch_missing(no_exists, mediakey, eligible_targets) allowed_episodes = _ready_episodes(no_exists, mediakey, eligible_targets) full_pack_no_exists = self._SubscribeChain__build_full_pack_first_no_exists( - subscribe=subscribe, mediakey=mediakey + subscribe=subscribe, mediakey=mediakey, no_exists=no_exists ) full_season_contexts = ( [ @@ -399,10 +400,10 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner): full_pack_contexts.append(context) if full_season_contexts and not full_pack_contexts: - logger.info(f"{subscribe.name} 全集候选优先级未高于全部目标集,回退到分集洗版") + logger.info(f"{subscribe.name} 全集候选优先级未高于全部目标集,回退到按集下载") if full_pack_contexts: - logger.info(f"{subscribe.name} 分集洗版优先尝试全集资源,共匹配到 {len(full_pack_contexts)} 个候选") + logger.info(f"{subscribe.name} 优先尝试全集资源,共匹配到 {len(full_pack_contexts)} 个候选") downloads, lefts = DownloadChain().batch_download( contexts=full_pack_contexts, no_exists=full_pack_no_exists, @@ -416,7 +417,7 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner): ) if downloads: return downloads, lefts - logger.info(f"{subscribe.name} 未下载到全集资源,回退到分集洗版") + logger.info(f"{subscribe.name} 未下载到全集资源,回退到按集下载") result: Tuple[List[Context], Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = ( DownloadChain().batch_download( diff --git a/app/chain/subscribe/search.py b/app/chain/subscribe/search.py index 04c8dacdc..7d34997bc 100644 --- a/app/chain/subscribe/search.py +++ b/app/chain/subscribe/search.py @@ -109,7 +109,9 @@ def _process_paged_subscription(owner: _SubscribeOwnerBase, subscribe: Subscript 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 strategy = _subscription_search_strategy(bool(subscribe.best_version)) - scan = SearchScan(owner=searchchain, plan=plan, full=strategy == "full", + prefer_initial_pack = bool(cast(Any, owner)._SubscribeChain__build_full_pack_first_no_exists( + subscribe, target.media_key, target.missing)) + scan = SearchScan(owner=searchchain, plan=plan, full=strategy == "full" or prefer_initial_pack, page_limit=1 if strategy == "single_page" else None, execution=execution_context, repository=repository, snapshot=snapshot, target_scope=(subscribe.start_episode or 1, subscribe.total_episode or 0)) diff --git a/docs/testing.md b/docs/testing.md index e7c866295..7c3eebd4c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -101,6 +101,12 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)): ## 种子下载重试回归 +普通电视剧首次下载的整包优先回归见 `tests/test_subscribe_paged_search.py`:订阅没有下载记录, +且最新媒体库缺失全部目标集时,先收集所选搜索范围内的候选再尝试完整包;单页策略仍只查每个 +来源的第一页。整包继续通过规则过滤、优先级门槛和种子文件完整覆盖校验,无可用整包时按集 +回退。已有下载记录或媒体库已有目标集时沿用补集行为,提交前重新检查当前订阅;不会开启洗版 +或写入洗版优先级。测试覆盖显式缺集列表、后页整包、不完整包和搜索期间进度变化,均隔离外部服务。 + 种子下载重试的离线边界见 `tests/test_torrent_download_retry.py`:普通 GET 在网络失败、 403/5xx、空响应或无法解析的响应后最多重试两次,退避为 1、2 秒。签名链接刷新、换票地址、 首次下载确认 POST 保持既有流程。HTTP 429 或 JSON `message` / `msg` 明确提示限流时, diff --git a/tests/test_subscribe_paged_search.py b/tests/test_subscribe_paged_search.py index 9b0a19e61..5ad48af7f 100644 --- a/tests/test_subscribe_paged_search.py +++ b/tests/test_subscribe_paged_search.py @@ -11,8 +11,13 @@ import app.chain.download.existence as download_existence import app.chain.download.selection as download_selection import app.chain.subscribe.policy as subscribe_policy import app.chain.subscribe.refresh as subscribe_refresh +import app.chain.subscribe.search as subscribe_search from app.application.site.observation import report_site_search_outcome, report_site_search_page from app.application.subscription.contract import SubscriptionSnapshot, build_subscribe_meta +from app.application.subscription.priority import ( + build_full_pack_first_no_exists, + should_prefer_full_pack_for_initial_download, +) from app.chain.download import DownloadChain from app.chain.search.facade import SearchChain from app.chain.subscribe.facade import SubscribeChain @@ -20,7 +25,7 @@ from app.chain.subscribe.metadata import SubscriptionSearchTarget from app.chain.subscribe.search import _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 +from app.schemas.mediaserver import ExistMediaInfo, NotExistMediaInfo from app.schemas.types import MediaSource, MediaType, SystemConfigKey @@ -61,6 +66,7 @@ def subscription(monkeypatch): def _missing(scope, item): + """通过真实订阅规则计算媒体库与下载记录合并后的缺集。""" return scope.chain.resolve_subscribe_missing( subscribe=item, meta=build_subscribe_meta(item), mediainfo=scope.media, mediakey="tmdb:1")[1] @@ -101,7 +107,9 @@ def test_paged_search_keeps_new_missing_episodes_in_completion(subscription, mon assert call.kwargs["no_exists"]["tmdb:1"][1].episodes == [2] -def test_whole_season_downloads_late_complete_pack_before_early_parts(subscription, monkeypatch): +@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): """整季缺失的真实分页与下载选集链路只提交后页全集包,不抢跑前页散集。""" scope = subscription current = replace(scope.initial, total_episode=2, manual_total_episode=2) @@ -109,16 +117,19 @@ def test_whole_season_downloads_late_complete_pack_before_early_parts(subscripti target = SubscriptionSearchTarget(current, build_subscribe_meta(current), scope.media, _missing(scope, current), "tmdb:1") assert target.missing["tmdb:1"][1].episodes == [] + if explicit_missing: + target.missing["tmdb:1"][1].episodes = [1, 2] 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]) + monkeypatch.setattr(subscribe_search, "_subscription_search_strategy", lambda _best_version: strategy) calls = [] def download(*_args, **_params): """下载副作用必须晚于真实末页,不因散集关闭证据提前执行。""" - assert calls == [0, 1, 2, 3] + assert calls == ([0] if strategy == "single_page" else [0, 1, 2, 3]) return "hash" scope.download.download_single.side_effect = download @@ -137,12 +148,85 @@ def test_whole_season_downloads_late_complete_pack_before_early_parts(subscripti monkeypatch.setattr(search, "search_site_torrents", request) _process_paged_subscription(scope.chain, current, search, target, SystemConfigKey.SubscribeFilterRuleGroups, None) - assert calls == [0, 1, 2, 3] + assert calls == ([0] if strategy == "single_page" else [0, 1, 2, 3]) scope.download.download_single.assert_called_once() - context = scope.download.download_single.call_args.kwargs["context"] - assert context.torrent_info.title == "Example Show S01 Complete" + call = scope.download.download_single.call_args + context = call.args[0] if call.args else call.kwargs["context"] + assert context.torrent_info.title == ("Example Show S01E01" if strategy == "single_page" else "Example Show S01 Complete") assert context.selected_episodes is None - assert scope.current["value"].lack_episode == 0 + assert scope.current["value"].lack_episode == (1 if strategy == "single_page" else 0) + + +@pytest.mark.parametrize("fields,episodes,expected", [ + ({}, [1, 2], True), + ({"lack_episode": 1}, [1, 2], True), + ({"note": [1]}, [1, 2], False), + ({"note": [9]}, [1, 2], False), + ({"episode_priority": {"1": 60}}, [1, 2], False), + ({}, [2], False), + ({"best_version": 1}, [1, 2], False), + ({"type": MediaType.MOVIE.value}, [1, 2], False), + ({"total_episode": 0}, [1, 2], False), + ({"start_episode": 2}, [2], True), +]) +def test_initial_pack_uses_download_records_and_current_missing(fields, episodes, expected): + """首次下载以当前缺集事实为准,已有下载或媒体库已有剧集时不能扩大到全集。""" + subscribe = SubscriptionSnapshot(id=1, name="Show", type=MediaType.TV.value, season=1, total_episode=2) + subscribe = replace(subscribe, **fields) + missing = {"tmdb:1": {1: NotExistMediaInfo(season=1, episodes=episodes, total_episode=2)}} + assert should_prefer_full_pack_for_initial_download(subscribe, "tmdb:1", missing) is expected + if not subscribe.best_version: + assert bool(build_full_pack_first_no_exists(subscribe, "tmdb:1", missing)) is expected + + +@pytest.mark.parametrize("pack_episodes,pack_success", [(None, False), ([1, 2], True), ([1], True), ([1, 2], False)]) +def test_initial_download_prefers_complete_pack_and_falls_back(subscription, pack_episodes, pack_success): + """显式全缺集时整包优先于散集;种子不完整或无整包时仍下载所需散集。""" + scope = subscription + current = replace(scope.initial, total_episode=2) + scope.current["value"] = current + missing = _missing(scope, current) + missing["tmdb:1"][1].episodes = [1, 2] + scope.chain.check_and_handle_existing_media = Mock(return_value=(False, missing)) + parts = [Context(meta_info=MetaInfo(f"Example Show S01E{episode:02d}"), media_info=scope.media, + torrent_info=TorrentInfo(site=1, title=f"Example Show S01E{episode:02d}", pri_order=100)) + for episode in (1, 2)] + pack = Context(meta_info=MetaInfo("Example Show S01"), media_info=scope.media, + torrent_info=TorrentInfo(site=1, title="Example Show S01", pri_order=80)) + contexts = parts + ([pack] if pack_episodes is not None else []) + + def prepare(torrent, **_params): + """解析出的剧集与资源标题独立,避免把标题整季误当成真实完整包。""" + return b"torrent", None, pack_episodes if torrent is pack.torrent_info else MetaInfo(torrent.title).episode_list + + def submit(context, **_params): + """整包下载器失败后仍允许提交散集,验证真实回退而非空候选。""" + return None if context is pack and not pack_success else "hash" + + scope.download.download_torrent.side_effect = prepare + scope.download.download_single.side_effect = submit + downloads, lefts = scope.chain._SubscribeChain__download_best_version_with_full_pack_first( + contexts=contexts, no_exists=missing, subscribe=current, mediakey="tmdb:1") + assert downloads == ([pack] if pack_episodes == [1, 2] and pack_success else parts) + assert not lefts + assert scope.current["value"].best_version == current.best_version + assert scope.current["value"].episode_priority == current.episode_priority + + +def test_initial_pack_rechecks_current_download_progress(subscription): + """搜索开始后新增下载记录时,提交前重读快照并恢复按集选择。""" + scope = subscription + prepared = replace(scope.initial, total_episode=2) + scope.current["value"] = replace(prepared, note=[1]) + part = Context(meta_info=MetaInfo("Example Show S01E02"), media_info=scope.media, + torrent_info=TorrentInfo(site=1, title="Example Show S01E02", pri_order=100)) + pack = Context(meta_info=MetaInfo("Example Show S01"), media_info=scope.media, + torrent_info=TorrentInfo(site=1, title="Example Show S01", pri_order=80)) + downloads, lefts = scope.chain._SubscribeChain__download_best_version_with_full_pack_first( + contexts=[part, pack], no_exists=_missing(scope, prepared), subscribe=prepared, mediakey="tmdb:1") + assert downloads == [part] + assert not lefts + scope.download.download_torrent.assert_not_called() def test_ready_batch_uses_original_partial_pack_selection_and_returns_full_missing(subscription):