diff --git a/plugins.v2/subscribeassistantenhanced/README.md b/plugins.v2/subscribeassistantenhanced/README.md index 13fffc8c..59ab3368 100644 --- a/plugins.v2/subscribeassistantenhanced/README.md +++ b/plugins.v2/subscribeassistantenhanced/README.md @@ -426,10 +426,10 @@ #### 分集转全集(`best_version_episode_to_full`) -- **解决什么问题**:分集洗版在订阅目标集满足后,可以原地切换为全集洗版,后续用整季包继续完成最终洗版。 +- **解决什么问题**:分集洗版在媒体库实际覆盖完整目标范围后,可以切换为全集洗版,后续用整季包统一做种和继续洗版。 - **什么时候开或关闭**:长期追剧并希望最终获得整季高质量版本时开启。电影或纯整季洗版不需要开启。 -- **典型联动**:「洗版类型」启用时才会注册洗版巡检。整理完成事件会先尝试即时切换;若未命中或错过事件,洗版巡检仍按「洗版检查周期」兜底推进分集转全集。转为全集洗版后会按「剧集洗版时限(天)」参与超时终止,分集洗版本身不适用该时限。「回填已存在集」能补齐存量下载事实,减少重复下载。 -- **常见误用**:开启分集转全集但没有任何目标集满足时不会立即切换。即时切换前提是当前订阅仍为分集洗版,且按主程序订阅目标满足口径已无剩余搜索 / 下载范围;分集洗版会把已下载过任意版本的目标集视为满足,全集洗版仍继续等待整季洗版资源。 +- **典型联动**:「洗版类型」启用时才会注册洗版巡检。整理完成事件与洗版巡检共用同一转换检查:目标范围有效、没有下载待定,且媒体库实际覆盖全部目标集。其他待定来源不会单独阻断转换。转为全集洗版后会按「剧集洗版时限(天)」参与超时终止,分集洗版本身不适用该时限。 +- **常见误用**:完整覆盖只表示可以切换模式,不代表转换后一定不会下载。关联分集下载历史为零或一条时保留当前全集准入基线,避免已完结剧直接下载一个完整包后重复下载;存在多条分集下载历史时,全集准入基线从 `0` 开始,以便后续下载整包统一做种。历史查询失败时本轮不会转换,会等待下次巡检。 #### 清理整理记录范围(`subscription_cleanup_history_type`) diff --git a/plugins.v2/subscribeassistantenhanced/__init__.py b/plugins.v2/subscribeassistantenhanced/__init__.py index 4857713c..529330dc 100644 --- a/plugins.v2/subscribeassistantenhanced/__init__.py +++ b/plugins.v2/subscribeassistantenhanced/__init__.py @@ -490,6 +490,7 @@ def _init_modules(self): subscription_cleanup=subscription_cleanup, converter=converter, best_version_episode_to_full=cfg.best_version_episode_to_full, + convert_episode_best_version_to_full_fn=self._convert_episode_best_version_to_full_if_ready, ) self._modules = { @@ -759,7 +760,7 @@ def run_best_version_check(self): if mediainfo: if is_full_best_version_subscribe(subscribe) and self._best_version_overdue(subscribe): logger.info(f"洗版巡检:{format_subscribe(subscribe)} {mode_label}超过洗版时限,标记洗版完成并停止洗版") - priority.mark_complete(subscribe) + priority.mark_full_best_version_complete(subscribe) self._notify_subscribe( f"{format_subscribe(subscribe)} {mode_label}超过时限" f"({self._best_version_timeout_days(subscribe)}天),已标记洗版优先级为完成", @@ -771,15 +772,12 @@ def run_best_version_check(self): and converter and is_tv_episode_best_version_subscribe(subscribe) ): - satisfied, _no_exists = self._resolve_subscribe_missing( + self._convert_episode_best_version_to_full_if_ready( + subscribe.id, subscribe, mediainfo, - best_version_accept_downloaded=True, + trigger="洗版巡检", ) - if not satisfied: - continue - logger.info(f"洗版巡检:{format_subscribe(subscribe)} 分集洗版目标满足,转为全集洗版") - converter.convert_to_full(subscribe, mediainfo) continue else: detail( @@ -919,11 +917,11 @@ def run_site_evidence_scan(self): detail("站点证据采样:开始") for subscribe in (self._subscribe_oper.list(state="P,R") or []): if ( - getattr(subscribe, "state", None) in ("P", "R") + subscribe.state in ("P", "R") and resolve_subscribe_media_type(subscribe) == MediaType.TV and not is_full_best_version_subscribe(subscribe) - and not bool(getattr(subscribe, "manual_total_episode", False)) + and not bool(subscribe.manual_total_episode) ): site_evidence.refresh_subscribe(subscribe) @@ -988,7 +986,7 @@ def _last_download_date(self, subscribe) -> Optional[datetime.date]: except Exception: return None - def _related_download_histories(self, subscribe) -> list: + def _related_download_histories(self, subscribe, raise_on_error: bool = False) -> list: """获取同一订阅完成后的分集下载历史,用于判断是否应自动洗版。""" try: if subscribe.type == "电影": @@ -1008,6 +1006,8 @@ def _related_download_histories(self, subscribe) -> list: ) except Exception as err: logger.warning(f"洗版编排:查询关联下载历史失败,跳过分集洗版判定:{err}") + if raise_on_error: + raise return [] related = [] @@ -1039,6 +1039,65 @@ def _related_download_histories(self, subscribe) -> list: related.append(history) return related + def _convert_episode_best_version_to_full_if_ready( + self, + subscribe_id, + subscribe=None, + mediainfo=None, + trigger: str = "洗版巡检", + ) -> bool: + """在下载待定已释放且媒体库完整覆盖目标范围时,将分集洗版转为全集洗版。""" + if not self._config or not self._config.best_version_episode_to_full: + return False + if not subscribe_id or not self._subscribe_oper: + return False + subscribe = subscribe or self._subscribe_oper.get(subscribe_id) + if not subscribe or not is_tv_episode_best_version_subscribe(subscribe): + return False + + try: + start_episode = int(subscribe.start_episode or 1) + total_episode = int(subscribe.total_episode or 0) + except (TypeError, ValueError): + return False + start_episode = max(start_episode, 1) + if total_episode < start_episode: + return False + target_episodes = set(range(start_episode, total_episode + 1)) + + download_monitor = self._modules.get("download_monitor") + if download_monitor and download_monitor.has_active_downloads(subscribe.id): + detail(f"{trigger}:{format_subscribe(subscribe)} 仍有下载待定,跳过分集转全集") + return False + + existing_episodes, missing_episodes = self._detect_episode_coverage(subscribe) + if missing_episodes or not target_episodes.issubset(set(existing_episodes)): + return False + + try: + episode_histories = self._related_download_histories(subscribe, raise_on_error=True) + except Exception: + return False + try: + current_priority = int(subscribe.current_priority or 0) + except (TypeError, ValueError): + current_priority = 0 + full_priority = 0 if len(episode_histories) > 1 else current_priority + + mediainfo = mediainfo or self._recognize_mediainfo(subscribe) + converter = self._modules.get("converter") + if not mediainfo or not converter: + return False + logger.info( + f"{trigger}:{format_subscribe(subscribe)} 媒体库已完整覆盖目标范围," + f"分集下载历史={len(episode_histories)},转为全集洗版" + ) + return converter.convert_to_full( + subscribe, + mediainfo, + current_priority=full_priority, + ) + @staticmethod def _is_full_pack_download(history, total_episode: Optional[int]) -> bool: """判断下载历史是否为合集/全集包;全集包不参与分集洗版触发计数。""" diff --git a/plugins.v2/subscribeassistantenhanced/best_version/converter.py b/plugins.v2/subscribeassistantenhanced/best_version/converter.py index 4ac2948c..dc7ee566 100644 --- a/plugins.v2/subscribeassistantenhanced/best_version/converter.py +++ b/plugins.v2/subscribeassistantenhanced/best_version/converter.py @@ -32,15 +32,15 @@ def __init__(self, subscribe_oper=None, clear_tasks_fn=None, send_event_fn=None, self._notification_image = notification_image_fn self._plugin_name = plugin_name - def convert_to_full(self, subscribe, mediainfo=None) -> bool: - """替换为全集洗版订阅,成功返回 True;失败时尽量恢复分集订阅。""" + def convert_to_full(self, subscribe, mediainfo=None, current_priority=None) -> bool: + """按指定全集准入基线替换为全集洗版订阅;失败时尽量恢复分集订阅。""" sid = subscribe.id if not sid or not self._subscribe_oper or not mediainfo: return False subscribe_dict = subscribe.to_dict() subscribe_desc = self._format_subscribe_desc(subscribe, mediainfo) - full_payload = self._build_full_payload(subscribe_dict) + full_payload = self._build_full_payload(subscribe_dict, current_priority=current_priority) try: if self._snapshot: @@ -81,7 +81,7 @@ def convert_to_full(self, subscribe, mediainfo=None) -> bool: self._notify_failure(subscribe, subscribe_desc, f"{err_msg}\n{restore_text}", mediainfo=mediainfo) return False - def _build_full_payload(self, subscribe_dict: dict) -> dict: + def _build_full_payload(self, subscribe_dict: dict, current_priority=None) -> dict: """从订阅快照构造全集洗版 payload,并保留订阅范围字段。""" payload = dict(subscribe_dict or {}) for field in DROP_REBUILT_FIELDS: @@ -91,6 +91,8 @@ def _build_full_payload(self, subscribe_dict: dict) -> dict: payload["username"] = self._plugin_name payload["state"] = "N" payload["manual_total_episode"] = 0 + if current_priority is not None: + payload["current_priority"] = current_priority return payload def _format_subscribe_desc(self, subscribe, mediainfo) -> str: diff --git a/plugins.v2/subscribeassistantenhanced/best_version/priority.py b/plugins.v2/subscribeassistantenhanced/best_version/priority.py index 196f5b3b..c32609d6 100644 --- a/plugins.v2/subscribeassistantenhanced/best_version/priority.py +++ b/plugins.v2/subscribeassistantenhanced/best_version/priority.py @@ -66,7 +66,14 @@ def rollback(self, subscribe, baseline: Optional[dict] = None): if media_type == MediaType.TV: episode_priority = baseline.get("episode_priority", {}) detail(f"洗版事实:{self._format_subscribe_label(subscribe)} 已恢复到下载前剧集优先级基线") - self._update_tv_episode_priority(subscribe, episode_priority, scene="plugin_rollback") + current_priority = baseline.get("current_priority", 0) \ + if is_full_best_version_subscribe(subscribe) else None + self._update_tv_episode_priority( + subscribe, + episode_priority, + scene="plugin_rollback", + current_priority=current_priority, + ) return if media_type == MediaType.MOVIE and self._subscribe_oper: @@ -124,7 +131,18 @@ def rollback_torrent(self, subscribe, torrent_id): if ep_priority.get(ep_key, 0) == contributed: ep_priority[ep_key] = old_value detail(f"洗版事实:{self._format_subscribe_label(subscribe)} 已恢复种子 {torrent_id} 对应集的优先级") - self._update_tv_episode_priority(subscribe, ep_priority, scene="plugin_rollback") + current_priority = None + if is_full_best_version_subscribe(subscribe): + current = subscribe.current_priority or 0 + contributed = baseline.get("contributed_priority", 0) + if current == contributed: + current_priority = baseline.get("current_priority_baseline", 0) + self._update_tv_episode_priority( + subscribe, + ep_priority, + scene="plugin_rollback", + current_priority=current_priority, + ) elif media_type == MediaType.MOVIE and self._subscribe_oper: current = subscribe.current_priority or 0 contributed = baseline.get("contributed_priority", 0) @@ -159,47 +177,13 @@ def backfill_existing(self, subscribe, existing_episodes: list, scene: str = "pl ) return bool(summary and summary.get("updated")) - def is_complete(self, subscribe) -> bool: - """判断洗版是否完成——所有目标集优先级达标(>=100)。""" - ep_priority = subscribe.episode_priority or {} - target_episodes = self._target_episodes(subscribe) - if not ep_priority or not target_episodes: - return False - return all(ep_priority.get(str(ep), 0) >= 100 for ep in target_episodes) - - def mark_complete(self, subscribe): - """标记洗版完成;TV 交给主程序 backfill 合同写事实,电影写整体优先级。""" - if resolve_subscribe_media_type(subscribe) == MediaType.MOVIE: - payload = {"current_priority": 100} - mode_label = self._mode_label(subscribe) - detail(f"洗版优先级:{self._format_subscribe_label(subscribe)} 标记{mode_label}完成(priority=100)") - if self._subscribe_oper: - update_subscribe(self._subscribe_oper, subscribe.id, payload) - return - - target_episodes = self._target_episodes(subscribe) + def mark_full_best_version_complete(self, subscribe): + """将超时的电影或全集洗版标记为完成,仅更新当前模式的资源准入基线。""" + payload = {"current_priority": 100} mode_label = self._mode_label(subscribe) detail(f"洗版优先级:{self._format_subscribe_label(subscribe)} 标记{mode_label}完成(priority=100)") - if target_episodes: - SubscribeChain().backfill_existing_episodes( - subscribe, - target_episodes, - priority=100, - scene=self._format_backfill_scene("plugin_complete"), - ) - - @staticmethod - def _target_episodes(subscribe) -> list: - """读取订阅目标集范围;范围无效时返回空,避免只凭已有 priority 键误判完成。""" - try: - start_episode = int(subscribe.start_episode or 1) - total_episode = int(subscribe.total_episode or 0) - except (TypeError, ValueError): - return [] - start_episode = max(start_episode, 1) - if total_episode < start_episode: - return [] - return list(range(start_episode, total_episode + 1)) + if self._subscribe_oper: + update_subscribe(self._subscribe_oper, subscribe.id, payload) @staticmethod def _format_subscribe_label(subscribe) -> str: @@ -220,10 +204,20 @@ def _episode_priority_snapshot(subscribe) -> dict: """读取剧集优先级快照;无按集事实时复用主程序 current_priority 兜底口径。""" return SubscribeChain.get_episode_priority(subscribe) - def _update_tv_episode_priority(self, subscribe, episode_priority: dict, scene: str): + def _update_tv_episode_priority( + self, + subscribe, + episode_priority: dict, + scene: str, + current_priority: Optional[int] = None, + ): """写回 TV 剧集事实后刷新主程序进度字段。""" if not self._subscribe_oper: return - update_subscribe(self._subscribe_oper, subscribe.id, {"episode_priority": episode_priority}) + payload = {"episode_priority": episode_priority} + if current_priority is not None: + payload["current_priority"] = current_priority + subscribe.current_priority = current_priority + update_subscribe(self._subscribe_oper, subscribe.id, payload) subscribe.episode_priority = episode_priority SubscribeChain().refresh_subscribe_progress(subscribe, scene=scene) diff --git a/plugins.v2/subscribeassistantenhanced/engine/types.py b/plugins.v2/subscribeassistantenhanced/engine/types.py index 51c9435b..a6bc778e 100644 --- a/plugins.v2/subscribeassistantenhanced/engine/types.py +++ b/plugins.v2/subscribeassistantenhanced/engine/types.py @@ -132,5 +132,4 @@ def rollback(self, subscribe, baseline) -> None: ... def rollback_torrent(self, subscribe, torrent_id) -> None: ... def can_backfill(self, subscribe) -> bool: ... def backfill_existing(self, subscribe, existing_episodes, scene: str = "plugin_backfill") -> bool: ... - def is_complete(self, subscribe) -> bool: ... - def mark_complete(self, subscribe) -> None: ... + def mark_full_best_version_complete(self, subscribe) -> None: ... diff --git a/plugins.v2/subscribeassistantenhanced/events.py b/plugins.v2/subscribeassistantenhanced/events.py index 4d77a94b..73067107 100644 --- a/plugins.v2/subscribeassistantenhanced/events.py +++ b/plugins.v2/subscribeassistantenhanced/events.py @@ -603,36 +603,10 @@ def on_transfer_complete(self, event): self._convert_episode_best_version_to_full_if_ready(subscribe_id) def _convert_episode_best_version_to_full_if_ready(self, subscribe_id): - """整理完成后补偿检查当前分集洗版订阅,避免目标集已齐全还要等下一次洗版巡检。""" - if not subscribe_id or not self.get("best_version_episode_to_full"): - return - subscribe_oper = self.get("subscribe_oper") - converter = self.get("converter") - detect_missing = self.get("detect_missing_episodes_fn") - resolve_missing = self.get("resolve_missing_fn") - recognize = self.get("recognize_mediainfo_fn") - if not (subscribe_oper and converter and recognize): - return - subscribe = subscribe_oper.get(subscribe_id) - if not subscribe or not is_tv_episode_best_version_subscribe(subscribe): - return - mediainfo = recognize(subscribe) - if not mediainfo: - return - if resolve_missing: - satisfied, _ = resolve_missing( - subscribe=subscribe, - mediainfo=mediainfo, - best_version_accept_downloaded=True, - ) - else: - if not detect_missing or (subscribe.lack_episode or 0) > 0: - return - satisfied = not detect_missing(subscribe) - if not satisfied: - return - detail(f"TransferComplete:{format_subscribe(subscribe)} 分集洗版目标满足,立即转为全集洗版") - converter.convert_to_full(subscribe, mediainfo) + """整理完成后委托共享 readiness 入口补偿检查分集转全集。""" + convert = self.get("convert_episode_best_version_to_full_fn") + if subscribe_id and convert: + convert(subscribe_id, trigger="TransferComplete") @staticmethod def _best_version_mode_label(subscribe) -> str: diff --git a/tests/v2/subscribeassistantenhanced/test_converter.py b/tests/v2/subscribeassistantenhanced/test_converter.py index e940e6f3..32e140c2 100644 --- a/tests/v2/subscribeassistantenhanced/test_converter.py +++ b/tests/v2/subscribeassistantenhanced/test_converter.py @@ -59,7 +59,7 @@ def test_success(self): ) media = _mediainfo() - assert conv.convert_to_full(sub, media) is True + assert conv.convert_to_full(sub, media, current_priority=0) is True oper.add_history.assert_called_once_with(**sub.to_dict()) oper.delete.assert_called_once_with(sid=1) @@ -73,7 +73,7 @@ def test_success(self): assert add_payload["username"] == "订阅助手(增强版)" assert add_payload["manual_total_episode"] == 0 assert add_payload["note"] == [1, 2] - assert add_payload["current_priority"] == 50 + assert add_payload["current_priority"] == 0 assert add_payload["episode_priority"] == {"1": 100, "2": 50} assert "id" not in add_payload snapshot.assert_called_once_with(subscribe=sub, mediainfo=media, scope=None) diff --git a/tests/v2/subscribeassistantenhanced/test_event_handlers.py b/tests/v2/subscribeassistantenhanced/test_event_handlers.py index a73e5b21..994b8ccc 100644 --- a/tests/v2/subscribeassistantenhanced/test_event_handlers.py +++ b/tests/v2/subscribeassistantenhanced/test_event_handlers.py @@ -240,18 +240,13 @@ def test_transfer_complete_clears_pending_then_lifecycle_then_best_version_conve monitor.clear_download_pending.side_effect = lambda *_args: call_order.append("clear") lifecycle = MagicMock() lifecycle.handle_library_updated.side_effect = lambda _subscribe_id: call_order.append("lifecycle") - converter = MagicMock() - converter.convert_to_full.side_effect = lambda *_args: call_order.append("convert") - mediainfo = SimpleNamespace() + convert = MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("convert")) proxy = EventProxy( subscribe_oper=subscribe_oper, task_manager=task_manager, download_monitor=monitor, lifecycle=lifecycle, - converter=converter, - best_version_episode_to_full=True, - recognize_mediainfo_fn=MagicMock(return_value=mediainfo), - resolve_missing_fn=MagicMock(return_value=(True, {})), + convert_episode_best_version_to_full_fn=convert, ) proxy.on_transfer_complete(SimpleNamespace(event_data={ @@ -263,4 +258,4 @@ def test_transfer_complete_clears_pending_then_lifecycle_then_best_version_conve monitor.clear_download_pending.assert_called_once_with(7, "hash1") task_manager.clean_torrent_tasks.assert_called_once_with("hash1") lifecycle.handle_library_updated.assert_called_once_with(7) - converter.convert_to_full.assert_called_once_with(subscribe, mediainfo) + convert.assert_called_once_with(7, trigger="TransferComplete") diff --git a/tests/v2/subscribeassistantenhanced/test_events.py b/tests/v2/subscribeassistantenhanced/test_events.py index 9017614b..d8847bfb 100644 --- a/tests/v2/subscribeassistantenhanced/test_events.py +++ b/tests/v2/subscribeassistantenhanced/test_events.py @@ -315,7 +315,7 @@ def test_movie_best_version_mode_label_uses_wash_label(self): ) == "洗版" def test_transfer_complete_converts_ready_episode_best_version_to_full(self): - """分集洗版整理完成且目标集齐全时,当前订阅立即转全集洗版。""" + """整理完成后委托共享入口检查分集转全集。""" sub = _sub( id=1, best_version=1, @@ -328,25 +328,22 @@ def test_transfer_complete_converts_ready_episode_best_version_to_full(self): tm.read.return_value = {"abc": {"subscribe_id": 1}} oper = MagicMock() oper.get.return_value = sub - converter = MagicMock() + convert = MagicMock() proxy = EventProxy( task_manager=tm, subscribe_oper=oper, download_monitor=MagicMock(), - converter=converter, - best_version_episode_to_full=True, - detect_missing_episodes_fn=MagicMock(return_value=[]), - recognize_mediainfo_fn=MagicMock(return_value=media), + convert_episode_best_version_to_full_fn=convert, ) proxy.on_transfer_complete(SimpleNamespace(event_data={ "download_hash": "abc", "transferinfo": None, })) - converter.convert_to_full.assert_called_once_with(sub, media) + convert.assert_called_once_with(1, trigger="TransferComplete") - def test_transfer_complete_uses_target_satisfied_resolver_for_episode_best_version(self): - """分集洗版转全集按主程序目标满足口径判断,允许任意已下载版本满足目标集。""" + def test_transfer_complete_does_not_duplicate_readiness_checks(self): + """事件层不读取订阅进度,统一委托共享 readiness 入口。""" sub = _sub( id=1, best_version=1, @@ -361,31 +358,22 @@ def test_transfer_complete_uses_target_satisfied_resolver_for_episode_best_versi tm.read.return_value = {"abc": {"subscribe_id": 1}} oper = MagicMock() oper.get.return_value = sub - converter = MagicMock() - resolver = MagicMock(return_value=(True, {})) + convert = MagicMock() proxy = EventProxy( task_manager=tm, subscribe_oper=oper, download_monitor=MagicMock(), - converter=converter, - best_version_episode_to_full=True, - resolve_missing_fn=resolver, - recognize_mediainfo_fn=MagicMock(return_value=media), + convert_episode_best_version_to_full_fn=convert, ) proxy.on_transfer_complete(SimpleNamespace(event_data={ "download_hash": "abc", "transferinfo": None, })) - resolver.assert_called_once_with( - subscribe=sub, - mediainfo=media, - best_version_accept_downloaded=True, - ) - converter.convert_to_full.assert_called_once_with(sub, media) + convert.assert_called_once_with(1, trigger="TransferComplete") - def test_transfer_complete_keeps_episode_best_version_when_target_missing(self): - """分集洗版整理完成但目标集未齐全时,不提前转全集。""" + def test_transfer_complete_delegates_missing_target_decision_to_shared_entry(self): + """目标是否缺集不由事件层判断,整理完成后仍委托共享入口。""" sub = _sub( id=1, best_version=1, @@ -397,42 +385,34 @@ def test_transfer_complete_keeps_episode_best_version_when_target_missing(self): tm.read.return_value = {"abc": {"subscribe_id": 1}} oper = MagicMock() oper.get.return_value = sub - converter = MagicMock() + convert = MagicMock() proxy = EventProxy( task_manager=tm, subscribe_oper=oper, download_monitor=MagicMock(), - converter=converter, - best_version_episode_to_full=True, - resolve_missing_fn=MagicMock(return_value=(False, {})), - detect_missing_episodes_fn=MagicMock(return_value=[2]), - recognize_mediainfo_fn=MagicMock(return_value=SimpleNamespace(tmdb_id=100)), + convert_episode_best_version_to_full_fn=convert, ) proxy.on_transfer_complete(SimpleNamespace(event_data={ "download_hash": "abc", "transferinfo": None, })) - converter.convert_to_full.assert_not_called() + convert.assert_called_once_with(1, trigger="TransferComplete") - def test_transfer_complete_skips_library_check_when_episodes_still_missing(self): - """分集洗版目标集仍有未下载集时,不触发媒体库缺集探测。""" + def test_transfer_complete_does_not_call_legacy_missing_probe(self): + """媒体库事实由共享转换入口统一读取。""" sub = _sub(id=1, best_version=1, best_version_full=0, lack_episode=1, episode_priority={"1": 100}) tm = MagicMock() tm.read.return_value = {"abc": {"subscribe_id": 1}} oper = MagicMock() oper.get.return_value = sub detect_missing = MagicMock(return_value=[]) - converter = MagicMock() + convert = MagicMock() proxy = EventProxy( task_manager=tm, subscribe_oper=oper, download_monitor=MagicMock(), - converter=converter, - best_version_episode_to_full=True, - resolve_missing_fn=MagicMock(return_value=(False, {})), - detect_missing_episodes_fn=detect_missing, - recognize_mediainfo_fn=MagicMock(return_value=SimpleNamespace(tmdb_id=100)), + convert_episode_best_version_to_full_fn=convert, ) proxy.on_transfer_complete(SimpleNamespace(event_data={ @@ -440,7 +420,7 @@ def test_transfer_complete_skips_library_check_when_episodes_still_missing(self) })) detect_missing.assert_not_called() - converter.convert_to_full.assert_not_called() + convert.assert_called_once_with(1, trigger="TransferComplete") def test_subscribe_complete_triggers_snapshot(self): """SubscribeComplete 触发 H snapshot(subscribe 由 subscribe_info 重建)。""" diff --git a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py index c0afefb7..ec864c8b 100644 --- a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py +++ b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py @@ -233,23 +233,155 @@ def test_detect_episode_coverage_preserves_special_season_zero(monkeypatch): def test_episode_to_full_converts_when_current_episodes_covered(): - sub = _sub(id=5, name="X", best_version=1, best_version_full=0) + sub = _sub(id=5, name="X", best_version=1, best_version_full=0, current_priority=82) plugin = SubscribeAssistantEnhanced() plugin.init_plugin({"best_version_type": "all", "best_version_episode_to_full": True}) plugin._subscribe_oper = MagicMock() - plugin._subscribe_oper.list.return_value = [sub] + plugin._subscribe_oper.get.return_value = sub plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) - plugin._resolve_subscribe_missing = MagicMock(return_value=(True, {})) + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=(list(range(1, 13)), [])) + plugin._related_download_histories = MagicMock(return_value=[]) conv = plugin._modules["converter"] conv.convert_to_full = MagicMock(return_value=True) - plugin.run_best_version_check() + plugin._convert_episode_best_version_to_full_if_ready(5, trigger="unit") + + conv.convert_to_full.assert_called_once_with( + sub, + plugin._recognize_mediainfo.return_value, + current_priority=82, + ) + + +def test_episode_to_full_multiple_episode_histories_reset_full_baseline(): + sub = _sub(id=5, best_version=1, best_version_full=0, current_priority=82) + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({"best_version_type": "all", "best_version_episode_to_full": True}) + plugin._subscribe_oper = MagicMock() + plugin._subscribe_oper.get.return_value = sub + plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=(list(range(1, 13)), [])) + plugin._related_download_histories = MagicMock(return_value=[object(), object()]) + converter = plugin._modules["converter"] + converter.convert_to_full = MagicMock(return_value=True) + + plugin._convert_episode_best_version_to_full_if_ready(5, trigger="unit") + + converter.convert_to_full.assert_called_once_with( + sub, + plugin._recognize_mediainfo.return_value, + current_priority=0, + ) + + +def test_episode_to_full_single_episode_history_preserves_full_baseline(): + sub = _sub(id=5, best_version=1, best_version_full=0, current_priority=82) + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({"best_version_type": "all", "best_version_episode_to_full": True}) + plugin._subscribe_oper = MagicMock() + plugin._subscribe_oper.get.return_value = sub + plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=(list(range(1, 13)), [])) + plugin._related_download_histories = MagicMock(return_value=[object()]) + converter = plugin._modules["converter"] + converter.convert_to_full = MagicMock(return_value=True) + + plugin._convert_episode_best_version_to_full_if_ready(5, trigger="unit") + + converter.convert_to_full.assert_called_once_with( + sub, + plugin._recognize_mediainfo.return_value, + current_priority=82, + ) + + +def test_episode_to_full_history_query_failure_skips_conversion(): + sub = _sub(id=5, best_version=1, best_version_full=0, current_priority=82) + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({"best_version_type": "all", "best_version_episode_to_full": True}) + plugin._subscribe_oper = MagicMock() + plugin._subscribe_oper.get.return_value = sub + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=(list(range(1, 13)), [])) + plugin._downloadhistory_oper = MagicMock() + plugin._downloadhistory_oper.get_last_by.side_effect = RuntimeError("history unavailable") + converter = plugin._modules["converter"] + converter.convert_to_full = MagicMock() + + plugin._convert_episode_best_version_to_full_if_ready(5, trigger="unit") - conv.convert_to_full.assert_called_once_with(sub, plugin._recognize_mediainfo.return_value) + converter.convert_to_full.assert_not_called() -def test_episode_to_full_uses_target_satisfied_resolver_for_any_downloaded_version(): - """分集洗版巡检按主程序目标满足口径转全集,不要求当前 priority 全部为 100。""" +def test_episode_to_full_blocks_active_download_before_library_probe(): + sub = _sub(id=5, best_version=1, best_version_full=0) + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({"best_version_type": "all", "best_version_episode_to_full": True}) + plugin._subscribe_oper = MagicMock() + plugin._subscribe_oper.get.return_value = sub + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=True) + plugin._detect_episode_coverage = MagicMock() + converter = plugin._modules["converter"] + converter.convert_to_full = MagicMock() + + plugin._convert_episode_best_version_to_full_if_ready(5, trigger="unit") + + plugin._detect_episode_coverage.assert_not_called() + converter.convert_to_full.assert_not_called() + + +def test_episode_to_full_invalid_target_fails_closed(): + sub = _sub(id=5, best_version=1, best_version_full=0, start_episode=13, total_episode=12) + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({"best_version_type": "all", "best_version_episode_to_full": True}) + plugin._subscribe_oper = MagicMock() + plugin._subscribe_oper.get.return_value = sub + converter = plugin._modules["converter"] + converter.convert_to_full = MagicMock() + + plugin._convert_episode_best_version_to_full_if_ready(5, trigger="unit") + + converter.convert_to_full.assert_not_called() + + +def test_episode_to_full_allows_non_download_pending_states(): + """剧集待定、完成守卫待定或未知 P 来源都不替代明确的下载待定判断。""" + for pending_source in ("pending_judge", "guard_veto", "unknown"): + sub = _sub( + id=5, + state="P", + best_version=1, + best_version_full=0, + current_priority=82, + ) + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({"best_version_type": "all", "best_version_episode_to_full": True}) + plugin._subscribe_oper = MagicMock() + plugin._subscribe_oper.get.return_value = sub + plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=(list(range(1, 13)), [])) + plugin._related_download_histories = MagicMock(return_value=[]) + plugin._task_manager.read = MagicMock(return_value={ + "5": {"pending_sources": {pending_source: {}}} + }) + converter = plugin._modules["converter"] + converter.convert_to_full = MagicMock(return_value=True) + + plugin._convert_episode_best_version_to_full_if_ready(5, trigger="unit") + + converter.convert_to_full.assert_called_once_with( + sub, + plugin._recognize_mediainfo.return_value, + current_priority=82, + ) + + +def test_episode_to_full_uses_media_library_coverage_for_any_downloaded_version(): + """分集转全集依据媒体库实际覆盖,不要求当前 priority 全部为 100。""" sub = _sub( id=5, name="X", @@ -265,34 +397,43 @@ def test_episode_to_full_uses_target_satisfied_resolver_for_any_downloaded_versi plugin._subscribe_oper = MagicMock() plugin._subscribe_oper.list.return_value = [sub] plugin._recognize_mediainfo = MagicMock(return_value=SimpleNamespace(tmdb_id=100, type=None)) - plugin._resolve_subscribe_missing = MagicMock(return_value=(True, {})) + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=([1, 2, 3], [])) + plugin._related_download_histories = MagicMock(return_value=[]) conv = plugin._modules["converter"] conv.convert_to_full = MagicMock(return_value=True) plugin.run_best_version_check() - plugin._resolve_subscribe_missing.assert_called_once_with( + plugin._detect_episode_coverage.assert_called_once_with(sub) + conv.convert_to_full.assert_called_once_with( sub, plugin._recognize_mediainfo.return_value, - best_version_accept_downloaded=True, + current_priority=0, ) - conv.convert_to_full.assert_called_once_with(sub, plugin._recognize_mediainfo.return_value) -def test_episode_to_full_converts_when_main_resolver_reports_target_satisfied(): - """主程序目标满足查询确认分集目标已下载时,分集洗版升级为全集洗版。""" +def test_episode_to_full_converts_when_library_covers_target(): + """媒体库事实确认目标集完整覆盖时,分集洗版升级为全集洗版。""" sub = _sub(id=5, name="X", best_version=1, best_version_full=0, note=list(range(1, 13))) plugin = SubscribeAssistantEnhanced() plugin.init_plugin({"best_version_type": "all", "best_version_episode_to_full": True}) plugin._subscribe_oper = MagicMock() plugin._subscribe_oper.list.return_value = [sub] plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=(list(range(1, 13)), [])) + plugin._related_download_histories = MagicMock(return_value=[]) conv = plugin._modules["converter"] conv.convert_to_full = MagicMock(return_value=True) plugin.run_best_version_check() - conv.convert_to_full.assert_called_once_with(sub, plugin._recognize_mediainfo.return_value) + conv.convert_to_full.assert_called_once_with( + sub, + plugin._recognize_mediainfo.return_value, + current_priority=0, + ) def test_episode_to_full_skipped_when_missing_episodes(): @@ -302,7 +443,8 @@ def test_episode_to_full_skipped_when_missing_episodes(): plugin._subscribe_oper = MagicMock() plugin._subscribe_oper.list.return_value = [sub] plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) - plugin._resolve_subscribe_missing = MagicMock(return_value=(False, {100: {1: [3]}})) + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=(list(range(1, 12)), [12])) conv = plugin._modules["converter"] conv.convert_to_full = MagicMock(return_value=True) @@ -420,12 +562,12 @@ def test_best_version_check_marks_overdue_subscription_complete(): "subscribes": {"5": {"best_version_anchor": now - 10 * 86400}}, }.get(key, {})) priority = plugin._modules["priority_manager"] - priority.mark_complete = MagicMock() + priority.mark_full_best_version_complete = MagicMock() plugin._notify_subscribe = MagicMock() plugin.run_best_version_check() - priority.mark_complete.assert_called_once_with(sub) + priority.mark_full_best_version_complete.assert_called_once_with(sub) plugin._notify_subscribe.assert_called_once_with( "X S1 洗版超过时限(3天),已标记洗版优先级为完成", image="poster.jpg", @@ -451,11 +593,11 @@ def test_best_version_check_uses_movie_remaining_days_for_movie(): "subscribes": {"5": {"best_version_anchor": now - 10 * 86400}}, }.get(key, {})) priority = plugin._modules["priority_manager"] - priority.mark_complete = MagicMock() + priority.mark_full_best_version_complete = MagicMock() plugin.run_best_version_check() - priority.mark_complete.assert_called_once_with(sub) + priority.mark_full_best_version_complete.assert_called_once_with(sub) def test_best_version_check_does_not_expire_tv_episode_best_version(): @@ -472,15 +614,15 @@ def test_best_version_check_does_not_expire_tv_episode_best_version(): "subscribes": {"5": {"best_version_anchor": now - 10 * 86400}}, }.get(key, {})) priority = plugin._modules["priority_manager"] - priority.mark_complete = MagicMock() + priority.mark_full_best_version_complete = MagicMock() plugin.run_best_version_check() - priority.mark_complete.assert_not_called() + priority.mark_full_best_version_complete.assert_not_called() -def test_full_best_version_check_does_not_mark_complete_without_timeout(): - """全集洗版不再由插件巡检按优先级达标主动完成,普通完成交还主程序洗版链路。""" +def test_full_best_version_check_does_not_mark_full_best_version_complete_without_timeout(): + """全集洗版无超时时不由插件巡检标记完成。""" sub = _sub(id=5, name="X", best_version=1, best_version_full=1) plugin = SubscribeAssistantEnhanced() plugin.init_plugin({"best_version_type": "all", "best_version_tv_remaining_days": 0}) @@ -489,13 +631,12 @@ def test_full_best_version_check_does_not_mark_complete_without_timeout(): plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) plugin._resolve_subscribe_missing = MagicMock(return_value=(True, [])) priority = plugin._modules["priority_manager"] - priority.mark_complete = MagicMock() - priority.is_complete = MagicMock(return_value=True) + priority.mark_full_best_version_complete = MagicMock() plugin.run_best_version_check() plugin._resolve_subscribe_missing.assert_not_called() - priority.mark_complete.assert_not_called() + priority.mark_full_best_version_complete.assert_not_called() def test_best_version_mode_label_uses_wash_label_for_movie_best_version(): @@ -531,11 +672,11 @@ def test_best_version_check_does_not_expire_when_remaining_days_unlimited(): "hash": {"subscribe_id": 5, "time": now - 10 * 86400}, }) priority = plugin._modules["priority_manager"] - priority.mark_complete = MagicMock() + priority.mark_full_best_version_complete = MagicMock() plugin.run_best_version_check() - priority.mark_complete.assert_not_called() + priority.mark_full_best_version_complete.assert_not_called() def test_best_version_check_keeps_subscription_with_recent_activity(): @@ -552,11 +693,11 @@ def test_best_version_check_keeps_subscription_with_recent_activity(): "subscribes": {"5": {"best_version_anchor": now - 10 * 86400}}, }.get(key, {})) priority = plugin._modules["priority_manager"] - priority.mark_complete = MagicMock() + priority.mark_full_best_version_complete = MagicMock() plugin.run_best_version_check() - priority.mark_complete.assert_not_called() + priority.mark_full_best_version_complete.assert_not_called() def test_get_state_uses_global_enabled(): @@ -2265,7 +2406,6 @@ def test_full_best_version_existing_library_does_not_complete_via_backfill(monke plugin._detect_existing_episodes.assert_not_called() subscribe_oper.update.assert_not_called() assert sub.episode_priority == {} - assert priority_manager.is_complete(sub) is False def test_notify_gate_blocks_when_disabled(): @@ -2486,12 +2626,11 @@ def test_transfer_complete_event_converts_ready_episode_best_version(self): plugin._task_manager.update("torrents", lambda _data: {"abc": {"subscribe_id": 7}}) mediainfo = _mediainfo() plugin._recognize_mediainfo = MagicMock(return_value=mediainfo) - plugin._resolve_subscribe_missing = MagicMock(return_value=(True, {})) + plugin._modules["download_monitor"].has_active_downloads = MagicMock(return_value=False) + plugin._detect_episode_coverage = MagicMock(return_value=(list(range(1, 13)), [])) + plugin._related_download_histories = MagicMock(return_value=[]) converter = plugin._modules["converter"] converter.convert_to_full = MagicMock(return_value=True) - # init_plugin 时注入的是绑定方法;替换 mock 后需同步给事件代理,验证入口 wiring 与事件链路。 - plugin._event_proxy._modules["resolve_missing_fn"] = plugin._resolve_subscribe_missing - plugin._event_proxy._modules["recognize_mediainfo_fn"] = plugin._recognize_mediainfo lifecycle = plugin._modules["lifecycle"] lifecycle._subscribe_oper = oper lifecycle.handle_library_updated = MagicMock(wraps=lifecycle.handle_library_updated) @@ -2502,7 +2641,7 @@ def test_transfer_complete_event_converts_ready_episode_best_version(self): })) lifecycle.handle_library_updated.assert_called_once_with(7) - converter.convert_to_full.assert_called_once_with(sub, mediainfo) + converter.convert_to_full.assert_called_once_with(sub, mediainfo, current_priority=0) def test_transfer_complete_event_pauses_after_lack_is_refreshed(self): """整理完成事件应读取入库后的订阅状态,并在短窗口下立即进入播出暂停。""" @@ -2571,7 +2710,7 @@ class TestPeriodicJobs: """定时巡检:洗版巡检和完成前观察释放(recognize/oper 以 mock 注入)。""" def test_best_version_check_does_not_mark_full_best_version_complete(self, monkeypatch): - """全集洗版普通完成不再由插件巡检主动标记完成。""" + """全集洗版普通完成由主程序洗版链路处理。""" plugin = SubscribeAssistantEnhanced() plugin.init_plugin({"best_version_type": "all"}) best_sub = _sub(id=1, name="X", best_version=1, best_version_full=1) @@ -2584,7 +2723,7 @@ def test_best_version_check_does_not_mark_full_best_version_complete(self, monke plugin.run_best_version_check() - priority.mark_complete.assert_not_called() + priority.mark_full_best_version_complete.assert_not_called() def test_best_version_check_skips_complete_check_for_tv_episode_best_version(self): """剧集分集洗版不进入电影/全集洗版的时限终止路径。""" @@ -2596,11 +2735,11 @@ def test_best_version_check_skips_complete_check_for_tv_episode_best_version(sel plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) plugin._resolve_subscribe_missing = MagicMock(return_value=(False, [])) priority = plugin._modules["priority_manager"] - priority.mark_complete = MagicMock() + priority.mark_full_best_version_complete = MagicMock() plugin.run_best_version_check() - priority.mark_complete.assert_not_called() + priority.mark_full_best_version_complete.assert_not_called() def test_best_version_check_keeps_episode_subscription_when_target_not_complete(self): """分集洗版目标范围未全达标时,即使已有优先级都是 100 也不能判定洗版完成。""" @@ -2620,11 +2759,11 @@ def test_best_version_check_keeps_episode_subscription_when_target_not_complete( plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo()) plugin._resolve_subscribe_missing = MagicMock(return_value=(False, [3])) priority = plugin._modules["priority_manager"] - priority.mark_complete = MagicMock() + priority.mark_full_best_version_complete = MagicMock() plugin.run_best_version_check() - priority.mark_complete.assert_not_called() + priority.mark_full_best_version_complete.assert_not_called() def test_detect_missing_episodes_returns_partial_missing_set(self, monkeypatch): """媒体库部分覆盖时,helper 返回缺失集而不是已存在集。""" diff --git a/tests/v2/subscribeassistantenhanced/test_priority.py b/tests/v2/subscribeassistantenhanced/test_priority.py index 9546c717..ff5feb9b 100644 --- a/tests/v2/subscribeassistantenhanced/test_priority.py +++ b/tests/v2/subscribeassistantenhanced/test_priority.py @@ -94,6 +94,15 @@ def test_movie_rollback_restores_current_priority(self): call_payload = mgr._oper.update.call_args[0][1] assert call_payload["current_priority"] == 30 + def test_full_tv_rollback_restores_episode_and_full_baseline(self): + mgr = _mgr() + baseline = {"episode_priority": {"1": 30}, "current_priority": 40} + with patch("subscribeassistantenhanced.best_version.priority.SubscribeChain") as chain_cls: + mgr.rollback(_sub(best_version_full=1, current_priority=80), baseline=baseline) + payload = mgr._oper.update.call_args.args[1] + assert payload == {"episode_priority": {"1": 30}, "current_priority": 40} + chain_cls.return_value.refresh_subscribe_progress.assert_called_once() + def test_rollback_no_baseline_no_op(self): mgr = _mgr() mgr.rollback(_sub()) @@ -167,6 +176,39 @@ def test_movie_rollback_by_torrent_restores_current_priority(self): payload = mgr._oper.update.call_args[0][1] assert payload["current_priority"] == 30 + def test_full_tv_rollback_by_torrent_restores_full_baseline_and_episode_contribution(self): + store = {} + mgr = _mgr(store) + baseline_sub = _sub( + best_version_full=1, + current_priority=30, + ep_priority={"1": 20, "2": 40}, + ) + mgr.capture_torrent_baseline( + baseline_sub, + "A", + episodes=[1, 2], + contributed_priority=80, + ) + + with patch( + "subscribeassistantenhanced.best_version.priority.SubscribeChain.refresh_subscribe_progress", + create=True, + ) as refresh_progress: + mgr.rollback_torrent( + _sub( + best_version_full=1, + current_priority=80, + ep_priority={"1": 80, "2": 90}, + ), + "A", + ) + + payload = mgr._oper.update.call_args.args[1] + assert payload["current_priority"] == 30 + assert payload["episode_priority"] == {"1": 20, "2": 90} + refresh_progress.assert_called_once() + def test_empty_episodes_uses_target_range(self): """整季包 episodes 为空 → 回退到目标集范围记录基线。""" store = {} @@ -220,79 +262,28 @@ def test_backfill_empty_no_op(self): chain_cls.return_value.backfill_existing_episodes.assert_not_called() -class TestIsComplete: - - def test_all_100_is_complete(self): - mgr = _mgr() - assert mgr.is_complete(_sub(ep_priority={"1": 100, "2": 100})) is True - - def test_invalid_target_range_not_complete(self): - """目标集范围不可解析时不能按已有 priority 键误判洗版完成。""" - mgr = _mgr() - sub = _sub(ep_priority={"1": 100}, start_episode="bad", total_episode=2) - - assert mgr.is_complete(sub) is False +class TestMarkFullBestVersionComplete: - def test_missing_target_episodes_not_complete(self): - """目标范围未全部达标时不能只因已有 priority 都是 100 就判洗版完成。""" - mgr = _mgr() - assert mgr.is_complete(_sub(ep_priority={"1": 100, "2": 100}, total_episode=9999)) is False - - def test_mixed_not_complete(self): - mgr = _mgr() - assert mgr.is_complete(_sub(ep_priority={"1": 100, "2": 50})) is False - - def test_empty_not_complete(self): + def test_movie_updates_current_priority(self): + """电影洗版没有按集事实,完成标记仍写整体优先级。""" mgr = _mgr() - assert mgr.is_complete(_sub(ep_priority={})) is False - -class TestMarkComplete: + mgr.mark_full_best_version_complete(_sub(media_type=MediaType.MOVIE)) - def test_mark_sets_all_100(self): - mgr = _mgr() - sub = _sub(ep_priority={"1": 50, "2": 80}) - with patch("subscribeassistantenhanced.best_version.priority.SubscribeChain") as chain_cls: - mgr.mark_complete(sub) - chain_cls.return_value.backfill_existing_episodes.assert_called_once_with( - sub, - [1, 2], - priority=100, - scene="plugin_complete<订阅助手(增强版)>", - ) - mgr._oper.update.assert_not_called() + call_payload = mgr._oper.update.call_args[0][1] + assert call_payload["current_priority"] == 100 - def test_mark_complete_fills_target_range(self): - """分集洗版完成标记应覆盖完整目标范围。""" + def test_full_tv_updates_baseline_without_changing_episode_facts(self): + """全集完成标记保留按集优先级。""" mgr = _mgr() - sub = _sub(ep_priority={"1": 100}, total_episode=3, best_version_full=0) - - with patch("subscribeassistantenhanced.best_version.priority.SubscribeChain") as chain_cls: - mgr.mark_complete(sub) - - chain_cls.return_value.backfill_existing_episodes.assert_called_once_with( - sub, - [1, 2, 3], - priority=100, - scene="plugin_complete<订阅助手(增强版)>", + sub = _sub( + best_version_full=1, + current_priority=80, + ep_priority={"1": 70, "2": 80}, ) - def test_mark_complete_without_target_range_noops_for_tv(self): - """目标范围不可用时不按已有 priority 键猜测 TV 完成范围。""" - mgr = _mgr() - sub = _sub(ep_priority={"1": 50, "2": 80}, start_episode="bad", total_episode=2) - - with patch("subscribeassistantenhanced.best_version.priority.SubscribeChain") as chain_cls: - mgr.mark_complete(sub) - - chain_cls.return_value.backfill_existing_episodes.assert_not_called() - mgr._oper.update.assert_not_called() + mgr.mark_full_best_version_complete(sub) - def test_movie_mark_complete_updates_current_priority(self): - """电影洗版没有按集事实,完成标记仍写整体优先级。""" - mgr = _mgr() - - mgr.mark_complete(_sub(media_type=MediaType.MOVIE)) - - call_payload = mgr._oper.update.call_args[0][1] - assert call_payload["current_priority"] == 100 + payload = mgr._oper.update.call_args.args[1] + assert payload == {"current_priority": 100} + assert sub.episode_priority == {"1": 70, "2": 80} diff --git a/tests/v2/subscribeassistantenhanced/test_types.py b/tests/v2/subscribeassistantenhanced/test_types.py index 3c121e80..46441af1 100644 --- a/tests/v2/subscribeassistantenhanced/test_types.py +++ b/tests/v2/subscribeassistantenhanced/test_types.py @@ -228,10 +228,7 @@ def can_backfill(self, subscribe): def backfill_existing(self, subscribe, existing_episodes, scene="plugin_backfill"): return False - def is_complete(self, subscribe): - return False - - def mark_complete(self, subscribe): + def mark_full_best_version_complete(self, subscribe): pass assert isinstance(Dummy(), PriorityManagerProtocol)