From 8312f47769c2159b3ee8d1507442974877a65c1b Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Wed, 21 Oct 2026 09:04:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E6=96=B0=E5=A2=9E=20run-comma?= =?UTF-8?q?nd=E3=80=81strm-writer=E3=80=81synology-chat-notifier=E3=80=81s?= =?UTF-8?q?lack-notifier=20=E7=A4=BA=E4=BE=8B=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/src/test/test_plugin_examples.py | 6 +- docs/dev/plugins/examples.md | 4 + docs/en/dev/plugins/examples.md | 4 + docs/ja/dev/plugins/examples.md | 4 + examples/plugins/run-command/README.md | 49 ++++++++ examples/plugins/run-command/plugin.toml | 9 ++ examples/plugins/run-command/pyproject.toml | 13 ++ .../run-command/run_command/__init__.py | 119 ++++++++++++++++++ .../run-command/tests/test_run_command.py | 98 +++++++++++++++ examples/plugins/slack-notifier/README.md | 28 +++++ examples/plugins/slack-notifier/plugin.toml | 10 ++ .../plugins/slack-notifier/pyproject.toml | 13 ++ .../slack-notifier/slack_notifier/__init__.py | 85 +++++++++++++ .../tests/test_slack_notifier.py | 63 ++++++++++ examples/plugins/strm-writer/README.md | 35 ++++++ examples/plugins/strm-writer/plugin.toml | 9 ++ examples/plugins/strm-writer/pyproject.toml | 13 ++ .../strm-writer/strm_writer/__init__.py | 84 +++++++++++++ .../strm-writer/tests/test_strm_writer.py | 63 ++++++++++ .../plugins/synology-chat-notifier/README.md | 30 +++++ .../synology-chat-notifier/plugin.toml | 10 ++ .../synology-chat-notifier/pyproject.toml | 13 ++ .../synology_chat_notifier/__init__.py | 78 ++++++++++++ .../tests/test_synology_chat_notifier.py | 63 ++++++++++ 24 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 examples/plugins/run-command/README.md create mode 100644 examples/plugins/run-command/plugin.toml create mode 100644 examples/plugins/run-command/pyproject.toml create mode 100644 examples/plugins/run-command/run_command/__init__.py create mode 100644 examples/plugins/run-command/tests/test_run_command.py create mode 100644 examples/plugins/slack-notifier/README.md create mode 100644 examples/plugins/slack-notifier/plugin.toml create mode 100644 examples/plugins/slack-notifier/pyproject.toml create mode 100644 examples/plugins/slack-notifier/slack_notifier/__init__.py create mode 100644 examples/plugins/slack-notifier/tests/test_slack_notifier.py create mode 100644 examples/plugins/strm-writer/README.md create mode 100644 examples/plugins/strm-writer/plugin.toml create mode 100644 examples/plugins/strm-writer/pyproject.toml create mode 100644 examples/plugins/strm-writer/strm_writer/__init__.py create mode 100644 examples/plugins/strm-writer/tests/test_strm_writer.py create mode 100644 examples/plugins/synology-chat-notifier/README.md create mode 100644 examples/plugins/synology-chat-notifier/plugin.toml create mode 100644 examples/plugins/synology-chat-notifier/pyproject.toml create mode 100644 examples/plugins/synology-chat-notifier/synology_chat_notifier/__init__.py create mode 100644 examples/plugins/synology-chat-notifier/tests/test_synology_chat_notifier.py diff --git a/backend/src/test/test_plugin_examples.py b/backend/src/test/test_plugin_examples.py index 5c78b17f..deb20d06 100644 --- a/backend/src/test/test_plugin_examples.py +++ b/backend/src/test/test_plugin_examples.py @@ -117,7 +117,11 @@ async def test_all_examples_load_in_host_with_expected_providers(tmp_path): try: assert {s.id: s.state for s in mgr.statuses()} == dict.fromkeys(ids, "active") assert set(registry.providers(points.RENAME_STRATEGY)) == {"mini-template"} - assert set(registry.providers(points.NOTIFIER)) == {"ntfy"} + assert set(registry.providers(points.NOTIFIER)) == { + "ntfy", + "slack", + "synology-chat", + } assert set(registry.providers(points.SEARCH_SITE)) == {"my-tracker"} assert [h.plugin_id for h in registry.hooks(points.HTTP_REQUEST)] == [ "custom-rss-site" diff --git a/docs/dev/plugins/examples.md b/docs/dev/plugins/examples.md index 95860a1e..20f6ca9b 100644 --- a/docs/dev/plugins/examples.md +++ b/docs/dev/plugins/examples.md @@ -9,6 +9,10 @@ | `template-rename` | `rename_strategy` Provider,自带过滤器的文件名模板;`RenameSkipped`;`RenameStrategyContract` | | `nfo-writer` | 订阅 `torrent.organized`,在正片旁写 `.nfo`;路径映射;幂等 | | `ntfy-notifier` | `notifier` Provider;后端拒绝时返回 `False`;`NotifierContract` | +| `run-command` | 订阅 `torrent.organized` / `file.renamed`,运行外部命令;参数列表不经过 shell;环境变量与占位符;超时终止 | +| `strm-writer` | 订阅 `torrent.organized`,按媒体目录结构写 `.strm`;幂等 | +| `synology-chat-notifier` | `notifier` Provider;表单字段 `payload=`;HTTP 200 但 `success: false` 时返回 `False` | +| `slack-notifier` | `notifier` Provider;mrkdwn 转义;带海报时附图片块 | | `manual-pick` | 前端挂载点 `bangumi.detail.tab`、插件路由、插件 KV、事件 | ## 安装一个示例 diff --git a/docs/en/dev/plugins/examples.md b/docs/en/dev/plugins/examples.md index e630ba6f..da3d3726 100644 --- a/docs/en/dev/plugins/examples.md +++ b/docs/en/dev/plugins/examples.md @@ -9,6 +9,10 @@ The examples are in [`examples/plugins/`](https://github.com/EstrellaXD/Auto_Ban | `template-rename` | A `rename_strategy` Provider with a file-name template that has its own filters. `RenameSkipped`. `RenameStrategyContract` | | `nfo-writer` | Subscribes to `torrent.organized` and writes an `.nfo` next to each media file. Path mapping. Idempotent | | `ntfy-notifier` | A `notifier` Provider. Returns `False` when the backend refuses. `NotifierContract` | +| `run-command` | Subscribes to `torrent.organized` / `file.renamed` and runs an external command. The argument list does not go through a shell. Environment variables and placeholders. Kill on timeout | +| `strm-writer` | Subscribes to `torrent.organized` and writes `.strm` files that mirror the media folders. Idempotent | +| `synology-chat-notifier` | A `notifier` Provider. Sends the form field `payload=`. Returns `False` on HTTP 200 with `success: false` | +| `slack-notifier` | A `notifier` Provider. Escapes mrkdwn. Adds an image block when a poster is available | | `manual-pick` | The frontend slot `bangumi.detail.tab`, plugin routes, plugin KV, events | ## Install an example diff --git a/docs/ja/dev/plugins/examples.md b/docs/ja/dev/plugins/examples.md index 98c32303..fb215a2d 100644 --- a/docs/ja/dev/plugins/examples.md +++ b/docs/ja/dev/plugins/examples.md @@ -9,6 +9,10 @@ | `template-rename` | `rename_strategy` Provider。独自フィルター付きのファイル名テンプレート。`RenameSkipped`。`RenameStrategyContract` | | `nfo-writer` | `torrent.organized` を購読し、本編の隣に `.nfo` を書きます。パス変換。冪等 | | `ntfy-notifier` | `notifier` Provider。バックエンドが拒否したときは `False` を返します。`NotifierContract` | +| `run-command` | `torrent.organized` / `file.renamed` を購読し、外部コマンドを実行します。引数リストはシェルを通しません。環境変数とプレースホルダー。タイムアウトで終了 | +| `strm-writer` | `torrent.organized` を購読し、メディアのフォルダー構成どおりに `.strm` を書きます。冪等 | +| `synology-chat-notifier` | `notifier` Provider。フォームフィールド `payload=` を送ります。HTTP 200 でも `success: false` なら `False` を返します | +| `slack-notifier` | `notifier` Provider。mrkdwn をエスケープします。ポスターがあるときは画像ブロックを付けます | | `manual-pick` | フロントエンドスロット `bangumi.detail.tab`、プラグインのルート、プラグイン KV、イベント | ## サンプルをインストールする diff --git a/examples/plugins/run-command/README.md b/examples/plugins/run-command/README.md new file mode 100644 index 00000000..48794538 --- /dev/null +++ b/examples/plugins/run-command/README.md @@ -0,0 +1,49 @@ +# 运行外部命令(示例插件) + +种子整理完成(`torrent.organized`)或文件被重命名(`file.renamed`)后运行一条外部命令,例如自己的清理脚本、FileBot、hlink。演示: + +- `@subscribe("*", timeout=...)` 订阅全部事件,按配置过滤;处理超时放宽到命令超时之上 +- `asyncio.create_subprocess_exec` 直接执行参数列表,不经过 shell +- 命令超时被杀掉,退出码非 0 时记录 stderr 末尾;失败只记日志,不让插件被熔断 + +## 配置 + +| 字段 | 说明 | +| --- | --- | +| 命令 | 程序及其参数,每项一个,如 `/config/scripts/link.sh`、`{files}`。不经过 shell,管道、重定向、`$变量` 都不会展开;需要时把命令写成脚本 | +| 事件 | 在哪些事件后运行,默认只有 `torrent.organized` | +| 超时(秒) | 默认 300,最大 3600。超时后杀掉命令 | + +## 传给命令的字段 + +每个字段既是环境变量,也可以在参数里写成占位符(环境变量名去掉 `AB_` 后的小写,如 `{torrent_hash}`)。未知的占位符原样保留。 + +| 环境变量 | 事件 | 内容 | +| --- | --- | --- | +| `AB_EVENT` | 全部 | 事件名 | +| `AB_BANGUMI_ID` | 全部 | 番剧 id;旧种子没有 `ab:` 标签时为空 | +| `AB_DOWNLOADER_ID` | 全部 | 下载器实例 id | +| `AB_TORRENT_HASH` | `torrent.organized` | 种子 hash | +| `AB_FILES` | `torrent.organized` | 正片与字幕的最终路径,每行一个 | +| `AB_OLD_PATH` / `AB_NEW_PATH` | `file.renamed` | 重命名前后的路径 | +| `AB_FILE_KIND` | `file.renamed` | `media` 或 `subtitle` | + +事件里没有种子的保存目录,需要时从 `AB_FILES` 取。路径是**下载器视角**,AutoBangumi 与下载器的挂载不同时,脚本要自己换算。命令按事件顺序逐个运行,一个命令运行时后面的事件排队。 + +`torrent.organized` 的投递是「至少一次」:每次 AutoBangumi 重启,已整理的种子会再发布一次。脚本必须幂等。 + +## 安装 + +```bash +cp -r examples/plugins/run-command config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写命令。Docker 中命令在 AutoBangumi 的容器里运行,只能使用镜像里有的程序和挂载进来的脚本。 + +## 测试 + +```bash +cd examples/plugins/run-command && uv run pytest +``` + +对应 issue:[#897](https://github.com/EstrellaXD/Auto_Bangumi/issues/897)、[#668](https://github.com/EstrellaXD/Auto_Bangumi/issues/668) diff --git a/examples/plugins/run-command/plugin.toml b/examples/plugins/run-command/plugin.toml new file mode 100644 index 00000000..cd5d65b8 --- /dev/null +++ b/examples/plugins/run-command/plugin.toml @@ -0,0 +1,9 @@ +[plugin] +id = "run-command" +name = "运行外部命令" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "run_command:RunCommand" +description = "示例插件:种子整理完成(或文件重命名)后运行一条外部命令,事件字段以环境变量和参数占位符传入。演示子进程与超时" +authors = ["AutoBangumi"] +permissions = ["subprocess"] diff --git a/examples/plugins/run-command/pyproject.toml b/examples/plugins/run-command/pyproject.toml new file mode 100644 index 00000000..81b34cb8 --- /dev/null +++ b/examples/plugins/run-command/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-run-command" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/run-command/run_command/__init__.py b/examples/plugins/run-command/run_command/__init__.py new file mode 100644 index 00000000..f415f339 --- /dev/null +++ b/examples/plugins/run-command/run_command/__init__.py @@ -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) diff --git a/examples/plugins/run-command/tests/test_run_command.py b/examples/plugins/run-command/tests/test_run_command.py new file mode 100644 index 00000000..771e5068 --- /dev/null +++ b/examples/plugins/run-command/tests/test_run_command.py @@ -0,0 +1,98 @@ +import asyncio +import json +import logging +import sys +import time + +import pytest +from run_command import RunCommand + +from ab_sdk.events import FileRenamed, OrganizedFile, TorrentOrganized +from ab_sdk.testing import create_plugin + +ORGANIZED = TorrentOrganized( + torrent_hash="abc", + bangumi_id=None, + files=( + OrganizedFile("/dl/Show/Season 1/Show S01E01.mkv", "media"), + OrganizedFile("/dl/Show/Season 1/Show S01E01.ass", "subtitle"), + ), +) +RENAMED = FileRenamed( + bangumi_id=7, old_path="/dl/a.mkv", new_path="/dl/b.mkv", file_kind="media" +) + +# 子进程把收到的参数与 AB_* 环境变量写进 argv[1] 指定的文件 +DUMP = ( + "import json, os, sys; " + "json.dump({'argv': sys.argv[2:], " + "'env': {k: v for k, v in os.environ.items() if k.startswith('AB_')}}, " + "open(sys.argv[1], 'w'))" +) + + +def run(options: dict, event=ORGANIZED) -> None: + plugin, _ = create_plugin(RunCommand, options) + asyncio.run(plugin.on_event(event)) + + +def test_on_event_organized_passes_env_and_placeholders_without_shell(tmp_path): + out = tmp_path / "out.json" + command = [sys.executable, "-c", DUMP, str(out), "{torrent_hash}", "$HOME; {x}"] + run({"command": command}) + got = json.loads(out.read_text()) + # $HOME 与 ; 按字面传入:没有经过 shell;未知占位符原样保留 + assert got["argv"] == ["abc", "$HOME; {x}"] + assert got["env"] == { + "AB_EVENT": "torrent.organized", + "AB_BANGUMI_ID": "", + "AB_DOWNLOADER_ID": "default", + "AB_TORRENT_HASH": "abc", + "AB_FILES": "/dl/Show/Season 1/Show S01E01.mkv\n" + "/dl/Show/Season 1/Show S01E01.ass", + } + + +def test_on_event_renamed_when_selected_passes_rename_fields(tmp_path): + out = tmp_path / "out.json" + command = [sys.executable, "-c", DUMP, str(out), "{old_path}"] + run({"command": command, "events": ["file.renamed"]}, RENAMED) + got = json.loads(out.read_text()) + assert got["argv"] == ["/dl/a.mkv"] + assert got["env"]["AB_BANGUMI_ID"] == "7" + assert got["env"]["AB_NEW_PATH"] == "/dl/b.mkv" + assert got["env"]["AB_FILE_KIND"] == "media" + + +@pytest.mark.parametrize( + ("options", "event"), + [({"command": []}, ORGANIZED), ({}, RENAMED)], +) +def test_on_event_unwanted_runs_nothing(tmp_path, options, event): + out = tmp_path / "out.json" + options.setdefault("command", [sys.executable, "-c", DUMP, str(out)]) + run(options, event) + assert not out.exists() + + +def test_on_event_timeout_kills_command(caplog): + command = [sys.executable, "-c", "import time; time.sleep(30)"] + plugin, _ = create_plugin(RunCommand, {"command": command, "timeout": 1}) + start = time.monotonic() + with caplog.at_level(logging.WARNING): + asyncio.run(plugin.on_event(ORGANIZED)) + assert time.monotonic() - start < 10 + assert "已终止" 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): + run({"command": [sys.executable, "-c", script]}) + assert "退出码 3" in caplog.text and "boom" in caplog.text + + +def test_on_event_missing_program_is_logged_not_raised(caplog): + with caplog.at_level(logging.WARNING): + run({"command": ["/nonexistent/ab-run-command-test"]}) + assert "无法启动命令" in caplog.text diff --git a/examples/plugins/slack-notifier/README.md b/examples/plugins/slack-notifier/README.md new file mode 100644 index 00000000..cfd630ea --- /dev/null +++ b/examples/plugins/slack-notifier/README.md @@ -0,0 +1,28 @@ +# Slack 通知(示例插件) + +把通知发到 Slack 的 [Incoming Webhook](https://api.slack.com/messaging/webhooks)。演示: + +- `@provider(points.NOTIFIER, id="slack")`:返回 `NotifierFactory` +- Webhook 地址含密钥,用 `secret_field` 声明,读取配置时只返回掩码 +- 只有 HTTP 2xx 且响应体为 `ok` 才算成功 +- 契约套件 `NotifierContract` + +## 消息格式 + +请求体为 `{"text": "*标题*\n正文"}`(Slack 的 mrkdwn,`&`、`<`、`>` 已转义)。配置了 `notification.base_url` 时单集通知带海报:再加 `blocks`(一段文字和一张图片),`text` 作为手机通知的摘要。海报地址必须能被 Slack 访问。 + +## 安装 + +```bash +cp -r examples/plugins/slack-notifier config/plugins/local/ +``` + +在 Slack 中创建 App,开启 Incoming Webhooks 并添加到频道,复制 Webhook 地址。在「设置 → 插件」中开启「允许未签名插件」,启用并填写地址;再到「设置 → 通知 → 添加渠道」选择类型 `slack`。 + +## 测试 + +```bash +cd examples/plugins/slack-notifier && uv run pytest +``` + +对应 issue:[#277](https://github.com/EstrellaXD/Auto_Bangumi/issues/277) diff --git a/examples/plugins/slack-notifier/plugin.toml b/examples/plugins/slack-notifier/plugin.toml new file mode 100644 index 00000000..9c074808 --- /dev/null +++ b/examples/plugins/slack-notifier/plugin.toml @@ -0,0 +1,10 @@ +[plugin] +id = "slack-notifier" +name = "Slack 通知" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "slack_notifier:SlackNotifier" +description = "示例插件:把通知发到 Slack 的 Incoming Webhook,带海报时附图片。在「设置 → 通知 → 添加渠道」选择类型 slack" +authors = ["AutoBangumi"] +permissions = ["network"] +extension_points = ["notifier"] diff --git a/examples/plugins/slack-notifier/pyproject.toml b/examples/plugins/slack-notifier/pyproject.toml new file mode 100644 index 00000000..9469acd7 --- /dev/null +++ b/examples/plugins/slack-notifier/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-slack-notifier" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/slack-notifier/slack_notifier/__init__.py b/examples/plugins/slack-notifier/slack_notifier/__init__.py new file mode 100644 index 00000000..5ea09723 --- /dev/null +++ b/examples/plugins/slack-notifier/slack_notifier/__init__.py @@ -0,0 +1,85 @@ +"""示例插件:Slack 通知渠道。 + +向 Incoming Webhook POST ``{"text": "*标题*\\n正文"}``(Slack 的 mrkdwn)。带海报时 +再加 ``blocks``:一段文字加一张图片,``text`` 留作手机通知里的摘要。Slack 成功时 +返回 HTTP 200 与响应体 ``ok``,失败时返回 4xx 与错误码(如 ``invalid_token``)。 +""" + +from collections.abc import Callable +from typing import Any + +import httpx2 +from pydantic import BaseModel + +from ab_sdk import Plugin, points, provider, secret_field +from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings + + +class Options(BaseModel): + webhook_url: str = secret_field( + title="Webhook 地址", + description="Slack App 的 Incoming Webhook 地址,形如 https://hooks.slack.com/services/...", + ) + + +def escape(text: str) -> str: + """mrkdwn 只要求转义这三个字符(番剧名里常有 &)。""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def slack_body(message: NotificationMessage) -> dict[str, Any]: + text = f"*{escape(message.title)}*\n{escape(message.body)}" + body: dict[str, Any] = {"text": text} + if message.poster_url: + body["blocks"] = [ + {"type": "section", "text": {"type": "mrkdwn", "text": text}}, + { + "type": "image", + "image_url": message.poster_url, + "alt_text": message.title, + }, + ] + return body + + +class Channel: + def __init__( + self, + options: Options, + settings: NotifierSettings, + client_factory: Callable[[], httpx2.AsyncClient], + ) -> None: + self.options = options + self.settings = settings + self.client_factory = client_factory + + async def send(self, message: NotificationMessage) -> bool: + if not self.options.webhook_url: + return False + try: + async with self.client_factory() as client: + resp = await client.post( + self.options.webhook_url, json=slack_body(message) + ) + except httpx2.HTTPError: + return False + return resp.is_success and resp.text == "ok" + + async def test(self) -> tuple[bool, str]: + ok = await self.send( + NotificationMessage(kind="event", title="AutoBangumi", body="通知测试") + ) + return ok, "ok" if ok else "发送失败" + + +class SlackNotifier(Plugin[Options]): + config_model = Options + + # 测试可替换为带 MockTransport 的客户端 + client_factory: Callable[[], httpx2.AsyncClient] = staticmethod( + lambda: httpx2.AsyncClient(timeout=10) + ) + + @provider(points.NOTIFIER, id="slack") + def channel(self) -> NotifierFactory: + return lambda settings: Channel(self.config, settings, self.client_factory) diff --git a/examples/plugins/slack-notifier/tests/test_slack_notifier.py b/examples/plugins/slack-notifier/tests/test_slack_notifier.py new file mode 100644 index 00000000..0f1b1f74 --- /dev/null +++ b/examples/plugins/slack-notifier/tests/test_slack_notifier.py @@ -0,0 +1,63 @@ +import asyncio +import json + +import httpx2 +import pytest +from slack_notifier import SlackNotifier + +from ab_sdk.notify import NotificationMessage, NotifierSettings +from ab_sdk.testing import NotifierContract, create_plugin + +URL = "https://hooks.slack.com/services/T/B/x" + + +def channel(response: httpx2.Response, seen: list | None = None): + plugin, _ = create_plugin(SlackNotifier, {"webhook_url": URL}) + + def handler(request: httpx2.Request) -> httpx2.Response: + if seen is not None: + seen.append(request) + return response + + plugin.client_factory = lambda: httpx2.AsyncClient( # type: ignore[method-assign] + transport=httpx2.MockTransport(handler) + ) + return plugin.channel()(NotifierSettings()) + + +class TestChannel(NotifierContract): + def create(self): + return channel(httpx2.Response(200, text="ok")) + + def create_failing(self): + return channel(httpx2.Response(403, text="invalid_token")) + + +def sent_body(message: NotificationMessage) -> dict: + seen: list[httpx2.Request] = [] + assert asyncio.run(channel(httpx2.Response(200, text="ok"), seen).send(message)) + return json.loads(seen[0].content) + + +def test_send_escapes_mrkdwn_and_bolds_title(): + body = sent_body(NotificationMessage(kind="event", title="A & ", body="x")) + assert body == {"text": "*A & <B>*\nx"} + + +def test_send_with_poster_adds_image_block(): + message = NotificationMessage( + kind="episode", title="T", body="b", poster_url="https://p/1.jpg" + ) + body = sent_body(message) + assert body["text"] == "*T*\nb" + assert body["blocks"][1] == { + "type": "image", + "image_url": "https://p/1.jpg", + "alt_text": "T", + } + + +@pytest.mark.parametrize("text", ["", "no_service"]) +def test_send_2xx_without_ok_body_is_failure(text): + message = NotificationMessage(kind="event", title="t", body="b") + assert asyncio.run(channel(httpx2.Response(200, text=text)).send(message)) is False diff --git a/examples/plugins/strm-writer/README.md b/examples/plugins/strm-writer/README.md new file mode 100644 index 00000000..e89cf363 --- /dev/null +++ b/examples/plugins/strm-writer/README.md @@ -0,0 +1,35 @@ +# STRM 写入(示例插件) + +种子整理完成后,为每个正片写一个 `.strm`。把 STRM 目录加入 Emby / Jellyfin 媒体库,再把媒体文件放到网盘或对象存储,客户端播放时直接从该地址读取,不经过媒体服务器。演示: + +- `@subscribe(TorrentOrganized.kind)`:只处理 `kind == "media"` 的文件 +- 投递为「至少一次」:内容相同就不重写,`.strm` 的修改时间不变 +- 文件操作放进 `asyncio.to_thread`,不阻塞事件循环 + +## 配置 + +| 字段 | 示例 | 说明 | +| --- | --- | --- | +| 媒体根目录 | `/downloads/Bangumi` | 下载器中要去掉的路径前缀(与事件路径同为下载器视角)。不在其下的文件被跳过并记录警告 | +| STRM 目录 | `/downloads/STRM` | 写入 `.strm` 的目录,是 AutoBangumi 看到的路径 | +| 地址前缀 | `http://nas:8096/media/` | 拼在相对路径前。含 `://` 时,路径的每一段做百分号编码(空格变为 `%20`);不含时(如本地路径 `/mnt/cloud/`)原样拼接 | + +例:正片 `/downloads/Bangumi/A/Season 1/A S01E01.mkv`,按上表配置写入 `/downloads/STRM/A/Season 1/A S01E01.strm`,内容为 `http://nas:8096/media/A/Season%201/A%20S01E01.mkv`。 + +前缀以 `/` 结尾时直接拼接相对路径;插件不补 `/`。字幕不写 `.strm`。 + +## 安装 + +```bash +cp -r examples/plugins/strm-writer config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写三个字段。 + +## 测试 + +```bash +cd examples/plugins/strm-writer && uv run pytest +``` + +对应 issue:[#428](https://github.com/EstrellaXD/Auto_Bangumi/issues/428) diff --git a/examples/plugins/strm-writer/plugin.toml b/examples/plugins/strm-writer/plugin.toml new file mode 100644 index 00000000..23a3547d --- /dev/null +++ b/examples/plugins/strm-writer/plugin.toml @@ -0,0 +1,9 @@ +[plugin] +id = "strm-writer" +name = "STRM 写入" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "strm_writer:StrmWriter" +description = "示例插件:种子整理完成后,按媒体目录结构在 STRM 目录写 .strm,内容为地址前缀加相对路径。演示 torrent.organized 事件与幂等写入" +authors = ["AutoBangumi"] +permissions = ["fs.write"] diff --git a/examples/plugins/strm-writer/pyproject.toml b/examples/plugins/strm-writer/pyproject.toml new file mode 100644 index 00000000..eed5adbe --- /dev/null +++ b/examples/plugins/strm-writer/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-strm-writer" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/strm-writer/strm_writer/__init__.py b/examples/plugins/strm-writer/strm_writer/__init__.py new file mode 100644 index 00000000..0cbe34c4 --- /dev/null +++ b/examples/plugins/strm-writer/strm_writer/__init__.py @@ -0,0 +1,84 @@ +"""示例插件:STRM 写入。 + +订阅 ``torrent.organized``。对事件里每个正片,去掉路径前缀 ``media_root`` 得到 +相对路径 ``rel``,写 ``/``,内容为 ``url_prefix + rel``。 +Emby / Jellyfin 播放 ``.strm`` 时直接读取这个地址,媒体文件可以放在网盘或对象存储里。 + +- ``media_root`` 与事件路径同为下载器视角,所以不需要路径映射;``strm_root`` 是 + AutoBangumi 看到的本地目录。 +- ``url_prefix`` 含 ``://`` 时按 URL 处理,路径的每一段做百分号编码;否则 + (本地路径)原样拼接。 +- 投递为「至少一次」:内容相同就不重写;``url_prefix`` 改了会在下次事件时更新。 +""" + +import asyncio +import posixpath +from pathlib import Path +from urllib.parse import quote + +from pydantic import BaseModel, Field + +from ab_sdk import Plugin, subscribe +from ab_sdk.events import TorrentOrganized + + +class Options(BaseModel): + strm_root: str = Field( + "", title="STRM 目录", description="写入 .strm 的目录(AutoBangumi 中的路径)" + ) + url_prefix: str = Field( + "", + title="地址前缀", + description="拼在相对路径前,如 http://nas:8096/media/ 或本地路径 /mnt/cloud/", + ) + media_root: str = Field( + "", + title="媒体根目录", + description="下载器中要去掉的路径前缀,如 /downloads/Bangumi;其下的目录结构原样映射", + ) + + +def relative(path: str, media_root: str) -> str | None: + """``path`` 不在 ``media_root`` 下时返回 None。""" + root = media_root.rstrip("/") + if not root or not path.startswith(root + "/"): + return None + return posixpath.normpath(path[len(root) + 1 :]) + + +def strm_target(rel: str, options: Options) -> tuple[Path, str]: + """(.strm 路径, 文件内容)。""" + link = quote(rel, safe="/") if "://" in options.url_prefix else rel + strm = Path(options.strm_root, *rel.split("/")).with_suffix(".strm") + return strm, options.url_prefix + link + + +def write_strm(strm: Path, content: str) -> bool: + """内容相同时不重写,返回 False。""" + if strm.is_file() and strm.read_text(encoding="utf-8") == content: + return False + strm.parent.mkdir(parents=True, exist_ok=True) + strm.write_text(content, encoding="utf-8") + return True + + +class StrmWriter(Plugin[Options]): + config_model = Options + + @subscribe(TorrentOrganized.kind) + async def on_organized(self, event: TorrentOrganized) -> None: + cfg = self.config + if not cfg.strm_root or not cfg.url_prefix: + return + for file in event.files: + if file.kind != "media": + continue + rel = relative(file.path, cfg.media_root) + if rel is None or rel.startswith("../"): + self.ctx.log.warning( + "%s 不在媒体根目录 %s 下", file.path, cfg.media_root + ) + continue + strm, content = strm_target(rel, cfg) + if await asyncio.to_thread(write_strm, strm, content): + self.ctx.log.info("已写入 %s", strm) diff --git a/examples/plugins/strm-writer/tests/test_strm_writer.py b/examples/plugins/strm-writer/tests/test_strm_writer.py new file mode 100644 index 00000000..5cf7b64b --- /dev/null +++ b/examples/plugins/strm-writer/tests/test_strm_writer.py @@ -0,0 +1,63 @@ +import asyncio +import logging + +import pytest +from strm_writer import StrmWriter + +from ab_sdk.events import OrganizedFile, TorrentOrganized +from ab_sdk.testing import create_plugin + +MEDIA = "/downloads/Bangumi/A/Season 1/A S01E01.mkv" + + +def organized(*files: OrganizedFile) -> TorrentOrganized: + return TorrentOrganized(torrent_hash="h", bangumi_id=1, files=files) + + +def run(tmp_path, url_prefix: str, *files: OrganizedFile) -> None: + options = { + "strm_root": str(tmp_path / "STRM"), + "url_prefix": url_prefix, + "media_root": "/downloads/Bangumi/", + } + plugin, _ = create_plugin(StrmWriter, options) + asyncio.run(plugin.on_organized(organized(*files))) + + +@pytest.mark.parametrize( + ("url_prefix", "content"), + [ + ( + "http://nas:8096/media/", + "http://nas:8096/media/A/Season%201/A%20S01E01.mkv", + ), + ("/mnt/cloud/", "/mnt/cloud/A/Season 1/A S01E01.mkv"), + ], +) +def test_on_organized_mirrors_media_path_into_strm(tmp_path, url_prefix, content): + run( + tmp_path, + url_prefix, + OrganizedFile(MEDIA, "media"), + OrganizedFile(MEDIA.replace(".mkv", ".ass"), "subtitle"), + ) + season = tmp_path / "STRM" / "A" / "Season 1" + assert [p.name for p in season.iterdir()] == ["A S01E01.strm"] + assert (season / "A S01E01.strm").read_text(encoding="utf-8") == content + + +def test_on_organized_prefix_change_rewrites_strm(tmp_path): + run(tmp_path, "http://old/", OrganizedFile(MEDIA, "media")) + run(tmp_path, "http://new/", OrganizedFile(MEDIA, "media")) + strm = tmp_path / "STRM" / "A" / "Season 1" / "A S01E01.strm" + assert strm.read_text(encoding="utf-8").startswith("http://new/") + + +@pytest.mark.parametrize( + "path", ["/other/A S01E01.mkv", "/downloads/Bangumi/../../etc/x.mkv"] +) +def test_on_organized_path_outside_media_root_is_skipped(tmp_path, caplog, path): + with caplog.at_level(logging.WARNING): + run(tmp_path, "http://nas/", OrganizedFile(path, "media")) + assert not (tmp_path / "STRM").exists() + assert "不在媒体根目录" in caplog.text diff --git a/examples/plugins/synology-chat-notifier/README.md b/examples/plugins/synology-chat-notifier/README.md new file mode 100644 index 00000000..c536ce19 --- /dev/null +++ b/examples/plugins/synology-chat-notifier/README.md @@ -0,0 +1,30 @@ +# Synology Chat 通知(示例插件) + +把通知发到群晖 Synology Chat 的传入 Webhook。演示: + +- `@provider(points.NOTIFIER, id="synology-chat")`:返回 `NotifierFactory` +- Webhook 地址含 token,用 `secret_field` 声明,读取配置时只返回掩码 +- 后端返回 HTTP 200 但 `{"success": false}` 时,`send` 同样返回 `False` +- 契约套件 `NotifierContract` + +## 获取 Webhook 地址 + +在 Synology Chat 中打开「整合 → 传入 Webhook」,新建一个并复制地址(形如 `https://:5001/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=...`)。 + +请求为表单字段 `payload=`,JSON 为 `{"text": "标题\n正文"}`;配置了 `notification.base_url` 时单集通知带海报地址 `file_url`,Chat 会下载并显示该图片,所以海报地址必须能被 NAS 访问。 + +## 安装 + +```bash +cp -r examples/plugins/synology-chat-notifier config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写 Webhook 地址;再到「设置 → 通知 → 添加渠道」选择类型 `synology-chat`。 + +## 测试 + +```bash +cd examples/plugins/synology-chat-notifier && uv run pytest +``` + +对应 issue:[#887](https://github.com/EstrellaXD/Auto_Bangumi/issues/887) diff --git a/examples/plugins/synology-chat-notifier/plugin.toml b/examples/plugins/synology-chat-notifier/plugin.toml new file mode 100644 index 00000000..4dee5504 --- /dev/null +++ b/examples/plugins/synology-chat-notifier/plugin.toml @@ -0,0 +1,10 @@ +[plugin] +id = "synology-chat-notifier" +name = "Synology Chat 通知" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "synology_chat_notifier:SynologyChatNotifier" +description = "示例插件:把通知发到 Synology Chat 的传入 Webhook。在「设置 → 通知 → 添加渠道」选择类型 synology-chat" +authors = ["AutoBangumi"] +permissions = ["network"] +extension_points = ["notifier"] diff --git a/examples/plugins/synology-chat-notifier/pyproject.toml b/examples/plugins/synology-chat-notifier/pyproject.toml new file mode 100644 index 00000000..57e64acc --- /dev/null +++ b/examples/plugins/synology-chat-notifier/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-synology-chat-notifier" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/synology-chat-notifier/synology_chat_notifier/__init__.py b/examples/plugins/synology-chat-notifier/synology_chat_notifier/__init__.py new file mode 100644 index 00000000..c728c104 --- /dev/null +++ b/examples/plugins/synology-chat-notifier/synology_chat_notifier/__init__.py @@ -0,0 +1,78 @@ +"""示例插件:Synology Chat 通知渠道。 + +向 Chat 的传入 Webhook 发送表单字段 ``payload=``(Synology Chat 只接受这种 +格式),JSON 为 ``{"text": ...}``,带海报时加 ``file_url``。Chat 即使拒绝也常返回 +HTTP 200,结果在响应体的 ``success`` 里,所以两者都要检查。 +""" + +import json +from collections.abc import Callable + +import httpx2 +from pydantic import BaseModel + +from ab_sdk import Plugin, points, provider, secret_field +from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings + + +class Options(BaseModel): + webhook_url: str = secret_field( + title="Webhook 地址", + description="Chat → 整合 → 传入 Webhook 中的地址,含 token", + ) + + +def accepted(resp: httpx2.Response) -> bool: + """2xx,且响应体是 JSON 时其中的 success 不为 false。""" + if not resp.is_success: + return False + try: + body = resp.json() + except ValueError: + return True + return not isinstance(body, dict) or body.get("success", True) is not False + + +class Channel: + def __init__( + self, + options: Options, + settings: NotifierSettings, + client_factory: Callable[[], httpx2.AsyncClient], + ) -> None: + self.options = options + self.settings = settings + self.client_factory = client_factory + + async def send(self, message: NotificationMessage) -> bool: + if not self.options.webhook_url: + return False + payload: dict[str, str] = {"text": f"{message.title}\n{message.body}"} + if message.poster_url: + payload["file_url"] = message.poster_url + data = {"payload": json.dumps(payload, ensure_ascii=False)} + try: + async with self.client_factory() as client: + resp = await client.post(self.options.webhook_url, data=data) + except httpx2.HTTPError: + return False + return accepted(resp) + + async def test(self) -> tuple[bool, str]: + ok = await self.send( + NotificationMessage(kind="event", title="AutoBangumi", body="通知测试") + ) + return ok, "ok" if ok else "发送失败" + + +class SynologyChatNotifier(Plugin[Options]): + config_model = Options + + # 测试可替换为带 MockTransport 的客户端 + client_factory: Callable[[], httpx2.AsyncClient] = staticmethod( + lambda: httpx2.AsyncClient(timeout=10) + ) + + @provider(points.NOTIFIER, id="synology-chat") + def channel(self) -> NotifierFactory: + return lambda settings: Channel(self.config, settings, self.client_factory) diff --git a/examples/plugins/synology-chat-notifier/tests/test_synology_chat_notifier.py b/examples/plugins/synology-chat-notifier/tests/test_synology_chat_notifier.py new file mode 100644 index 00000000..67a4e07d --- /dev/null +++ b/examples/plugins/synology-chat-notifier/tests/test_synology_chat_notifier.py @@ -0,0 +1,63 @@ +import asyncio +import json +from urllib.parse import parse_qs + +import httpx2 +import pytest +from synology_chat_notifier import SynologyChatNotifier + +from ab_sdk.notify import NotificationMessage, NotifierSettings +from ab_sdk.testing import NotifierContract, create_plugin + +URL = "https://nas:5001/webapi/entry.cgi?api=SYNO.Chat.External&token=t" + + +def channel(response: httpx2.Response, seen: list | None = None): + plugin, _ = create_plugin(SynologyChatNotifier, {"webhook_url": URL}) + + def handler(request: httpx2.Request) -> httpx2.Response: + if seen is not None: + seen.append(request) + return response + + plugin.client_factory = lambda: httpx2.AsyncClient( # type: ignore[method-assign] + transport=httpx2.MockTransport(handler) + ) + return plugin.channel()(NotifierSettings()) + + +class TestChannel(NotifierContract): + def create(self): + return channel(httpx2.Response(200, json={"success": True})) + + def create_failing(self): + # Chat 拒绝请求时仍返回 HTTP 200 + return channel(httpx2.Response(200, json={"success": False, "error": {}})) + + +def test_send_posts_form_encoded_payload_json(): + seen: list[httpx2.Request] = [] + ch = channel(httpx2.Response(200, json={"success": True}), seen) + message = NotificationMessage( + kind="episode", title="新番更新", body="第 5 集", poster_url="https://p/1.jpg" + ) + assert asyncio.run(ch.send(message)) is True + [request] = seen + assert str(request.url) == URL + form = parse_qs(request.content.decode()) + assert json.loads(form["payload"][0]) == { + "text": "新番更新\n第 5 集", + "file_url": "https://p/1.jpg", + } + + +@pytest.mark.parametrize( + ("response", "ok"), + [ + (httpx2.Response(200, text="ok"), True), + (httpx2.Response(500, json={"success": True}), False), + ], +) +def test_send_judges_status_and_optional_json_body(response, ok): + message = NotificationMessage(kind="event", title="t", body="b") + assert asyncio.run(channel(response).send(message)) is ok