From d72ed5ed8ebecebab09c86c78a30d086ef955835 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 10 Aug 2026 13:23:20 +0800 Subject: [PATCH] feat(v3): move notification templates into DB migration and support music notifications --- app/db/transferhistory_oper.py | 19 ++- app/helper/message.py | 61 ++++++-- database/versions/4dadad1d161a_3_0_0.py | 69 +++++++++ tests/test_notification_template_render.py | 154 +++++++++++++++++++++ 4 files changed, 285 insertions(+), 18 deletions(-) create mode 100644 database/versions/4dadad1d161a_3_0_0.py create mode 100644 tests/test_notification_template_render.py diff --git a/app/db/transferhistory_oper.py b/app/db/transferhistory_oper.py index c5f5a47c0..a08641ec1 100644 --- a/app/db/transferhistory_oper.py +++ b/app/db/transferhistory_oper.py @@ -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, diff --git a/app/helper/message.py b/app/helper/message.py index ba094aeb3..da93dbf37 100644 --- a/app/helper/message.py +++ b/app/helper/message.py @@ -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): diff --git a/database/versions/4dadad1d161a_3_0_0.py b/database/versions/4dadad1d161a_3_0_0.py new file mode 100644 index 000000000..8f4d6a80b --- /dev/null +++ b/database/versions/4dadad1d161a_3_0_0.py @@ -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 \ No newline at end of file diff --git a/tests/test_notification_template_render.py b/tests/test_notification_template_render.py new file mode 100644 index 000000000..3f343431b --- /dev/null +++ b/tests/test_notification_template_render.py @@ -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"]