""" 消息查询、模板渲染、队列处理与实时通知分发。 """ from __future__ import annotations import ast import asyncio import json import queue import re import socket import threading import time from contextvars import Context, copy_context from datetime import datetime from functools import partial from typing import Any, Callable, Dict, List, Literal, Optional, Protocol, Union, cast, overload from jinja2 import Template from app.application.configuration import get_chain_runtime_config_snapshot, get_configured_system_config from app.domain.context import MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.foundation import size as size_tools from app.foundation.crypto import HashUtils from app.foundation.singleton import Singleton, SingletonClass from app.foundation.text import convert as zhconv_convert from app.runtime.cache import TTLCache from app.runtime.log import logger from app.runtime.stop import runtime_stop_state from app.schemas.message import Message from app.schemas.tmdb import TmdbEpisode from app.schemas.transfer import TransferInfo from app.schemas.types import MUSIC_ENTITY_ALBUM, MessageType, SystemConfigKey # 专辑名尾部的括号年份标记;重命名模板会独立追加 `({{year}})`, # 标签或目录名中自带的尾部年份若不剥离,会生成重复年份的目录名(issue #6355) _ALBUM_TRAILING_YEAR_RE = re.compile( r"(?:[\s\u3000]*[\(\[(【]\s*(?:19|20)\d{2}\s*[\)\])】])+$" ) _MESSAGE_QUEUE_STOP_TIMEOUT_SECONDS = 10.0 # SSE 实时消息队列上限:前端关闭或切到后台时没有消费者,超出后丢弃最旧消息, # 避免长时间无人打开页面时队列无界增长,重新打开后又逐条补弹积压消息 _SSE_QUEUE_MAXSIZE = 100 class AsyncMessageQueryRepository(Protocol): """ 消息查询用例依赖的异步持久化端口。 """ async def async_list_by_page( self, page: int = 1, count: int = 30 ) -> list[Any]: """ 分页读取 Web 消息。 :param page: 页码,从 1 开始 :param count: 每页最多返回的消息数,默认 30 :return: 按记录时间和主键降序排列的消息记录 """ ... async def async_list_sent_by_page( self, page: int = 1, count: int = 30, all_clear_before: Optional[str] = None, system_clear_before: Optional[str] = None, media_clear_before: Optional[str] = None, ) -> list[Any]: """ 分页读取清理水位之后的通知消息。 :param page: 页码,从 1 开始 :param count: 每页最多返回的消息数,默认 30 :param all_clear_before: 全部通知的清理时间水位,按存储层时间字符串比较;None 表示不限制 :param system_clear_before: 无图片通知的清理时间水位,None 表示不限制此类消息 :param media_clear_before: 含图片通知的清理时间水位,None 表示不限制此类消息 :return: 记录时间严格晚于相应水位的已发送通知,按时间和主键降序排列 """ ... class MessageQueryService: """ 封装消息历史读取与持久化对象投影。 """ def __init__(self, repository: AsyncMessageQueryRepository): """ 使用显式消息查询端口初始化服务。 """ self._repository = repository async def list_web(self, page: int = 1, count: int = 20) -> list[dict[str, Any]]: """ 分页返回可由 API schema 消费的 Web 消息字典。 """ messages = await self._repository.async_list_by_page(page=page, count=count) result: list[dict[str, Any]] = [] for message in messages: try: result.append(message.to_dict()) except Exception as error: logger.error(f"获取WEB消息列表失败: {str(error)}") return result async def list_notifications( self, page: int = 1, count: int = 20, all_clear_before: Optional[str] = None, system_clear_before: Optional[str] = None, media_clear_before: Optional[str] = None, ) -> list[dict[str, Any]]: """ 分页返回清理水位之后的通知消息字典。 """ messages = await self._repository.async_list_sent_by_page( page=page, count=count, all_clear_before=all_clear_before, system_clear_before=system_clear_before, media_clear_before=media_clear_before, ) return [message.to_dict() for message in messages] class TemplateContextBuilder: """ 模板上下文构建器。 无状态实现:所有 ``_add_*`` 方法均为静态方法,接受并就地修改调用方提供的 ``context`` 字典。``build`` 每次调用都基于一份新的本地字典装填后返回, 实例自身不持有任何中间状态——可以被多线程共享调用而不会产生互相串味的 ``rename_dict``,配合 ``settings.TRANSFER_THREADS > 1`` 的并发整理场景安全。 保留为类(而非自由函数)是为了向后兼容现有调用方式 (``TemplateHelper().builder.build(...)``)。 """ def build( self, meta: Optional[MetaBase] = None, mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, torrentinfo: Optional[TorrentInfo] = None, transferinfo: Optional[TransferInfo] = None, file_extension: Optional[str] = None, episodes_info: Optional[List[TmdbEpisode]] = None, include_raw_objects: bool = True, aggregate_music_album: bool = False, **kwargs: Any, ) -> Dict[str, Any]: """ 构建一次性渲染上下文字典。 每次调用都新建本地 ``context`` 字典,依次填充各业务来源和当前实例的 主机名后返回过滤掉 None 值的副本,调用之间互不影响。 音乐在字段取值完成后统一按开关转简体,避免原始标签覆盖在线转换结果。 :param meta: 媒体元数据 :param mediainfo: 识别的媒体信息 :param torrentinfo: 种子信息 :param transferinfo: 整理结果信息 :param file_extension: 文件扩展名 :param episodes_info: 当前季的全部集信息 :param include_raw_objects: 是否在 dict 里附带原始对象引用(``__meta__`` 等) :param aggregate_music_album: 是否按整专聚合(通知场景):专辑实体 批量下载/入库只发一条通知,标题取专辑名且不展示单曲序号; 重命名等逐文件场景必须为 False 以保留每个文件的曲名和曲序 :return: 渲染上下文字典 """ context: Dict[str, Any] = {} self._add_episode_details(context, meta, episodes_info) self._add_media_info(context, mediainfo, aggregate_music_album) self._add_transfer_info(context, transferinfo) self._add_torrent_info(context, torrentinfo) self._add_file_info(context, file_extension) if isinstance(meta, MetaMusic) or isinstance(mediainfo, MusicInfo): self._simplify_music_context(context) context.update(kwargs, instance_name=socket.gethostname()) if include_raw_objects: self._add_raw_objects(context, meta, mediainfo, torrentinfo, transferinfo, episodes_info) return {k: v for k, v in context.items() if v is not None} @staticmethod def _simplify_music_context(context: Dict[str, Any]) -> None: """ 只转换最终音乐展示字段,保留原始文件名、标签、媒体身份和取值优先级。 """ if not get_chain_runtime_config_snapshot().music_metadata_to_simplified: return for field_name in ("title", "name", "artist", "album", "album_artist", "title_year", "version"): value = context.get(field_name) if isinstance(value, str): context[field_name] = zhconv_convert(value, "zh-hans") if "artists" in context: context["artists"] = [zhconv_convert(artist, "zh-hans") for artist in context["artists"]] @classmethod def _add_media_info( cls, context: Dict[str, Any], mediainfo: Optional[Union[MediaInfo, MusicInfo]], aggregate_music_album: bool = False, ) -> None: """ 将 MediaInfo 中的标题、季年份、海报等业务字段就地写入 ``context``。 会读取 ``context`` 中由 ``_add_episode_details`` 先填好的 ``season`` / ``year`` / ``title_year`` 占位,保证电视剧场景下季/年优先沿用 meta 解析值; 音乐场景保留曲目级标签;已识别专辑实体时以所选数据源覆盖专辑级字段; 通知场景(``aggregate_music_album=True``)下整专批量以专辑为标题主体。 """ if not mediainfo: return if isinstance(mediainfo, MusicInfo): # 专辑实体批量下载/入库只发一条通知:标题取专辑名、不展示单曲 # 序号;重命名等逐文件场景保持 False,继续使用文件自己的曲名和曲序。 is_album_entity = mediainfo.music_type == MUSIC_ENTITY_ALBUM is_album_context = aggregate_music_album and is_album_entity has_album_identity = bool(is_album_entity and mediainfo.media_source and mediainfo.media_id) # 专辑场景以识别结果的专辑名为标题;整专年份以识别结果为准, # 逐文件场景沿用 meta 解析年份,保证文件级年份优先。 year = ( mediainfo.year if (has_album_identity and mediainfo.year) else (context.get("year") or mediainfo.year) ) if is_album_context and mediainfo.album: title = cls.__strip_album_trailing_year( cls.__convert_invalid_characters(mediainfo.album), year ) else: title = context.get("title") or cls.__convert_invalid_characters(mediainfo.title) artists = context.get("artists") or [ cls.__convert_invalid_characters(item) for item in mediainfo.artists ] artist = context.get("artist") or cls.__convert_invalid_characters(mediainfo.artist) # 标签/目录名自带的尾部年份会被重命名模板的 `({{year}})` 再次追加, # 统一剥离避免生成 "专辑 (2018) (2018)" 这类重复年份目录(issue #6355) album_value = mediainfo.album or mediainfo.title if has_album_identity else context.get("album") or mediainfo.album album = cls.__strip_album_trailing_year( cls.__convert_invalid_characters(album_value), year, ) album_artist_value = mediainfo.album_artist or mediainfo.artist \ if has_album_identity else context.get("album_artist") or mediainfo.album_artist album_artist = cls.__convert_invalid_characters(album_artist_value) disc_number = context.get("disc_number") or mediainfo.disc_number track_number = ( None if is_album_context else (context.get("track_number") or mediainfo.track_number) ) context.update({ "type": (mediainfo.type.value if mediainfo.type else ""), "title": title, "name": title if is_album_context else (context.get("name") or title), "artists": artists, "artist": artist, "album": album, "album_artist": album_artist, "year": year, "title_year": f"{title} ({year})" if title and year else title, "disc_number": disc_number, "track_number": track_number, "track": f"{track_number:02d}" if track_number else None, "total_tracks": context.get("total_tracks") or mediainfo.total_tracks, "duration": context.get("duration") or mediainfo.duration, "isrc": context.get("isrc") or mediainfo.isrc, "version": context.get("version") or mediainfo.version, "audio_format": context.get("audio_format") or mediainfo.audio_format, "audio_lossless": context.get("audio_lossless") if context.get("audio_lossless") is not None else mediainfo.audio_lossless, "audio_quality": context.get("audio_quality") or mediainfo.audio_quality, "audio_specs": context.get("audio_specs") or mediainfo.audio_specs, "bit_depth": context.get("bit_depth") or mediainfo.bit_depth, "sample_rate": context.get("sample_rate") or mediainfo.sample_rate, "sample_rate_khz": context.get("sample_rate_khz") or (f"{mediainfo.sample_rate / 1000:g}" if mediainfo.sample_rate else None), "bitrate": context.get("bitrate") or mediainfo.bitrate, "bitrate_kbps": context.get("bitrate_kbps") or (round(mediainfo.bitrate / 1000) if mediainfo.bitrate else None), "category": mediainfo.category, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), "media_source": mediainfo.media_source, "media_id": mediainfo.media_id, }) return season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None media_source = mediainfo.media_source media_id = mediainfo.media_id base_info = { # 标题 "title": cls.__convert_invalid_characters(mediainfo.title), # 英文标题 "en_title": cls.__convert_invalid_characters(mediainfo.en_title), # 原语种标题 "original_title": cls.__convert_invalid_characters(mediainfo.original_title), # 季号 "season": context.get("season") or mediainfo.season, # 季号格式为 Sxx "season_fmt": context.get("season_fmt") or season_fmt, # 年份 "year": mediainfo.year or context.get("year"), # 媒体标题 + 年份 "title_year": mediainfo.title_year or context.get("title_year"), } _meta_season = context.get("season") media_info = { # 类型 "type": (mediainfo.type.value if mediainfo.type else ""), # 类别 "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[:5]]), # 简介 "overview": mediainfo.overview, # TMDB 媒体标识 "tmdbid": mediainfo.tmdb_id, # IMDb 媒体标识 "imdbid": mediainfo.imdb_id, # 豆瓣ID "doubanid": mediainfo.douban_id, # Bangumi 条目标识 "bangumiid": mediainfo.bangumi_id, # AniList 条目标识 "anilistid": mediainfo.anilist_id, # 当前媒体数据源 "media_source": media_source, # 当前数据源原生ID "media_id": str(media_id) if media_id is not None else None, } context.update({**base_info, **media_info}) @classmethod def _add_episode_details( cls, context: Dict[str, Any], meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]], ) -> None: """ 将 meta 解析得到的剧集级信息、技术字段写入 ``context``,并尝试匹配 TMDB 集详情填入 ``episode_title`` / ``episode_date``。 """ if not meta: return if isinstance(meta, MetaMusic): context.update({ "original_name": meta.org_string or meta.title, "name": cls.__convert_invalid_characters(meta.title), "title": cls.__convert_invalid_characters(meta.title), "artists": [cls.__convert_invalid_characters(item) for item in meta.artists], "artist": cls.__convert_invalid_characters(meta.artist), # 标签专辑名常自带尾部年份,与模板独立追加的年份去重(issue #6355) "album": cls.__strip_album_trailing_year( cls.__convert_invalid_characters(meta.album or ""), meta.year ), "album_artist": cls.__convert_invalid_characters(meta.album_artist or ""), "year": meta.year, "disc_number": meta.disc_number, "track_number": meta.track_number, "track": f"{meta.track_number:02d}" if meta.track_number else None, "total_discs": meta.total_discs, "total_tracks": meta.total_tracks, "audio_format": meta.audio_format, "audio_lossless": meta.audio_lossless, "audio_quality": meta.audio_quality, "audio_specs": meta.audio_specs, "bit_depth": meta.bit_depth, "sample_rate": meta.sample_rate, "sample_rate_khz": f"{meta.sample_rate / 1000:g}" if meta.sample_rate else None, "bitrate": meta.bitrate, "bitrate_kbps": round(meta.bitrate / 1000) if meta.bitrate else None, "duration": meta.duration, "isrc": meta.isrc, "version": meta.version, }) return episode_data: Dict[str, Optional[str]] = {"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": cls.__convert_invalid_characters(episode.name or ""), "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": context.get("title_year") or "%s (%s)" % ( meta.name, meta.year) if meta.year else meta.name, # 季号 "season": meta.season_seq, # 季号格式为 Sxx "season_fmt": meta.season, # 集号 "episode": meta.episode_seqs, # 当前季总集数 "total_episodes": len(episodes) if episodes else 0, # 季集 SxxExx "season_episode": "%s%s" % (meta.season, meta.episode), # 段/节 "part": meta.part, # 自定义占位符 "customization": meta.customization, # 每秒帧数 "fps": meta.fps, } 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, # 视频位深 "videoBit": meta.video_bit, # 音频编码 "audioCodec": meta.audio_encode, # 流媒体平台 "webSource": meta.web_source, } context.update({**meta_info, **tech_metadata, **episode_data}) @staticmethod def _add_torrent_info(context: Dict[str, Any], torrentinfo: Optional[TorrentInfo]) -> None: """ 将种子信息写入 ``context``,描述字段会去除 HTML 标签。 副作用提醒:当 ``torrentinfo.description`` 包含 HTML 时,会就地清洗 原对象的 description 字段——保留原始行为,避免破坏现有调用方对清洗后 描述的依赖。 """ if not torrentinfo: return size: Union[str, int] = 0 if torrentinfo.size: if str(torrentinfo.size).replace(".", "").isdigit(): size = size_tools.format_compact_size(torrentinfo.size) else: size = str(torrentinfo.size) else: size = 0 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, } context.update(torrent_info) @staticmethod def _add_transfer_info(context: Dict[str, Any], transferinfo: Optional[TransferInfo]) -> None: """ 将整理结果(类型、文件数、总大小、错误信息)写入 ``context``。 """ if not transferinfo: return ctx = { "transfer_type": transferinfo.transfer_type, "file_count": transferinfo.file_count, "total_size": size_tools.format_compact_size(transferinfo.total_size or 0), "err_msg": transferinfo.message, } context.update(ctx) @staticmethod def _add_file_info(context: Dict[str, Any], file_extension: Optional[str]) -> None: """ 将文件扩展名写入 ``context.fileExt``。 """ if not file_extension: return file_info = { # 文件后缀 "fileExt": file_extension, } context.update(file_info) @staticmethod def _add_raw_objects( context: Dict[str, Any], meta: Optional[MetaBase], mediainfo: Optional[Union[MediaInfo, MusicInfo]], torrentinfo: Optional[TorrentInfo], transferinfo: Optional[TransferInfo], episodes_info: Optional[List[TmdbEpisode]], ) -> None: """ 以双下划线键名将原始对象引用写入 ``context``。 约定:消费方仅读不写,避免在事件回调里改这些对象污染下游流程。 """ raw_objects = { # 文件元数据 "__meta__": meta, # 识别的媒体信息 "__mediainfo__": mediainfo, # 种子信息 "__torrentinfo__": torrentinfo, # 文件转移信息 "__transferinfo__": transferinfo, # 当前季的全部集信息 "__episodes_info__": episodes_info, } context.update(raw_objects) @staticmethod @overload def __convert_invalid_characters(filename: str) -> str: """ 按文件名输入是否存在声明对应的清理结果,保留 None 的缺省语义。 """ ... @staticmethod @overload def __convert_invalid_characters(filename: None) -> None: """ 按文件名输入是否存在声明对应的清理结果,保留 None 的缺省语义。 """ ... @staticmethod def __convert_invalid_characters(filename: Optional[str]) -> Optional[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 @staticmethod def __strip_album_trailing_year( album: Optional[str], year: Optional[Union[int, str]] ) -> Optional[str]: """ 去除专辑名尾部的括号年份标记(如 ``欲望反光 (2018)`` -> ``欲望反光``)。 音频标签或下载目录常把发行年份写进专辑名,而重命名模板会独立追加 ``({{year}})``,两者叠加会生成 "专辑 (2018) (2018)" 这类重复年份目录; 仅当存在可独立渲染的年份时才剥离,避免丢失只存在于专辑名中的年份信息。 """ if not album or not year: return album return _ALBUM_TRAILING_YEAR_RE.sub("", album) or album class TemplateHelper(metaclass=SingletonClass): """ 模板格式渲染帮助类 """ def __init__(self) -> None: """ 初始化模板上下文构建器和短期渲染缓存。 """ self.builder = TemplateContextBuilder() self.cache = TTLCache(region="notification", maxsize=100, ttl=600) @staticmethod def _generate_cache_key(cuntent: Union[str, Dict[str, Any]]) -> str: """ 生成缓存键 """ if isinstance(cuntent, dict): base_str = cuntent.get("title", '') + cuntent.get("text", '') return HashUtils.md5(json.dumps(base_str, sort_keys=True, ensure_ascii=False)) return HashUtils.md5(cuntent) def get_cache_context(self, cuntent: Union[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]: """ 获取缓存上下文 """ cache_key = self._generate_cache_key(cuntent) return cast(Optional[Dict[str, Any]], self.cache.get(cache_key)) def set_cache_context(self, cuntent: Union[str, Dict[str, Any]], context: Dict[str, Any]) -> None: """ 设置缓存上下文 """ cache_key = self._generate_cache_key(cuntent) self.cache[cache_key] = context def render(self, template_content: str, template_type: Literal['string', 'dict', 'literal'] = "literal", **kwargs: Any) -> Optional[Union[str, Dict[str, Any]]]: """ 根据模板格式渲染内容 :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(aggregate_music_album=True, **kwargs) if not context: 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 processed: self.set_cache_context(processed, context) return processed return None except Exception as e: raise ValueError(f"模板处理失败: {str(e)}") from e @staticmethod def render_with_context(template_content: str, context: Dict[str, Any]) -> str: """ 使用指定上下文渲染 Jinja2 模板字符串 template_content: Jinja2 模板字符串 context: 渲染用的上下文数据 """ template = Template(template_content) return cast(str, template.render(context)) @staticmethod def parse_template_content(template_content: Union[str, Dict[str, Any]], template_type: Optional[Literal['string', 'dict', 'literal']] = None) -> Optional[Union[str, Dict[str, Any]]]: """ 解析模板字符 :param template_content 模板格式字符 :param template_type 模板字符类型 """ def parse_literal(_template_content: str) -> Union[Dict[str, Any], 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 template_dict except (ValueError, SyntaxError) as err: raise ValueError(f"无效的Python字面量格式: {str(err)}") try: if template_type: parse_map = { 'string': lambda x: str(x), 'dict': lambda x: x, 'literal': parse_literal } return cast(Optional[Union[str, Dict[str, Any]]], parse_map[template_type](template_content)) if isinstance(template_content, dict): return template_content 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, Any], 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) try: rendered_dict = json.loads(processed) return cast(Optional[Union[Dict[str, Any], str]], restore_chars(rendered_dict)) except json.JSONDecodeError: return rendered def close(self) -> None: """ 清理资源 """ if self.cache: self.cache.close() class MessageTemplateHelper: """ 消息模板渲染器 """ @staticmethod def render(message: Message, *args: Any, **kwargs: Any) -> Optional[Message]: """ 从媒体上下文补充独立海报并渲染模板,保留渠道原有图片和显式海报。 """ mediainfo = kwargs.get("mediainfo") if isinstance(message, Message) and not message.poster and isinstance(mediainfo, (MediaInfo, MusicInfo)): message.poster = mediainfo.get_poster_image() or None 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: Message) -> bool: """ 检查消息是否有效 """ if isinstance(message, Message): return bool(message.title or message.text) return False @staticmethod def meets_update_conditions(message: Message, *args: Any, **kwargs: Any) -> bool: """ 判断是否满足消息实例更新条件 满足条件需同时具备: 1. 消息为有效Notification实例 2. 消息指定了模板类型(ctype) 3. 存在待渲染的模板变量数据 """ if isinstance(message, Message): return True if message.ctype and (args or kwargs) else False return False @staticmethod def _apply_template_data(message: Message, *args: Any, **kwargs: Any) -> Optional[Message]: """ 更新消息实例 """ try: if template := MessageTemplateHelper._get_template(message): try: rendered = TemplateHelper().render(template, *args, **kwargs) except ValueError as err: logger.warning( f"通知模板 {message.ctype.value if message.ctype else 'unknown'} 渲染失败,消息保持原样:{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) return message except Exception as e: logger.error(f"更新Notification时出现错误:{str(e)}") return message @staticmethod def _get_template(message: Message) -> Optional[str]: """ 获取消息模板 """ try: template_dict = get_configured_system_config().get(SystemConfigKey.NotificationTemplates) or {} if isinstance(template_dict, dict): configured = template_dict.get(message.ctype.value) if message.ctype else None if str(configured or "").strip() not in {"", "{}", "{ }"}: return configured except Exception as err: logger.warning(f"读取通知模板失败:{str(err)}") return None class MessageQueueClient: """ 把当前 Chain 的发送回调绑定到共享消息队列,不持有后台资源。 """ def __init__( self, manager: "MessageQueueManager", send_callback: Callable[..., Any], ) -> None: """ 保存共享队列和当前调用方回调。 """ self._manager = manager self._send_callback = send_callback def send_message(self, *args: Any, **kwargs: Any) -> None: """ 使用当前调用方回调同步派发或排队消息。 """ self._manager.send_message_for(self._send_callback, *args, **kwargs) async def async_send_message(self, *args: Any, **kwargs: Any) -> None: """ 使用当前调用方回调异步派发或排队消息。 """ await self._manager.async_send_message_for( self._send_callback, *args, **kwargs, ) class MessageQueueManager(metaclass=SingletonClass): """ 消息发送队列管理器 """ def __init__( self, send_callback: Optional[Callable[..., Any]] = None, check_interval: Optional[int] = 10, auto_start: bool = True, ) -> None: """ 消息队列管理器初始化 :param send_callback: 实际发送消息的回调函数 :param check_interval: 时间检查间隔(秒) :param auto_start: 是否保留旧构造即启动语义;生产组合根传入 False """ self.schedule_periods: List[tuple[int, int, int, int]] = [] self.init_config() self.queue: queue.Queue[Any] = queue.Queue() self.send_callback = send_callback self.check_interval = check_interval self._state_lock = threading.RLock() self._running = False self._stop_event = threading.Event() self.thread: Optional[threading.Thread] = None if auto_start: self.start() def start(self) -> None: """ 显式启动唯一监控线程,重复调用保持幂等。 """ with self._state_lock: if self.thread is not None and self.thread.is_alive(): return self._stop_event = threading.Event() self._running = True thread = threading.Thread( target=self._monitor_loop, daemon=True, name="MessageQueueMonitor", ) self.thread = thread try: thread.start() except BaseException: self._running = False self.thread = None raise def bind(self, send_callback: Callable[..., Any]) -> MessageQueueClient: """ 为一个 Chain 创建不拥有线程的轻量发送客户端。 """ return MessageQueueClient(self, send_callback) def init_config(self) -> None: """ 初始化配置 """ self.schedule_periods = self._parse_schedule( get_configured_system_config().get(SystemConfigKey.NotificationSendTime) ) @staticmethod def _parse_schedule(periods: Union[List[Dict[str, Any]], Dict[str, Any], None]) -> List[tuple[int, int, int, int]]: """ 将字符串时间格式转换为分钟数元组 支持格式为 'HH:MM' 或 'HH:MM:SS' 的时间字符串 """ parsed: List[tuple[int, int, int, int]] = [] if not periods: return parsed if not isinstance(periods, list): periods = [periods] for period in periods: if not period: continue if not period.get('start') or not period.get('end'): continue try: start_parts = period['start'].split(':') if len(start_parts) == 2: start_h, start_m = map(int, start_parts) elif len(start_parts) >= 3: # 只取前两个部分 (HH:MM) start_h, start_m = map(int, start_parts[:2]) else: continue end_parts = period['end'].split(':') if len(end_parts) == 2: end_h, end_m = map(int, end_parts) elif len(end_parts) >= 3: # 只取前两个部分 (HH:MM) end_h, end_m = map(int, end_parts[:2]) else: continue parsed.append((start_h, start_m, end_h, end_m)) except ValueError as e: logger.error(f"解析时间周期时出现错误:{period}. 错误:{str(e)}. 跳过此周期。") continue except Exception as e: logger.error(f"解析时间周期时出现意外错误:{period}. 错误:{str(e)}. 跳过此周期。") continue return parsed @staticmethod def _time_to_minutes(time_str: str) -> int: """ 将 'HH:MM' 格式转换为分钟数 """ hours, minutes = map(int, time_str.split(':')) return hours * 60 + minutes def _is_in_scheduled_time(self, current_time: datetime) -> bool: """ 检查当前时间是否在允许发送的时间段内 """ if not self.schedule_periods: return True current_minutes = current_time.hour * 60 + current_time.minute for period in self.schedule_periods: s_h, s_m, e_h, e_m = period start = s_h * 60 + s_m end = e_h * 60 + e_m if start <= end: if start <= current_minutes <= end: return True else: if current_minutes >= start or current_minutes <= end: return True return False def send_message(self, *args: Any, **kwargs: Any) -> None: """ 发送消息(立即发送或加入队列) """ self._dispatch_message(None, False, *args, **kwargs) def send_message_for( self, send_callback: Callable[..., Any], *args: Any, **kwargs: Any, ) -> None: """ 使用显式调用方回调立即发送或排队,避免共享队列绑定首个 Chain。 """ self._dispatch_message(send_callback, True, *args, **kwargs) async def async_send_message(self, *args: Any, **kwargs: Any) -> None: """ 异步发送消息:``immediately=True`` 立即发送,否则按调度时段入队。 历史实现把 ``immediately`` 标志直接 pop 后丢弃,所有异步消息一律 进队列;如果调用时落在用户配置的"免打扰时段"之外,消息会一直挂 着不发。这里与同步 ``send_message`` 行为对齐: 指定 ``immediately=True`` 必须当场发出,与时段无关。 """ await self._async_dispatch_message(None, False, *args, **kwargs) async def async_send_message_for( self, send_callback: Callable[..., Any], *args: Any, **kwargs: Any, ) -> None: """ 使用显式调用方回调异步发送或排队。 """ await self._async_dispatch_message(send_callback, True, *args, **kwargs) def _dispatch_message( self, send_callback: Optional[Callable[..., Any]], explicit_callback: bool, *args: Any, **kwargs: Any, ) -> None: """ 执行同步消息入口,共享调度计划但保留同步回调边界。 """ scheduled = ( False if kwargs.get("immediately", False) else self._is_in_scheduled_time(datetime.now()) ) send_now, message = self._build_message_dispatch( send_callback=send_callback, explicit_callback=explicit_callback, args=args, kwargs=kwargs, scheduled=scheduled, ) if send_now: if explicit_callback: self._send_with_callback( send_callback, *message["args"], **message["kwargs"] ) else: self._send(*message["args"], **message["kwargs"]) return self._enqueue_message(message) async def _async_dispatch_message( self, send_callback: Optional[Callable[..., Any]], explicit_callback: bool, *args: Any, **kwargs: Any, ) -> None: """ 执行异步消息入口,共享调度计划并在线程池运行同步回调。 """ scheduled = ( False if kwargs.get("immediately", False) else self._is_in_scheduled_time(datetime.now()) ) send_now, message = self._build_message_dispatch( send_callback=send_callback, explicit_callback=explicit_callback, args=args, kwargs=kwargs, scheduled=scheduled, ) if send_now: callback = ( partial(self._send_with_callback, send_callback) if explicit_callback else self._send ) context = copy_context() call = partial(callback, *message["args"], **message["kwargs"]) loop = asyncio.get_running_loop() submission = partial(loop.run_in_executor, None, context.run, call) # 默认执行器保持空底层上下文,渠道调用只使用当前消息快照。 await Context().run(submission) return self._enqueue_message(message) @staticmethod def _build_message_dispatch( *, send_callback: Optional[Callable[..., Any]], explicit_callback: bool, args: tuple[Any, ...], kwargs: dict[str, Any], scheduled: bool, ) -> tuple[bool, dict[str, Any]]: """ 纯函数生成同步、异步入口共用的立即发送结论和队列载荷。 """ dispatch_kwargs = dict(kwargs) immediately = bool(dispatch_kwargs.pop("immediately", False)) message: dict[str, Any] = { "args": args, "kwargs": dispatch_kwargs, } if explicit_callback: message["send_callback"] = send_callback return immediately or scheduled, message def _enqueue_message(self, message: dict[str, Any]) -> None: """ 写入一条已完成调度决策的消息并记录当前队列长度。 """ self.queue.put(message) logger.info(f"消息已加入队列,当前队列长度:{self.queue.qsize()}") def _send(self, *args: Any, **kwargs: Any) -> None: """ 实际发送消息(可通过回调函数自定义) """ self._send_with_callback(self.send_callback, *args, **kwargs) @staticmethod def _send_with_callback( send_callback: Optional[Callable[..., Any]], *args: Any, **kwargs: Any, ) -> None: """ 调用一条消息携带的发送回调并隔离渠道异常。 """ if send_callback: try: logger.info(f"发送消息:{kwargs}") send_callback(*args, **kwargs) except Exception as e: logger.error(f"发送消息错误:{str(e)}") def _monitor_loop(self) -> None: """ 后台线程循环检查时间并处理队列 """ while self._running: current_time = datetime.now() if self._is_in_scheduled_time(current_time): while self._running and not self.queue.empty(): if runtime_stop_state.is_system_stopped: break if not self._is_in_scheduled_time(datetime.now()): break try: message = self.queue.get_nowait() send_callback = message.get("send_callback") if send_callback is None: self._send(*message['args'], **message['kwargs']) else: self._send_with_callback( send_callback, *message['args'], **message['kwargs'], ) logger.info(f"队列剩余消息:{self.queue.qsize()}") except queue.Empty: break if self._stop_event.wait(self.check_interval): break def stop( self, timeout: float = _MESSAGE_QUEUE_STOP_TIMEOUT_SECONDS, ) -> bool: """ 在有限时间内停止队列管理器。 :param timeout: 等待监控线程退出的最长秒数 :return: 监控线程已经终止时返回 True,超时或线程自停时返回 False """ self._running = False self._stop_event.set() logger.info("正在停止消息队列...") thread = self.thread if thread is None: return True if thread is threading.current_thread(): logger.error("消息队列不能在自身监控线程内等待退出") return False thread.join(timeout=max(0.0, timeout)) if thread.is_alive(): logger.error(f"消息队列在 {timeout:g} 秒内未停止") return False with self._state_lock: if self.thread is thread: self.thread = None logger.info("消息队列已停止") return True # 实时通知严重级别:前端据此决定图标与颜色,error/warning 优先于业务类型展示 NotificationLevel = Literal["info", "success", "warning", "error"] class MessageHelper(metaclass=Singleton): """ 消息队列管理器,负责系统和插件实时消息的 SSE 推送 """ def __init__(self) -> None: """ 初始化系统消息队列和通知去重缓存。 """ self.sys_queue: queue.Queue[str] = queue.Queue(maxsize=_SSE_QUEUE_MAXSIZE) self._recent_notification_keys = TTLCache(region="message:notification", maxsize=500, ttl=60) def close(self) -> None: """ 清空未推送的实时消息并关闭通知去重缓存;真实收敛后由生命周期释放单例身份。 """ self.drain() self._recent_notification_keys.close() @staticmethod def _build_system_notification_key( message: Any, role: str, title: Optional[str] = None, note: Optional[Union[List[Any], Dict[str, Any]]] = None ) -> str: """ 构建系统通知短期去重键。 """ return json.dumps( { "role": role, "title": title or "", "text": str(message), "note": note or {}, "time": time.strftime("%Y-%m-%d %H:%M", time.localtime()), }, ensure_ascii=False, sort_keys=True, ) def _is_recent_system_notification( self, message: Any, role: str, title: Optional[str] = None, note: Optional[Union[List[Any], Dict[str, Any]]] = None ) -> bool: """ 判断系统通知是否在短时间内重复。 """ key = self._build_system_notification_key(message, role, title=title, note=note) if self._recent_notification_keys.get(key): return True self._recent_notification_keys.set(key, True) return False def put( self, message: Any, role: str = "plugin", title: Optional[str] = None, note: Optional[Union[List[Any], Dict[str, Any]]] = None, mtype: Optional[MessageType] = None, level: Optional[NotificationLevel] = None, ) -> None: """ 存消息 :param message: 消息 :param role: 消息通道 system:系统消息,plugin:插件消息;只表示来源,不表示严重程度 :param title: 标题 :param note: 附件json :param mtype: 业务类型,前端据此选择业务图标,例如订阅消息显示订阅图标 :param level: 严重级别 info/success/warning/error;未设置时前端按普通通知展示, 失败或需要用户处理的消息必须显式标记,才会显示为警示样式 """ if role not in ["system", "plugin"]: return # 没有标题时获取插件名称 if role == "plugin" and not title: title = "插件通知" if self._is_recent_system_notification(message, role, title=title, note=note): return self._put_drop_oldest(json.dumps({ "type": role, "title": title, "text": message, "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "note": note, "mtype": mtype.value if mtype else None, "level": level, })) def _put_drop_oldest(self, payload: str) -> None: """ 入队实时消息,队列已满时丢弃最旧的一条再重试。 多个生产线程并发写入时,腾出的位置可能先被其他生产者占用而再次遇到队列已满; 每次重试前都有其他生产者写入成功,队列上限远大于并发生产者数,很快就能写入。 """ while True: try: self.sys_queue.put_nowait(payload) return except queue.Full: try: self.sys_queue.get_nowait() logger.debug("实时消息队列已满,丢弃最旧的一条消息") except queue.Empty: pass def get(self, role: str = "system") -> Optional[str]: """ 取消息 :param role: 兼容旧参数,当前所有 SSE 消息共用一个队列 """ try: return self.sys_queue.get_nowait() except queue.Empty: return None def drain(self, role: str = "system") -> list[str]: """ 按入队顺序取出当前积压的全部实时消息。 最多取出队列上限条,生产方持续写入时也能在有限次数内返回。 :param role: 兼容旧参数,当前所有 SSE 消息共用一个队列 """ messages: list[str] = [] for _ in range(_SSE_QUEUE_MAXSIZE): detail = self.get(role) if detail is None: break messages.append(detail) return messages def stop_message( timeout: float = _MESSAGE_QUEUE_STOP_TIMEOUT_SECONDS, queue_manager: Optional[MessageQueueManager] = None, message_helper: Optional[MessageHelper] = None, ) -> bool: """ 停止已启动的消息服务并返回全部资源是否收敛。 :param timeout: 等待消息队列监控线程退出的最长秒数 :param queue_manager: 组合根发布的消息队列 owner :param message_helper: 组合根发布的消息通知 owner :return: 已启动资源均完成关闭时返回 True,否则返回 False """ all_converged = True # 只关闭已启动的服务,避免清理路径反向创建后台线程和缓存 queue_owner = queue_manager or MessageQueueManager.get_existing_instance() if queue_owner: try: if queue_owner.stop(timeout=timeout) is False: all_converged = False else: MessageQueueManager.release_existing_instance(queue_owner) except Exception as err: logger.error(f"停止消息队列失败:{str(err)}") all_converged = False helper_owner = ( message_helper if message_helper is not None else MessageHelper.get_existing_instance() ) if helper_owner: try: helper_owner.close() MessageHelper.release_existing_instance(helper_owner) except Exception as err: logger.error(f"关闭消息通知缓存失败:{str(err)}") all_converged = False if template_helper := TemplateHelper.get_existing_instance(): try: template_helper.close() TemplateHelper.release_existing_instance(template_helper) except Exception as err: logger.error(f"关闭消息模板缓存失败:{str(err)}") all_converged = False return all_converged