mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-06 07:29:09 +08:00
feat(plugins): 新增 run-command、strm-writer、synology-chat-notifier、slack-notifier 示例插件
- run-command:torrent.organized / file.renamed 后运行外部命令,参数列表直接 exec,
事件字段以 AB_* 环境变量与 {占位符} 传入,超时终止(#897、#668)
- strm-writer:按媒体目录结构写 .strm,内容为地址前缀加相对路径(#428)
- synology-chat-notifier:Synology Chat 传入 Webhook,payload= 表单 JSON(#887)
- slack-notifier:Slack Incoming Webhook,带海报时附图片块(#277)
This commit is contained in:
119
examples/plugins/run-command/run_command/__init__.py
Normal file
119
examples/plugins/run-command/run_command/__init__.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""示例插件:整理完成后运行外部命令。
|
||||
|
||||
订阅全部事件(``@subscribe("*")``),只对配置里列出的 ``torrent.organized`` /
|
||||
``file.renamed`` 运行 ``command``。命令是参数列表,直接 ``exec``,不经过 shell,
|
||||
所以参数里的空格、``$``、``;`` 都按字面传给程序。
|
||||
|
||||
事件字段以 ``AB_*`` 环境变量传给命令,参数里也可以写 ``{字段}`` 占位符:
|
||||
|
||||
- 两个事件都有:``AB_EVENT``、``AB_BANGUMI_ID``(旧种子可能为空)、``AB_DOWNLOADER_ID``
|
||||
- ``torrent.organized``:``AB_TORRENT_HASH``、``AB_FILES``(正片与字幕的路径,每行一个)
|
||||
- ``file.renamed``:``AB_OLD_PATH``、``AB_NEW_PATH``、``AB_FILE_KIND``
|
||||
|
||||
路径是下载器视角。命令超时被杀掉;退出码非 0 时记录 stderr 末尾。命令的失败只记日志,
|
||||
不抛给宿主:外部脚本出错不应让插件被熔断。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from subprocess import DEVNULL, PIPE
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ab_sdk import Event, Plugin, subscribe
|
||||
from ab_sdk.events import FileRenamed, TorrentOrganized
|
||||
|
||||
MAX_TIMEOUT = 3600
|
||||
_PLACEHOLDER = re.compile(r"\{(\w+)\}")
|
||||
|
||||
|
||||
class Options(BaseModel):
|
||||
command: list[str] = Field(
|
||||
default_factory=list,
|
||||
title="命令",
|
||||
description="程序及其参数,每项一个,如 /config/scripts/link.sh、{files};不经过 shell",
|
||||
)
|
||||
events: list[str] = Field(
|
||||
default_factory=lambda: [TorrentOrganized.kind],
|
||||
title="事件",
|
||||
description="在哪些事件后运行:torrent.organized、file.renamed",
|
||||
)
|
||||
timeout: int = Field(
|
||||
300, ge=1, le=MAX_TIMEOUT, title="超时(秒)", description="超时后杀掉命令"
|
||||
)
|
||||
|
||||
|
||||
def event_fields(event: Event) -> dict[str, str] | None:
|
||||
"""事件 → 字段(键为小写,不带 ``AB_`` 前缀);不支持的事件返回 None。"""
|
||||
match event:
|
||||
case TorrentOrganized():
|
||||
specific = {
|
||||
"torrent_hash": event.torrent_hash,
|
||||
"files": "\n".join(f.path for f in event.files),
|
||||
}
|
||||
case FileRenamed():
|
||||
specific = {
|
||||
"old_path": event.old_path,
|
||||
"new_path": event.new_path,
|
||||
"file_kind": event.file_kind,
|
||||
}
|
||||
case _:
|
||||
return None
|
||||
bangumi_id = "" if event.bangumi_id is None else str(event.bangumi_id)
|
||||
return {
|
||||
"event": event.kind,
|
||||
"bangumi_id": bangumi_id,
|
||||
"downloader_id": event.downloader_id,
|
||||
**specific,
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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()}}
|
||||
|
||||
|
||||
class RunCommand(Plugin[Options]):
|
||||
config_model = Options
|
||||
|
||||
@subscribe("*", timeout=MAX_TIMEOUT + 10)
|
||||
async def on_event(self, event: Event) -> None:
|
||||
cfg = self.config
|
||||
if not cfg.command or event.kind not in cfg.events:
|
||||
return
|
||||
fields = event_fields(event)
|
||||
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()
|
||||
if proc.returncode:
|
||||
self.ctx.log.warning(
|
||||
"命令 %s 退出码 %s:%s", argv[0], proc.returncode, tail
|
||||
)
|
||||
else:
|
||||
self.ctx.log.info("命令 %s 已完成(%s)", argv[0], event.kind)
|
||||
Reference in New Issue
Block a user