fix(transfer): render aggregated success notifications with user template (#7007)

This commit is contained in:
jxxghp
2026-10-15 13:10:43 +08:00
committed by GitHub
parent 5e476e2419
commit 63c8c0d71a
6 changed files with 82 additions and 43 deletions

View File

@@ -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

View File

@@ -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` 再执行快照测试及一次

View File

@@ -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 等基线均为零 |

View File

@@ -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`),不增加通知粒度设置。

View File

@@ -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",

View File

@@ -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()