From 883b7f47c58a9d99d199d59c53c71f79b65c1069 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 19 Jul 2026 19:03:47 +0800 Subject: [PATCH] fix(subscribeassistantenhanced): refine notification images --- package.v2.json | 3 +- .../subscribeassistantenhanced/README.md | 1 + .../subscribeassistantenhanced/__init__.py | 40 +++++++-- .../best_version/converter.py | 25 ++++-- .../best_version/orchestrator.py | 15 +++- .../subscribeassistantenhanced/events.py | 24 ++++-- .../postcheck/verifier.py | 10 ++- .../test_converter.py | 1 + .../subscribeassistantenhanced/test_events.py | 53 +++++++++++- .../test_orchestrator.py | 2 + .../test_plugin_integration.py | 84 +++++++++++++++++-- .../test_verifier.py | 26 +++++- 12 files changed, 245 insertions(+), 39 deletions(-) diff --git a/package.v2.json b/package.v2.json index 4530e4c3..e8e09ae2 100644 --- a/package.v2.json +++ b/package.v2.json @@ -3,12 +3,13 @@ "name": "订阅助手(增强版)", "description": "多场景管理订阅,实现订阅全生命周期管理。", "labels": "订阅", - "version": "0.6.7", + "version": "0.6.8", "icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png", "author": "InfinityPacer", "level": 1, "system_version": ">2.14.1", "history": { + "v0.6.8": "优化订阅通知图片策略,媒体通知优先使用订阅图片,无图消息改为纯文本并标注插件来源。", "v0.6.7": "分集洗版转为全集洗版时保留已下载剧集、当前优先级和分集优先级,避免转换后订阅进度重置。", "v0.6.6": "修正洗版订阅继承手动总集数的问题,补充分集转全集完成快照,并仅在已有订阅覆盖最新 TMDB 集数时完成自动纠错。", "v0.6.5": "优化插件品牌图标与配置页视觉效果,提升界面一致性与操作辨识度。", diff --git a/plugins.v2/subscribeassistantenhanced/README.md b/plugins.v2/subscribeassistantenhanced/README.md index 92b0a26b..13fffc8c 100644 --- a/plugins.v2/subscribeassistantenhanced/README.md +++ b/plugins.v2/subscribeassistantenhanced/README.md @@ -16,6 +16,7 @@ ## 版本更新日志 +- v0.6.8:优化订阅通知图片策略,媒体通知优先使用订阅图片,无图消息改为纯文本并标注插件来源。 - v0.6.7:分集洗版转为全集洗版时保留已下载剧集、当前优先级和分集优先级,避免转换后订阅进度重置。 - v0.6.6:修正洗版订阅继承手动总集数的问题,补充分集转全集完成快照,并仅在已有订阅覆盖最新 TMDB 集数时完成自动纠错。 - v0.6.5:优化插件品牌图标与配置页视觉效果,提升界面一致性与操作辨识度。 diff --git a/plugins.v2/subscribeassistantenhanced/__init__.py b/plugins.v2/subscribeassistantenhanced/__init__.py index a90308a2..4857713c 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.7" + plugin_version = "0.6.8" _site_cache_candidate_helper_warned = False # 插件作者 plugin_author = "InfinityPacer" @@ -244,6 +244,7 @@ def _init_modules(self): retention_days=cfg.verify_retention_days, notify_fn=self._notify_subscribe, rebuild_subscribe_fn=self._rebuild_subscribe_from_snapshot, + get_subscribe_image_fn=self._get_subscribe_image, ) priority_manager = PriorityManager( tm.read, @@ -259,6 +260,7 @@ def _init_modules(self): restore_fn=self._restore_subscribe_from_snapshot, snapshot_fn=verifier.snapshot, format_desc_fn=lambda subscribe, mediainfo: self._format_subscribe_desc(subscribe, mediainfo), + notification_image_fn=self._resolve_notification_image, plugin_name=self.plugin_name, ) pending_refresh = PendingRefresh() @@ -423,6 +425,7 @@ def _init_modules(self): notify_fn=self._notify_subscribe, related_downloads_fn=self._related_download_histories, best_version_type=cfg.best_version_type, + notification_image_fn=self._resolve_notification_image, plugin_name=self.plugin_name, ) subscription_cleanup = SubscriptionCleanup( @@ -454,6 +457,7 @@ def _init_modules(self): subscribe_oper=self._subscribe_oper, post_message=self.post_message, notify_fn=self._notify_subscribe, + notification_image_fn=self._resolve_notification_image, plugin_name=self.plugin_name, deletes_store=deletes_store if cfg.download_monitor_enabled else None, skip_deletion=cfg.skip_deletion, @@ -675,7 +679,7 @@ def _run_backfill_now(self): f"跳过 {results['skipped']} 个,累计补写 {results['filled_episodes']} 集" ) self._notify_subscribe( - "洗版订阅下载事实回填", + "洗版下载事实回填完成", action=( f"扫描 {results['scanned']} 个订阅,成功回填 {results['updated']} 个," f"跳过 {results['skipped']} 个,累计补写 {results['filled_episodes']} 集" @@ -759,7 +763,7 @@ def run_best_version_check(self): self._notify_subscribe( f"{format_subscribe(subscribe)} {mode_label}超过时限" f"({self._best_version_timeout_days(subscribe)}天),已标记洗版优先级为完成", - image=mediainfo.get_message_image(), + image=self._resolve_notification_image(subscribe, mediainfo), ) continue if ( @@ -1641,7 +1645,7 @@ def _send_no_download_notification(self, subscribe, mediainfo, action: str, score=mediainfo.vote_average, user=subscribe.username, reason=reason or "上映后超期且无下载", - image=mediainfo.get_message_image(), + image=self._resolve_notification_image(subscribe, mediainfo), link="#/subscribe/tv?tab=mysub" if subscribe.type == "电视剧" else "#/subscribe/movie?tab=mysub", ) @@ -1656,7 +1660,7 @@ def _send_subscribe_status_notification(self, subscribe, title_suffix: str, score=mediainfo.vote_average if mediainfo else None, user=subscribe.username, reason=detail, - image=mediainfo.get_message_image() if mediainfo else None, + image=self._resolve_notification_image(subscribe, mediainfo), link="#/subscribe/tv?tab=mysub" if media_type == "电视剧" else "#/subscribe/movie?tab=mysub", ) @@ -1682,8 +1686,23 @@ def _notify_subscribe(self, title, text=None, image=None, link=None, follow_up=follow_up if follow_up is not None else next_step, diagnostic=diagnostic, ) - image = image or self.plugin_icon - self.post_message(mtype=NotificationType.Subscribe, title=title, text=text, image=image, link=link) + message_options = {} + if not image: + text = self._append_notification_source(text) + message_options["disable_web_page_preview"] = True + self.post_message( + mtype=NotificationType.Subscribe, + title=title, + text=text, + image=image or None, + link=link, + **message_options, + ) + + def _append_notification_source(self, text: Optional[str]) -> str: + """无图消息追加插件来源,便于在通知转发和多插件场景中识别发送方。""" + source = f"来源:{self.plugin_name}" + return f"{text}\n\n{source}" if text else source @staticmethod def _format_notification_text(text=None, score=None, user=None, reason=None, @@ -1717,3 +1736,10 @@ def _get_subscribe_image(subscribe): if subscribe.poster: return subscribe.poster.replace("original", "w500") return "" + + def _resolve_notification_image(self, subscribe=None, mediainfo=None): + """解析媒体通知图片,优先保持订阅卡片当前展示图片。""" + subscribe_image = self._get_subscribe_image(subscribe) if subscribe else "" + if subscribe_image: + return subscribe_image + return mediainfo.get_message_image() if mediainfo else None diff --git a/plugins.v2/subscribeassistantenhanced/best_version/converter.py b/plugins.v2/subscribeassistantenhanced/best_version/converter.py index d44f40f7..4ac2948c 100644 --- a/plugins.v2/subscribeassistantenhanced/best_version/converter.py +++ b/plugins.v2/subscribeassistantenhanced/best_version/converter.py @@ -19,6 +19,7 @@ class BestVersionConverter: def __init__(self, subscribe_oper=None, clear_tasks_fn=None, send_event_fn=None, notify_fn=None, restore_fn=None, snapshot_fn=None, format_desc_fn=None, + notification_image_fn=None, plugin_name: str = "订阅助手(增强版)"): """注入订阅写库、任务清理、事件、通知、完成快照和失败恢复依赖。""" self._subscribe_oper = subscribe_oper @@ -28,6 +29,7 @@ def __init__(self, subscribe_oper=None, clear_tasks_fn=None, send_event_fn=None, self._restore = restore_fn self._snapshot = snapshot_fn self._format_desc = format_desc_fn + self._notification_image = notification_image_fn self._plugin_name = plugin_name def convert_to_full(self, subscribe, mediainfo=None) -> bool: @@ -45,7 +47,7 @@ def convert_to_full(self, subscribe, mediainfo=None) -> bool: 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) + self._notify_failure(subscribe, subscribe_desc, str(err), mediainfo=mediainfo) return False try: @@ -56,7 +58,7 @@ def convert_to_full(self, subscribe, mediainfo=None) -> bool: except Exception as err: self._remove_history_snapshot(subscribe_dict) logger.error(f"{subscribe_desc} 原因=删除分集洗版订阅失败,处理=停止转全集处理,错误={err}") - self._notify_failure(subscribe_desc, str(err), mediainfo=mediainfo) + self._notify_failure(subscribe, subscribe_desc, str(err), mediainfo=mediainfo) return False try: @@ -67,7 +69,7 @@ def convert_to_full(self, subscribe, mediainfo=None) -> bool: if new_sid: logger.info(f"{subscribe_desc} 原因=分集洗版集数已符合目标集数,处理=已转为全集洗版订阅 (ID: {new_sid})") self._send_subscribe_added(new_sid, mediainfo) - self._notify_success(subscribe_desc, mediainfo) + self._notify_success(subscribe, subscribe_desc, mediainfo) return True restored = self._restore(subscribe_dict, mediainfo) if self._restore else False @@ -76,7 +78,7 @@ def convert_to_full(self, subscribe, mediainfo=None) -> bool: f"错误信息={err_msg},分集订阅重建状态={restored}" ) restore_text = "分集洗版订阅已尝试重建" if restored else "分集洗版订阅重建失败,请手动检查" - self._notify_failure(subscribe_desc, f"{err_msg}\n{restore_text}", mediainfo=mediainfo) + 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: @@ -109,19 +111,18 @@ def _send_subscribe_added(self, sid, mediainfo): "mediainfo": media_payload, }) - def _notify_success(self, subscribe_desc: str, mediainfo): + def _notify_success(self, subscribe, subscribe_desc: str, mediainfo): """发送转全集成功通知。""" if not self._notify: return self._notify( f"{subscribe_desc} 分集洗版集数已符合目标集数,已从分集洗版转为全集洗版订阅", score=mediainfo.vote_average, - user=self._plugin_name, - image=mediainfo.get_message_image(), + image=self._resolve_notification_image(subscribe, mediainfo), link="#/subscribe/tv?tab=mysub", ) - def _notify_failure(self, subscribe_desc: str, text: str, mediainfo=None): + def _notify_failure(self, subscribe, subscribe_desc: str, text: str, mediainfo=None): """发送转全集失败通知。""" if not self._notify: return @@ -130,9 +131,15 @@ def _notify_failure(self, subscribe_desc: str, text: str, mediainfo=None): text=text, follow_up="请检查订阅状态", diagnostic=True, - image=mediainfo.get_message_image() if mediainfo else None, + image=self._resolve_notification_image(subscribe, mediainfo), ) + 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) diff --git a/plugins.v2/subscribeassistantenhanced/best_version/orchestrator.py b/plugins.v2/subscribeassistantenhanced/best_version/orchestrator.py index c0146621..e8b32376 100644 --- a/plugins.v2/subscribeassistantenhanced/best_version/orchestrator.py +++ b/plugins.v2/subscribeassistantenhanced/best_version/orchestrator.py @@ -22,6 +22,7 @@ def __init__(self, priority_manager: PriorityManager, notify_fn: Optional[Callable] = None, related_downloads_fn: Optional[Callable] = None, best_version_type: str = "no", + notification_image_fn: Optional[Callable] = None, plugin_name: str = "订阅助手(增强版)"): """注入洗版编排依赖与自动洗版范围。""" self._priority = priority_manager @@ -30,6 +31,7 @@ def __init__(self, priority_manager: PriorityManager, self._notify = notify_fn self._related_downloads = related_downloads_fn self._best_version_type = best_version_type + self._notification_image = notification_image_fn self._plugin_name = plugin_name def build_payload(self, subscribe) -> dict: @@ -70,7 +72,7 @@ def start_best_version(self, subscribe, mediainfo): if self._notify: self._notify( f"{format_subscribe_desc(subscribe)} 已达顶档,跳过洗版订阅", - image=mediainfo.get_message_image(), + image=self._resolve_notification_image(subscribe, mediainfo), link="#/subscribe/movie?tab=mysub", ) return None @@ -113,8 +115,7 @@ def start_best_version(self, subscribe, mediainfo): self._notify( f"{format_subscribe_desc(subscribe)} 已添加{mode_label}订阅", score=mediainfo.vote_average, - user=self._plugin_name, - image=mediainfo.get_message_image(), + image=self._resolve_notification_image(subscribe, mediainfo), link="#/subscribe/movie?tab=mysub" if is_movie else "#/subscribe/tv?tab=mysub", ) elif self._notify: @@ -127,10 +128,16 @@ def start_best_version(self, subscribe, mediainfo): reason=err_msg, follow_up="请检查订阅创建错误", diagnostic=True, - image=mediainfo.get_message_image(), + image=self._resolve_notification_image(subscribe, mediainfo), ) return sid + def _resolve_notification_image(self, subscribe, mediainfo): + """解析洗版通知图片;未注入统一解析器时沿用媒体图片。""" + if self._notification_image: + return self._notification_image(subscribe, mediainfo) + return mediainfo.get_message_image() + @staticmethod def _mode_label(subscribe) -> str: """按订阅实际洗版形态返回用户可见标签。""" diff --git a/plugins.v2/subscribeassistantenhanced/events.py b/plugins.v2/subscribeassistantenhanced/events.py index 1048784e..4d77a94b 100644 --- a/plugins.v2/subscribeassistantenhanced/events.py +++ b/plugins.v2/subscribeassistantenhanced/events.py @@ -293,9 +293,20 @@ def on_subscribe_complete(self, event): subscribe = SimpleNamespace(**subscribe_info) detail(f"订阅完成事件:{format_subscribe_label(subscribe, subscribe_id)}") + mediainfo_from_dict = self.get("mediainfo_from_dict") + mediainfo = None + if mediainfo_from_dict: + try: + mediainfo = mediainfo_from_dict(data.get("mediainfo")) + except Exception: + logger.warning( + f"订阅完成事件:{format_subscribe_label(subscribe, subscribe_id)} " + "媒体信息解析失败,按无媒体信息继续保存快照并清理任务" + ) + verifier = self.get("verifier") if subscribe and verifier: - verifier.snapshot(subscribe=subscribe, mediainfo=None, scope=None) + verifier.snapshot(subscribe=subscribe, mediainfo=mediainfo, scope=None) task_manager = self.get("task_manager") if subscribe_id and task_manager: @@ -306,12 +317,9 @@ def on_subscribe_complete(self, event): # 自动洗版创建(按开关;mediainfo 由事件重建,洗版编排判断是否新建洗版订阅) orchestrator = self.get("orchestrator") - mediainfo_from_dict = self.get("mediainfo_from_dict") - if orchestrator and mediainfo_from_dict: - mediainfo = mediainfo_from_dict(data.get("mediainfo")) - if mediainfo: - detail(f"订阅完成:{format_subscribe(subscribe)} 检查是否需要自动创建洗版订阅") - orchestrator.start_best_version(subscribe, mediainfo) + if orchestrator and mediainfo: + detail(f"订阅完成:{format_subscribe(subscribe)} 检查是否需要自动创建洗版订阅") + orchestrator.start_best_version(subscribe, mediainfo) def on_transfer_intercept(self, event): """TransferIntercept → 订阅清理目标文件删除。""" @@ -532,6 +540,7 @@ def _notify_download_resume(self, subscribe, reason: str): if external else f"{format_subscribe(subscribe)} 检测到下载任务,已恢复暂停订阅" ) + notification_image_fn = self.get("notification_image_fn") notify_fn( title, reason=self._pause_reason_label(reason), @@ -541,6 +550,7 @@ def _notify_download_resume(self, subscribe, reason: str): if external else "48小时内不会因同一原因再次自动暂停" ), + image=notification_image_fn(subscribe) if notification_image_fn else None, ) @staticmethod diff --git a/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py b/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py index ef7dc580..f3c15a16 100644 --- a/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py +++ b/plugins.v2/subscribeassistantenhanced/postcheck/verifier.py @@ -17,7 +17,8 @@ def __init__(self, task_data_read: Callable, task_data_update: Callable, subscribe_oper=None, retention_days: int = 180, notify_fn: Optional[Callable] = None, - rebuild_subscribe_fn: Optional[Callable] = None): + rebuild_subscribe_fn: Optional[Callable] = None, + get_subscribe_image_fn: Optional[Callable] = None): """注入完成快照存储、集数查询、订阅查询和真实订阅重建能力。""" self._read = task_data_read self._update = task_data_update @@ -26,6 +27,7 @@ def __init__(self, task_data_read: Callable, task_data_update: Callable, self._retention_seconds = retention_days * 86400 self._notify = notify_fn self._rebuild_subscribe = rebuild_subscribe_fn + self._get_subscribe_image = get_subscribe_image_fn def snapshot(self, subscribe, mediainfo, scope: Optional[SeasonScope]): """保存完成快照,同一季同一剧集组只保留最新记录。""" @@ -43,6 +45,11 @@ def snapshot(self, subscribe, mediainfo, scope: Optional[SeasonScope]): "completed_at": time.time(), "subscribe_config": _extract_config(subscribe), } + image = self._get_subscribe_image(subscribe) if self._get_subscribe_image else None + if not image and mediainfo: + image = mediainfo.get_message_image() + if image: + snap["subscribe_image"] = image def updater(data: dict) -> dict: snapshots = data.get("list", []) @@ -141,6 +148,7 @@ def _rebuild(self, snap: dict, current_total: int) -> bool: action_text = "已移除旧洗版订阅并重建订阅" if removed_full_best_version else "已自动重建订阅" self._notify( f"{name}{season_text} 检测到新增集数({old_total}→{current_total}),{action_text}", + image=snap.get("subscribe_image"), ) return True diff --git a/tests/v2/subscribeassistantenhanced/test_converter.py b/tests/v2/subscribeassistantenhanced/test_converter.py index a6c64901..e940e6f3 100644 --- a/tests/v2/subscribeassistantenhanced/test_converter.py +++ b/tests/v2/subscribeassistantenhanced/test_converter.py @@ -82,6 +82,7 @@ def test_success(self): assert send_event.call_args.args[1]["subscribe_id"] == 9 notify.assert_called_once() assert notify.call_args.args[0] == "测试剧 S1 分集洗版集数已符合目标集数,已从分集洗版转为全集洗版订阅" + assert "user" not in notify.call_args.kwargs assert "reason" not in notify.call_args.kwargs def test_failure_keeps_original(self): diff --git a/tests/v2/subscribeassistantenhanced/test_events.py b/tests/v2/subscribeassistantenhanced/test_events.py index af38f4f5..9017614b 100644 --- a/tests/v2/subscribeassistantenhanced/test_events.py +++ b/tests/v2/subscribeassistantenhanced/test_events.py @@ -30,7 +30,13 @@ def _sub(**kwargs): def _mi(**kwargs): """构造完整 MediaInfo 替身,默认包含事件处理会读取的固定字段。""" - defaults = dict(type="tv", next_episode_to_air=None, release_date=None, first_air_date=None) + defaults = dict( + type="tv", + next_episode_to_air=None, + release_date=None, + first_air_date=None, + get_message_image=lambda: "media.jpg", + ) defaults.update(kwargs) return SimpleNamespace(**defaults) @@ -127,6 +133,7 @@ def test_download_added_registers_monitor_then_lifecycle_and_notifies_once(self) download_monitor=monitor, lifecycle=lifecycle, notify_fn=notify, + notification_image_fn=lambda _subscribe: "subscribe.jpg", ) proxy.on_download_added(SimpleNamespace(event_data={ "source": 'Subscribe|{"id": 1}', "hash": "h1", "episodes": [1, 2], "downloader": "qb", @@ -140,6 +147,7 @@ def test_download_added_registers_monitor_then_lifecycle_and_notifies_once(self) assert "已恢复暂停订阅" in notify.call_args.args[0] assert notify.call_args.kwargs["reason"] == "无下载暂停" assert notify.call_args.kwargs["follow_up"] == "48小时内不会因同一原因再次自动暂停" + assert notify.call_args.kwargs["image"] == "subscribe.jpg" def test_download_added_external_result_uses_external_notification(self): """外部暂停归属由 lifecycle 处理,事件层只按结果生成外部恢复通知。""" @@ -153,7 +161,12 @@ def test_download_added_external_result_uses_external_notification(self): reason="external", ) notify = MagicMock() - proxy = EventProxy(subscribe_oper=oper, lifecycle=lifecycle, notify_fn=notify) + proxy = EventProxy( + subscribe_oper=oper, + lifecycle=lifecycle, + notify_fn=notify, + notification_image_fn=lambda _subscribe: "subscribe.jpg", + ) proxy.on_download_added(SimpleNamespace(event_data={ "source": 'Subscribe|{"id": 1}', "hash": "h1", @@ -163,6 +176,7 @@ def test_download_added_external_result_uses_external_notification(self): notify.assert_called_once() assert "已恢复外部暂停订阅" in notify.call_args.args[0] assert notify.call_args.kwargs["follow_up"] == "用户再次手动暂停仍会立即生效" + assert notify.call_args.kwargs["image"] == "subscribe.jpg" def test_download_added_only_notifies_when_lifecycle_changes(self): """source 无法解析不进 lifecycle;已解析订阅是否恢复由 lifecycle 结果决定。""" @@ -758,10 +772,16 @@ def test_complete_clears_tasks_and_snapshots(self): tm.clear_tasks.side_effect = lambda _sid: order.append("clear") verifier = MagicMock() verifier.snapshot.side_effect = lambda **_kwargs: order.append("snapshot") + mediainfo = _mi() sub = _sub(id=5, tmdbid=100, season=1) oper = MagicMock() oper.get.return_value = sub - proxy = EventProxy(task_manager=tm, verifier=verifier, subscribe_oper=oper) + proxy = EventProxy( + task_manager=tm, + verifier=verifier, + subscribe_oper=oper, + mediainfo_from_dict=lambda _data: mediainfo, + ) proxy.on_subscribe_complete(SimpleNamespace(event_data={ "subscribe_id": 5, "subscribe_info": {"tmdbid": 100, "season": 1}, @@ -771,6 +791,7 @@ def test_complete_clears_tasks_and_snapshots(self): assert order == ["snapshot", "clear"] _, kwargs = verifier.snapshot.call_args assert kwargs.get("subscribe") is sub + assert kwargs.get("mediainfo") is mediainfo def test_complete_without_subscribe_snapshot_still_clears_instance_state(self): """完成事件缺少订阅快照时仍按 ID 清理实例状态。""" @@ -786,6 +807,32 @@ def test_complete_without_subscribe_snapshot_still_clears_instance_state(self): task_manager.clear_tasks.assert_called_once_with(5) verifier.snapshot.assert_not_called() + def test_complete_media_parse_failure_still_snapshots_and_clears_tasks(self): + """完成事件媒体补充解析失败时,快照与任务清理仍必须继续。""" + order = [] + task_manager = MagicMock() + task_manager.clear_tasks.side_effect = lambda _sid: order.append("clear") + verifier = MagicMock() + verifier.snapshot.side_effect = lambda **_kwargs: order.append("snapshot") + subscribe = _sub(id=5) + subscribe_oper = MagicMock() + subscribe_oper.get.return_value = subscribe + + proxy = EventProxy( + task_manager=task_manager, + verifier=verifier, + subscribe_oper=subscribe_oper, + mediainfo_from_dict=MagicMock(side_effect=ValueError("invalid media payload")), + ) + + proxy.on_subscribe_complete(SimpleNamespace(event_data={ + "subscribe_id": 5, + "mediainfo": {"broken": True}, + })) + + assert order == ["snapshot", "clear"] + assert verifier.snapshot.call_args.kwargs["mediainfo"] is None + def test_complete_triggers_best_version_creation(self): """SubscribeComplete → 委托洗版编排创建洗版订阅(mediainfo 由事件重建)。""" sub = _sub(id=5, tmdbid=100, season=1, best_version=0) diff --git a/tests/v2/subscribeassistantenhanced/test_orchestrator.py b/tests/v2/subscribeassistantenhanced/test_orchestrator.py index b774c4d1..319c078b 100644 --- a/tests/v2/subscribeassistantenhanced/test_orchestrator.py +++ b/tests/v2/subscribeassistantenhanced/test_orchestrator.py @@ -108,6 +108,7 @@ def test_creates_best_version_when_type_enabled(self): notify.assert_called_once() assert notify.call_args.args[0].endswith("已添加洗版订阅") assert "reason" not in notify.call_args.kwargs + assert "user" not in notify.call_args.kwargs _args, kwargs = oper.add.call_args assert kwargs["best_version"] == 1 and kwargs["season"] == 1 assert kwargs["best_version_full"] == 1 @@ -138,6 +139,7 @@ def test_create_failure_notifies_error_and_image(self): assert notify.call_args.kwargs["follow_up"] == "请检查订阅创建错误" assert notify.call_args.kwargs["diagnostic"] is True assert notify.call_args.kwargs["image"] == "poster.jpg" + assert "user" not in notify.call_args.kwargs def test_skips_when_already_best_version(self): oper = MagicMock() diff --git a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py index 6f33ada1..c0afefb7 100644 --- a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py +++ b/tests/v2/subscribeassistantenhanced/test_plugin_integration.py @@ -41,6 +41,8 @@ def _sub(**kwargs): filter_groups=[], save_path=None, sites=None, + backdrop="", + poster="", date=None, last_update=None, ) @@ -82,7 +84,21 @@ def test_release_metadata_requires_main_program_newer_than_2_14_1(): def test_converter_is_wired(): plugin = SubscribeAssistantEnhanced() plugin.init_plugin({}) - assert plugin._modules.get("converter") is not None + converter = plugin._modules.get("converter") + assert converter is not None + assert converter._notification_image.__self__ is plugin + assert converter._notification_image.__func__ is plugin._resolve_notification_image.__func__ + + +def test_orchestrator_uses_shared_notification_image_resolver(): + """自动洗版通知与状态通知共用订阅图片优先策略。""" + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({}) + + orchestrator = plugin._modules["orchestrator"] + + assert orchestrator._notification_image.__self__ is plugin + assert orchestrator._notification_image.__func__ is plugin._resolve_notification_image.__func__ def test_target_satisfied_resolver_is_wired_to_guard_and_events(): @@ -2088,10 +2104,13 @@ def test_backfill_best_version_now_scans_existing_subscriptions_and_resets_flag( sub, [1, 2, 3], scene="plugin_backfill<订阅助手(增强版)>" ) plugin.post_message.assert_called_once() - assert plugin.post_message.call_args.kwargs["title"] == "洗版订阅下载事实回填" + assert plugin.post_message.call_args.kwargs["title"] == "洗版下载事实回填完成" assert "扫描 1 个订阅" in plugin.post_message.call_args.kwargs["text"] assert "成功回填 1 个" in plugin.post_message.call_args.kwargs["text"] assert "累计补写 3 集" in plugin.post_message.call_args.kwargs["text"] + assert plugin.post_message.call_args.kwargs["text"].endswith("来源:订阅助手(增强版)") + assert plugin.post_message.call_args.kwargs["image"] is None + assert plugin.post_message.call_args.kwargs["disable_web_page_preview"] is True plugin.update_config.assert_called_once() assert plugin.update_config.call_args.args[0]["backfill_best_version_now"] is False @@ -2115,6 +2134,45 @@ def test_status_notification_uses_ordered_single_line_fields(monkeypatch): assert kwargs["image"] == "poster.jpg" +def test_status_notification_falls_back_to_subscribe_image(): + """媒体识别结果缺图时仍使用订阅卡片图片。""" + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({"notify": True}) + plugin.post_message = MagicMock() + media = _mediainfo() + media.get_message_image = lambda: "" + + plugin._send_subscribe_status_notification( + _sub(backdrop="https://image.tmdb.org/t/p/original/backdrop.jpg"), + "播出满足订阅暂停,已标记暂停", + mediainfo=media, + ) + + kwargs = plugin.post_message.call_args.kwargs + assert kwargs["image"] == "https://image.tmdb.org/t/p/w500/backdrop.jpg" + assert "来源:" not in (kwargs["text"] or "") + assert "disable_web_page_preview" not in kwargs + + +def test_best_version_notification_without_image_has_single_plugin_source(): + """缺图洗版成功通知仅通过来源字段标识插件,不伪装成订阅用户。""" + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({"notify": True, "best_version_type": "all"}) + plugin.post_message = MagicMock() + orchestrator = plugin._modules["orchestrator"] + orchestrator._subscribe_oper = MagicMock() + orchestrator._subscribe_oper.add.return_value = (8, "") + media = _mediainfo() + media.get_message_image = lambda: "" + + orchestrator.start_best_version(_sub(best_version=0), media) + + kwargs = plugin.post_message.call_args.kwargs + assert "用户:" not in kwargs["text"] + assert kwargs["text"].count("来源:订阅助手(增强版)") == 1 + assert kwargs["image"] is None + + def test_no_download_notification_does_not_repeat_title_action(): """无下载通知标题已包含处理结果,正文只保留判断依据。""" plugin = SubscribeAssistantEnhanced() @@ -2134,8 +2192,8 @@ def test_no_download_notification_does_not_repeat_title_action(): assert "处理:" not in kwargs["text"] -def test_diagnostic_notification_uses_ordered_multiline_fields(): - """诊断类订阅通知按统一字段顺序输出多行正文,缺失字段不生成空行。""" +def test_diagnostic_notification_without_image_uses_text_source(): + """无图诊断通知保持纯文本,并补充插件来源和关闭网页预览。""" plugin = SubscribeAssistantEnhanced() plugin.init_plugin({"notify": True}) plugin.post_message = MagicMock() @@ -2150,9 +2208,23 @@ def test_diagnostic_notification_uses_ordered_multiline_fields(): kwargs = plugin.post_message.call_args.kwargs assert kwargs["text"] == ( "低进度删除 3/3 次\n" - "后续:请手动判断" + "后续:请手动判断\n\n" + "来源:订阅助手(增强版)" ) - assert kwargs["image"] == plugin.plugin_icon + assert kwargs["image"] is None + assert kwargs["disable_web_page_preview"] is True + + +def test_notification_source_uses_runtime_plugin_name(): + """无图来源跟随运行时插件名称,不固化显示文案。""" + plugin = SubscribeAssistantEnhanced() + plugin.plugin_name = "自定义订阅助手" + plugin.init_plugin({"notify": True}) + plugin.post_message = MagicMock() + + plugin._notify_subscribe("测试通知") + + assert plugin.post_message.call_args.kwargs["text"] == "来源:自定义订阅助手" def test_backfill_best_version_now_skips_full_best_version_before_detection(monkeypatch): diff --git a/tests/v2/subscribeassistantenhanced/test_verifier.py b/tests/v2/subscribeassistantenhanced/test_verifier.py index cb012216..6464f28f 100644 --- a/tests/v2/subscribeassistantenhanced/test_verifier.py +++ b/tests/v2/subscribeassistantenhanced/test_verifier.py @@ -16,7 +16,8 @@ def _sub(tmdbid=100, season=1, episode_group=None, total=12, best_version=0, bes ) -def _verifier(store=None, tmdb_fn=None, retention_days=90, rebuild_fn=None): +def _verifier(store=None, tmdb_fn=None, retention_days=90, rebuild_fn=None, + subscribe_image_fn=None): store = store if store is not None else {} oper = MagicMock() oper.list.return_value = [] @@ -29,6 +30,7 @@ def _verifier(store=None, tmdb_fn=None, retention_days=90, rebuild_fn=None): retention_days=retention_days, notify_fn=notify, rebuild_subscribe_fn=rebuild_fn, + get_subscribe_image_fn=subscribe_image_fn, ) v._store = store v._oper = oper @@ -52,6 +54,26 @@ def test_saves_snapshot(self): assert snaps[0]["subscribe_config"]["best_version"] == 1 assert snaps[0]["subscribe_config"]["best_version_full"] == 1 + def test_saves_media_image_for_later_rebuild_notification(self): + """完成快照保存媒体图片,供未来增集重建通知继续使用。""" + store = {} + v = _verifier(store) + media = SimpleNamespace(get_message_image=lambda: "media.jpg") + + v.snapshot(_sub(), media, SeasonScope(source="main_season")) + + assert store["snapshots"]["list"][0]["subscribe_image"] == "media.jpg" + + def test_snapshot_falls_back_to_subscribe_image(self): + """完成快照优先使用订阅记录图片。""" + store = {} + v = _verifier(store, subscribe_image_fn=lambda _subscribe: "subscribe.jpg") + media = SimpleNamespace(get_message_image=lambda: "media.jpg") + + v.snapshot(_sub(), media, SeasonScope(source="main_season")) + + assert store["snapshots"]["list"][0]["subscribe_image"] == "subscribe.jpg" + def test_dedup_by_key(self): """同 (tmdbid, season, episode_group_id) 幂等去重。""" store = {} @@ -97,6 +119,7 @@ def test_increase_triggers_rebuild(self): store = {"snapshots": {"list": [{ "tmdbid": 100, "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), + "subscribe_image": "subscribe.jpg", "subscribe_config": {"name": "测试剧", "season": 1}, }]}} rebuild = MagicMock(return_value=True) @@ -107,6 +130,7 @@ def test_increase_triggers_rebuild(self): assert len(store["snapshots"]["list"]) == 0 v._notify_mock.assert_called_once() assert v._notify_mock.call_args.args[0] == "测试剧 S1 检测到新增集数(12→15),已自动重建订阅" + assert v._notify_mock.call_args.kwargs["image"] == "subscribe.jpg" assert "action" not in v._notify_mock.call_args.kwargs assert "reason" not in v._notify_mock.call_args.kwargs