From 20c1f30877a39f0bb5b08e17b40334736d07e247 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Mon, 5 May 2025 05:27:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(message):=20=E5=AE=9E=E7=8E=B0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=B6=88=E6=81=AF=E6=A8=A1=E6=9D=BF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MessageTemplateHelper 类用于渲染消息模板 - 在 ChainBase 中集成消息模板渲染功能 - 修改 DownloadChain、SubscribeChain 和 TransferChain 以使用新消息模板 - 新增 TemplateHelper 类用于处理模板格式 - 在 SystemConfigKey 中添加 NotificationTemplates 配置项 - 更新 Notification 模型以支持 ctype 字段 --- app/chain/__init__.py | 22 +- app/chain/download.py | 75 ++---- app/chain/subscribe.py | 45 ++-- app/chain/transfer.py | 34 +-- app/helper/message.py | 65 +++++ app/helper/template.py | 389 ++++++++++++++++++++++++++++ app/modules/filemanager/__init__.py | 93 +------ app/schemas/message.py | 4 +- app/schemas/types.py | 17 ++ 9 files changed, 547 insertions(+), 197 deletions(-) create mode 100644 app/helper/template.py diff --git a/app/chain/__init__.py b/app/chain/__init__.py index f0e6dd86..a806efa6 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -18,7 +18,7 @@ from app.core.module import ModuleManager from app.core.plugin import PluginManager from app.db.message_oper import MessageOper from app.db.user_oper import UserOper -from app.helper.message import MessageHelper, MessageQueueManager +from app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper from app.helper.service import ServiceConfigHelper from app.log import logger from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \ @@ -542,13 +542,27 @@ class ChainBase(metaclass=ABCMeta): """ return self.run_module("media_files", mediainfo=mediainfo) - def post_message(self, message: Notification) -> None: + def post_message(self, + message: Optional[Notification] = None, + meta: Optional[MetaBase] = None, + mediainfo: Optional[MediaInfo] = None, + torrentinfo: Optional[TorrentInfo] = None, + transferinfo: Optional[TransferInfo] = None, + **kwargs) -> None: """ 发送消息 - :param message: 消息体 + :param message: Notification实例 + :param meta: 元数据 + :param mediainfo: 媒体信息 + :param torrentinfo: 种子信息 + :param transferinfo: 文件整理信息 + :param kwargs: 其他参数(覆盖业务对象属性值) :return: 成功或失败 """ - # 保存原消息 + # 渲染消息 + message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo, + torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs) + # 保存消息 self.messagehelper.put(message, role="user", title=message.title) self.messageoper.add(**message.dict()) # 发送消息按设置隔离 diff --git a/app/chain/download.py b/app/chain/download.py index 5e64dd3b..73a49f06 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -20,7 +20,7 @@ from app.helper.message import MessageHelper from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, ResourceDownloadEventData -from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ChainEventType +from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, ChainEventType from app.utils.http import RequestUtils from app.utils.string import StringUtils @@ -38,63 +38,6 @@ class DownloadChain(ChainBase): self.directoryhelper = DirectoryHelper() self.messagehelper = MessageHelper() - def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo, - channel: MessageChannel = None, username: Optional[str] = None, - download_episodes: Optional[str] = None): - """ - 发送添加下载的消息,根据消息场景开关决定发给谁 - :param meta: 元数据 - :param mediainfo: 媒体信息 - :param torrent: 种子信息 - :param channel: 通知渠道 - :param username: 通知显示的下载用户信息 - :param download_episodes: 下载的集数 - """ - # 拼装消息内容 - msg_text = "" - if username: - msg_text = f"用户:{username}" - if torrent.site_name: - msg_text = f"{msg_text}\n站点:{torrent.site_name}" - if meta.resource_term: - msg_text = f"{msg_text}\n质量:{meta.resource_term}" - if torrent.size: - if str(torrent.size).replace(".", "").isdigit(): - size = StringUtils.str_filesize(torrent.size) - else: - size = torrent.size - msg_text = f"{msg_text}\n大小:{size}" - if torrent.title: - msg_text = f"{msg_text}\n种子:{torrent.title}" - if torrent.pubdate: - msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}" - if torrent.freedate: - msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}" - if torrent.seeders: - msg_text = f"{msg_text}\n做种数:{torrent.seeders}" - if torrent.uploadvolumefactor and torrent.downloadvolumefactor: - msg_text = f"{msg_text}\n促销:{torrent.volume_factor}" - if torrent.hit_and_run: - msg_text = f"{msg_text}\nHit&Run:是" - if torrent.labels: - msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}" - if torrent.description: - html_re = re.compile(r'<[^>]+>', re.S) - description = html_re.sub('', torrent.description) - torrent.description = re.sub(r'<[^>]+>', '', description) - msg_text = f"{msg_text}\n描述:{torrent.description}" - - # 下载成功按规则发送消息 - self.post_message(Notification( - channel=channel, - mtype=NotificationType.Download, - title=f"{mediainfo.title_year} " - f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载", - text=msg_text, - image=mediainfo.get_message_image(), - link=settings.MP_DOMAIN('/#/downloading'), - username=username)) - def download_torrent(self, torrent: TorrentInfo, channel: MessageChannel = None, source: Optional[str] = None, @@ -384,8 +327,20 @@ class DownloadChain(ChainBase): self.downloadhis.add_files(files_to_add) # 下载成功发送消息 - self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, - username=username, download_episodes=download_episodes) + self.post_message( + Notification( + channel=channel, + mtype=NotificationType.Download, + ctype=ContentType.DownloadAdded, + image=_media.get_message_image(), + link=settings.MP_DOMAIN('/#/downloading'), + username=username + ), + meta=_meta, + mediainfo=_media, + torrentinfo=_torrent, + download_episodes=download_episodes + ) # 下载成功后处理 self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file) # 广播事件 diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 0d224e34..218620c9 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -29,7 +29,7 @@ from app.helper.subscribe import SubscribeHelper from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import MediaRecognizeConvertEventData -from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType +from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, ContentType from app.utils.singleton import Singleton @@ -228,26 +228,22 @@ class SubscribeChain(ChainBase, metaclass=Singleton): userid=userid)) return None, err_msg elif message: - logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功') - if username: - text = f"评分:{mediainfo.vote_average},来自用户:{username}" - else: - text = f"评分:{mediainfo.vote_average}" - if mediainfo.actors: - text += f"\n演员:{'、 '.join([actor['name'] for actor in mediainfo.actors])}" - if mediainfo.overview: - text += f"\n简介:{mediainfo.overview}" if mediainfo.type == MediaType.TV: link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') else: link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 订阅成功按规则发送消息 - self.post_message(schemas.Notification(mtype=NotificationType.Subscribe, - title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅", - text=text, - image=mediainfo.get_message_image(), - link=link, - username=username)) + self.post_message( + schemas.Notification( + mtype=NotificationType.Subscribe, + ctype=ContentType.SubscribeAdded, + image=mediainfo.get_message_image(), + link=link, + username=username + ), + mediainfo=mediainfo, + username=username + ) # 发送事件 EventManager().send_event(EventType.SubscribeAdded, { "subscribe_id": sid, @@ -1017,11 +1013,18 @@ class SubscribeChain(ChainBase, metaclass=Singleton): else: link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 完成订阅按规则发送消息 - self.post_message(schemas.Notification(mtype=NotificationType.Subscribe, - title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}', - image=mediainfo.get_message_image(), - link=link, - username=subscribe.username)) + self.post_message( + schemas.Notification( + mtype=NotificationType.Subscribe, + ctype=ContentType.SubscribeComplete, + image=mediainfo.get_message_image(), + link=link, + username=subscribe.username + ), + meta=meta, + mediainfo=mediainfo, + msgstr=msgstr + ) # 发送事件 EventManager().send_event(EventType.SubscribeComplete, { "subscribe_id": subscribe.id, diff --git a/app/chain/transfer.py b/app/chain/transfer.py index d89c0e77..e3b1b60c 100644 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -30,7 +30,7 @@ from app.log import logger from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \ TransferTask, TransferQueue, TransferJob, TransferJobTask from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \ - SystemConfigKey, ChainEventType + SystemConfigKey, ChainEventType, ContentType from app.schemas import StorageOperSelectionEventData from app.utils.singleton import Singleton from app.utils.string import StringUtils @@ -1374,22 +1374,16 @@ class TransferChain(ChainBase, metaclass=Singleton): """ 发送入库成功的消息 """ - msg_title = f"{mediainfo.title_year} {meta.season_episode if not season_episode else season_episode} 已入库" - if mediainfo.vote_average: - msg_str = f"评分:{mediainfo.vote_average},类型:{mediainfo.type.value}" - else: - msg_str = f"类型:{mediainfo.type.value}" - if mediainfo.category: - msg_str = f"{msg_str},类别:{mediainfo.category}" - if meta.resource_term: - msg_str = f"{msg_str},质量:{meta.resource_term}" - msg_str = f"{msg_str},共{transferinfo.file_count}个文件," \ - f"大小:{StringUtils.str_filesize(transferinfo.total_size)}" - if transferinfo.message: - msg_str = f"{msg_str},以下文件处理失败:\n{transferinfo.message}" - # 发送 - self.post_message(Notification( - mtype=NotificationType.Organize, - title=msg_title, text=msg_str, image=mediainfo.get_message_image(), - username=username, - link=settings.MP_DOMAIN('#/history'))) + self.post_message( + Notification( + mtype=NotificationType.Organize, + ctype=ContentType.OrganizeSuccess, + image=mediainfo.get_message_image(), + username=username, + link=settings.MP_DOMAIN('#/history') + ), + meta=meta, + mediainfo=mediainfo, + transferinfo=transferinfo, + season_episode=season_episode + ) diff --git a/app/helper/message.py b/app/helper/message.py index 07e8650b..ab184894 100644 --- a/app/helper/message.py +++ b/app/helper/message.py @@ -10,6 +10,8 @@ from typing import List, Optional, Callable from app.core.config import global_vars from app.db.systemconfig_oper import SystemConfigOper +from app.helper.template import TemplateHelper +from app.schemas.message import Notification from app.schemas.types import SystemConfigKey from app.utils.singleton import Singleton, SingletonClass from app.log import logger @@ -209,3 +211,66 @@ class MessageHelper(metaclass=Singleton): if not self.user_queue.empty(): return self.user_queue.get(block=False) return None + + +class MessageTemplateHelper: + """ + 消息模板渲染器 + """ + @staticmethod + def render(message: Notification, *args, **kwargs) -> Optional[Notification]: + """ + 渲染消息模板 + """ + if not MessageTemplateHelper.is_instance_valid(message): + if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs): + logger.info("将使用模板渲染消息内容") + return MessageTemplateHelper._apply_template_data(message, *args, **kwargs) + return message + + @staticmethod + def is_instance_valid(message: Notification) -> bool: + """ + 检查消息是否有效 + """ + if isinstance(message, Notification): + return bool(message.title or message.text) + return False + + @staticmethod + def meets_update_conditions(message: Notification, *args, **kwargs) -> bool: + """ + 判断是否满足消息实例更新条件 + + 满足条件需同时具备: + 1. 消息为有效Notification实例 + 2. 消息指定了模板类型(ctype) + 3. 存在待渲染的模板变量数据 + """ + if isinstance(message, Notification): + return message.ctype and (args or kwargs) + return False + + @staticmethod + def _apply_template_data(message: Notification, *args, **kwargs) -> Optional[Notification]: + """ + 更新消息实例 + """ + try: + if template := MessageTemplateHelper._get_template(message): + rendered = TemplateHelper().render(template_content=template, *args, **kwargs) + for key, value in rendered.items(): + if hasattr(message, key): + setattr(message, key, value) + return message + except Exception as e: + logger.error(f"更新Notification时出现错误:{str(e)}") + return message + + @staticmethod + def _get_template(message: Notification) -> Optional[str]: + """ + 获取消息模板 + """ + template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates) + return template_dict.get(message.ctype.value) diff --git a/app/helper/template.py b/app/helper/template.py new file mode 100644 index 00000000..960ce171 --- /dev/null +++ b/app/helper/template.py @@ -0,0 +1,389 @@ +import ast, json, re +from typing import Any, Literal, Optional, List, Dict, Union + +from jinja2 import Template + +from app.core.context import MediaInfo, TorrentInfo +from app.core.meta import MetaBase +from app.schemas.tmdb import TmdbEpisode +from app.schemas.transfer import TransferInfo +from app.utils.singleton import SingletonClass +from app.utils.string import StringUtils +from app.log import logger + + +class TemplateHelper(metaclass=SingletonClass): + """ + 模板格式渲染帮助类 + """ + def __init__(self): + self.builder = TemplateContextBuilder() + + def render(self, + template_content: str, + template_type: Literal['string', 'dict', 'literal'] = "literal", + **kwargs) -> Union[dict, str]: + """ + 根据模板格式渲染内容 + :param template_content: 模板字符串 + :param template_type: 模板字符串类型(消息通知`literal`, 路径`string`) + :param kwargs: 补传业务对象 + :raises ValueError: 当模板处理过程中出现错误 + :return: 渲染后的结果 + """ + try: + # 解析模板字符 + parsed = self.parse_template_content(template_content, template_type) + if not parsed: + raise ValueError("模板解析失败") + + context = self.builder.build(**kwargs) + if not context: + raise ValueError("上下文构建失败") + + rendered = self.render_with_context(parsed, context) + if not rendered: + raise ValueError("模板渲染失败") + + return (rendered if template_type == 'string' + else self.__process_formatted_string(rendered)) + + except Exception as e: + logger.error(f"模板处理失败: {str(e)}") + raise ValueError(f"模板处理失败: {str(e)}") from e + + @staticmethod + def render_with_context(template_content: str, context: dict) -> str: + """ + 使用指定上下文渲染 Jinja2 模板字符串 + template_content: Jinja2 模板字符串 + context: 渲染用的上下文数据 + """ + # 渲染模板 + template = Template(template_content) + return template.render(context) + + @staticmethod + def parse_template_content(template_content: Union[str, dict], template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[str]: + """ + 解析模板字符 + :param template_content 模板格式字符 + :param template_type 模板字符类型 + """ + def parse_literal(template_content: str) -> str: + """ + 解析Python字面量 + """ + try: + template_dict = ast.literal_eval(template_content) if isinstance(template_content, str) else template_content + if not isinstance(template_dict, dict): + raise ValueError("解析结果必须是一个字典") + return json.dumps(template_dict, ensure_ascii=False) + except (ValueError, SyntaxError) as e: + raise ValueError(f"无效的Python字面量格式: {str(e)}") + + try: + if template_type: + parse_map = { + 'string': lambda x: str(x), + 'dict': lambda x: json.dumps(x, ensure_ascii=False), + 'literal': parse_literal + } + return parse_map[template_type](template_content) + + # 自动判断模板类型 + if isinstance(template_content, dict): + return json.dumps(template_content, ensure_ascii=False) + elif isinstance(template_content, str): + try: + json.loads(template_content) + return template_content + except json.JSONDecodeError: + try: + return parse_literal(template_content) + except (ValueError, SyntaxError): + return template_content + else: + raise ValueError(f"不支持的模板类型: {type(template_content)}") + + except Exception as e: + logger.error(f"模板解析失败: {str(e)}") + return None + + @staticmethod + def __process_formatted_string(rendered: str) -> Optional[Union[dict, str]]: + """ + 处理格式化字符串 + 保留转义字符 + """ + def restore_chars(obj: Any) -> Any: + """恢复特殊字符""" + if isinstance(obj, str): + return obj.replace('\\n', '\n').replace('\\r', '\r').replace('\\t', '\t').replace('\\b', '\b').replace('\\f', '\f') + elif isinstance(obj, dict): + return {k: restore_chars(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [restore_chars(item) for item in obj] + return obj + + # 定义特殊字符映射 + special_chars = { + '\n': '\\n', # 换行符 + '\r': '\\r', # 回车符 + '\t': '\\t', # 制表符 + '\b': '\\b', # 退格符 + '\f': '\\f', # 换页符 + } + + # 处理特殊字符 + processed = rendered + for char, escape in special_chars.items(): + processed = processed.replace(char, escape) + + # 尝试解析为JSON + try: + rendered_dict = json.loads(processed) + return restore_chars(rendered_dict) + except json.JSONDecodeError: + return rendered + + +class TemplateContextBuilder: + """ + 模板上下文构建器 + """ + def __init__(self): + self._context = {} + + def build( + self, + meta: Optional[MetaBase] = None, + mediainfo: Optional[MediaInfo] = None, + torrentinfo: Optional[TorrentInfo] = None, + transferinfo: Optional[TransferInfo] = None, + file_extension: Optional[str] = None, + episodes_info: Optional[List[TmdbEpisode]] = None, + include_raw_objects: bool = False, + **kwargs + ) -> Dict[str, Any]: + """ + :param meta: 媒体信息 + :param mediainfo: 媒体信息 + :param torrentinfo: 种子信息 + :param transferinfo: 传输信息 + :param file_extension: 文件扩展名 + :param episodes_info: 剧集信息 + :param include_raw_objects: 是否包含原始对象 + :return: 渲染上下文字典 + """ + self._context.clear() + self._add_episode_details(meta, episodes_info) + self._add_media_info(mediainfo) + self._add_transfer_info(transferinfo) + self._add_torrent_info(torrentinfo) + self._add_file_info(file_extension) + if kwargs: self._context.update(kwargs) + + if include_raw_objects: + self._add_raw_objects(meta, mediainfo, torrentinfo, transferinfo, episodes_info) + + return self._context + + def _add_media_info(self, mediainfo: MediaInfo): + """ + 增加媒体信息 + """ + if not mediainfo: return + base_info = { + # 标题 + "title": self.__convert_invalid_characters(mediainfo.title), + # 英文标题 + "en_title": self.__convert_invalid_characters(mediainfo.en_title), + # 原语种标题 + "original_title": self.__convert_invalid_characters(mediainfo.original_title), + # 年份 + "year": mediainfo.year or self._context.get("year"), + "title_year": mediainfo.title_year or self._context.get("title_year"), + } + + _meta_season = self._context.get("season") + media_info = { + # 类型 + "type": mediainfo.type.value, + # 类别 + "category": mediainfo.category, + # 评分 + "vote_average": mediainfo.vote_average, + # 海报 + "poster": mediainfo.get_poster_image(), + # 背景图 + "backdrop": mediainfo.get_backdrop_image(), + # 季年份根据season值获取 + "season_year": mediainfo.season_years.get( + int(_meta_season), + None) if (mediainfo.season_years and _meta_season) else None, + # 演员 + "actors": '、 '.join([actor['name'] for actor in mediainfo.actors]), + # 简介 + "overview": mediainfo.overview, + # TMDBID + "tmdbid": mediainfo.tmdb_id, + # IMDBID + "imdbid": mediainfo.imdb_id, + # 豆瓣ID + "doubanid": mediainfo.douban_id, + } + self._context.update({**base_info, **media_info}) + + def _add_episode_details(self, meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]]): + """添加剧集详细信息""" + if not meta: + return + + episode_data = {"episode_title": None, "episode_date": None} + if meta.begin_episode and episodes: + for episode in episodes: + if episode.episode_number == meta.begin_episode: + episode_data.update({ + "episode_title": self.__convert_invalid_characters(episode.name), + "episode_date": episode.air_date if episode.air_date else None + }) + break + + meta_info = { + # 原文件名 + "original_name": meta.title, + # 识别名称(优先使用中文) + "name": meta.name, + # 识别的英文名称(可能为空) + "en_name": meta.en_name, + # 年份 + "year": meta.year, + # 名字 + 年份 + "title_year": self._context.get("title_year") or "%s (%s)" % (meta.name, meta.year) if meta.year else meta.name, + # 季号 + "season": meta.season_seq, + # 集号 + "episode": meta.episode_seqs, + # 季集 SxxExx + "season_episode": "%s%s" % (meta.season, meta.episode), + # 段/节 + "part": meta.part, + # 自定义占位符 + "customization": meta.customization, + } + + tech_metadata = { + # 资源类型 + "resourceType": meta.resource_type, + # 特效 + "effect": meta.resource_effect, + # 版本 + "edition": meta.edition, + # 分辨率 + "videoFormat": meta.resource_pix, + # 质量 + "resource_term": meta.resource_term, + # 制作组/字幕组 + "releaseGroup": meta.resource_team, + # 视频编码 + "videoCodec": meta.video_encode, + # 音频编码 + "audioCodec": meta.audio_encode, + } + self._context.update({**meta_info, **tech_metadata, **episode_data}) + + def _add_torrent_info(self, torrentinfo: Optional[TorrentInfo]): + if not torrentinfo: return + if torrentinfo.size: + if str(torrentinfo.size).replace(".", "").isdigit(): + size = StringUtils.str_filesize(torrentinfo.size) + else: + size = torrentinfo.size + + if torrentinfo.description: + html_re = re.compile(r'<[^>]+>', re.S) + description = html_re.sub('', torrentinfo.description) + torrentinfo.description = re.sub(r'<[^>]+>', '', description) + + torrent_info = { + # 种子标题 + "torrent_title": torrentinfo.title, + # 发布时间 + "pubdate": torrentinfo.pubdate, + # 免费剩余时间 + "freedate": torrentinfo.freedate_diff, + # 做种数 + "seeders": torrentinfo.seeders, + # 促销信息 + "volume_factor": torrentinfo.volume_factor, + # Hit&Run + "hit_and_run": "是" if torrentinfo.hit_and_run else "否", + # 种子标签 + "labels": ' '.join(torrentinfo.labels), + # 描述 + "description": torrentinfo.description, + # 站点名称 + "site_name": torrentinfo.site_name, + # 种子大小 + "size": size, + } + self._context.update(torrent_info) + + def _add_transfer_info(self, transferinfo: Optional[TransferInfo]) -> Optional[Dict]: + """添加文件转移上下文""" + if not transferinfo: return + ctx = { + "transfer_type": transferinfo.transfer_type, + "file_count": transferinfo.file_count, + "total_size": StringUtils.str_filesize(transferinfo.total_size), + "err_msg": transferinfo.message, + } + self._context.update(ctx) + + def _add_file_info(self, file_extension: Optional[str]): + """添加文件信息""" + if not file_extension: return + file_info = { + # 文件后缀 + "fileExt": file_extension, + } + self._context.update(file_info) + + def _add_raw_objects( + self, + meta: Optional[MetaBase], + mediainfo: Optional[MediaInfo], + torrentinfo: Optional[TorrentInfo], + transferinfo: Optional[TransferInfo], + episodes_info: Optional[List[TmdbEpisode]], + ): + """添加原始对象引用""" + raw_objects = { + # 文件元数据 + "__meta__": meta, + # 识别的媒体信息 + "__mediainfo__": mediainfo, + # 种子信息 + "__torrentinfo__": torrentinfo, + # 文件转移信息 + "__transferinfo__": transferinfo, + # 当前季的全部集信息 + "__episodes_info__": episodes_info, + } + self._context.update({k: v for k, v in raw_objects.items() if v is not None}) + + @staticmethod + def __convert_invalid_characters(filename: str): + if not filename: + return filename + invalid_characters = r'\/:*?"<>|' + # 创建半角到全角字符的转换表 + halfwidth_chars = "".join([chr(i) for i in range(33, 127)]) + fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)]) + translation_table = str.maketrans(halfwidth_chars, fullwidth_chars) + # 将不支持的字符替换为对应的全角字符 + for char in invalid_characters: + filename = filename.replace(char, char.translate(translation_table)) + return filename + diff --git a/app/modules/filemanager/__init__.py b/app/modules/filemanager/__init__.py index 4178bb9e..d0d2e65f 100644 --- a/app/modules/filemanager/__init__.py +++ b/app/modules/filemanager/__init__.py @@ -13,6 +13,7 @@ from app.core.metainfo import MetaInfo, MetaInfoPath from app.helper.directory import DirectoryHelper from app.helper.message import MessageHelper from app.helper.module import ModuleHelper +from app.helper.template import TemplateHelper from app.log import logger from app.modules import _ModuleBase from app.modules.filemanager.storages import StorageBase @@ -1218,97 +1219,7 @@ class FileManagerModule(_ModuleBase): :param file_ext: 文件扩展名 :param episodes_info: 当前季的全部集信息 """ - - def __convert_invalid_characters(filename: str): - if not filename: - return filename - invalid_characters = r'\/:*?"<>|' - # 创建半角到全角字符的转换表 - halfwidth_chars = "".join([chr(i) for i in range(33, 127)]) - fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)]) - translation_table = str.maketrans(halfwidth_chars, fullwidth_chars) - # 将不支持的字符替换为对应的全角字符 - for char in invalid_characters: - filename = filename.replace(char, char.translate(translation_table)) - return filename - - # 获取集标题 - episode_title = None - if meta.begin_episode and episodes_info: - for episode in episodes_info: - if episode.episode_number == meta.begin_episode: - episode_title = episode.name - break - # 获取集播出日期 - episode_date = None - if meta.begin_episode and episodes_info: - for episode in episodes_info: - if episode.episode_number == meta.begin_episode: - episode_date = episode.air_date - break - - return { - # 标题 - "title": __convert_invalid_characters(mediainfo.title), - # 英文标题 - "en_title": __convert_invalid_characters(mediainfo.en_title), - # 原语种标题 - "original_title": __convert_invalid_characters(mediainfo.original_title), - # 原文件名 - "original_name": meta.title, - # 识别名称(优先使用中文) - "name": meta.name, - # 识别的英文名称(可能为空) - "en_name": meta.en_name, - # 年份 - "year": mediainfo.year or meta.year, - # 季年份根据season值获取 - "season_year": mediainfo.season_years.get( - int(meta.season_seq), - None) if (mediainfo.season_years and meta.season_seq) else None, - # 资源类型 - "resourceType": meta.resource_type, - # 特效 - "effect": meta.resource_effect, - # 版本 - "edition": meta.edition, - # 分辨率 - "videoFormat": meta.resource_pix, - # 制作组/字幕组 - "releaseGroup": meta.resource_team, - # 视频编码 - "videoCodec": meta.video_encode, - # 音频编码 - "audioCodec": meta.audio_encode, - # TMDBID - "tmdbid": mediainfo.tmdb_id, - # IMDBID - "imdbid": mediainfo.imdb_id, - # 豆瓣ID - "doubanid": mediainfo.douban_id, - # 季号 - "season": meta.season_seq, - # 集号 - "episode": meta.episode_seqs, - # 季集 SxxExx - "season_episode": "%s%s" % (meta.season, meta.episode), - # 段/节 - "part": meta.part, - # 剧集标题 - "episode_title": __convert_invalid_characters(episode_title), - # 剧集日期根据episodes_info值获取 - "episode_date": episode_date, - # 文件后缀 - "fileExt": file_ext, - # 自定义占位符 - "customization": meta.customization, - # 文件元数据 - "__meta__": meta, - # 识别的媒体信息 - "__mediainfo__": mediainfo, - # 当前季的全部集信息 - "__episodes_info__": episodes_info, - } + return TemplateHelper().builder.build(meta=meta, mediainfo=mediainfo, file_extension=file_ext, episodes_info=episodes_info) @staticmethod def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path: diff --git a/app/schemas/message.py b/app/schemas/message.py index de04138c..ba785c63 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -2,7 +2,7 @@ from typing import Optional, Union from pydantic import BaseModel, Field -from app.schemas.types import NotificationType, MessageChannel +from app.schemas.types import ContentType, NotificationType, MessageChannel class CommingMessage(BaseModel): @@ -45,6 +45,8 @@ class Notification(BaseModel): source: Optional[str] = None # 消息类型 mtype: Optional[NotificationType] = None + # 内容类型 + ctype: Optional[ContentType] = None # 标题 title: Optional[str] = None # 文本内容 diff --git a/app/schemas/types.py b/app/schemas/types.py index c159613a..f6c66911 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -151,6 +151,8 @@ class SystemConfigKey(Enum): FollowSubscribers = "FollowSubscribers" # 通知发送时间 NotificationSendTime = "NotificationSendTime" + # 通知消息格式模板 + NotificationTemplates = "NotificationTemplates" # 处理进度Key字典 @@ -189,6 +191,21 @@ class NotificationType(Enum): Other = "其它" +class ContentType(str, Enum): + """ + 消息内容类型 + 操作状态的通知消息类型标识 + """ + # 订阅添加成功 + SubscribeAdded: str = "subscribeAdded" + # 订阅完成 + SubscribeComplete: str = "subscribeComplete" + # 入库成功 + OrganizeSuccess: str = "organizeSuccess" + # 下载开始(添加下载任务成功) + DownloadAdded: str = "downloadAdded" + + # 消息渠道 class MessageChannel(Enum): """