mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 02:05:13 +08:00
feat(v3): move notification templates into DB migration and support music notifications
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
69
database/versions/4dadad1d161a_3_0_0.py
Normal file
69
database/versions/4dadad1d161a_3_0_0.py
Normal 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
|
||||
154
tests/test_notification_template_render.py
Normal file
154
tests/test_notification_template_render.py
Normal 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"]
|
||||
Reference in New Issue
Block a user