Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "优化站点扩集证据租约,在搜索消费前稳定保持目标集数,并在已消费或超时后抑制同值证据重复生效。",
Expand Down
1 change: 1 addition & 0 deletions plugins.v2/subscribeassistantenhanced/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

## 版本更新日志

- v0.6.6:修正洗版订阅继承手动总集数的问题,补充分集转全集完成快照,并仅在已有订阅覆盖最新 TMDB 集数时完成自动纠错。
- v0.6.5:优化插件品牌图标与配置页视觉效果,提升界面一致性与操作辨识度。
- v0.6.4:修正新增订阅时集数刷新事件可能中断插件处理的问题。
- v0.6.3:优化站点扩集证据租约,在搜索消费前稳定保持目标集数,并在已消费或超时后抑制同值证据重复生效。
Expand Down
5 changes: 4 additions & 1 deletion plugins.v2/subscribeassistantenhanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
14 changes: 12 additions & 2 deletions plugins.v2/subscribeassistantenhanced/best_version/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = "洗版"
Expand Down
25 changes: 16 additions & 9 deletions plugins.v2/subscribeassistantenhanced/postcheck/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion tests/v2/subscribeassistantenhanced/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions tests/v2/subscribeassistantenhanced/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions tests/v2/subscribeassistantenhanced/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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"]

Expand Down
53 changes: 44 additions & 9 deletions tests/v2/subscribeassistantenhanced/test_plugin_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,触发主程序订阅创建链路。"""
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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):
"""同身份真正洗版订阅已存在时,完成快照可删除旧订阅并按新增集重建。"""
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
Loading