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)