mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-05 06:58:12 +08:00
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:
@@ -16,7 +16,15 @@
|
||||
|
||||
## 传给命令的字段
|
||||
|
||||
每个字段既是环境变量,也可以在参数里写成占位符(环境变量名去掉 `AB_` 后的小写,如 `{torrent_hash}`)。未知的占位符原样保留。
|
||||
每个字段都是环境变量。参数列表里某一项**整项**写成占位符(环境变量名去掉 `AB_` 后的小写,如 `{torrent_hash}`)时,这一项替换为字段值;`{files}` 展开为每个文件一个参数。只替换整项:`--hash={torrent_hash}`、`sh -c "echo {new_path}"` 这类夹在参数里的占位符不替换,原样传给程序;未知的占位符也原样保留。
|
||||
|
||||
文件名来自 RSS,可能含有 `;`、`$()`、引号。不要把占位符写进 `sh -c` 的脚本文本,脚本里从 `AB_*` 环境变量读取事件数据,并给变量加双引号:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# /config/scripts/on-rename.sh,命令填 ["/config/scripts/on-rename.sh"]
|
||||
ln -f "$AB_NEW_PATH" "/media/links/$(basename "$AB_NEW_PATH")"
|
||||
```
|
||||
|
||||
| 环境变量 | 事件 | 内容 |
|
||||
| --- | --- | --- |
|
||||
@@ -28,7 +36,7 @@
|
||||
| `AB_OLD_PATH` / `AB_NEW_PATH` | `file.renamed` | 重命名前后的路径 |
|
||||
| `AB_FILE_KIND` | `file.renamed` | `media` 或 `subtitle` |
|
||||
|
||||
事件里没有种子的保存目录,需要时从 `AB_FILES` 取。路径是**下载器视角**,AutoBangumi 与下载器的挂载不同时,脚本要自己换算。命令按事件顺序逐个运行,一个命令运行时后面的事件排队。
|
||||
命令继承 AutoBangumi 的环境变量,但 AutoBangumi 自己的 `AB_*`(如下载器密码)不传给命令。事件里没有种子的保存目录,需要时从 `AB_FILES` 取。路径是**下载器视角**,AutoBangumi 与下载器的挂载不同时,脚本要自己换算。命令按事件顺序逐个运行,一个命令运行时后面的事件排队。
|
||||
|
||||
`torrent.organized` 的投递是「至少一次」:每次 AutoBangumi 重启,已整理的种子会再发布一次。脚本必须幂等。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,13 +36,27 @@ def run(options: dict, event=ORGANIZED) -> None:
|
||||
asyncio.run(plugin.on_event(event))
|
||||
|
||||
|
||||
def test_on_event_organized_passes_env_and_placeholders_without_shell(tmp_path):
|
||||
def test_on_event_organized_passes_env_and_placeholders_without_shell(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
# 宿主自己的 AB_* 凭据不传给命令
|
||||
monkeypatch.setenv("AB_DOWNLOADER_PASSWORD", "s3cret")
|
||||
out = tmp_path / "out.json"
|
||||
command = [sys.executable, "-c", DUMP, str(out), "{torrent_hash}", "$HOME; {x}"]
|
||||
command = [
|
||||
*(sys.executable, "-c", DUMP, str(out)),
|
||||
*("{torrent_hash}", "$HOME; {x}", "pre-{torrent_hash}", "{files}"),
|
||||
]
|
||||
run({"command": command})
|
||||
got = json.loads(out.read_text())
|
||||
# $HOME 与 ; 按字面传入:没有经过 shell;未知占位符原样保留
|
||||
assert got["argv"] == ["abc", "$HOME; {x}"]
|
||||
# $HOME 与 ; 按字面传入:没有经过 shell;未知占位符与夹在参数里的占位符原样保留;
|
||||
# {files} 展开为每个文件一个参数
|
||||
assert got["argv"] == [
|
||||
"abc",
|
||||
"$HOME; {x}",
|
||||
"pre-{torrent_hash}",
|
||||
"/dl/Show/Season 1/Show S01E01.mkv",
|
||||
"/dl/Show/Season 1/Show S01E01.ass",
|
||||
]
|
||||
assert got["env"] == {
|
||||
"AB_EVENT": "torrent.organized",
|
||||
"AB_BANGUMI_ID": "",
|
||||
@@ -85,6 +99,17 @@ def test_on_event_timeout_kills_command(caplog):
|
||||
assert "已终止" in caplog.text
|
||||
|
||||
|
||||
def test_on_event_background_child_does_not_delay_success(caplog):
|
||||
# 后台进程继承了 stderr;命令本身已退出,不应等到后台进程结束
|
||||
command = ["sh", "-c", "sleep 5 & exit 0"]
|
||||
plugin, _ = create_plugin(RunCommand, {"command": command, "timeout": 2})
|
||||
start = time.monotonic()
|
||||
with caplog.at_level(logging.INFO):
|
||||
asyncio.run(plugin.on_event(ORGANIZED))
|
||||
assert time.monotonic() - start < 2
|
||||
assert "已完成" in caplog.text and "已终止" not in caplog.text
|
||||
|
||||
|
||||
def test_on_event_nonzero_exit_logs_code_and_stderr(caplog):
|
||||
script = "import sys; sys.stderr.write('boom'); sys.exit(3)"
|
||||
with caplog.at_level(logging.WARNING):
|
||||
|
||||
Reference in New Issue
Block a user