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

@@ -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()