From 2f0867e18d8ef5649fe939da52bb7883342de132 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 19 Jul 2026 17:34:23 +0800 Subject: [PATCH] fix(subscribeassistantenhanced): preserve episode correction lifecycle --- package.v2.json | 3 +- .../subscribeassistantenhanced/README.md | 1 + .../subscribeassistantenhanced/__init__.py | 5 +- .../best_version/converter.py | 14 ++++- .../best_version/orchestrator.py | 3 + .../postcheck/verifier.py | 25 +++++--- .../test_converter.py | 31 +++++++++- .../test_integration.py | 1 + .../test_orchestrator.py | 2 + .../test_plugin_integration.py | 53 ++++++++++++++--- .../test_verifier.py | 59 ++++++++++++++++++- 11 files changed, 171 insertions(+), 26 deletions(-) diff --git a/package.v2.json b/package.v2.json index ddff2eb2..3ab08227 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,12 +3,13 @@ "name": "订阅助手(增强版)", "description": "多场景管理订阅,实现订阅全生命周期管理。", "labels": "订阅", - "version": "0.6.5", + "version": "0.6.6", "icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png", "author": "InfinityPacer", "level": 1, "system_version": ">2.14.1", "history": { + "v0.6.6": "修正洗版订阅继承手动总集数的问题,补充分集转全集完成快照,并仅在已有订阅覆盖最新 TMDB 集数时完成自动纠错。", "v0.6.5": "优化插件品牌图标与配置页视觉效果,提升界面一致性与操作辨识度。", "v0.6.4": "修正新增订阅时集数刷新事件可能中断插件处理的问题。", "v0.6.3": "优化站点扩集证据租约,在搜索消费前稳定保持目标集数,并在已消费或超时后抑制同值证据重复生效。", diff --git a/plugins.v2/subscribeassistantenhanced/README.md b/plugins.v2/subscribeassistantenhanced/README.md index f4b28afb..d28ac8a7 100644 --- a/plugins.v2/subscribeassistantenhanced/README.md +++ b/plugins.v2/subscribeassistantenhanced/README.md @@ -16,6 +16,7 @@ ## 版本更新日志 +- v0.6.6:修正洗版订阅继承手动总集数的问题,补充分集转全集完成快照,并仅在已有订阅覆盖最新 TMDB 集数时完成自动纠错。 - v0.6.5:优化插件品牌图标与配置页视觉效果,提升界面一致性与操作辨识度。 - v0.6.4:修正新增订阅时集数刷新事件可能中断插件处理的问题。 - v0.6.3:优化站点扩集证据租约,在搜索消费前稳定保持目标集数,并在已消费或超时后抑制同值证据重复生效。 diff --git a/plugins.v2/subscribeassistantenhanced/__init__.py b/plugins.v2/subscribeassistantenhanced/__init__.py index 8868a904..3dfde3c6 100644 --- a/plugins.v2/subscribeassistantenhanced/__init__.py +++ b/plugins.v2/subscribeassistantenhanced/__init__.py @@ -88,7 +88,7 @@ class SubscribeAssistantEnhanced(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png" # 插件版本 - plugin_version = "0.6.5" + plugin_version = "0.6.6" _site_cache_candidate_helper_warned = False # 插件作者 plugin_author = "InfinityPacer" @@ -257,6 +257,7 @@ def _init_modules(self): send_event_fn=eventmanager.send_event, notify_fn=self._notify_subscribe, restore_fn=self._restore_subscribe_from_snapshot, + snapshot_fn=verifier.snapshot, format_desc_fn=lambda subscribe, mediainfo: self._format_subscribe_desc(subscribe, mediainfo), plugin_name=self.plugin_name, ) @@ -1457,6 +1458,7 @@ def _rebuild_subscribe_from_snapshot(self, snap: dict, config: dict) -> bool: payload = dict(config) payload["season"] = snap.get("season") payload["episode_group"] = snap.get("episode_group_id") + payload["manual_total_episode"] = 0 try: subscribe_id, _ = self._subscribe_oper.add(mediainfo=mediainfo, **payload) if subscribe_id: @@ -1617,6 +1619,7 @@ def _restore_subscribe_from_snapshot(self, subscribe_dict: dict, mediainfo=None) for key, value in (subscribe_dict or {}).items() if hasattr(Subscribe, key) } + restore_payload["manual_total_episode"] = 0 restored = Subscribe(**restore_payload) restored.create(self._subscribe_oper._db) sid = restore_payload.get("id") diff --git a/plugins.v2/subscribeassistantenhanced/best_version/converter.py b/plugins.v2/subscribeassistantenhanced/best_version/converter.py index 46a3b0ed..2e535e5d 100644 --- a/plugins.v2/subscribeassistantenhanced/best_version/converter.py +++ b/plugins.v2/subscribeassistantenhanced/best_version/converter.py @@ -19,14 +19,15 @@ class BestVersionConverter: """ def __init__(self, subscribe_oper=None, clear_tasks_fn=None, send_event_fn=None, - notify_fn=None, restore_fn=None, format_desc_fn=None, + notify_fn=None, restore_fn=None, snapshot_fn=None, format_desc_fn=None, plugin_name: str = "订阅助手(增强版)"): - """注入订阅写库、任务清理、事件、通知和失败恢复依赖。""" + """注入订阅写库、任务清理、事件、通知、完成快照和失败恢复依赖。""" self._subscribe_oper = subscribe_oper self._clear_tasks = clear_tasks_fn self._send_event = send_event_fn self._notify = notify_fn self._restore = restore_fn + self._snapshot = snapshot_fn self._format_desc = format_desc_fn self._plugin_name = plugin_name @@ -40,6 +41,14 @@ def convert_to_full(self, subscribe, mediainfo=None) -> bool: subscribe_desc = self._format_subscribe_desc(subscribe, mediainfo) full_payload = self._build_full_payload(subscribe_dict) + try: + if self._snapshot: + self._snapshot(subscribe=subscribe, mediainfo=mediainfo, scope=None) + except Exception as err: + logger.error(f"{subscribe_desc} 原因=登记完成快照失败,处理=停止转全集处理,错误={err}") + self._notify_failure(subscribe_desc, str(err), mediainfo=mediainfo) + return False + try: self._subscribe_oper.add_history(**subscribe_dict) self._subscribe_oper.delete(sid=sid) @@ -80,6 +89,7 @@ def _build_full_payload(self, subscribe_dict: dict) -> dict: payload["best_version_full"] = 1 payload["username"] = self._plugin_name payload["state"] = "N" + payload["manual_total_episode"] = 0 return payload def _format_subscribe_desc(self, subscribe, mediainfo) -> str: diff --git a/plugins.v2/subscribeassistantenhanced/best_version/orchestrator.py b/plugins.v2/subscribeassistantenhanced/best_version/orchestrator.py index bbd2defe..c0146621 100644 --- a/plugins.v2/subscribeassistantenhanced/best_version/orchestrator.py +++ b/plugins.v2/subscribeassistantenhanced/best_version/orchestrator.py @@ -46,6 +46,7 @@ def build_payload(self, subscribe) -> dict: } payload = {key: value for key, value in payload.items() if value is not None} payload["best_version"] = 1 + payload["manual_total_episode"] = 0 return payload def start_best_version(self, subscribe, mediainfo): @@ -97,6 +98,8 @@ def start_best_version(self, subscribe, mediainfo): else: payload["current_priority"] = self._movie_current_priority(subscribe) payload = {key: value for key, value in payload.items() if value is not None} + # 插件创建的订阅始终重新跟随 TMDB 总集数,不继承已完成订阅的手动锁定状态。 + payload["manual_total_episode"] = 0 sid, err_msg = self._subscribe_oper.add(mediainfo=mediainfo, **payload) if sid: mode_label = "洗版" diff --git a/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py b/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py index 504e5e13..ef7dc580 100644 --- a/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py +++ b/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py @@ -108,19 +108,26 @@ def _rebuild(self, snap: dict, current_total: int) -> bool: removed_full_best_version = False existing = self._subscribe_oper.list() - for sub in (existing or []): + matched = [ + sub for sub in (existing or []) if ( sub.tmdbid == tmdbid and sub.season == season and sub.episode_group == episode_group_id - ): - if is_full_best_version_subscribe(sub): - logger.info(f"完成后验证:删除旧洗版订阅 {format_subscribe_label(sub)} 以便重建增集订阅") - _merge_missing_config_from_subscribe(config, sub) - self._subscribe_oper.delete(sub.id) - removed_full_best_version = True - else: - return True + ) + ] + if any((sub.total_episode or 0) >= current_total for sub in matched): + return True + + # 普通或分集洗版订阅由现有订阅流程继续处理;目标范围尚未覆盖时不能消费完成快照。 + if any(not is_full_best_version_subscribe(sub) for sub in matched): + return False + + for sub in matched: + logger.info(f"完成后验证:删除旧洗版订阅 {format_subscribe_label(sub)} 以便重建增集订阅") + _merge_missing_config_from_subscribe(config, sub) + self._subscribe_oper.delete(sub.id) + removed_full_best_version = True old_total = snap.get("total_at_completion", 0) config["start_episode"] = old_total + 1 diff --git a/tests/v2/subscribeassistantenhanced/test_converter.py b/tests/v2/subscribeassistantenhanced/test_converter.py index 6f1d0b5a..3f5d8fc0 100644 --- a/tests/v2/subscribeassistantenhanced/test_converter.py +++ b/tests/v2/subscribeassistantenhanced/test_converter.py @@ -32,11 +32,15 @@ def test_success(self): clear_tasks = MagicMock() send_event = MagicMock() notify = MagicMock() + call_order = [] + snapshot = MagicMock(side_effect=lambda **_kwargs: call_order.append("snapshot")) + oper.delete.side_effect = lambda **_kwargs: call_order.append("delete") conv = BestVersionConverter( subscribe_oper=oper, clear_tasks_fn=clear_tasks, send_event_fn=send_event, notify_fn=notify, + snapshot_fn=snapshot, format_desc_fn=lambda subscribe, mediainfo: f"{subscribe.name} S{subscribe.season}", ) sub = _SubscribeSnapshot( @@ -49,10 +53,12 @@ def test_success(self): username="user", state="R", current_priority=50, + manual_total_episode=92, note=[1], ) + media = _mediainfo() - assert conv.convert_to_full(sub, _mediainfo()) is True + assert conv.convert_to_full(sub, media) is True oper.add_history.assert_called_once_with(**sub.to_dict()) oper.delete.assert_called_once_with(sid=1) @@ -64,8 +70,11 @@ def test_success(self): assert add_payload["episode_group"] == "eg-1" assert add_payload["state"] == "N" assert add_payload["username"] == "订阅助手(增强版)" + assert add_payload["manual_total_episode"] == 0 assert "id" not in add_payload assert "current_priority" not in add_payload + snapshot.assert_called_once_with(subscribe=sub, mediainfo=media, scope=None) + assert call_order == ["snapshot", "delete"] send_event.assert_called_once() assert send_event.call_args.args[1]["subscribe_id"] == 9 notify.assert_called_once() @@ -91,6 +100,26 @@ def test_failure_keeps_original(self): notify.assert_called_once() assert notify.call_args.args[0] == "测试剧 S1 转为全集洗版订阅失败" + def test_snapshot_failure_stops_before_subscription_replacement(self): + """完成快照写入失败时不得删除分集订阅,避免转换后失去增集基线。""" + oper = MagicMock() + notify = MagicMock() + snapshot = MagicMock(side_effect=RuntimeError("snapshot failed")) + conv = BestVersionConverter( + subscribe_oper=oper, + snapshot_fn=snapshot, + notify_fn=notify, + format_desc_fn=lambda subscribe, mediainfo: "测试剧 S1", + ) + sub = _SubscribeSnapshot(id=1, name="测试剧", season=1) + + assert conv.convert_to_full(sub, _mediainfo()) is False + + oper.add_history.assert_not_called() + oper.delete.assert_not_called() + oper.add.assert_not_called() + assert notify.call_args.args[0] == "测试剧 S1 转为全集洗版订阅失败" + def test_no_oper_returns_false(self): conv = BestVersionConverter(subscribe_oper=None) sub = SimpleNamespace(id=1) diff --git a/tests/v2/subscribeassistantenhanced/test_integration.py b/tests/v2/subscribeassistantenhanced/test_integration.py index 76b5dee8..5e90fcf9 100644 --- a/tests/v2/subscribeassistantenhanced/test_integration.py +++ b/tests/v2/subscribeassistantenhanced/test_integration.py @@ -346,6 +346,7 @@ def test_rebuild_deletes_bv(self): bv = SimpleNamespace( id=99, tmdbid=100, season=1, episode_group=None, type="电视剧", best_version=1, best_version_full=1, + total_episode=12, name="测试剧", save_path=None, sites=None, filter=None, filter_groups=[], ) oper.list.return_value = [bv] diff --git a/tests/v2/subscribeassistantenhanced/test_orchestrator.py b/tests/v2/subscribeassistantenhanced/test_orchestrator.py index 2e27f8c9..b774c4d1 100644 --- a/tests/v2/subscribeassistantenhanced/test_orchestrator.py +++ b/tests/v2/subscribeassistantenhanced/test_orchestrator.py @@ -38,6 +38,7 @@ def test_preserves_episode_group(self): payload = orch.build_payload(_sub(episode_group="eg-abc")) assert payload["episode_group"] == "eg-abc" assert payload["best_version"] == 1 + assert payload["manual_total_episode"] == 0 def test_includes_subscribe_fields(self): orch = BestVersionOrchestrator(priority_manager=MagicMock(spec=PriorityManager)) @@ -110,6 +111,7 @@ def test_creates_best_version_when_type_enabled(self): _args, kwargs = oper.add.call_args assert kwargs["best_version"] == 1 and kwargs["season"] == 1 assert kwargs["best_version_full"] == 1 + assert kwargs["manual_total_episode"] == 0 assert kwargs["filter"] == "r" assert kwargs["filter_groups"] == ["g1"] diff --git a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py index de6fa477..6f33ada1 100644 --- a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py +++ b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py @@ -3,7 +3,7 @@ import json from datetime import date, timedelta from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pathlib import Path from packaging.specifiers import SpecifierSet @@ -2644,13 +2644,14 @@ def add(self, *, mediainfo, **kwargs): result = plugin._rebuild_subscribe_from_snapshot( {"tmdbid": 100, "season": 1, "episode_group_id": "eg-1"}, - {"name": "测试", "start_episode": 13}, + {"name": "测试", "start_episode": 13, "manual_total_episode": 92}, ) assert result is True assert oper.call[0] is mediainfo assert oper.call[1]["season"] == 1 assert oper.call[1]["episode_group"] == "eg-1" + assert oper.call[1]["manual_total_episode"] == 0 def test_snapshot_rebuild_sends_subscribe_added_event(self): """H 重建成功后应补发 SubscribeAdded,触发主程序订阅创建链路。""" @@ -2670,8 +2671,8 @@ def test_snapshot_rebuild_sends_subscribe_added_event(self): plugin._send_subscribe_added.assert_called_once() assert plugin._send_subscribe_added.call_args.args[0] == 88 - def test_completion_verify_keeps_existing_episode_best_version_subscription(self): - """同身份分集洗版订阅已存在时,完成快照应失效而不是删除重建。""" + def test_completion_verify_keeps_snapshot_for_lagging_episode_best_version_subscription(self): + """同身份分集洗版订阅未覆盖最新总集数时保留快照,不删除重建。""" snap = { "tmdbid": 100, "season": 1, @@ -2697,10 +2698,10 @@ def test_completion_verify_keeps_existing_episode_best_version_subscription(self plugin._modules["verifier"]._subscribe_oper.delete.assert_not_called() plugin._modules["verifier"]._rebuild_subscribe.assert_not_called() - assert data_store["snapshots"]["list"] == [] + assert data_store["snapshots"]["list"] == [snap] - def test_completion_verify_keeps_existing_normal_subscription(self): - """同身份普通订阅已存在时,完成快照应失效而不是重复重建。""" + def test_completion_verify_keeps_snapshot_for_lagging_normal_subscription(self): + """同身份普通订阅未覆盖最新总集数时保留快照,不重复重建。""" snap = { "tmdbid": 100, "season": 1, @@ -2726,7 +2727,7 @@ def test_completion_verify_keeps_existing_normal_subscription(self): plugin._modules["verifier"]._subscribe_oper.delete.assert_not_called() plugin._modules["verifier"]._rebuild_subscribe.assert_not_called() - assert data_store["snapshots"]["list"] == [] + assert data_store["snapshots"]["list"] == [snap] def test_completion_verify_replaces_existing_full_best_version_subscription(self): """同身份真正洗版订阅已存在时,完成快照可删除旧订阅并按新增集重建。""" @@ -2780,7 +2781,15 @@ def test_completion_verify_replaces_existing_movie_best_version_subscription(sel plugin._modules["verifier"]._tmdb_fn = MagicMock(return_value=[object()] * 2) plugin._modules["verifier"]._subscribe_oper = MagicMock() plugin._modules["verifier"]._subscribe_oper.list.return_value = [ - _sub(id=8, tmdbid=100, season=None, type=MediaType.MOVIE, best_version=1, best_version_full=0) + _sub( + id=8, + tmdbid=100, + season=None, + type=MediaType.MOVIE, + total_episode=1, + best_version=1, + best_version_full=0, + ) ] plugin._modules["verifier"]._rebuild_subscribe = MagicMock(return_value=True) plugin._modules["verifier"]._notify = MagicMock() @@ -2792,6 +2801,32 @@ def test_completion_verify_replaces_existing_movie_best_version_subscription(sel assert plugin._modules["verifier"]._notify.call_args.args[0].endswith("已移除旧洗版订阅并重建订阅") assert data_store["snapshots"]["list"] == [] + def test_restore_subscribe_from_snapshot_unlocks_manual_total_episode(self): + """转换失败恢复分集洗版订阅时,不继承旧订阅的手动总集数锁定。""" + from app.db.models import Subscribe + + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({}) + plugin._subscribe_oper = MagicMock() + plugin._subscribe_oper.get.return_value = object() + plugin._send_subscribe_added = MagicMock() + + with patch.object(Subscribe, "create", autospec=True) as create: + result = plugin._restore_subscribe_from_snapshot( + { + "id": 7, + "name": "测试剧", + "tmdbid": 100, + "season": 1, + "manual_total_episode": 92, + }, + _mediainfo(), + ) + + assert result is True + restored = create.call_args.args[0] + assert restored.manual_total_episode == 0 + def test_pending_release_active_guard_veto_uses_pending_judge_not_orphan_cleanup(self, monkeypatch): """活跃 guard_veto P 必须交给 PendingJudge,不绕过证据流水线释放。""" plugin = SubscribeAssistantEnhanced() diff --git a/tests/v2/subscribeassistantenhanced/test_verifier.py b/tests/v2/subscribeassistantenhanced/test_verifier.py index 7ae5e709..cb012216 100644 --- a/tests/v2/subscribeassistantenhanced/test_verifier.py +++ b/tests/v2/subscribeassistantenhanced/test_verifier.py @@ -191,6 +191,7 @@ def test_rebuild_deletes_best_version(self): existing_bv = SimpleNamespace( id=99, tmdbid=100, season=1, episode_group=None, type="电视剧", best_version=1, best_version_full=1, + total_episode=12, name="测试剧", save_path=None, sites=None, filter=None, filter_groups=[], ) rebuild = MagicMock(return_value=True) @@ -240,17 +241,69 @@ def test_rebuild_sends_notification(self): assert "测试剧" in msg assert "12" in msg and "15" in msg - def test_skip_if_active_normal_subscribe(self): - """已有同 tmdbid+season 普通订阅 → 跳过重建。""" + def test_covered_active_normal_subscribe_consumes_snapshot(self): + """已有普通订阅覆盖最新 TMDB 总集数时,完成快照已完成交接。""" store = {"snapshots": {"list": [{ "tmdbid": 100, "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, }]}} existing = SimpleNamespace( - id=50, tmdbid=100, season=1, episode_group=None, best_version=0 + id=50, tmdbid=100, season=1, episode_group=None, + total_episode=15, best_version=0, best_version_full=0, ) v = _verifier(store, tmdb_fn=lambda *a, **kw: [object()] * 15) v._oper.list.return_value = [existing] v.verify_all() v._oper.add.assert_not_called() + assert store["snapshots"]["list"] == [] + + def test_lagging_active_normal_subscribe_keeps_snapshot(self): + """已有普通订阅未覆盖最新 TMDB 总集数时,不得误判纠错成功。""" + store = {"snapshots": {"list": [{ + "tmdbid": 100, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "测试"}, + }]}} + existing = SimpleNamespace( + id=50, tmdbid=100, season=1, episode_group=None, + total_episode=12, best_version=0, best_version_full=0, + ) + rebuild = MagicMock(return_value=True) + v = _verifier( + store, + tmdb_fn=lambda *a, **kw: [object()] * 15, + rebuild_fn=rebuild, + ) + v._oper.list.return_value = [existing] + + v.verify_all() + + rebuild.assert_not_called() + v._oper.delete.assert_not_called() + assert len(store["snapshots"]["list"]) == 1 + + def test_covered_full_best_version_does_not_rebuild_again(self): + """已有全集洗版订阅覆盖最新总集数时直接消费快照,不重复删除重建。""" + store = {"snapshots": {"list": [{ + "tmdbid": 100, "season": 1, "episode_group_id": None, + "total_at_completion": 12, "completed_at": time.time(), + "subscribe_config": {"name": "测试"}, + }]}} + existing = SimpleNamespace( + id=50, tmdbid=100, season=1, episode_group=None, + total_episode=15, best_version=1, best_version_full=1, + ) + rebuild = MagicMock(return_value=True) + v = _verifier( + store, + tmdb_fn=lambda *a, **kw: [object()] * 15, + rebuild_fn=rebuild, + ) + v._oper.list.return_value = [existing] + + v.verify_all() + + rebuild.assert_not_called() + v._oper.delete.assert_not_called() + assert store["snapshots"]["list"] == []