diff --git a/plugins.v2/subscribeassistantenhanced/__init__.py b/plugins.v2/subscribeassistantenhanced/__init__.py index 529330dc..958bd9f0 100644 --- a/plugins.v2/subscribeassistantenhanced/__init__.py +++ b/plugins.v2/subscribeassistantenhanced/__init__.py @@ -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( diff --git a/plugins.v2/subscribeassistantenhanced/best_version/converter.py b/plugins.v2/subscribeassistantenhanced/best_version/converter.py index dc7ee566..2d74a652 100644 --- a/plugins.v2/subscribeassistantenhanced/best_version/converter.py +++ b/plugins.v2/subscribeassistantenhanced/best_version/converter.py @@ -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: @@ -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}") diff --git a/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py b/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py index f3c15a16..f036edc0 100644 --- a/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py +++ b/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py @@ -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 @@ -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 diff --git a/tests/v2/subscribeassistantenhanced/test_converter.py b/tests/v2/subscribeassistantenhanced/test_converter.py index 32e140c2..5882fe14 100644 --- a/tests/v2/subscribeassistantenhanced/test_converter.py +++ b/tests/v2/subscribeassistantenhanced/test_converter.py @@ -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() @@ -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() @@ -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() diff --git a/tests/v2/subscribeassistantenhanced/test_integration.py b/tests/v2/subscribeassistantenhanced/test_integration.py index 5e90fcf9..f8cc6b07 100644 --- a/tests/v2/subscribeassistantenhanced/test_integration.py +++ b/tests/v2/subscribeassistantenhanced/test_integration.py @@ -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) diff --git a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py index ec864c8b..1828712c 100644 --- a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py +++ b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py @@ -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): """同身份分集洗版订阅未覆盖最新总集数时保留快照,不删除重建。""" @@ -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"] == [] diff --git a/tests/v2/subscribeassistantenhanced/test_verifier.py b/tests/v2/subscribeassistantenhanced/test_verifier.py index 6464f28f..6718f1ed 100644 --- a/tests/v2/subscribeassistantenhanced/test_verifier.py +++ b/tests/v2/subscribeassistantenhanced/test_verifier.py @@ -11,8 +11,11 @@ def _sub(tmdbid=100, season=1, episode_group=None, total=12, best_version=0, bes return SimpleNamespace( id=1, tmdbid=tmdbid, season=season, episode_group=episode_group, total_episode=total, best_version=best_version, best_version_full=best_version_full, - name="测试剧", type="电视剧", save_path="/media", - sites="site1", filter="rule1", filter_groups=["group1"], + name="测试剧", year="2026", type="电视剧", keyword="测试关键字", + save_path="/media", sites="site1", downloader="qbittorrent", + filter="rule1", filter_groups=["group1"], include="包含", exclude="排除", + quality="WEB-DL", resolution="1080p", effect="杜比视界", + search_imdbid=1, custom_words="自定义识别词", media_category="国漫", ) @@ -51,8 +54,19 @@ def test_saves_snapshot(self): assert snaps[0]["total_at_completion"] == 12 assert snaps[0]["subscribe_config"]["filter"] == "rule1" assert snaps[0]["subscribe_config"]["filter_groups"] == ["group1"] - assert snaps[0]["subscribe_config"]["best_version"] == 1 - assert snaps[0]["subscribe_config"]["best_version_full"] == 1 + assert "best_version" not in snaps[0]["subscribe_config"] + assert "best_version_full" not in snaps[0]["subscribe_config"] + assert snaps[0]["subscribe_config"]["year"] == "2026" + assert snaps[0]["subscribe_config"]["keyword"] == "测试关键字" + assert snaps[0]["subscribe_config"]["quality"] == "WEB-DL" + assert snaps[0]["subscribe_config"]["resolution"] == "1080p" + assert snaps[0]["subscribe_config"]["effect"] == "杜比视界" + assert snaps[0]["subscribe_config"]["include"] == "包含" + assert snaps[0]["subscribe_config"]["exclude"] == "排除" + assert snaps[0]["subscribe_config"]["downloader"] == "qbittorrent" + assert snaps[0]["subscribe_config"]["search_imdbid"] == 1 + assert snaps[0]["subscribe_config"]["custom_words"] == "自定义识别词" + assert snaps[0]["subscribe_config"]["media_category"] == "国漫" def test_saves_media_image_for_later_rebuild_notification(self): """完成快照保存媒体图片,供未来增集重建通知继续使用。""" @@ -225,6 +239,13 @@ def test_rebuild_deletes_best_version(self): v.verify_all() v._oper.delete.assert_called_once_with(99) rebuild.assert_called_once() + rebuild_config = rebuild.call_args.args[1] + assert rebuild_config == { + "name": "测试", + "start_episode": 13, + "total_episode": 15, + "lack_episode": 3, + } assert v._notify_mock.call_args.args[0] == "测试 S1 检测到新增集数(12→15),已移除旧洗版订阅并重建订阅" def test_rebuild_does_not_touch_different_episode_group(self):