fix(plugins): 审查修复——示例插件与通知渠道的若干问题

- run-command:等待进程退出而不是等 stderr 关闭(后台子进程不再导致误判超时),
  stderr 写临时文件取末尾;只替换整项为 {字段} 的参数,{files} 展开为每个文件
  一个参数,夹带的占位符原样保留;不把宿主自己的 AB_* 环境变量传给命令;README
  说明脚本从 AB_* 环境变量读取事件数据
- strm-writer:路径规范化后为媒体根目录本身(. / ..)时跳过,不再写出 STRM 目录之外的文件
- slack-notifier / onebot-notifier:带海报发送失败时去掉图片只发文字重试一次
- wpush-notifier / onebot-notifier:非 2xx 时报告 HTTP 状态,响应不是 JSON 时不再报解析异常
- 插件通知渠道的 extra 包含已填写的已声明字段(group、icon 等),不只是 model_extra
- 新增通知渠道时切换类型会清掉上一类型的字段,避免 Bark 带上残留的 Webhook URL
This commit is contained in:
Estrella Pan
2026-10-21 11:05:59 +02:00
parent 4b739bcfc3
commit 81bbf9c71d
17 changed files with 310 additions and 64 deletions

View File

@@ -4,7 +4,9 @@
``file.renamed`` 运行 ``command``。命令是参数列表,直接 ``exec``,不经过 shell,
所以参数里的空格、``$``、``;`` 都按字面传给程序。
事件字段以 ``AB_*`` 环境变量传给命令,参数里也可以写 ``{字段}`` 占位符:
事件字段以 ``AB_*`` 环境变量传给命令。整个参数恰好是 ``{字段}`` 时替换为字段值
(``{files}`` 展开为每个文件一个参数);参数里夹带的占位符不替换,以免文件名被拼进
``sh -c`` 之类的脚本文本:
- 两个事件都有:``AB_EVENT``、``AB_BANGUMI_ID``(旧种子可能为空)、``AB_DOWNLOADER_ID``
- ``torrent.organized``:``AB_TORRENT_HASH``、``AB_FILES``(正片与字幕的路径,每行一个)
@@ -17,7 +19,8 @@
import asyncio
import os
import re
from subprocess import DEVNULL, PIPE
import tempfile
from subprocess import DEVNULL
from pydantic import BaseModel, Field
@@ -32,7 +35,10 @@ class Options(BaseModel):
command: list[str] = Field(
default_factory=list,
title="命令",
description="程序及其参数,每项一个,如 /config/scripts/link.sh、{files};不经过 shell",
description=(
"程序及其参数,每项一个,如 /config/scripts/link.sh、{files};不经过 shell,"
"只替换整项为占位符的参数"
),
)
events: list[str] = Field(
default_factory=lambda: [TorrentOrganized.kind],
@@ -70,13 +76,29 @@ def event_fields(event: Event) -> dict[str, str] | None:
def build_argv(command: list[str], fields: dict[str, str]) -> list[str]:
"""替换参数里的 ``{字段}``;未知的占位符原样保留。"""
return [_PLACEHOLDER.sub(lambda m: fields.get(m[1], m[0]), arg) for arg in command]
"""整项为 ``{字段}`` 的参数替换为字段值,``{files}`` 展开为每个文件一个参数。
部分包含占位符的参数与未知的占位符原样保留。
"""
argv: list[str] = []
for arg in command:
m = _PLACEHOLDER.fullmatch(arg)
if m is None or m[1] not in fields:
argv.append(arg)
elif m[1] == "files":
argv.extend(fields["files"].splitlines())
else:
argv.append(fields[m[1]])
return argv
def build_env(fields: dict[str, str]) -> dict[str, str]:
"""继承宿主环境(否则子进程没有 PATH),再加上 ``AB_*``。"""
return {**os.environ, **{f"AB_{k.upper()}": v for k, v in fields.items()}}
"""继承宿主环境(否则子进程没有 PATH),再加上 ``AB_*``。
宿主自己的 ``AB_*``(如 ``AB_DOWNLOADER_PASSWORD``)不传给命令。
"""
env = {k: v for k, v in os.environ.items() if not k.startswith("AB_")}
return {**env, **{f"AB_{k.upper()}": v for k, v in fields.items()}}
class RunCommand(Plugin[Options]):
@@ -91,26 +113,34 @@ class RunCommand(Plugin[Options]):
if fields is None:
return
argv = build_argv(cfg.command, fields)
try:
proc = await asyncio.create_subprocess_exec(
*argv, stdin=DEVNULL, stdout=DEVNULL, stderr=PIPE, env=build_env(fields)
)
except OSError as e:
self.ctx.log.warning("无法启动命令 %s:%s", argv[0], e)
return
try:
async with asyncio.timeout(cfg.timeout):
_, stderr = await proc.communicate()
except TimeoutError:
self.ctx.log.warning("命令 %s 超过 %s 秒,已终止", argv[0], cfg.timeout)
return
finally:
# 超时或宿主取消处理时,不留下孤儿进程
# ponytail: 只杀直接子进程;命令自己再派生的进程需由脚本负责
if proc.returncode is None:
proc.kill()
await proc.wait()
tail = stderr[-2000:].decode(errors="replace").strip()
# stderr 写进临时文件而不是管道:命令留在后台的子进程继承 stderr 时,
# 读管道要等到它们退出,命令本身早已结束也会被当成超时
with tempfile.TemporaryFile() as err:
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdin=DEVNULL,
stdout=DEVNULL,
stderr=err,
env=build_env(fields),
)
except OSError as e:
self.ctx.log.warning("无法启动命令 %s:%s", argv[0], e)
return
try:
async with asyncio.timeout(cfg.timeout):
await proc.wait()
except TimeoutError:
self.ctx.log.warning("命令 %s 超过 %s 秒,已终止", argv[0], cfg.timeout)
return
finally:
# 超时或宿主取消处理时,不留下孤儿进程
# ponytail: 只杀直接子进程;命令自己再派生的进程需由脚本负责
if proc.returncode is None:
proc.kill()
await proc.wait()
err.seek(max(0, err.seek(0, os.SEEK_END) - 2000))
tail = err.read().decode(errors="replace").strip()
if proc.returncode:
self.ctx.log.warning(
"命令 %s 退出码 %s:%s", argv[0], proc.returncode, tail