feat(v3): move notification templates into DB migration and support music notifications

This commit is contained in:
jxxghp
2026-08-10 13:23:20 +08:00
parent 75e56d8e9a
commit d72ed5ed8e
4 changed files with 285 additions and 18 deletions

View File

@@ -2,7 +2,7 @@ import time
from typing import Any, List, Optional
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.meta import MetaBase, MetaMusic
from app.db import DbOper
from app.db.models.transferhistory import TransferHistory
from app.schemas import TransferInfo, FileItem
@@ -224,6 +224,17 @@ class TransferHistoryOper(DbOper):
"""
TransferHistory.update_download_hash(self._db, historyid, download_hash)
@staticmethod
def _history_title(
meta: MetaBase, mediainfo: Optional[MediaInfo] = None
) -> Optional[str]:
"""音乐文件优先记录曲目标题,其它媒体保持识别标题。"""
if isinstance(meta, MetaMusic) and meta.title:
return meta.title
if mediainfo and mediainfo.title:
return mediainfo.title
return meta.name
def add_success(self, fileitem: FileItem, mode: str, meta: MetaBase,
mediainfo: MediaInfo, transferinfo: TransferInfo,
downloader: Optional[str] = None, download_hash: Optional[str] = None):
@@ -240,7 +251,7 @@ class TransferHistoryOper(DbOper):
mode=mode,
type=mediainfo.type.value,
category=mediainfo.category,
title=mediainfo.title,
title=self._history_title(meta, mediainfo),
year=mediainfo.year,
tmdbid=mediainfo.tmdb_id,
imdbid=mediainfo.imdb_id,
@@ -282,7 +293,7 @@ class TransferHistoryOper(DbOper):
mode=mode,
type=mediainfo.type.value,
category=mediainfo.category,
title=mediainfo.title or meta.name,
title=self._history_title(meta, mediainfo),
year=mediainfo.year or meta.year,
tmdbid=mediainfo.tmdb_id,
imdbid=mediainfo.imdb_id,
@@ -311,7 +322,7 @@ class TransferHistoryOper(DbOper):
)
else:
his = self.add_force(
title=meta.name,
title=self._history_title(meta),
year=meta.year,
tmdbid=meta.tmdbid,
doubanid=meta.doubanid,

View File

@@ -493,15 +493,31 @@ class TemplateHelper(metaclass=SingletonClass):
if not context:
raise ValueError("上下文构建失败")
rendered = self.render_with_context(parsed, context)
if not rendered:
raise ValueError("模板渲染失败")
if isinstance(parsed, dict):
# 字典模板按字段独立渲染,避免 JSON 序列化转义引号
# 破坏 {% if type == "音乐" %} 等带引号的 Jinja 表达式
rendered = json.dumps(
{
key: self.render_with_context(value, context)
if isinstance(value, str) else value
for key, value in parsed.items()
},
ensure_ascii=False,
)
if not rendered:
raise ValueError("模板渲染失败")
processed = self.__process_formatted_string(rendered)
else:
rendered = self.render_with_context(parsed, context)
if not rendered:
raise ValueError("模板渲染失败")
processed = rendered if template_type == 'string' else self.__process_formatted_string(rendered)
if rendered := rendered if template_type == 'string' else self.__process_formatted_string(rendered):
if processed:
# 缓存上下文
self.set_cache_context(rendered, context)
self.set_cache_context(processed, context)
# 返回渲染结果
return rendered
return processed
return None
except Exception as e:
raise ValueError(f"模板处理失败: {str(e)}") from e
@@ -519,14 +535,14 @@ class TemplateHelper(metaclass=SingletonClass):
@staticmethod
def parse_template_content(template_content: Union[str, dict],
template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[str]:
template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[Union[str, dict]]:
"""
解析模板字符
:param template_content 模板格式字符
:param template_type 模板字符类型
"""
def parse_literal(_template_content: str) -> str:
def parse_literal(_template_content: str) -> Union[dict, str]:
"""
解析Python字面量
"""
@@ -535,7 +551,7 @@ class TemplateHelper(metaclass=SingletonClass):
str) else _template_content
if not isinstance(template_dict, dict):
raise ValueError("解析结果必须是一个字典")
return json.dumps(template_dict, ensure_ascii=False)
return template_dict
except (ValueError, SyntaxError) as err:
raise ValueError(f"无效的Python字面量格式: {str(err)}")
@@ -543,14 +559,14 @@ class TemplateHelper(metaclass=SingletonClass):
if template_type:
parse_map = {
'string': lambda x: str(x),
'dict': lambda x: json.dumps(x, ensure_ascii=False),
'dict': lambda x: x,
'literal': parse_literal
}
return parse_map[template_type](template_content)
# 自动判断模板类型
if isinstance(template_content, dict):
return json.dumps(template_content, ensure_ascii=False)
return template_content
elif isinstance(template_content, str):
try:
json.loads(template_content)
@@ -661,7 +677,17 @@ class MessageTemplateHelper:
"""
try:
if template := MessageTemplateHelper._get_template(message):
rendered = TemplateHelper().render(template_content=template, *args, **kwargs)
try:
rendered = TemplateHelper().render(
template_content=template, *args, **kwargs
)
except ValueError as err:
logger.warning(
f"通知模板 {message.ctype.value} 渲染失败,消息保持原样:{str(err)}"
)
return message
if not isinstance(rendered, dict):
raise ValueError("通知模板渲染结果必须是字典")
for key, value in rendered.items():
if hasattr(message, key):
setattr(message, key, value)
@@ -675,8 +701,15 @@ class MessageTemplateHelper:
"""
获取消息模板
"""
template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates)
return template_dict.get(message.ctype.value)
try:
template_dict = SystemConfigOper().get(SystemConfigKey.NotificationTemplates) or {}
if isinstance(template_dict, dict):
configured = template_dict.get(message.ctype.value)
if str(configured or "").strip() not in {"", "{}", "{ }"}:
return configured
except Exception as err:
logger.warning(f"读取通知模板失败:{str(err)}")
return None
class MessageQueueManager(metaclass=SingletonClass):

View File

@@ -0,0 +1,69 @@
"""3.0.0
V3 大版本初始化默认通知模板
Revision ID: 4dadad1d161a
Revises: e8b1c4d7a2f9
Create Date: 2026-08-10
"""
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas.types import SystemConfigKey
# revision identifiers, used by Alembic.
revision = "4dadad1d161a"
down_revision = "e8b1c4d7a2f9"
branch_labels = None
depends_on = None
def upgrade() -> None:
# V3 为大版本升级,通知模板直接覆盖用户旧设置,且迁移只执行一次;
# 默认模板同时兼容影视与音乐(音乐的下载、入库通知补齐艺术家/专辑/音质信息)。
value = {
"organizeSuccess": """
{
'title': '{{ title_year }}{% if track_number %} #{{ track_number }}{% endif %}'
'{% if season_episode %} {{ season_episode }}{% endif %} 已入库',
'text': '类型:{{ type }}{% if category %},类别:{{ category }}{% endif %}'
'{% if type == "音乐" and artist %}\\n艺术家{{ artist }}{% endif %}'
'{% if type == "音乐" and album %}\\n专辑{{ album }}{% endif %}'
'{% if type == "音乐" and audio_specs %}\\n音质{{ audio_specs }}{% endif %}'
'{% if resource_term %},质量:{{ resource_term }}{% endif %}'
',共{{ file_count }}个文件,大小:{{ total_size }}'
'{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}'
}""",
"downloadAdded": """
{
'title': '{{ title_year }}{% if track_number %} #{{ track_number }}{% endif %}'
'{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
'{% if type == "音乐" and artist %}\\n艺术家{{ artist }}{% endif %}'
'{% if type == "音乐" and album %}\\n专辑{{ album }}{% endif %}'
'{% if type == "音乐" and audio_specs %}\\n音质{{ audio_specs }}{% endif %}'
'{% if resource_term %}\\n质量{{ resource_term }}{% endif %}'
'{% if size %}\\n大小{{ size }}{% endif %}'
'{% if torrent_title %}\\n种子{{ torrent_title }}{% endif %}'
'{% if pubdate %}\\n发布时间{{ pubdate }}{% endif %}'
'{% if freedate %}\\n免费时间{{ freedate }}{% endif %}'
'{% if seeders %}\\n做种数{{ seeders }}{% endif %}'
'{% if volume_factor %}\\n促销{{ volume_factor }}{% endif %}'
'{% if hit_and_run %}\\nHit&Run{{ hit_and_run }}{% endif %}'
'{% if labels %}\\n标签{{ labels }}{% endif %}'
'{% if description %}\\n描述{{ description }}{% endif %}'
}""",
"subscribeAdded": "{'title': '{{ title_year }}{% if season_fmt %} {{ season_fmt }}{% endif %} 已添加订阅'}",
"subscribeComplete": """
{
'title': '{{ title_year }}'
'{% if season_fmt %} {{ season_fmt }}{% endif %} 已完成{{ msgstr }}',
'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}'
'{% if username %},来自用户:{{ username }}{% endif %}'
'{% if actors %}\\n演员{{ actors }}{% endif %}'
'{% if overview %}\\n简介{{ overview }}{% endif %}'
}"""
}
SystemConfigOper().set(SystemConfigKey.NotificationTemplates, value)
def downgrade() -> None:
pass

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
通知模板渲染回归测试。
V3 起通知模板不再硬编码在程序中,默认模板由数据库升级迁移写入
``SystemConfigKey.NotificationTemplates``。本测试验证:
1. V3 迁移会把默认模板一次性写入数据库配置,并覆盖用户旧设置;
2. 带双引号条件的 Jinja 模板(如 ``{% if type == "音乐" %}``)可正常渲染,
避免 JSON 序列化转义引号导致音乐下载/入库通知内容为空的历史问题;
3. 模板完全由数据库配置驱动,配置缺失时不渲染、消息保持原样。
"""
from pathlib import Path
import importlib.util
import pytest
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageTemplateHelper, TemplateHelper
from app.schemas.message import Notification
from app.schemas.types import ContentType, SystemConfigKey
MUSIC_ORGANIZE_TEMPLATE = """
{
'title': '{{ title_year }}{% if track_number %} #{{ track_number }}{% endif %}'
'{% if season_episode %} {{ season_episode }}{% endif %} 已入库',
'text': '类型:{{ type }}{% if category %},类别:{{ category }}{% endif %}'
'{% if type == "音乐" and artist %}\\n艺术家{{ artist }}{% endif %}'
'{% if type == "音乐" and album %}\\n专辑{{ album }}{% endif %}'
'{% if type == "音乐" and audio_specs %}\\n音质{{ audio_specs }}{% endif %}'
'{% if resource_term %},质量:{{ resource_term }}{% endif %}'
',共{{ file_count }}个文件,大小:{{ total_size }}'
}"""
MUSIC_CONTEXT = {
"type": "音乐",
"title_year": "晴天 (2003)",
"track_number": 3,
"artist": "周杰伦",
"album": "叶惠美",
"audio_specs": "FLAC · 24-bit · 96 kHz",
"resource_term": "无损",
"file_count": 12,
"total_size": "1.2 GB",
}
@pytest.fixture()
def notification_templates() -> SystemConfigOper:
"""备份并还原通知模板配置,避免测试污染其它用例。"""
config_oper = SystemConfigOper()
original = config_oper.get(SystemConfigKey.NotificationTemplates)
yield config_oper
config_oper.set(SystemConfigKey.NotificationTemplates, original)
def test_literal_template_with_quoted_condition_renders_music() -> None:
"""
带 ``{% if type == "音乐" %}`` 双引号条件的模板应能正常渲染,
音乐字段(艺术家/专辑/音质)必须出现在渲染结果中。
"""
rendered = TemplateHelper().render(
template_content=MUSIC_ORGANIZE_TEMPLATE, **MUSIC_CONTEXT
)
assert isinstance(rendered, dict)
assert rendered["title"] == "晴天 (2003) #3 已入库"
assert "艺术家:周杰伦" in rendered["text"]
assert "专辑:叶惠美" in rendered["text"]
assert "音质FLAC · 24-bit · 96 kHz" in rendered["text"]
def test_literal_template_skips_music_fields_for_video() -> None:
"""
影视场景下模板中的音乐专属字段不应出现,影视字段正常渲染。
"""
rendered = TemplateHelper().render(
template_content=MUSIC_ORGANIZE_TEMPLATE,
type="电视剧",
title_year="测试剧集 (2025)",
season_episode="S01E05",
category="国产剧",
resource_term="1080p",
file_count=1,
total_size="5 GB",
)
assert isinstance(rendered, dict)
assert rendered["title"] == "测试剧集 (2025) S01E05 已入库"
assert "艺术家" not in rendered["text"]
assert "专辑" not in rendered["text"]
assert "类别:国产剧" in rendered["text"]
def test_message_renders_from_db_config(notification_templates: SystemConfigOper) -> None:
"""
MessageTemplateHelper 应使用数据库配置的模板渲染消息,
而不再依赖程序内硬编码模板。
"""
notification_templates.set(
SystemConfigKey.NotificationTemplates,
{"organizeSuccess": MUSIC_ORGANIZE_TEMPLATE},
)
message = Notification(ctype=ContentType.OrganizeSuccess)
MessageTemplateHelper.render(message, **MUSIC_CONTEXT)
assert message.title == "晴天 (2003) #3 已入库"
assert "艺术家:周杰伦" in message.text
def test_message_without_template_config_stays_unchanged(
notification_templates: SystemConfigOper,
) -> None:
"""
数据库中没有模板配置时消息应保持原样,不应渲染也不应报错。
"""
notification_templates.set(SystemConfigKey.NotificationTemplates, None)
message = Notification(ctype=ContentType.OrganizeSuccess)
MessageTemplateHelper.render(message, **MUSIC_CONTEXT)
assert message.title is None
assert message.text is None
def test_v3_migration_overwrites_templates_once(
notification_templates: SystemConfigOper,
) -> None:
"""
V3 大版本迁移应无条件覆盖用户旧通知模板配置,并写入全部 4 类模板。
"""
migration_path = (
Path(__file__).resolve().parent.parent
/ "database" / "versions" / "4dadad1d161a_3_0_0.py"
)
spec = importlib.util.spec_from_file_location("v3_migration", migration_path)
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
notification_templates.set(
SystemConfigKey.NotificationTemplates, {"organizeSuccess": "用户自定义模板"}
)
migration.upgrade()
templates = notification_templates.get(SystemConfigKey.NotificationTemplates)
assert set(templates.keys()) == {
"organizeSuccess", "downloadAdded", "subscribeAdded", "subscribeComplete",
}
assert templates["organizeSuccess"] != "用户自定义模板"
assert "音乐" in templates["organizeSuccess"]
assert "音乐" in templates["downloadAdded"]