mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +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 typing import Any, List, Optional
|
||||||
|
|
||||||
from app.core.context import MediaInfo
|
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 import DbOper
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
from app.schemas import TransferInfo, FileItem
|
from app.schemas import TransferInfo, FileItem
|
||||||
@@ -224,6 +224,17 @@ class TransferHistoryOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
TransferHistory.update_download_hash(self._db, historyid, download_hash)
|
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,
|
def add_success(self, fileitem: FileItem, mode: str, meta: MetaBase,
|
||||||
mediainfo: MediaInfo, transferinfo: TransferInfo,
|
mediainfo: MediaInfo, transferinfo: TransferInfo,
|
||||||
downloader: Optional[str] = None, download_hash: Optional[str] = None):
|
downloader: Optional[str] = None, download_hash: Optional[str] = None):
|
||||||
@@ -240,7 +251,7 @@ class TransferHistoryOper(DbOper):
|
|||||||
mode=mode,
|
mode=mode,
|
||||||
type=mediainfo.type.value,
|
type=mediainfo.type.value,
|
||||||
category=mediainfo.category,
|
category=mediainfo.category,
|
||||||
title=mediainfo.title,
|
title=self._history_title(meta, mediainfo),
|
||||||
year=mediainfo.year,
|
year=mediainfo.year,
|
||||||
tmdbid=mediainfo.tmdb_id,
|
tmdbid=mediainfo.tmdb_id,
|
||||||
imdbid=mediainfo.imdb_id,
|
imdbid=mediainfo.imdb_id,
|
||||||
@@ -282,7 +293,7 @@ class TransferHistoryOper(DbOper):
|
|||||||
mode=mode,
|
mode=mode,
|
||||||
type=mediainfo.type.value,
|
type=mediainfo.type.value,
|
||||||
category=mediainfo.category,
|
category=mediainfo.category,
|
||||||
title=mediainfo.title or meta.name,
|
title=self._history_title(meta, mediainfo),
|
||||||
year=mediainfo.year or meta.year,
|
year=mediainfo.year or meta.year,
|
||||||
tmdbid=mediainfo.tmdb_id,
|
tmdbid=mediainfo.tmdb_id,
|
||||||
imdbid=mediainfo.imdb_id,
|
imdbid=mediainfo.imdb_id,
|
||||||
@@ -311,7 +322,7 @@ class TransferHistoryOper(DbOper):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
his = self.add_force(
|
his = self.add_force(
|
||||||
title=meta.name,
|
title=self._history_title(meta),
|
||||||
year=meta.year,
|
year=meta.year,
|
||||||
tmdbid=meta.tmdbid,
|
tmdbid=meta.tmdbid,
|
||||||
doubanid=meta.doubanid,
|
doubanid=meta.doubanid,
|
||||||
|
|||||||
+44
-11
@@ -493,15 +493,31 @@ class TemplateHelper(metaclass=SingletonClass):
|
|||||||
if not context:
|
if not context:
|
||||||
raise ValueError("上下文构建失败")
|
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)
|
rendered = self.render_with_context(parsed, context)
|
||||||
if not rendered:
|
if not rendered:
|
||||||
raise ValueError("模板渲染失败")
|
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
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"模板处理失败: {str(e)}") from e
|
raise ValueError(f"模板处理失败: {str(e)}") from e
|
||||||
@@ -519,14 +535,14 @@ class TemplateHelper(metaclass=SingletonClass):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_template_content(template_content: Union[str, dict],
|
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_content 模板格式字符
|
||||||
:param template_type 模板字符类型
|
:param template_type 模板字符类型
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def parse_literal(_template_content: str) -> str:
|
def parse_literal(_template_content: str) -> Union[dict, str]:
|
||||||
"""
|
"""
|
||||||
解析Python字面量
|
解析Python字面量
|
||||||
"""
|
"""
|
||||||
@@ -535,7 +551,7 @@ class TemplateHelper(metaclass=SingletonClass):
|
|||||||
str) else _template_content
|
str) else _template_content
|
||||||
if not isinstance(template_dict, dict):
|
if not isinstance(template_dict, dict):
|
||||||
raise ValueError("解析结果必须是一个字典")
|
raise ValueError("解析结果必须是一个字典")
|
||||||
return json.dumps(template_dict, ensure_ascii=False)
|
return template_dict
|
||||||
except (ValueError, SyntaxError) as err:
|
except (ValueError, SyntaxError) as err:
|
||||||
raise ValueError(f"无效的Python字面量格式: {str(err)}")
|
raise ValueError(f"无效的Python字面量格式: {str(err)}")
|
||||||
|
|
||||||
@@ -543,14 +559,14 @@ class TemplateHelper(metaclass=SingletonClass):
|
|||||||
if template_type:
|
if template_type:
|
||||||
parse_map = {
|
parse_map = {
|
||||||
'string': lambda x: str(x),
|
'string': lambda x: str(x),
|
||||||
'dict': lambda x: json.dumps(x, ensure_ascii=False),
|
'dict': lambda x: x,
|
||||||
'literal': parse_literal
|
'literal': parse_literal
|
||||||
}
|
}
|
||||||
return parse_map[template_type](template_content)
|
return parse_map[template_type](template_content)
|
||||||
|
|
||||||
# 自动判断模板类型
|
# 自动判断模板类型
|
||||||
if isinstance(template_content, dict):
|
if isinstance(template_content, dict):
|
||||||
return json.dumps(template_content, ensure_ascii=False)
|
return template_content
|
||||||
elif isinstance(template_content, str):
|
elif isinstance(template_content, str):
|
||||||
try:
|
try:
|
||||||
json.loads(template_content)
|
json.loads(template_content)
|
||||||
@@ -661,7 +677,17 @@ class MessageTemplateHelper:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if template := MessageTemplateHelper._get_template(message):
|
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():
|
for key, value in rendered.items():
|
||||||
if hasattr(message, key):
|
if hasattr(message, key):
|
||||||
setattr(message, key, value)
|
setattr(message, key, value)
|
||||||
@@ -675,8 +701,15 @@ class MessageTemplateHelper:
|
|||||||
"""
|
"""
|
||||||
获取消息模板
|
获取消息模板
|
||||||
"""
|
"""
|
||||||
template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates)
|
try:
|
||||||
return template_dict.get(message.ctype.value)
|
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):
|
class MessageQueueManager(metaclass=SingletonClass):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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