feat(filemanager): add TransferRenameBuild chain event and fix TemplateContextBuilder concurrency (#5792)

This commit is contained in:
InfinityPacer
2026-05-20 09:41:42 +08:00
committed by GitHub
parent 71dc9df7ff
commit 9e0846961f
6 changed files with 346 additions and 48 deletions

View File

@@ -28,11 +28,16 @@ from app.utils.string import StringUtils
class TemplateContextBuilder:
"""
模板上下文构建器
"""
模板上下文构建器
def __init__(self):
self._context = {}
无状态实现:所有 ``_add_*`` 方法均为静态方法,接受并就地修改调用方提供的
``context`` 字典。``build`` 每次调用都基于一份新的本地字典装填后返回,
实例自身不持有任何中间状态——可以被多线程共享调用而不会产生互相串味的
``rename_dict``,配合 ``settings.TRANSFER_THREADS > 1`` 的并发整理场景安全。
保留为类(而非自由函数)是为了向后兼容现有调用方式
``TemplateHelper().builder.build(...)``)。
"""
def build(
self,
@@ -46,55 +51,64 @@ class TemplateContextBuilder:
**kwargs
) -> Dict[str, Any]:
"""
:param meta: 媒体信息
:param mediainfo: 媒体信息
构建一次性渲染上下文字典。
每次调用都新建本地 ``context`` 字典,依次填充各业务来源后返回过滤掉
None 值的副本,调用之间互不影响。
:param meta: 媒体元数据
:param mediainfo: 识别的媒体信息
:param torrentinfo: 种子信息
:param transferinfo: 传输信息
:param transferinfo: 整理结果信息
:param file_extension: 文件扩展名
:param episodes_info: 集信息
:param include_raw_objects: 是否包含原始对象
:param episodes_info: 当前季的全部集信息
:param include_raw_objects: 是否在 dict 里附带原始对象引用(``__meta__`` 等)
: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)
context: Dict[str, Any] = {}
self._add_episode_details(context, meta, episodes_info)
self._add_media_info(context, mediainfo)
self._add_transfer_info(context, transferinfo)
self._add_torrent_info(context, torrentinfo)
self._add_file_info(context, file_extension)
if kwargs:
self._context.update(kwargs)
context.update(kwargs)
if include_raw_objects:
self._add_raw_objects(meta, mediainfo, torrentinfo, transferinfo, episodes_info)
self._add_raw_objects(context, meta, mediainfo, torrentinfo, transferinfo, episodes_info)
# 移除空值
return {k: v for k, v in self._context.items() if v is not None}
return {k: v for k, v in context.items() if v is not None}
def _add_media_info(self, mediainfo: MediaInfo):
@classmethod
def _add_media_info(cls, context: Dict[str, Any], mediainfo: Optional[MediaInfo]) -> None:
"""
增加媒体信息
将 MediaInfo 中的标题、季年份、海报等业务字段就地写入 ``context``。
会读取 ``context`` 中由 ``_add_episode_details`` 先填好的 ``season`` /
``year`` / ``title_year`` 占位,保证电视剧场景下季/年优先沿用 meta 解析值。
"""
if not mediainfo:
return
season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
base_info = {
# 标题
"title": self.__convert_invalid_characters(mediainfo.title),
"title": cls.__convert_invalid_characters(mediainfo.title),
# 英文标题
"en_title": self.__convert_invalid_characters(mediainfo.en_title),
"en_title": cls.__convert_invalid_characters(mediainfo.en_title),
# 原语种标题
"original_title": self.__convert_invalid_characters(mediainfo.original_title),
"original_title": cls.__convert_invalid_characters(mediainfo.original_title),
# 季号
"season": self._context.get("season") or mediainfo.season,
"season": context.get("season") or mediainfo.season,
# Sxx
"season_fmt": self._context.get("season_fmt") or season_fmt,
"season_fmt": context.get("season_fmt") or season_fmt,
# 年份
"year": mediainfo.year or self._context.get("year"),
"year": mediainfo.year or context.get("year"),
# 媒体标题 + 年份
"title_year": mediainfo.title_year or self._context.get("title_year"),
"title_year": mediainfo.title_year or context.get("title_year"),
}
_meta_season = self._context.get("season")
_meta_season = context.get("season")
media_info = {
# 类型
"type": mediainfo.type.value,
@@ -121,11 +135,18 @@ class TemplateContextBuilder:
# 豆瓣ID
"doubanid": mediainfo.douban_id,
}
self._context.update({**base_info, **media_info})
context.update({**base_info, **media_info})
def _add_episode_details(self, meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]]):
@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
@@ -135,7 +156,7 @@ class TemplateContextBuilder:
for episode in episodes:
if episode.episode_number == meta.begin_episode:
episode_data.update({
"episode_title": self.__convert_invalid_characters(episode.name),
"episode_title": cls.__convert_invalid_characters(episode.name),
"episode_date": episode.air_date if episode.air_date else None
})
break
@@ -150,7 +171,7 @@ class TemplateContextBuilder:
# 年份
"year": meta.year,
# 名字 + 年份
"title_year": self._context.get("title_year") or "%s (%s)" % (
"title_year": context.get("title_year") or "%s (%s)" % (
meta.name, meta.year) if meta.year else meta.name,
# 季号
"season": meta.season_seq,
@@ -188,11 +209,16 @@ class TemplateContextBuilder:
# 流媒体平台
"webSource": meta.web_source,
}
self._context.update({**meta_info, **tech_metadata, **episode_data})
context.update({**meta_info, **tech_metadata, **episode_data})
def _add_torrent_info(self, torrentinfo: Optional[TorrentInfo]):
@staticmethod
def _add_torrent_info(context: Dict[str, Any], torrentinfo: Optional[TorrentInfo]) -> None:
"""
添加种子信息
种子信息写入 ``context``,描述字段会去除 HTML 标签。
副作用提醒:当 ``torrentinfo.description`` 包含 HTML 时,会就地清洗
原对象的 description 字段——保留原始行为,避免破坏现有调用方对清洗后
描述的依赖。
"""
if not torrentinfo:
return
@@ -231,25 +257,27 @@ class TemplateContextBuilder:
# 种子大小
"size": size,
}
self._context.update(torrent_info)
context.update(torrent_info)
def _add_transfer_info(self, transferinfo: Optional[TransferInfo]) -> Optional[Dict]:
@staticmethod
def _add_transfer_info(context: Dict[str, Any], transferinfo: Optional[TransferInfo]) -> None:
"""
添加文件转移上下文
将整理结果(类型、文件数、总大小、错误信息)写入 ``context``。
"""
if not transferinfo:
return None
return
ctx = {
"transfer_type": transferinfo.transfer_type,
"file_count": transferinfo.file_count,
"total_size": StringUtils.str_filesize(transferinfo.total_size),
"err_msg": transferinfo.message,
}
return self._context.update(ctx)
context.update(ctx)
def _add_file_info(self, file_extension: Optional[str]):
@staticmethod
def _add_file_info(context: Dict[str, Any], file_extension: Optional[str]) -> None:
"""
添加文件信息
将文件扩展名写入 ``context.fileExt``。
"""
if not file_extension:
return
@@ -257,18 +285,21 @@ class TemplateContextBuilder:
# 文件后缀
"fileExt": file_extension,
}
self._context.update(file_info)
context.update(file_info)
@staticmethod
def _add_raw_objects(
self,
context: Dict[str, Any],
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
torrentinfo: Optional[TorrentInfo],
transferinfo: Optional[TransferInfo],
episodes_info: Optional[List[TmdbEpisode]],
):
) -> None:
"""
添加原始对象引用
以双下划线键名将原始对象引用写入 ``context``。
约定:消费方仅读不写,避免在事件回调里改这些对象污染下游流程。
"""
raw_objects = {
# 文件元数据
@@ -282,7 +313,7 @@ class TemplateContextBuilder:
# 当前季的全部集信息
"__episodes_info__": episodes_info,
}
self._context.update(raw_objects)
context.update(raw_objects)
@staticmethod
def __convert_invalid_characters(filename: str):

View File

@@ -20,6 +20,7 @@ from app.schemas import (
FileItem,
TransferInterceptEventData,
TransferOverwriteCheckEventData,
TransferRenameBuildEventData,
TransferRenameEventData,
)
from app.schemas.types import MediaType, ChainEventType
@@ -1142,6 +1143,19 @@ class TransHandler:
:param source_item: 源文件信息,即待整理的文件信息
:return: 生成的完整路径
"""
# 渲染前先发事件,让插件有机会往 rename_dict 写字段
build_event_data = TransferRenameBuildEventData(
template_string=template_string,
rename_dict=rename_dict,
source_path=source_path,
source_item=source_item,
)
build_event = eventmanager.send_event(
ChainEventType.TransferRenameBuild, build_event_data
)
if build_event and build_event.event_data:
rename_dict = build_event.event_data.rename_dict
# 创建jinja2模板对象
template = Template(template_string)
# 渲染生成的字符串

View File

@@ -183,6 +183,44 @@ class CommandRegisterEventData(ChainEventData):
source: str = Field(default="未知拦截源", description="拦截源")
class TransferRenameBuildEventData(ChainEventData):
"""
TransferRenameBuild 事件的数据模型
在 ``transhandler.get_rename_path`` 渲染文件名之前发出,给插件一次往
``rename_dict`` 写字段的机会。典型用法是通过 ffprobe 或外部接口探测源文件,
把分辨率、视频/音频编码、HDR 等字段写入 ``rename_dict``,主程序下一步渲染时
就能直接用到这些字段,不需要插件事后再渲染一次去覆盖结果。
与 ``TransferRenameEventData`` 的分工:
- 本事件负责"往 ``rename_dict`` 里写字段",没有输出参数;
- ``TransferRename`` 在渲染之后触发,负责对已渲染好的字符串再做改写(大小写、
词替换、模板覆盖等),由智能重命名一类插件使用。
使用约定:
- 只往 ``rename_dict`` 写字段,不要在这里改写已经渲染好的字符串;
- ``source_path`` / ``source_item`` 为空时(如重命名预览场景),需要源文件
才能工作的插件请直接 return
- ``rename_dict`` 中以双下划线开头的键(``__meta__`` / ``__mediainfo__`` 等)
存放的是原始对象引用,只读使用,不要修改这些对象本身。
Attributes:
template_string (str): Jinja2 模板字符串
rename_dict (Dict[str, Any]): 渲染上下文,可直接修改
source_path (Optional[str]): 源文件路径,即待整理的文件路径
source_item (Optional[FileItem]): 源文件信息,即待整理的文件信息
"""
template_string: str = Field(..., description="模板字符串")
rename_dict: Dict[str, Any] = Field(..., description="渲染上下文")
source_path: Optional[str] = Field(
None, description="源文件路径,即待整理的文件路径"
)
source_item: Optional[FileItem] = Field(
None, description="源文件信息,即待整理的文件信息"
)
class TransferRenameEventData(ChainEventData):
"""
TransferRename 事件的数据模型

View File

@@ -154,6 +154,8 @@ class ChainEventType(Enum):
CommandRegister = "command.register"
# 整理重命名
TransferRename = "transfer.rename"
# 整理重命名上下文构建
TransferRenameBuild = "transfer.rename.build"
# 整理拦截
TransferIntercept = "transfer.intercept"
# 整理覆盖检查

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
TemplateContextBuilder 的并发安全单元测试。
历史上 builder 持有 ``self._context`` 实例字段,``build()`` 内 ``clear()`` →
``_add_*`` → 推导式返回这一序列在 ``TRANSFER_THREADS > 1`` 下会被多线程相互
覆盖,导致同一 builder 实例并发调用产生互相串味的 rename_dict。本测试在多
线程下连续调用 ``build()``,校验每个线程拿到的字典只反映自己的入参。
"""
import threading
import unittest
from app.helper.message import TemplateContextBuilder
class TemplateContextBuilderConcurrencyTest(unittest.TestCase):
"""
使用 8 个线程并发调用同一 TemplateContextBuilder 实例的 build()
确保各自的 file_extension / 自定义 kwargs 不会被其它线程覆盖。
"""
THREAD_COUNT = 8
ITERATIONS_PER_THREAD = 200
def test_concurrent_build_no_cross_contamination(self):
builder = TemplateContextBuilder()
errors = []
def worker(tag: int) -> None:
try:
for _ in range(self.ITERATIONS_PER_THREAD):
ctx = builder.build(
file_extension=f".{tag}",
marker=tag,
)
self.assertEqual(ctx.get("fileExt"), f".{tag}")
self.assertEqual(ctx.get("marker"), tag)
except AssertionError as exc:
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(i,), name=f"builder-{i}")
for i in range(self.THREAD_COUNT)
]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertFalse(
errors,
msg=f"检测到并发串味,共 {len(errors)} 条;首个错误:{errors[0] if errors else ''}",
)
def test_build_returns_independent_dicts(self):
"""
即便不开线程,连续两次 build() 也应当返回相互独立的 dict 实例,
避免无状态化后调用方误以为返回的还是 builder 内部共享对象。
"""
builder = TemplateContextBuilder()
first = builder.build(file_extension=".a", marker=1)
second = builder.build(file_extension=".b", marker=2)
self.assertIsNot(first, second)
self.assertEqual(first.get("fileExt"), ".a")
self.assertEqual(second.get("fileExt"), ".b")
# 第二次调用不应反向污染第一次的结果
self.assertEqual(first.get("marker"), 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
"""
TransferRenameBuild 事件的单元测试。
通过 patch ``eventmanager.send_event`` 模拟链上插件处理器,避免依赖
MoviePilot 的"插件实例反查"机制(``__get_class_instance`` 要求 handler 位于
``app.<plugin>`` 模块内)。覆盖以下关键路径:
1. 插件就地 mutate ``rename_dict`` 后,主程序首次渲染读取到补充字段;
2. 插件用"返回新 dict"的方式(替换 ``event_data.rename_dict`` 引用)也能生效;
3. 没有任何监听者时(``send_event`` 返回 None渲染输出与改造前完全一致
4. ``get_rename_path`` 把正确的 ``source_path`` / ``source_item`` / ``template_string``
注入到 ``TransferRenameBuildEventData``,便于插件做条件早退。
"""
import unittest
from unittest.mock import patch
from app.core.event import Event
from app.modules.filemanager.transhandler import TransHandler
from app.schemas.event import TransferRenameBuildEventData
from app.schemas.types import ChainEventType
class TransferRenameBuildEventTest(unittest.TestCase):
"""
通过 ``unittest.mock.patch`` 替换 ``eventmanager.send_event`` 实现:
每个测试自定义一个 ``fake_send_event``,根据事件类型决定如何模拟链上插件
对 ``event_data`` 的修改,再返回 ``Event(event_data=...)``,与真实 dispatcher
返回行为对齐。
"""
TEMPLATE = "{{title}}{% if effect %} {{effect}}{% endif %}{% if codec %} {{codec}}{% endif %}"
@staticmethod
def _make_event(event_type: ChainEventType, data) -> Event:
"""构造真实 dispatcher 返回的 Event 对象,便于和生产代码读取路径对齐。"""
return Event(event_type=event_type.value, event_data=data)
def test_in_place_field_supplement_takes_effect(self):
captured = {}
def fake_send_event(event_type, data, **_kwargs):
if event_type is ChainEventType.TransferRenameBuild:
self.assertIsInstance(data, TransferRenameBuildEventData)
captured["template_string"] = data.template_string
captured["source_path"] = data.source_path
# 模拟插件就地补充字段
data.rename_dict["effect"] = "SDR"
return self._make_event(event_type, data)
return self._make_event(event_type, data)
with patch(
"app.modules.filemanager.transhandler.eventmanager.send_event",
side_effect=fake_send_event,
):
path = TransHandler.get_rename_path(
template_string=self.TEMPLATE,
rename_dict={"title": "Foo"},
source_path="/downloads/foo.mkv",
)
self.assertEqual(path.as_posix(), "Foo SDR")
self.assertEqual(captured["template_string"], self.TEMPLATE)
self.assertEqual(captured["source_path"], "/downloads/foo.mkv")
def test_returning_new_dict_reference_is_respected(self):
"""
模拟插件用"完整替换 rename_dict 引用"的写法,验证 get_rename_path 在事件
返回后会重新取引用,新 dict 中的字段也能被首次渲染读到。
"""
def fake_send_event(event_type, data, **_kwargs):
if event_type is ChainEventType.TransferRenameBuild:
new_dict = dict(data.rename_dict)
new_dict["codec"] = "H265"
data.rename_dict = new_dict
return self._make_event(event_type, data)
with patch(
"app.modules.filemanager.transhandler.eventmanager.send_event",
side_effect=fake_send_event,
):
path = TransHandler.get_rename_path(
template_string=self.TEMPLATE,
rename_dict={"title": "Foo"},
source_path="/downloads/foo.mkv",
)
self.assertEqual(path.as_posix(), "Foo H265")
def test_no_listeners_yields_unchanged_render(self):
"""
监听者缺席时 send_event 返回 Noneget_rename_path 应跳过引用刷新并按原
rename_dict 渲染,行为与改造前完全一致。
"""
def fake_send_event(event_type, _data, **_kwargs):
# 真实 dispatcher 在无 enabled handler 时返回 None
return None
with patch(
"app.modules.filemanager.transhandler.eventmanager.send_event",
side_effect=fake_send_event,
):
path = TransHandler.get_rename_path(
template_string=self.TEMPLATE,
rename_dict={"title": "Foo"},
source_path="/downloads/foo.mkv",
)
self.assertEqual(path.as_posix(), "Foo")
def test_event_data_carries_source_metadata(self):
"""
即便没有 source_pathrecommend_name 预览场景),事件仍会触发,但
``source_path`` / ``source_item`` 都为 None供插件自行早退。
"""
captured = {}
def fake_send_event(event_type, data, **_kwargs):
if event_type is ChainEventType.TransferRenameBuild:
captured["source_path"] = data.source_path
captured["source_item"] = data.source_item
return self._make_event(event_type, data)
with patch(
"app.modules.filemanager.transhandler.eventmanager.send_event",
side_effect=fake_send_event,
):
TransHandler.get_rename_path(
template_string=self.TEMPLATE,
rename_dict={"title": "Foo"},
)
self.assertIsNone(captured["source_path"])
self.assertIsNone(captured["source_item"])
if __name__ == "__main__":
unittest.main()