From 63c8c0d71a12358f92e1e3056b2c79edb2931279 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 15 Oct 2026 13:10:43 +0800 Subject: [PATCH] fix(transfer): render aggregated success notifications with user template (#7007) --- app/chain/transfer/history.py | 34 +++------ docs/architecture-overview.md | 4 +- docs/refactor/optimization-checklist.md | 2 +- docs/transfer-notifications.md | 8 ++- .../architecture/dependency-baseline.json | 6 +- ...ansfer_success_notification_aggregation.py | 71 ++++++++++++++++--- 6 files changed, 82 insertions(+), 43 deletions(-) diff --git a/app/chain/transfer/history.py b/app/chain/transfer/history.py index e73b2fa09..aa4affffc 100644 --- a/app/chain/transfer/history.py +++ b/app/chain/transfer/history.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import Any, List, Optional, Tuple, Union, cast from app.application.audio import use_music_cue -from app.application.messaging.message import TemplateContextBuilder from app.application.transfer.models import TransferTask from app.application.transfer.notifications import ( TransferSuccessNotification, @@ -325,10 +324,9 @@ class TransferHistoryOwner(_TransferOwnerBase): episodes_info: Optional[List[TmdbEpisode]] = None, username: Optional[str] = None, buttons: Optional[list[list[dict[str, Any]]]] = None, - batch: bool = False, ): """ - 发送入库成功的消息 + 通过入库通知模板发送单文件或聚合后的成功结果,不预填标题和正文。 :param meta: 文件元数据 :param mediainfo: 识别的媒体信息 :param transferinfo: 文件整理信息 @@ -336,30 +334,15 @@ class TransferHistoryOwner(_TransferOwnerBase): :param episodes_info: 当前季的全部集信息 :param username: 用户名 :param buttons: 批次通知使用的历史入口 - :param batch: 多文件批次使用简洁汇总,单文件沿用用户通知模板 """ message = Message( - mtype=MessageType.Organize, - ctype=ContentType.OrganizeSuccess, - image=mediainfo.get_message_image(), - username=username, - link=self.runtime_config.history_url, - buttons=buttons, - ) - if batch: - context = TemplateContextBuilder().build( - meta=meta, mediainfo=cast(MediaInfo, mediainfo), transferinfo=transferinfo, - aggregate_music_album=True, season_episode=season_episode, - ) - message.title = context.get("title_year") or mediainfo.title - if season_episode: - message.title = f"{message.title} {season_episode}" - message.text = f"📂 已入库 {transferinfo.file_count} 个文件" - total_tracks = getattr(mediainfo, "total_tracks", None) - if mediainfo.type == MediaType.MUSIC and total_tracks: - message.text += f" / 共 {total_tracks} 首曲目" - if transferinfo.target_diritem: - message.text += f"\n目录:{transferinfo.target_diritem.path}" + mtype=MessageType.Organize, + ctype=ContentType.OrganizeSuccess, + image=mediainfo.get_message_image(), + username=username, + link=self.runtime_config.history_url, + buttons=buttons, + ) self.post_message( message, meta=meta, @@ -429,7 +412,6 @@ class TransferHistoryOwner(_TransferOwnerBase): meta=first.meta, mediainfo=media, transferinfo=merged, season_episode=season_episode, episodes_info=first.episodes_info, username=first.username, buttons=[[{"text": "查看整理记录", "url": self.runtime_config.history_url}]] if len(files) > 1 else None, - batch=len(files) > 1, ) @staticmethod diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index cd82759fe..8b6cf7431 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -763,7 +763,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1121 | -| 内部导入边 | 9,555 | +| 内部导入边 | 9,553 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 49(债务已清零,49 条精确 containment) | @@ -773,7 +773,7 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,555;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,553;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index c43f7232c..7ef3db1d4 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -100,7 +100,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1121 / 9,555 | `dependency-baseline.json` 当前快照;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份 | +| 宿主 Python 模块 / 内部依赖边 | 1121 / 9,553 | `dependency-baseline.json` 当前快照;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/transfer-notifications.md b/docs/transfer-notifications.md index ed4081fab..595db0630 100644 --- a/docs/transfer-notifications.md +++ b/docs/transfer-notifications.md @@ -3,8 +3,12 @@ 系统设置 → 高级设置 → 实验室中的“按批次聚合整理成功通知”默认开启, 对应 `TRANSFER_SUCCESS_NOTIFICATION_AGGREGATION=true`。一张分轨专辑的任务全部结束后, 按下载器/种子、目标媒体目录、季和用户合并成一条成功通知。没有下载 hash 时按源目录分组。 -音乐标题显示专辑,正文显示实际入库文件数、已知的专辑总曲目数及目标目录, -多文件通知提供“查看整理记录”入口;单文件明确批次立即发送并沿用用户通知模板。 +单文件与多文件汇总均使用用户配置的入库成功通知模板(`organizeSuccess`), +不在发送代码中固定标题和正文。聚合后向模板提供实际入库文件数(`file_count`)、 +总大小(`total_size`)、音乐专辑及已知总曲目数(`album`、`total_tracks`)、 +电视剧季集范围(`season_episode`)等上下文;最终文案和排版由模板决定。 +目标目录可从 `__transferinfo__.target_diritem.path` 读取。 +多文件通知保留“查看整理记录”入口;单文件明确批次结束即发,不等待静默窗口。 电影、电视剧和音乐使用同一个全局开关。关闭时保持原有按媒体作业发送的行为。 目录设置继续使用原来的“发送通知”开关(`notify: true/false`),不增加通知粒度设置。 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 87bf4a516..93edaddd9 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -941,8 +941,8 @@ "runtime_only": true } }, - "edge_count": 9555, - "edge_sha256": "c90fbb78ee03a1a1a4dddfb21bc9d31dc30a1add57b60016883571b7ea1404d7", + "edge_count": 9553, + "edge_sha256": "5cabdf16599788b91c81063ea71b02ceddc8b51586960497ba567ac5d78518b9", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -5832,8 +5832,6 @@ "app.chain.transfer.format -> app.schemas.types", "app.chain.transfer.history -> app.application", "app.chain.transfer.history -> app.application.audio", - "app.chain.transfer.history -> app.application.messaging", - "app.chain.transfer.history -> app.application.messaging.message", "app.chain.transfer.history -> app.application.transfer", "app.chain.transfer.history -> app.application.transfer.models", "app.chain.transfer.history -> app.application.transfer.notifications", diff --git a/tests/test_transfer_success_notification_aggregation.py b/tests/test_transfer_success_notification_aggregation.py index cd1c4b38a..5e4cb7e1f 100644 --- a/tests/test_transfer_success_notification_aggregation.py +++ b/tests/test_transfer_success_notification_aggregation.py @@ -24,7 +24,7 @@ from app.runtime.config import ConfigModel from app.runtime.loop import main_loop_registry from app.schemas.file import FileItem from app.schemas.transfer import TransferInfo -from app.schemas.types import ContentType, EventType, MediaType, MessageType +from app.schemas.types import ContentType, EventType, MediaType, MessageType, SystemConfigKey class _Loop: @@ -122,8 +122,10 @@ def test_all_74_slow_tracks_wait_for_batch_and_send_once(chain): chain._finish_scrape_batch_task(task) assert chain.post_message.call_count == (1 if number == 74 else 0) message = chain.post_message.call_args.args[0] - assert message.title == "Album (2013)" - assert "已入库 74 个文件 / 共 74 首曲目" in message.text + assert message.title is None and message.text is None + assert chain.post_message.call_args.kwargs["mediainfo"].album == "Album" + assert chain.post_message.call_args.kwargs["mediainfo"].total_tracks == 74 + assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 74 assert message.buttons == [[{"text": "查看整理记录", "url": "https://example.test/history"}]] assert message.ctype == ContentType.OrganizeSuccess assert message.mtype == MessageType.Organize @@ -178,7 +180,7 @@ def test_overlapping_batches_wait_for_both_and_shutdown_flushes_once(chain): chain.success_notification_aggregator.close() chain.success_notification_aggregator.release_batch("b") chain.post_message.assert_called_once() - assert "已入库 2 个文件" in chain.post_message.call_args.args[0].text + assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 2 @pytest.mark.parametrize("notify,manual,background", [(False, False, True), (True, True, False)]) @@ -216,8 +218,9 @@ def test_movie_batch_uses_same_success_aggregation(chain): chain.post_message.assert_not_called() chain.success_notification_aggregator.release_batch("batch") message = chain.post_message.call_args.args[0] - assert message.title == "Movie (2026)" - assert "已入库 2 个文件" in message.text + assert message.title is None and message.text is None + assert chain.post_message.call_args.kwargs["mediainfo"].title == "Movie" + assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 2 assert message.ctype == ContentType.OrganizeSuccess assert chain.post_message.call_count == 1 @@ -231,7 +234,8 @@ def test_television_batch_retains_season_episode_range(chain): chain.queue_successful_transfer_notification(task, _result(number)) chain.success_notification_aggregator.release_batch("batch") message = chain.post_message.call_args.args[0] - assert message.title == "Show (2026) S01 E01-E02" + assert message.title is None and message.text is None + assert chain.post_message.call_args.kwargs["season_episode"] == "S01 E01-E02" def test_mixed_outcomes_preserve_each_history_event_and_only_count_success(chain, monkeypatch): @@ -271,7 +275,7 @@ def test_mixed_outcomes_preserve_each_history_event_and_only_count_success(chain assert chain.queue_failed_transfer_notification.call_count == 1 assert chain._record_scrape_target.call_count == 2 assert chain._TransferChain__mark_torrent_completed_if_done.call_count == 3 - assert "已入库 2 个文件" in chain.post_message.call_args.args[0].text + assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 2 assert chain.post_message.call_count == 1 calls = chain.durable_event_writer.transfer_result.call_args_list assert [call.kwargs["topic"] for call in calls] == ["transfer.completed", "transfer.completed", "transfer.audio.failed"] @@ -296,3 +300,54 @@ def test_old_callback_cannot_flush_recreated_group(): loop.timers[1].callback(*loop.timers[1].args) assert callback.call_count == 2 callback.assert_called_with(["new"]) + + +@pytest.mark.parametrize("media_type", [MediaType.MOVIE, MediaType.TV, MediaType.MUSIC]) +@pytest.mark.parametrize("file_count", [1, 3]) +def test_success_notifications_render_configured_template(chain, monkeypatch, media_type, file_count): + """单文件与汇总通知经真实入口和模板引擎渲染,隔离配置、历史和渠道边界。""" + template = """{ + 'title': '自定义 {{ title_year }}{% if track_number %} #{{ track_number }}{% endif %}' + '{% if season_episode %} {{ season_episode }}{% endif %}', + 'text': '{{ type }}|文件={{ file_count }}|大小={{ total_size }}' + '{% if total_tracks %}|总曲目={{ total_tracks }}{% endif %}' + '|目录={{ __transferinfo__.target_diritem.path }}' + }""" + config = Mock() + config.get.return_value = {ContentType.OrganizeSuccess.value: template} + monkeypatch.setattr("app.application.messaging.message.get_configured_system_config", lambda: config) + chain.post_message = TransferChain.post_message.__get__(chain) + chain.messageoper = Mock() + chain._dispatch_notification_steps = Mock() + + for number in range(1, file_count + 1): + task = _task(number) + if media_type != MediaType.MUSIC: + task.meta = MetaInfo("Movie 2026" if media_type == MediaType.MOVIE else f"Show S01E{number:02d}") + task.mediainfo = MediaInfo(title="Movie" if media_type == MediaType.MOVIE else "Show", year="2026", type=media_type) + chain._register_scrape_batch_task(task) + chain.notify_successful_transfer(task, _result(number)) + chain._finish_scrape_batch_task(task) + chain._dispatch_notification_steps.assert_not_called() + chain._close_scrape_batch("batch") + + chain._dispatch_notification_steps.assert_called_once() + message = chain._dispatch_notification_steps.call_args.args[0] + expected_title = { + MediaType.MUSIC: "Album (2013)", + MediaType.MOVIE: "Movie (2026)", + MediaType.TV: "Show (2026) S01 E01" + ("-E03" if file_count > 1 else ""), + }[media_type] + assert message.title == f"自定义 {expected_title}" + assert message.text.startswith(f"{media_type.value}|文件={file_count}|大小={file_count * 100}.0B") + assert message.text.endswith("|目录=/library/Album") + if media_type == MediaType.MUSIC: + assert "|总曲目=74" in message.text + assert "#" not in message.title + assert message.buttons == ([[{"text": "查看整理记录", "url": "https://example.test/history"}]] if file_count > 1 else None) + assert message.ctype == ContentType.OrganizeSuccess + assert message.mtype == MessageType.Organize + assert message.link == "https://example.test/history" + assert message.username == "tester" + config.get.assert_called_with(SystemConfigKey.NotificationTemplates) + chain.messageoper.add.assert_called_once()