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
39 changes: 22 additions & 17 deletions plugins.v2/subscribeassistantenhanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1504,29 +1504,34 @@ def _detect_episode_coverage(self, subscribe) -> Tuple[list, list]:
return [], sorted(target)

def _rebuild_subscribe_from_snapshot(self, snap: dict, config: dict) -> bool:
"""把 H 完成快照转换为主程序要求的 MediaInfo + kwargs 订阅新增调用。"""
if not self._subscribe_oper:
return False
probe = SimpleNamespace(
name=config.get("name", ""),
year=config.get("year"),
season=snap.get("season"),
type=config.get("type", "电视剧"),
tmdbid=snap.get("tmdbid"),
episode_group=snap.get("episode_group_id"),
)
mediainfo = self._recognize_mediainfo(probe)
if not mediainfo:
"""使用当前默认订阅规则和完成快照重建增集订阅。"""
if not self._subscribe_chain:
return False
payload = dict(config)
payload["season"] = snap.get("season")
payload["episode_group"] = snap.get("episode_group_id")
title = payload.pop("name", "")
year = payload.pop("year", None)
for field in (
"id", "type", "tmdbid", "season", "episode_group",
"best_version", "best_version_full",
):
payload.pop(field, None)
payload["manual_total_episode"] = 0
payload["state"] = "N"
try:
subscribe_id, _ = self._subscribe_oper.add(mediainfo=mediainfo, **payload)
subscribe_id, _ = self._subscribe_chain.add(
title=title,
year=year,
mtype=MediaType.TV,
tmdbid=snap.get("tmdbid"),
season=snap.get("season"),
episode_group=snap.get("episode_group_id"),
username=self.plugin_name,
message=False,
exist_ok=True,
**payload,
)
if subscribe_id:
logger.info(f"完成后验证:{_format_snapshot_label(snap)} 检测到增集,已重建订阅(新 id={subscribe_id})")
self._send_subscribe_added(subscribe_id, mediainfo)
return bool(subscribe_id)
except Exception as err:
logger.warning(
Expand Down
44 changes: 12 additions & 32 deletions plugins.v2/subscribeassistantenhanced/best_version/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,24 @@ def convert_to_full(self, subscribe, mediainfo=None, current_priority=None) -> b

try:
self._subscribe_oper.add_history(**subscribe_dict)
except Exception as err:
logger.error(f"{subscribe_desc} 原因=写入订阅历史失败,处理=停止转全集处理,错误={err}")
self._notify_failure(subscribe, subscribe_desc, str(err), mediainfo=mediainfo)
return False

try:
self._subscribe_oper.delete(sid=sid)
if self._clear_tasks:
self._clear_tasks(sid)
except Exception as err:
self._remove_history_snapshot(subscribe_dict)
logger.error(f"{subscribe_desc} 原因=删除分集洗版订阅失败,处理=停止转全集处理,错误={err}")
self._notify_failure(subscribe, subscribe_desc, str(err), mediainfo=mediainfo)
return False

if self._clear_tasks:
try:
self._clear_tasks(sid)
except Exception as err:
logger.warning(f"{subscribe_desc} 清理旧订阅任务失败,继续创建全集洗版订阅,错误={err}")

try:
new_sid, err_msg = self._subscribe_oper.add(mediainfo=mediainfo, **full_payload)
except Exception as err:
Expand Down Expand Up @@ -141,32 +150,3 @@ def _resolve_notification_image(self, subscribe, mediainfo=None):
if self._notification_image:
return self._notification_image(subscribe, mediainfo)
return mediainfo.get_message_image() if mediainfo else None

def _remove_history_snapshot(self, subscribe_dict: dict):
"""删除刚写入的完成历史,避免删除失败后同时存在活动订阅和完成记录。"""
remover = getattr(self._subscribe_oper, "remove_history", None)
if callable(remover):
remover(subscribe_dict)
return
db = getattr(self._subscribe_oper, "_db", None)
if not db:
return
try:
from app.db.models import SubscribeHistory
query = db.query(SubscribeHistory)
tmdbid = subscribe_dict.get("tmdbid")
doubanid = subscribe_dict.get("doubanid")
season = subscribe_dict.get("season")
if tmdbid:
query = query.filter(SubscribeHistory.tmdbid == tmdbid)
elif doubanid:
query = query.filter(SubscribeHistory.doubanid == doubanid)
else:
query = query.filter(SubscribeHistory.name == subscribe_dict.get("name"))
query = query.filter(SubscribeHistory.season == season)
history = query.order_by(SubscribeHistory.id.desc()).first()
if history:
db.delete(history)
db.commit()
except Exception as err:
logger.warning(f"清理转换完成历史失败: {err}")
40 changes: 13 additions & 27 deletions plugins.v2/subscribeassistantenhanced/postcheck/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,13 @@ def _rebuild(self, snap: dict, current_total: int) -> bool:

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
config["total_episode"] = current_total
config["lack_episode"] = current_total - old_total
if not self._rebuild_subscribe or not self._rebuild_subscribe(snap, config):
return False

Expand Down Expand Up @@ -181,39 +182,24 @@ def _extract_config(subscribe) -> dict:
"""提取订阅配置用于重建。"""
values = {
"name": subscribe.name,
"year": subscribe.year,
"tmdbid": subscribe.tmdbid,
"season": subscribe.season,
"episode_group": subscribe.episode_group,
"type": subscribe.type,
"keyword": subscribe.keyword,
"save_path": subscribe.save_path,
"sites": subscribe.sites,
"downloader": subscribe.downloader,
"filter": subscribe.filter,
"filter_groups": subscribe.filter_groups,
"best_version": subscribe.best_version,
"best_version_full": subscribe.best_version_full,
"include": subscribe.include,
"exclude": subscribe.exclude,
"quality": subscribe.quality,
"resolution": subscribe.resolution,
"effect": subscribe.effect,
"search_imdbid": subscribe.search_imdbid,
"custom_words": subscribe.custom_words,
"media_category": subscribe.media_category,
}
return {field: value for field, value in values.items() if value is not None}


def _merge_missing_config_from_subscribe(config: dict, subscribe) -> None:
"""删除旧订阅前补齐快照缺失配置,避免旧快照重建时丢失订阅模式。"""
mode_values = {
"type": subscribe.type,
"best_version": subscribe.best_version,
"best_version_full": subscribe.best_version_full,
}
for field, value in mode_values.items():
if value is not None:
config[field] = value

optional_values = {
"save_path": subscribe.save_path,
"sites": subscribe.sites,
"filter": subscribe.filter,
"filter_groups": subscribe.filter_groups,
}
for field, value in optional_values.items():
if field in config:
continue
if value is not None:
config[field] = value
70 changes: 32 additions & 38 deletions tests/v2/subscribeassistantenhanced/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def test_success(self):
assert "reason" not in notify.call_args.kwargs

def test_failure_keeps_original(self):
"""删除分集订阅失败时不得创建全集洗版,并通知失败。"""
"""删除分集订阅失败时保留活动订阅和已写历史,不做不精确回滚。"""
oper = MagicMock()
oper.delete.side_effect = RuntimeError("DB error")
oper.remove_history = MagicMock()
Expand All @@ -100,10 +100,40 @@ def test_failure_keeps_original(self):
sub = _SubscribeSnapshot(id=1, name="测试剧", season=1)
assert conv.convert_to_full(sub, _mediainfo()) is False
oper.add.assert_not_called()
oper.remove_history.assert_called_once()
oper.remove_history.assert_not_called()
notify.assert_called_once()
assert notify.call_args.args[0] == "测试剧 S1 转为全集洗版订阅失败"

def test_history_failure_stops_before_deleting_active_subscribe(self):
"""历史写入失败时不得删除仍在运行的分集洗版订阅。"""
oper = MagicMock()
oper.add_history.side_effect = RuntimeError("history failed")
conv = BestVersionConverter(subscribe_oper=oper, notify_fn=MagicMock())
sub = _SubscribeSnapshot(id=1, name="测试剧", season=1)

assert conv.convert_to_full(sub, _mediainfo()) is False

oper.delete.assert_not_called()
oper.add.assert_not_called()

def test_task_cleanup_failure_does_not_interrupt_rebuild(self):
"""插件任务清理属于尽力操作,失败时仍继续创建全集洗版订阅。"""
oper = MagicMock()
oper.add.return_value = (9, "")
clear_tasks = MagicMock(side_effect=RuntimeError("cleanup failed"))
conv = BestVersionConverter(
subscribe_oper=oper,
clear_tasks_fn=clear_tasks,
send_event_fn=MagicMock(),
notify_fn=MagicMock(),
)
sub = _SubscribeSnapshot(id=1, name="测试剧", season=1)

assert conv.convert_to_full(sub, _mediainfo()) is True

oper.delete.assert_called_once_with(sid=1)
oper.add.assert_called_once()

def test_snapshot_failure_stops_before_subscription_replacement(self):
"""完成快照写入失败时不得删除分集订阅,避免转换后失去增集基线。"""
oper = MagicMock()
Expand Down Expand Up @@ -192,39 +222,3 @@ def test_default_description_and_optional_callbacks(self):
payload = oper.add.call_args.kwargs
assert payload["best_version_full"] == 1
assert payload["username"] == "订阅助手(增强版)"

def test_remove_history_snapshot_uses_database_fallback(self):
"""旧订阅删除失败时,没有 remove_history 也应按身份从数据库清理刚写入的历史。"""
history = object()
query = MagicMock()
query.filter.return_value = query
query.order_by.return_value.first.return_value = history
db = MagicMock()
db.query.return_value = query
oper = MagicMock()
del oper.remove_history
oper._db = db
conv = BestVersionConverter(subscribe_oper=oper)

conv._remove_history_snapshot({"tmdbid": 100, "season": 1, "name": "测试剧"})

db.delete.assert_called_once_with(history)
db.commit.assert_called_once()

def test_remove_history_snapshot_without_identity_uses_name(self):
"""没有 tmdb/douban 身份时按名称回退清理,覆盖旧数据兼容路径。"""
history = object()
query = MagicMock()
query.filter.return_value = query
query.order_by.return_value.first.return_value = history
db = MagicMock()
db.query.return_value = query
oper = MagicMock()
del oper.remove_history
oper._db = db
conv = BestVersionConverter(subscribe_oper=oper)

conv._remove_history_snapshot({"name": "测试剧", "season": 1})

db.delete.assert_called_once_with(history)
db.commit.assert_called_once()
5 changes: 4 additions & 1 deletion tests/v2/subscribeassistantenhanced/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ def _sub(sid=1, season=1, episode_group=None, best_version=0, state="R",
name="测试剧", total_episode=total_episode, lack_episode=0,
episode_priority={}, current_priority=0,
start_episode=1, best_version_full=0,
save_path=None, sites=None, filter=None, filter_groups=[],
keyword=None, save_path=None, sites=None, downloader=None,
filter=None, filter_groups=[], include=None, exclude=None,
quality=None, resolution=None, effect=None, search_imdbid=0,
custom_words=None, media_category=None,
year=None, username="", date=None, last_update=None,
)
defaults.update(kw)
Expand Down
74 changes: 47 additions & 27 deletions tests/v2/subscribeassistantenhanced/test_plugin_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2832,55 +2832,72 @@ def fake_no_exists(*args, **kwargs):
assert plugin._detect_missing_episodes(sub) == []
assert captured["totals"] == {2: 8}

def test_snapshot_rebuild_calls_real_subscribe_add_contract(self):
"""H 重建适配器必须以 MediaInfo + kwargs 调用主程序 SubscribeOper.add。"""
def test_snapshot_rebuild_calls_subscribe_chain_with_default_mode(self):
"""H 重建由主程序链应用当前默认模式,并保留快照中的媒体专属配置。"""
plugin = SubscribeAssistantEnhanced()
plugin.init_plugin({})
mediainfo = _mediainfo()

class StrictSubscribeOper:
"""仅接受主程序真实 add 签名的测试替身。"""
class StrictSubscribeChain:
"""记录主程序订阅链收到的重建参数。"""

def __init__(self):
self.call = None

def add(self, *, mediainfo, **kwargs):
"""记录 MediaInfo 与订阅配置。"""
self.call = (mediainfo, kwargs)
def add(self, **kwargs):
"""记录订阅链参数。"""
self.call = kwargs
return 88, "新增订阅成功"

oper = StrictSubscribeOper()
plugin._subscribe_oper = oper
plugin._recognize_mediainfo = MagicMock(return_value=mediainfo)
chain = StrictSubscribeChain()
plugin._subscribe_chain = chain

result = plugin._rebuild_subscribe_from_snapshot(
{"tmdbid": 100, "season": 1, "episode_group_id": "eg-1"},
{"name": "测试", "start_episode": 13, "manual_total_episode": 92},
{
"name": "测试",
"year": "2026",
"quality": "WEB-DL",
"best_version": 1,
"best_version_full": 1,
"start_episode": 13,
"total_episode": 15,
"lack_episode": 3,
"manual_total_episode": 92,
},
)

assert result is True
assert 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,触发主程序订阅创建链路。"""
assert chain.call["title"] == "测试"
assert chain.call["year"] == "2026"
assert chain.call["mtype"] == MediaType.TV
assert chain.call["tmdbid"] == 100
assert chain.call["season"] == 1
assert chain.call["episode_group"] == "eg-1"
assert chain.call["quality"] == "WEB-DL"
assert chain.call["start_episode"] == 13
assert chain.call["total_episode"] == 15
assert chain.call["lack_episode"] == 3
assert chain.call["manual_total_episode"] == 0
assert chain.call["state"] == "N"
assert chain.call["message"] is False
assert "best_version" not in chain.call
assert "best_version_full" not in chain.call

def test_snapshot_rebuild_does_not_duplicate_subscribe_added_event(self):
"""主程序订阅链负责新增事件,H 重建不得再次手工发送。"""
plugin = SubscribeAssistantEnhanced()
plugin.init_plugin({})
plugin._subscribe_oper = MagicMock()
plugin._subscribe_oper.add.return_value = (88, "新增订阅成功")
plugin._recognize_mediainfo = MagicMock(return_value=_mediainfo())
plugin._subscribe_chain = MagicMock()
plugin._subscribe_chain.add.return_value = (88, "新增订阅成功")
plugin._send_subscribe_added = MagicMock()

result = plugin._rebuild_subscribe_from_snapshot(
{"tmdbid": 100, "season": 1, "episode_group_id": "eg-1"},
{"name": "测试", "start_episode": 13},
{"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3},
)

assert result is True
plugin._send_subscribe_added.assert_called_once()
assert plugin._send_subscribe_added.call_args.args[0] == 88
plugin._send_subscribe_added.assert_not_called()

def test_completion_verify_keeps_snapshot_for_lagging_episode_best_version_subscription(self):
"""同身份分集洗版订阅未覆盖最新总集数时保留快照,不删除重建。"""
Expand Down Expand Up @@ -2968,8 +2985,11 @@ def test_completion_verify_replaces_existing_full_best_version_subscription(self

plugin._modules["verifier"]._subscribe_oper.delete.assert_called_once_with(7)
plugin._modules["verifier"]._rebuild_subscribe.assert_called_once()
assert plugin._modules["verifier"]._rebuild_subscribe.call_args.args[1]["start_episode"] == 13
assert plugin._modules["verifier"]._rebuild_subscribe.call_args.args[1]["best_version_full"] == 1
rebuild_config = plugin._modules["verifier"]._rebuild_subscribe.call_args.args[1]
assert rebuild_config["start_episode"] == 13
assert rebuild_config["total_episode"] == 13
assert rebuild_config["lack_episode"] == 1
assert "best_version_full" not in rebuild_config
assert plugin._modules["verifier"]._notify.call_args.args[0].endswith("已移除旧洗版订阅并重建订阅")
assert data_store["snapshots"]["list"] == []

Expand Down
Loading