From 0b5919b6a2d8fb371c8e71829964df7a69132a9b Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sat, 1 Aug 2026 17:39:37 +0800 Subject: [PATCH] fix(subscribeassistantenhanced): rebuild added episodes outside full-season mode --- package.v2.json | 3 +- .../subscribeassistantenhanced/README.md | 1 + .../subscribeassistantenhanced/__init__.py | 52 +---- .../postcheck/rebuilder.py | 127 +++++++++++ .../postcheck/verifier.py | 53 +++-- .../test_plugin_integration.py | 71 +----- .../test_rebuilder.py | 211 ++++++++++++++++++ .../test_verifier.py | 190 +++++++++++++++- 8 files changed, 576 insertions(+), 132 deletions(-) create mode 100644 plugins.v2/subscribeassistantenhanced/postcheck/rebuilder.py create mode 100644 tests/v2/subscribeassistantenhanced/test_rebuilder.py diff --git a/package.v2.json b/package.v2.json index d7a15858..7ce61af9 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,12 +3,13 @@ "name": "订阅助手(增强版)", "description": "多场景管理订阅,实现订阅全生命周期管理。", "labels": "订阅", - "version": "0.6.10", + "version": "0.6.11", "icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png", "author": "InfinityPacer", "level": 1, "system_version": ">=2.14.6", "history": { + "v0.6.11": "修正全集洗版可能阻断新增集自动纠错的问题。", "v0.6.10": "完善配置页前端质量检查与构建门禁,提升构建稳定性。", "v0.6.9": "完善分集转全集的覆盖校验、优先级迁移与回滚,并优化新增剧集订阅重建和转换生命周期。", "v0.6.8": "优化订阅通知图片策略,媒体通知优先使用订阅图片,无图消息改为纯文本并标注插件来源。", diff --git a/plugins.v2/subscribeassistantenhanced/README.md b/plugins.v2/subscribeassistantenhanced/README.md index 56d0d799..be995456 100644 --- a/plugins.v2/subscribeassistantenhanced/README.md +++ b/plugins.v2/subscribeassistantenhanced/README.md @@ -16,6 +16,7 @@ ## 版本更新日志 +- v0.6.11:修正全集洗版可能阻断新增集自动纠错的问题。 - v0.6.10:完善配置页前端质量检查与构建门禁,提升构建稳定性。 - v0.6.9:完善分集转全集的覆盖校验、优先级迁移与回滚,并优化新增剧集订阅重建和转换生命周期。 - v0.6.8:优化订阅通知图片策略,媒体通知优先使用订阅图片,无图消息改为纯文本并标注插件来源。 diff --git a/plugins.v2/subscribeassistantenhanced/__init__.py b/plugins.v2/subscribeassistantenhanced/__init__.py index 3ebba1a4..9f123c2b 100644 --- a/plugins.v2/subscribeassistantenhanced/__init__.py +++ b/plugins.v2/subscribeassistantenhanced/__init__.py @@ -57,7 +57,8 @@ is_tv_episode_best_version_subscribe, resolve_subscribe_media_type, ) -from .postcheck.verifier import CompletionVerifier, _format_snapshot_label +from .postcheck.verifier import CompletionVerifier +from .postcheck.rebuilder import CompletionSubscribeRebuilder from .postcheck.timeout import PendingTimeoutManager from .events import EventProxy from .shared.media import parse_date @@ -88,7 +89,7 @@ class SubscribeAssistantEnhanced(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png" # 插件版本 - plugin_version = "0.6.10" + plugin_version = "0.6.11" _site_cache_candidate_helper_warned = False # 插件作者 plugin_author = "InfinityPacer" @@ -237,13 +238,20 @@ def _init_modules(self): cadence_acceleration=cfg.timeout_cadence_acceleration, subscribe_get_fn=self._subscribe_oper.get, ) + completion_rebuilder = CompletionSubscribeRebuilder( + subscribe_chain=self._subscribe_chain, + subscribe_oper=self._subscribe_oper, + default_config_getter=self.systemconfig.get, + plugin_name=self.plugin_name, + ) verifier = CompletionVerifier( tm.read, tm.update, tmdb_episodes_fn=self._tmdb_episodes, subscribe_oper=self._subscribe_oper, retention_days=cfg.verify_retention_days, notify_fn=self._notify_subscribe, - rebuild_subscribe_fn=self._rebuild_subscribe_from_snapshot, + rebuild_subscribe_fn=completion_rebuilder.rebuild, + validate_rebuild_subscribe_fn=completion_rebuilder.validate, get_subscribe_image_fn=self._get_subscribe_image, ) priority_manager = PriorityManager( @@ -497,6 +505,7 @@ def _init_modules(self): self._modules = { "volatility": volatility, "timeout_manager": timeout_manager, + "completion_rebuilder": completion_rebuilder, "verifier": verifier, "priority_manager": priority_manager, "converter": converter, @@ -1504,43 +1513,6 @@ def _detect_episode_coverage(self, subscribe) -> Tuple[list, list]: except Exception: return [], sorted(target) - def _rebuild_subscribe_from_snapshot(self, snap: dict, config: dict) -> bool: - """使用当前默认订阅规则和完成快照重建增集订阅。""" - if not self._subscribe_chain: - return False - payload = dict(config) - title = payload.pop("name", "") - year = payload.pop("year", None) - for field in ( - "id", "type", "tmdbid", "season", "episode_group", - "best_version", "best_version_full", - ): - payload.pop(field, None) - payload["manual_total_episode"] = 0 - payload["state"] = "N" - try: - subscribe_id, _ = self._subscribe_chain.add( - title=title, - year=year, - mtype=MediaType.TV, - tmdbid=snap.get("tmdbid"), - season=snap.get("season"), - episode_group=snap.get("episode_group_id"), - username=self.plugin_name, - message=False, - exist_ok=True, - **payload, - ) - if subscribe_id: - logger.info(f"完成后验证:{_format_snapshot_label(snap)} 检测到增集,已重建订阅(新 id={subscribe_id})") - return bool(subscribe_id) - except Exception as err: - logger.warning( - "订阅助手(增强版)按完成快照重建订阅失败:" - f"{_format_snapshot_label(snap)}, error={err}" - ) - return False - def _delete_downloader_torrent(self, downloader, torrent_hash): """从下载器删除种子(delete_file=True,连源文件一并删);缺下载器服务或参数时跳过。 diff --git a/plugins.v2/subscribeassistantenhanced/postcheck/rebuilder.py b/plugins.v2/subscribeassistantenhanced/postcheck/rebuilder.py new file mode 100644 index 00000000..f56af966 --- /dev/null +++ b/plugins.v2/subscribeassistantenhanced/postcheck/rebuilder.py @@ -0,0 +1,127 @@ +"""完成快照订阅重建:解析模式、创建订阅并校验实际接管范围。""" +from typing import Callable, Tuple + +from app.log import logger +from app.schemas.types import MediaType, SystemConfigKey + +from .verifier import format_snapshot_label + + +class CompletionSubscribeRebuilder: + """按完成快照重建增集订阅,并确保结果不是全集洗版。""" + + def __init__(self, subscribe_chain, subscribe_oper, + default_config_getter: Callable, plugin_name: str): + """注入订阅创建、订阅读取和主程序默认规则查询能力。""" + self._subscribe_chain = subscribe_chain + self._subscribe_oper = subscribe_oper + self._default_config_getter = default_config_getter + self._plugin_name = plugin_name + + def rebuild(self, snap: dict, config: dict) -> bool: + """使用当前默认规则和完成快照重建按集追踪的增集订阅。""" + if not self._subscribe_chain or not self._subscribe_oper: + return False + payload = dict(config) + title = payload.pop("name", "") + year = payload.pop("year", None) + for field in ( + "id", "type", "tmdbid", "season", "episode_group", + "best_version", "best_version_full", + ): + payload.pop(field, None) + best_version, best_version_full = self._resolve_mode(snap) + payload["best_version"] = best_version + payload["best_version_full"] = best_version_full + payload["manual_total_episode"] = 0 + payload["state"] = "N" + try: + subscribe_id, _ = self._subscribe_chain.add( + title=title, + year=year, + mtype=MediaType.TV, + tmdbid=snap.get("tmdbid"), + season=snap.get("season"), + episode_group=snap.get("episode_group_id"), + username=self._plugin_name, + message=False, + exist_ok=True, + **payload, + ) + rebuilt = self._subscribe_oper.get(subscribe_id) if subscribe_id else None + if not self._is_valid( + rebuilt, + snap=snap, + config=config, + best_version=best_version, + ): + logger.warning( + f"完成后验证:{format_snapshot_label(snap)} 重建结果未接管目标新增集," + "已保留快照等待重试" + ) + return False + logger.info( + f"完成后验证:{format_snapshot_label(snap)} 检测到增集," + f"已重建订阅(新 id={subscribe_id})" + ) + return True + except Exception as err: + logger.warning( + f"{self._plugin_name}按完成快照重建订阅失败:" + f"{format_snapshot_label(snap)}, error={err}" + ) + return False + + def validate(self, subscribe, snap: dict, current_total: int) -> bool: + """验证既有订阅是否按当前纠错规则接管快照发现的新增集。""" + best_version, _ = self._resolve_mode(snap) + return self._is_valid( + subscribe, + snap=snap, + config={ + "start_episode": snap.get("total_at_completion", 0) + 1, + "total_episode": current_total, + }, + best_version=best_version, + ) + + @staticmethod + def _is_valid(subscribe, snap: dict, config: dict, best_version: int) -> bool: + """确认实际订阅按目标模式覆盖完整新增集区间。""" + if not subscribe: + return False + requested_start = config.get("start_episode") or 1 + requested_total = config.get("total_episode") or 0 + actual_start = subscribe.start_episode or 1 + actual_total = subscribe.total_episode or 0 + return ( + subscribe.tmdbid == snap.get("tmdbid") + and subscribe.season == snap.get("season") + and subscribe.episode_group == snap.get("episode_group_id") + and bool(subscribe.best_version) == bool(best_version) + and not bool(subscribe.best_version_full) + and actual_start <= requested_start + and actual_total >= requested_total + ) + + def _resolve_mode(self, snap: dict) -> Tuple[int, int]: + """解析自动纠错重建模式,保证结果只可能是普通订阅或分集洗版。""" + default_best_version, default_best_version_full = self._get_default_tv_mode() + if not default_best_version_full: + return int(default_best_version), 0 + + snapshot_config = snap.get("subscribe_config") or {} + snapshot_best_version = bool(snapshot_config.get("best_version")) + snapshot_best_version_full = bool(snapshot_config.get("best_version_full")) + if snapshot_best_version and not snapshot_best_version_full: + return 1, 0 + return 0, 0 + + def _get_default_tv_mode(self) -> Tuple[bool, bool]: + """读取用户在主程序中保存的默认电视剧订阅模式。""" + default_config = self._default_config_getter(SystemConfigKey.DefaultTvSubscribeConfig) + if not isinstance(default_config, dict): + default_config = {} + best_version = bool(default_config.get("best_version")) + best_version_full = best_version and bool(default_config.get("best_version_full")) + return best_version, best_version_full diff --git a/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py b/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py index f036edc0..c6863eff 100644 --- a/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py +++ b/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py @@ -18,6 +18,7 @@ def __init__(self, task_data_read: Callable, task_data_update: Callable, retention_days: int = 180, notify_fn: Optional[Callable] = None, rebuild_subscribe_fn: Optional[Callable] = None, + validate_rebuild_subscribe_fn: Optional[Callable] = None, get_subscribe_image_fn: Optional[Callable] = None): """注入完成快照存储、集数查询、订阅查询和真实订阅重建能力。""" self._read = task_data_read @@ -27,6 +28,7 @@ def __init__(self, task_data_read: Callable, task_data_update: Callable, self._retention_seconds = retention_days * 86400 self._notify = notify_fn self._rebuild_subscribe = rebuild_subscribe_fn + self._validate_rebuild_subscribe = validate_rebuild_subscribe_fn self._get_subscribe_image = get_subscribe_image_fn def snapshot(self, subscribe, mediainfo, scope: Optional[SeasonScope]): @@ -70,12 +72,19 @@ def verify_all(self): to_remove = [] for snap in snapshots: - current_total = self._fetch_current_total(snap) - if current_total is not None and current_total > snap.get("total_at_completion", 0): - snap_label = _format_snapshot_label(snap) - logger.info(f"完成后验证:{snap_label} 检测到增集 {snap.get('total_at_completion', 0)}→{current_total},尝试重建订阅") - if self._rebuild(snap, current_total): - to_remove.append(snap) + try: + current_total = self._fetch_current_total(snap) + if current_total is not None and current_total > snap.get("total_at_completion", 0): + snap_label = format_snapshot_label(snap) + logger.info(f"完成后验证:{snap_label} 检测到增集 {snap.get('total_at_completion', 0)}→{current_total},尝试重建订阅") + if self._rebuild(snap, current_total): + to_remove.append(snap) + except Exception as err: + # 单条快照失败时保留原记录重试,不能阻断同批其他订阅的纠错。 + logger.warning( + f"完成后验证:{format_snapshot_label(snap)} 处理失败," + f"已保留快照等待重试,error={err}" + ) if to_remove: self._remove_snapshots(to_remove) @@ -123,17 +132,27 @@ def _rebuild(self, snap: dict, current_total: int) -> bool: and sub.episode_group == episode_group_id ) ] - if any((sub.total_episode or 0) >= current_total for sub in matched): - return True - # 普通或分集洗版订阅由现有订阅流程继续处理;目标范围尚未覆盖时不能消费完成快照。 - if any(not is_full_best_version_subscribe(sub) for sub in matched): - return False + full_best_version_subscribes = [ + sub for sub in matched if is_full_best_version_subscribe(sub) + ] + if full_best_version_subscribes: + for sub in full_best_version_subscribes: + logger.info(f"完成后验证:删除旧洗版订阅 {format_subscribe_label(sub)} 以便重建增集订阅") + self._subscribe_oper.delete(sub.id) + removed_full_best_version = True + else: + covered = [sub for sub in matched if (sub.total_episode or 0) >= current_total] + if ( + covered + and self._validate_rebuild_subscribe + and any(self._validate_rebuild_subscribe(sub, snap, current_total) for sub in covered) + ): + return True - for sub in matched: - logger.info(f"完成后验证:删除旧洗版订阅 {format_subscribe_label(sub)} 以便重建增集订阅") - self._subscribe_oper.delete(sub.id) - removed_full_best_version = True + # 普通或分集洗版订阅由现有订阅流程继续处理;目标范围尚未覆盖时不能消费完成快照。 + if matched: + return False old_total = snap.get("total_at_completion", 0) config["start_episode"] = old_total + 1 @@ -168,7 +187,7 @@ def _snap_key(snap: dict) -> tuple: return (snap.get("tmdbid"), snap.get("season"), snap.get("episode_group_id")) -def _format_snapshot_label(snap: dict) -> str: +def format_snapshot_label(snap: dict) -> str: """格式化完成快照日志标签;配置缺名称时回退到 TMDB/季号。""" config = snap.get("subscribe_config") or {} name = config.get("name") @@ -187,6 +206,8 @@ def _extract_config(subscribe) -> dict: "season": subscribe.season, "episode_group": subscribe.episode_group, "type": subscribe.type, + "best_version": int(bool(subscribe.best_version)), + "best_version_full": int(bool(subscribe.best_version_full)), "keyword": subscribe.keyword, "save_path": subscribe.save_path, "sites": subscribe.sites, diff --git a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py index 326ac5e9..9bcb727a 100644 --- a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py +++ b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py @@ -2837,72 +2837,17 @@ def fake_no_exists(*args, **kwargs): assert plugin._detect_missing_episodes(sub) == [] assert captured["totals"] == {2: 8} - def test_snapshot_rebuild_calls_subscribe_chain_with_default_mode(self): - """H 重建由主程序链应用当前默认模式,并保留快照中的媒体专属配置。""" + def test_completion_rebuilder_is_wired_into_verifier(self): + """入口装配的 verifier 使用同一重建组件完成创建与结果校验。""" plugin = SubscribeAssistantEnhanced() plugin.init_plugin({}) + rebuilder = plugin._modules["completion_rebuilder"] + verifier = plugin._modules["verifier"] - class StrictSubscribeChain: - """记录主程序订阅链收到的重建参数。""" - - def __init__(self): - self.call = None - - def add(self, **kwargs): - """记录订阅链参数。""" - self.call = kwargs - return 88, "新增订阅成功" - - chain = StrictSubscribeChain() - plugin._subscribe_chain = chain - - result = plugin._rebuild_subscribe_from_snapshot( - {"tmdbid": 100, "season": 1, "episode_group_id": "eg-1"}, - { - "name": "测试", - "year": "2026", - "quality": "WEB-DL", - "best_version": 1, - "best_version_full": 1, - "start_episode": 13, - "total_episode": 15, - "lack_episode": 3, - "manual_total_episode": 92, - }, - ) - - assert result is True - assert chain.call["title"] == "测试" - assert chain.call["year"] == "2026" - assert chain.call["mtype"] == MediaType.TV - assert chain.call["tmdbid"] == 100 - assert chain.call["season"] == 1 - assert chain.call["episode_group"] == "eg-1" - assert chain.call["quality"] == "WEB-DL" - assert chain.call["start_episode"] == 13 - assert chain.call["total_episode"] == 15 - assert chain.call["lack_episode"] == 3 - assert chain.call["manual_total_episode"] == 0 - assert chain.call["state"] == "N" - assert chain.call["message"] is False - assert "best_version" not in chain.call - assert "best_version_full" not in chain.call - - def test_snapshot_rebuild_does_not_duplicate_subscribe_added_event(self): - """主程序订阅链负责新增事件,H 重建不得再次手工发送。""" - plugin = SubscribeAssistantEnhanced() - plugin.init_plugin({}) - plugin._subscribe_chain = MagicMock() - plugin._subscribe_chain.add.return_value = (88, "新增订阅成功") - plugin._send_subscribe_added = MagicMock() - - result = plugin._rebuild_subscribe_from_snapshot( - {"tmdbid": 100, "season": 1, "episode_group_id": "eg-1"}, - {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3}, - ) - - assert result is True - plugin._send_subscribe_added.assert_not_called() + assert verifier._rebuild_subscribe == rebuilder.rebuild + assert verifier._validate_rebuild_subscribe == rebuilder.validate + assert rebuilder._subscribe_chain is plugin._subscribe_chain + assert rebuilder._subscribe_oper is plugin._subscribe_oper def test_completion_verify_keeps_snapshot_for_lagging_episode_best_version_subscription(self): """同身份分集洗版订阅未覆盖最新总集数时保留快照,不删除重建。""" diff --git a/tests/v2/subscribeassistantenhanced/test_rebuilder.py b/tests/v2/subscribeassistantenhanced/test_rebuilder.py new file mode 100644 index 00000000..a0ffe697 --- /dev/null +++ b/tests/v2/subscribeassistantenhanced/test_rebuilder.py @@ -0,0 +1,211 @@ +"""完成快照订阅重建组件单测。""" +from types import SimpleNamespace +from unittest.mock import MagicMock + +from app.schemas.types import MediaType, SystemConfigKey + +from subscribeassistantenhanced.postcheck.rebuilder import CompletionSubscribeRebuilder + + +def _sub(**kwargs): + """构造重建结果校验所需的稳定订阅字段。""" + values = { + "tmdbid": 100, + "season": 1, + "episode_group": None, + "best_version": 0, + "best_version_full": 0, + "start_episode": 13, + "total_episode": 15, + } + values.update(kwargs) + return SimpleNamespace(**values) + + +def _rebuilder(default_config=None, rebuilt=None): + chain = MagicMock() + chain.add.return_value = (88, "新增订阅成功") + oper = MagicMock() + oper.get.return_value = rebuilt or _sub() + config_getter = MagicMock(return_value={} if default_config is None else default_config) + rebuilder = CompletionSubscribeRebuilder( + subscribe_chain=chain, + subscribe_oper=oper, + default_config_getter=config_getter, + plugin_name="订阅助手(增强版)", + ) + return rebuilder, chain, oper, config_getter + + +def test_rebuild_uses_non_full_default_mode_and_preserves_media_config(): + """非全集默认规则优先,并保留媒体专属配置与新增集范围。""" + rebuilder, chain, _, _ = _rebuilder( + default_config={"best_version": 1, "best_version_full": 0}, + rebuilt=_sub(episode_group="eg-1", best_version=1), + ) + + result = rebuilder.rebuild( + { + "tmdbid": 100, + "season": 1, + "episode_group_id": "eg-1", + "subscribe_config": {"best_version": 0, "best_version_full": 0}, + }, + { + "name": "测试", + "year": "2026", + "quality": "WEB-DL", + "best_version": 1, + "best_version_full": 1, + "start_episode": 13, + "total_episode": 15, + "lack_episode": 3, + "manual_total_episode": 92, + }, + ) + + assert result is True + call = chain.add.call_args.kwargs + assert call["title"] == "测试" + assert call["year"] == "2026" + assert call["mtype"] == MediaType.TV + assert call["tmdbid"] == 100 + assert call["season"] == 1 + assert call["episode_group"] == "eg-1" + assert call["quality"] == "WEB-DL" + assert call["start_episode"] == 13 + assert call["total_episode"] == 15 + assert call["lack_episode"] == 3 + assert call["manual_total_episode"] == 0 + assert call["state"] == "N" + assert call["message"] is False + assert call["exist_ok"] is True + assert call["best_version"] == 1 + assert call["best_version_full"] == 0 + + +def test_full_default_restores_snapshot_episode_mode(): + """全集默认规则下,原分集洗版快照恢复为分集洗版。""" + rebuilder, chain, _, _ = _rebuilder( + default_config={"best_version": 1, "best_version_full": 1}, + rebuilt=_sub(best_version=1), + ) + + assert rebuilder.rebuild( + { + "tmdbid": 100, + "season": 1, + "subscribe_config": {"best_version": 1, "best_version_full": 0}, + }, + {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3}, + ) is True + assert chain.add.call_args.kwargs["best_version"] == 1 + assert chain.add.call_args.kwargs["best_version_full"] == 0 + + +def test_full_default_downgrades_full_or_legacy_snapshot_to_normal(): + """全集或旧快照在全集默认规则下回退普通订阅。""" + for snap in ( + { + "tmdbid": 100, + "season": 1, + "subscribe_config": {"best_version": 1, "best_version_full": 1}, + }, + {"tmdbid": 100, "season": 1}, + ): + rebuilder, chain, _, _ = _rebuilder( + default_config={"best_version": 1, "best_version_full": 1}, + ) + assert rebuilder.rebuild( + snap, + {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3}, + ) is True + assert chain.add.call_args.kwargs["best_version"] == 0 + assert chain.add.call_args.kwargs["best_version_full"] == 0 + + +def test_default_tv_mode_reads_saved_system_config(): + """模式解析读取主程序保存的默认电视剧订阅规则。""" + rebuilder, _, _, config_getter = _rebuilder( + default_config={"best_version": 1, "best_version_full": 0}, + ) + + assert rebuilder._get_default_tv_mode() == (True, False) + config_getter.assert_called_once_with(SystemConfigKey.DefaultTvSubscribeConfig) + + +def test_rebuild_rejects_wrong_identity_returned_by_exist_ok(): + """exist_ok 返回其他媒体或剧集组的记录时不得误报成功。""" + rebuilder, _, oper, _ = _rebuilder( + rebuilt=_sub(episode_group="eg-old"), + ) + snap = {"tmdbid": 100, "season": 1, "episode_group_id": "eg-new"} + config = {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3} + + assert rebuilder.rebuild(snap, config) is False + + oper.get.return_value = _sub(tmdbid=101, episode_group="eg-new") + assert rebuilder.rebuild(snap, config) is False + + +def test_rebuild_rejects_result_without_requested_episode_range(): + """回读订阅未覆盖完整新增集区间时保留快照重试。""" + rebuilder, _, oper, _ = _rebuilder(rebuilt=_sub(total_episode=14)) + snap = {"tmdbid": 100, "season": 1} + config = {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3} + + assert rebuilder.rebuild(snap, config) is False + + oper.get.return_value = _sub(start_episode=14) + assert rebuilder.rebuild(snap, config) is False + + +def test_rebuild_rejects_full_or_wrong_resolved_mode(): + """回读订阅必须匹配解析模式,且绝不能继续保持全集洗版。""" + rebuilder, _, oper, _ = _rebuilder( + default_config={"best_version": 1, "best_version_full": 0}, + rebuilt=_sub(best_version=1, best_version_full=1), + ) + snap = {"tmdbid": 100, "season": 1} + config = {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3} + + assert rebuilder.rebuild(snap, config) is False + + oper.get.return_value = _sub(best_version=0, best_version_full=0) + assert rebuilder.rebuild(snap, config) is False + + +def test_validate_checks_existing_subscription_against_added_episode_range(): + """verifier 复用组件规则判断既有订阅是否已接管新增集。""" + rebuilder, _, _, _ = _rebuilder( + default_config={"best_version": 1, "best_version_full": 0}, + ) + snap = {"tmdbid": 100, "season": 1, "total_at_completion": 12} + + assert rebuilder.validate(_sub(best_version=1), snap, current_total=15) is True + assert rebuilder.validate(None, snap, current_total=15) is False + + +def test_rebuild_fails_closed_without_dependencies_or_when_chain_raises(): + """重建能力不可用或订阅链异常时返回失败,交由 verifier 保留快照。""" + rebuilder = CompletionSubscribeRebuilder( + subscribe_chain=None, + subscribe_oper=None, + default_config_getter=MagicMock(return_value={}), + plugin_name="订阅助手(增强版)", + ) + assert rebuilder.rebuild({}, {}) is False + + rebuilder, chain, _, _ = _rebuilder() + chain.add.side_effect = RuntimeError("subscribe chain failed") + assert rebuilder.rebuild( + {"tmdbid": 100, "season": 1}, + {"name": "测试", "start_episode": 13, "total_episode": 15}, + ) is False + + +def test_default_tv_mode_treats_non_mapping_config_as_normal(): + """主程序未保存结构化默认规则时按普通订阅处理。""" + rebuilder, _, _, _ = _rebuilder(default_config=[]) + + assert rebuilder._get_default_tv_mode() == (False, False) diff --git a/tests/v2/subscribeassistantenhanced/test_verifier.py b/tests/v2/subscribeassistantenhanced/test_verifier.py index 6718f1ed..b9327303 100644 --- a/tests/v2/subscribeassistantenhanced/test_verifier.py +++ b/tests/v2/subscribeassistantenhanced/test_verifier.py @@ -20,7 +20,7 @@ def _sub(tmdbid=100, season=1, episode_group=None, total=12, best_version=0, bes def _verifier(store=None, tmdb_fn=None, retention_days=90, rebuild_fn=None, - subscribe_image_fn=None): + subscribe_image_fn=None, validate_rebuild_fn=None): store = store if store is not None else {} oper = MagicMock() oper.list.return_value = [] @@ -33,6 +33,7 @@ def _verifier(store=None, tmdb_fn=None, retention_days=90, rebuild_fn=None, retention_days=retention_days, notify_fn=notify, rebuild_subscribe_fn=rebuild_fn, + validate_rebuild_subscribe_fn=validate_rebuild_fn, get_subscribe_image_fn=subscribe_image_fn, ) v._store = store @@ -54,8 +55,8 @@ def test_saves_snapshot(self): assert snaps[0]["total_at_completion"] == 12 assert snaps[0]["subscribe_config"]["filter"] == "rule1" assert snaps[0]["subscribe_config"]["filter_groups"] == ["group1"] - assert "best_version" not in snaps[0]["subscribe_config"] - assert "best_version_full" not in snaps[0]["subscribe_config"] + assert snaps[0]["subscribe_config"]["best_version"] == 1 + assert snaps[0]["subscribe_config"]["best_version_full"] == 1 assert snaps[0]["subscribe_config"]["year"] == "2026" assert snaps[0]["subscribe_config"]["keyword"] == "测试关键字" assert snaps[0]["subscribe_config"]["quality"] == "WEB-DL" @@ -150,11 +151,12 @@ def test_increase_triggers_rebuild(self): def test_rebuild_failure_keeps_snapshot_for_retry(self): """真实重建失败时必须保留快照,避免丢失后续补救机会。""" - store = {"snapshots": {"list": [{ + snap = { "tmdbid": 100, "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, - }]}} + } + store = {"snapshots": {"list": [snap]}} rebuild = MagicMock(return_value=False) v = _verifier(store, tmdb_fn=lambda *a, **kw: [object()] * 15, rebuild_fn=rebuild) @@ -248,6 +250,118 @@ def test_rebuild_deletes_best_version(self): } assert v._notify_mock.call_args.args[0] == "测试 S1 检测到新增集数(12→15),已移除旧洗版订阅并重建订阅" + def test_deleted_full_subscribe_rebuild_failure_keeps_snapshot(self): + """全集洗版删除后重建失败时保留快照,供后续巡检重试。""" + snap = { + "tmdbid": 100, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "测试"}, + } + store = {"snapshots": {"list": [snap]}} + full_subscribe = SimpleNamespace( + id=99, tmdbid=100, season=1, episode_group=None, + type="电视剧", best_version=1, best_version_full=1, + total_episode=15, name="测试剧", + ) + v = _verifier( + store, + tmdb_fn=lambda *a, **kw: [object()] * 15, + rebuild_fn=MagicMock(return_value=False), + ) + v._oper.list.return_value = [full_subscribe] + + v.verify_all() + + v._oper.delete.assert_called_once_with(99) + assert store["snapshots"]["list"] == [snap] + + def test_rebuild_exception_keeps_snapshot_and_continues_next_snapshot(self): + """单条重建异常不能消费快照,也不能阻断后续快照。""" + first = { + "tmdbid": 100, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "第一部"}, + } + second = { + "tmdbid": 101, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "第二部"}, + } + store = {"snapshots": {"list": [first, second]}} + rebuild = MagicMock(side_effect=[RuntimeError("boom"), True]) + v = _verifier( + store, + tmdb_fn=lambda *a, **kw: [object()] * 15, + rebuild_fn=rebuild, + ) + + v.verify_all() + + assert [snap["tmdbid"] for snap in store["snapshots"]["list"]] == [100] + assert rebuild.call_count == 2 + + def test_delete_exception_keeps_snapshot_and_continues_next_snapshot(self): + """单条全集洗版删除异常不能阻断其他完成快照的纠错。""" + first = { + "tmdbid": 100, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "第一部"}, + } + second = { + "tmdbid": 101, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "第二部"}, + } + store = {"snapshots": {"list": [first, second]}} + first_full = SimpleNamespace( + id=99, tmdbid=100, season=1, episode_group=None, + type="电视剧", best_version=1, best_version_full=1, + total_episode=15, name="第一部", + ) + v = _verifier( + store, + tmdb_fn=lambda *a, **kw: [object()] * 15, + rebuild_fn=MagicMock(return_value=True), + ) + v._oper.list.return_value = [first_full] + v._oper.delete.side_effect = RuntimeError("delete failed") + + v.verify_all() + + assert [snap["tmdbid"] for snap in store["snapshots"]["list"]] == [100] + + def test_mixed_full_and_normal_subscribes_rebuilds_after_removing_only_full(self): + """同范围混合订阅时只删除全集洗版,并仍执行重建接管校验。""" + snap = { + "tmdbid": 100, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "测试"}, + } + store = {"snapshots": {"list": [snap]}} + full_subscribe = SimpleNamespace( + id=99, tmdbid=100, season=1, episode_group=None, + type="电视剧", best_version=1, best_version_full=1, + total_episode=15, name="测试剧", + ) + normal_subscribe = SimpleNamespace( + id=100, tmdbid=100, season=1, episode_group=None, + type="电视剧", best_version=0, best_version_full=0, + total_episode=15, name="测试剧", + ) + rebuild = MagicMock(return_value=True) + v = _verifier( + store, + tmdb_fn=lambda *a, **kw: [object()] * 15, + rebuild_fn=rebuild, + ) + v._oper.list.return_value = [full_subscribe, normal_subscribe] + + v.verify_all() + + v._oper.delete.assert_called_once_with(99) + rebuild.assert_called_once() + assert store["snapshots"]["list"] == [] + def test_rebuild_does_not_touch_different_episode_group(self): """同 TMDB 同季但不同剧集组不是同一目标范围。""" store = {"snapshots": {"list": [{ @@ -288,21 +402,47 @@ def test_rebuild_sends_notification(self): def test_covered_active_normal_subscribe_consumes_snapshot(self): """已有普通订阅覆盖最新 TMDB 总集数时,完成快照已完成交接。""" - store = {"snapshots": {"list": [{ + snap = { "tmdbid": 100, "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, - }]}} + } + store = {"snapshots": {"list": [snap]}} existing = SimpleNamespace( id=50, tmdbid=100, season=1, episode_group=None, total_episode=15, best_version=0, best_version_full=0, ) - v = _verifier(store, tmdb_fn=lambda *a, **kw: [object()] * 15) + validate = MagicMock(return_value=True) + v = _verifier( + store, + tmdb_fn=lambda *a, **kw: [object()] * 15, + validate_rebuild_fn=validate, + ) v._oper.list.return_value = [existing] v.verify_all() v._oper.add.assert_not_called() + validate.assert_called_once_with(existing, snap, 15) assert store["snapshots"]["list"] == [] + def test_covered_subscribe_without_validator_keeps_snapshot(self): + """校验依赖缺失时必须保留快照,不能退回仅按总集数判断成功。""" + snap = { + "tmdbid": 100, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "测试"}, + } + store = {"snapshots": {"list": [snap]}} + existing = SimpleNamespace( + id=50, tmdbid=100, season=1, episode_group=None, + total_episode=15, best_version=0, best_version_full=0, + ) + v = _verifier(store, tmdb_fn=lambda *a, **kw: [object()] * 15) + v._oper.list.return_value = [existing] + + v.verify_all() + + assert store["snapshots"]["list"] == [snap] + def test_lagging_active_normal_subscribe_keeps_snapshot(self): """已有普通订阅未覆盖最新 TMDB 总集数时,不得误判纠错成功。""" store = {"snapshots": {"list": [{ @@ -328,8 +468,33 @@ def test_lagging_active_normal_subscribe_keeps_snapshot(self): v._oper.delete.assert_not_called() assert len(store["snapshots"]["list"]) == 1 - def test_covered_full_best_version_does_not_rebuild_again(self): - """已有全集洗版订阅覆盖最新总集数时直接消费快照,不重复删除重建。""" + def test_covered_subscribe_with_wrong_resolved_mode_keeps_snapshot(self): + """重试轮次不能仅凭集数覆盖消费模式不符的既有订阅。""" + snap = { + "tmdbid": 100, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "测试"}, + } + store = {"snapshots": {"list": [snap]}} + existing = SimpleNamespace( + id=50, tmdbid=100, season=1, episode_group=None, + total_episode=15, best_version=0, best_version_full=0, + ) + validate = MagicMock(return_value=False) + v = _verifier( + store, + tmdb_fn=lambda *a, **kw: [object()] * 15, + validate_rebuild_fn=validate, + ) + v._oper.list.return_value = [existing] + + v.verify_all() + + validate.assert_called_once_with(existing, snap, 15) + assert store["snapshots"]["list"] == [snap] + + def test_covered_full_best_version_is_replaced_for_added_episodes(self): + """全集洗版即使总集数已同步,也必须重建才能接管新增集数。""" store = {"snapshots": {"list": [{ "tmdbid": 100, "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), @@ -337,6 +502,7 @@ def test_covered_full_best_version_does_not_rebuild_again(self): }]}} existing = SimpleNamespace( id=50, tmdbid=100, season=1, episode_group=None, + type="电视剧", total_episode=15, best_version=1, best_version_full=1, ) rebuild = MagicMock(return_value=True) @@ -349,6 +515,6 @@ def test_covered_full_best_version_does_not_rebuild_again(self): v.verify_all() - rebuild.assert_not_called() - v._oper.delete.assert_not_called() + rebuild.assert_called_once() + v._oper.delete.assert_called_once_with(50) assert store["snapshots"]["list"] == []