From 3aff767c9982ccc5152bbdcb5fedc5f97e8c9801 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 8 Oct 2026 14:51:00 +0200 Subject: [PATCH] =?UTF-8?q?ci(4.0-P7):=20CI=20=E8=BF=90=E8=A1=8C=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E6=8F=92=E4=BB=B6=E6=B5=8B=E8=AF=95=EF=BC=8Crelease?= =?UTF-8?q?=20=E9=99=84=E5=B8=A6=20SDK=20=E8=BD=AE=E5=AD=90=E4=B8=8E?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E4=BD=9C=E8=80=85=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test 作业新增示例插件步骤 - release 作业构建 autobangumi-sdk 轮子并压缩 skills/autobangumi-plugin,随 beta 与稳定版一起上传 - 新增 skills/autobangumi-plugin:插件作者 agent skill - beta tag 原本就创建预发布 release,分类脚本与 Docker 标签不变,新增测试固定该行为 --- .github/workflows/build.yml | 16 ++++ .../src/test/test_release_classification.py | 14 +++ skills/autobangumi-plugin/SKILL.md | 85 +++++++++++++++++++ .../references/extension-points.md | 75 ++++++++++++++++ .../autobangumi-plugin/references/frontend.md | 34 ++++++++ .../autobangumi-plugin/references/testing.md | 56 ++++++++++++ 6 files changed, 280 insertions(+) create mode 100644 skills/autobangumi-plugin/SKILL.md create mode 100644 skills/autobangumi-plugin/references/extension-points.md create mode 100644 skills/autobangumi-plugin/references/frontend.md create mode 100644 skills/autobangumi-plugin/references/testing.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8401d675..881a5fd0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,10 @@ jobs: run: | mkdir -p backend/config cd backend && uv run pytest src/test -v -m "not e2e" + # 示例插件:校验清单并跑各自的契约 / 行为测试(与插件作者在示例目录里 + # `uv run pytest` 一致) + - name: Example plugins + run: scripts/test_example_plugins.sh webui-test: runs-on: ubuntu-latest @@ -311,6 +315,16 @@ jobs: cd backend && zip -r app-v${{ needs.version-info.outputs.version }}.zip \ src pyproject.toml uv.lock requirements.txt + # 插件作者用的两个附件:autobangumi-sdk 轮子(ab_sdk + ab-plugin,不发 PyPI, + # 作者从 release 下载安装)和 autobangumi-plugin agent skill 的 zip。 + # beta(预发布)与稳定版 release 都会带上它们。 + - name: Build plugin SDK wheel + run: uv build --wheel backend/sdk --out-dir sdk-dist + + - name: Zip plugin-author skill + run: | + cd skills && zip -r "../autobangumi-plugin-skill-${{ needs.version-info.outputs.version }}.zip" autobangumi-plugin + # 在线自动更新 bundle:module 源码树 + pyproject/uv.lock + 前端 dist + manifest。 # 同时产出 .sha256(客户端下载后据此校验完整性)。min_image_version 是能应用 # 此 bundle 布局的最低镜像版本(引入在线更新的首个版本)。 @@ -398,6 +412,8 @@ jobs: update-bundle-${{ needs.version-info.outputs.version }}.zip update-bundle-${{ needs.version-info.outputs.version }}.zip.sha256 update-bundle-${{ needs.version-info.outputs.version }}.zip.sig + sdk-dist/*.whl + autobangumi-plugin-skill-${{ needs.version-info.outputs.version }}.zip env: GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} diff --git a/backend/src/test/test_release_classification.py b/backend/src/test/test_release_classification.py index 16972f3a..8a8c8059 100644 --- a/backend/src/test/test_release_classification.py +++ b/backend/src/test/test_release_classification.py @@ -108,3 +108,17 @@ def test_pr_title_is_never_a_version_source(pr_title): assert result["version"] == "Test" assert result["release"] == "0" + + +def test_release_job_marks_dev_tags_prerelease_and_attaches_plugin_assets(): + """beta 与稳定版共用 release 作业;dev=1 即预发布,两者都带 SDK 轮子与 skill。""" + workflow = SCRIPT_PATH.parents[1] / ".github" / "workflows" / "build.yml" + source = workflow.read_text(encoding="utf-8") + release = source[source.index("\n release:\n") : source.index("\n telegram:\n")] + assert "needs.version-info.outputs.release == 1" in release + assert "pre_release=true" in release + assert "sdk-dist/*.whl" in release + assert "autobangumi-plugin-skill-" in release + assert ( + SCRIPT_PATH.parents[1] / "skills" / "autobangumi-plugin" / "SKILL.md" + ).is_file() diff --git a/skills/autobangumi-plugin/SKILL.md b/skills/autobangumi-plugin/SKILL.md new file mode 100644 index 00000000..f1f6b2dc --- /dev/null +++ b/skills/autobangumi-plugin/SKILL.md @@ -0,0 +1,85 @@ +--- +name: autobangumi-plugin +description: Write, test, pack and debug AutoBangumi (AB) 4.0 plugins with the ab_sdk Python SDK and the ab-plugin CLI. Use when the user asks to "write an AutoBangumi plugin", "add a downloader / notifier / search site / rename strategy / RSS site to AutoBangumi", "hook into AutoBangumi", mentions ab_sdk, ab-plugin, plugin.toml, @provider, @hook, @subscribe, secret_field, or wants a custom rename template, webhook on torrent.organized, nfo writer, private tracker Cookie, or a plugin web component for the AutoBangumi WebUI. +--- + +# AutoBangumi plugin authoring + +AB plugins are Python packages (optional Web Component UI) that run inside the AB process. They import only `ab_sdk`, never `module.*`. SDK is 0.x during 4.0: pin `sdk = ">=0.5,<1"` in the manifest. + +## Workflow + +1. Pick the extension point (table below). If unsure, read `references/extension-points.md`. +2. Scaffold: `ab-plugin new --kind rename|notifier|search` (ids: lowercase, digits, hyphens; `core` and `local` are reserved). For other points, copy the closest example from `examples/plugins/` in the AB repo. +3. Write the plugin: one `Plugin[Options]` subclass, `config_model = Options`, decorated methods. +4. Test first: `uv run pytest`. Provider points have contract suites in `ab_sdk.testing` (subclass, implement `create()`). Other behavior: `create_plugin(Cls, options)` plus `asyncio.run`. +5. `ab-plugin validate .` then `ab-plugin dev .` (links into `config/plugins/local/`, enables hot reload; restart AB once) then `ab-plugin pack .`. + +Install the tooling from the GitHub release wheel (`autobangumi_sdk--py3-none-any.whl`), not PyPI. + +## Skeleton + +```python +from pydantic import BaseModel, Field +from ab_sdk import Plugin, points, provider, hook, subscribe, secret_field + +class Options(BaseModel): + url: str = Field("", title="URL") # title/description become the WebUI form labels + token: str = secret_field(title="Token") # password box; masked on read + +class MyPlugin(Plugin[Options]): + config_model = Options # self.config is a validated Options + + @provider(points.NOTIFIER, id="my-push") # Provider: the thing the user selects by id + def channel(self): ... + + @hook(points.TORRENT_FILTER, priority=50) # Hook: runs in the AB pipeline + def check(self, torrent, release, bangumi): ... + + @subscribe("torrent.organized", timeout=600) # Event subscriber ("*" = all events) + async def on_organized(self, event): ... +``` + +`self.ctx`: `plugin_id`, `config`, `log`, `kv` (async get/set/delete, JSON values), `data_dir`, `bus` (`publish`, `subscribe`). + +## Extension points + +| Point constant | Kind | Returns / signature | +| --- | --- | --- | +| `DOWNLOADER` | provider | `DownloaderFactory`: `(DownloaderConnection) -> client` with `capabilities`, `auth`, `logout`, `add_torrents` | +| `NOTIFIER` | provider | `NotifierFactory`: `(NotifierSettings) -> Notifier` (`send -> bool`, `test -> (bool, str)`) | +| `LLM_PROVIDER` | provider | `LLMProviderAdapter` subclass; id must equal `info.id` | +| `SEARCH_SITE` | provider | `SearchSite(url_with_%s, parser="tmdb"\|"mikan")` | +| `SCHEDULED_TASK` | provider | `ScheduledTask(run, interval, initial_delay, enabled)` | +| `METADATA_PROVIDER` | provider | object with `async enrich(MetadataRequest) -> Metadata \| None` | +| `RENAME_STRATEGY` | provider | object with `target_name(RenameInput) -> str`; raise `RenameSkipped` for bad input | +| `MEDIA_FILES` | provider | `classify(path) -> "media"\|"subtitle"\|"ignore"` | +| `CONFLICT_POLICY` | provider | `decide(ConflictRequest) -> ConflictDecision("hold"\|"replace", reason)` | +| `API_ROUTER` | provider | `fastapi.APIRouter`, mounted at `/api/v1/plugins//`, login required | +| `MCP_TOOL` / `MCP_RESOURCE` | provider | `McpTool` / `McpResource` | +| `TORRENT_FILTER` | filter hook | `(torrent, release, bangumi) -> Verdict` | +| `TITLE_PARSED` | transform hook | `(release) -> release \| None` | +| `TORRENT_ADDING` | transform hook | `(AddRequest) -> AddRequest \| None` | +| `HTTP_REQUEST` | transform hook | `(HttpRequest) -> HttpRequest \| None` (headers only) | +| `MESSAGE_TEMPLATE` | transform hook | `(RenderedMessage, event, channel) -> RenderedMessage \| None` | + +Details, imports and field lists: `references/extension-points.md`. + +## Rules that prevent most bugs + +- Hooks get frozen snapshots. Return `dataclasses.replace(obj, ...)`; return `None` for "no change". A wrong return type counts as a failure. +- Five consecutive failures trip the circuit breaker and disable the plugin. `RenameSkipped` is not a failure. Never fall back to another naming method after a failure. +- Filter hooks fail open (a crash accepts the torrent). Notifier `send` returns `False` on a backend refusal; it does not raise. +- Events: plugin event `kind` must start with `.`. Subclass `SystemEvent` to make a notifiable event. `torrent.organized` is delivered at least once: make handlers idempotent. Event paths are downloader-side paths; map them (`path_from` / `path_to` options) before touching the disk. +- Blocking work (file IO, copies) goes in `asyncio.to_thread`; `target_name` is sync with no timeout, so no IO there. +- Validate config deeply in `field_validator` so bad values give HTTP 422 at save time. +- No native extensions (`.so`, `.pyd`, `.dylib`, `.dll`). Third-party pure-Python deps go in `vendor/`; AB does not run pip for catalog or local plugins. +- Plugin routes cannot use the paths `web/`, `install`, `catalog`. +- Local and pip plugins are unsigned: the user must turn on "Allow unsigned plugins". Only maintainers sign the catalog (`scripts/build_plugin_catalog.py`). + +## References + +- `references/extension-points.md`: imports, dataclass fields and gotchas per extension point. +- `references/testing.md`: contract suites, `create_plugin`, `ab-plugin` commands, the manifest. +- `references/frontend.md`: `[[plugin.ui]]`, slots, `AbHost`, CSP, build template. +- Examples (in the AB repo): `examples/plugins/` has `webhook-on-event`, `custom-rss-site`, `template-rename`, `nfo-writer`, `ntfy-notifier`, `manual-pick`. diff --git a/skills/autobangumi-plugin/references/extension-points.md b/skills/autobangumi-plugin/references/extension-points.md new file mode 100644 index 00000000..a618f42f --- /dev/null +++ b/skills/autobangumi-plugin/references/extension-points.md @@ -0,0 +1,75 @@ +# Extension points + +All imports come from `ab_sdk`. Constants live in `ab_sdk.points`. + +## Providers + +### downloader (`points.DOWNLOADER`) +```python +from ab_sdk.downloader import AddResult, DownloaderCapabilities, DownloaderConnection +``` +- Factory is called with `DownloaderConnection(host, username, password, ssl)`; return a client object. Provider `id` is the instance type the user picks (`plugins.instances[].provider`). +- Minimum (`CoreDownloaderClient`): class attribute `capabilities = DownloaderCapabilities(can_query, can_rename, can_manage, can_rss_rules)`, `async auth(retry=3) -> bool`, `async logout()`, `async add_torrents(torrent_urls, torrent_files, save_path, category, tags=None) -> AddResult` (`ADDED` / `DUPLICATE` / `FAILED`). +- A declared capability requires its methods: `can_query` -> `torrents_info`, `torrent_exists`, `torrents_files`; `can_rename` -> `torrents_rename_file` (returns `RenameResult`); `can_manage` -> delete/pause/resume/move/category/tag methods. Undeclared operations are skipped and logged. +- Test: subclass `DownloaderContract`; set `behavioral = True` only when `create()` returns a client with a working backend or stub. + +### notifier (`points.NOTIFIER`) +```python +from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings +``` +- `NotificationMessage(kind="episode"|"event", title, body, official_title, season, episode, poster_url)`. +- `NotifierSettings(template, extra)`. Put credentials in the plugin `config_model`, not in channel entries. +- Non-ASCII header values: pass `bytes` to httpx (`str` headers must be ASCII). +- Test: `NotifierContract`; implement `create_failing()` for the backend-refusal case. + +### llm_provider (`points.LLM_PROVIDER`) +`ab_sdk.llm.LLMProviderAdapter` subclass with `info = ProviderInfo(id=..., display_name=..., auth_kind="api_key"|"oauth"|"device_code", ...)`. `@provider(..., id=)` must equal `info.id`. Raise `AuthExpiredError` when credentials are dead and refresh failed. + +### search_site (`points.SEARCH_SITE`) +`SearchSite(url="https://x/rss?q=%s", parser="tmdb"|"mikan")`. Exactly one `%s`. User-defined site of the same name wins. Test: `SearchSiteContract`. + +### scheduled_task (`points.SCHEDULED_TASK`) +`ScheduledTask(run, interval, initial_delay=0.0, enabled=None)`. `interval` and `enabled` may be callables re-read each round. Interval counts from the end of the previous run. + +### metadata_provider (`points.METADATA_PROVIDER`) +Object with `async enrich(MetadataRequest) -> Metadata | None`. `MetadataRequest(kind, torrent, language, episode_type, current)`; return `replace(request.current, ...)`. The id appears in the subscription "parser" list. + +### rename_strategy (`points.RENAME_STRATEGY`) +```python +from ab_sdk.rename import RenameInput, RenameSkipped, pad +``` +- `RenameInput(kind, media_path, title, bangumi_name, season, episode, suffix, episode_type, language, group)`. +- Return the new relative path inside the torrent (add suffix and subtitle `.language` yourself). Return `f.media_path` for "no rename". `pad(9.5) == "09.5"`: keep half-episode fractions. +- `RenameSkipped(reason)`: file keeps its name, user is notified, not a breaker failure. Other exceptions, empty or non-str results count as failures. +- User picks the id at `plugins.slots.rename_strategy`; unregistered id acts as `none`. Test: `RenameStrategyContract` (override `samples()` for narrow strategies). + +### media_files / conflict_policy +- `MediaFiles.classify(path) -> "media"|"subtitle"|"ignore"`; slot `plugins.slots.media_files`, default `default`. +- `ConflictPolicy.decide(ConflictRequest(target_path, incoming, owners, strict_upgrade)) -> ConflictDecision(action, reason)`. `replace` deletes the old task and files; the host runs it only for a single owner, single-file torrents and complete identities. Slot `plugins.slots.conflict_policy`, default `hold`. + +### api_router (`points.API_ROUTER`) +Return `fastapi.APIRouter`. Mounted at `/api/v1/plugins//`. Always login-protected. `HTTPException` passes through; other exceptions give 500 and count as failures. Not in `/docs`. + +### mcp_tool / mcp_resource +`McpTool(description, handler(args)->json, input_schema, timeout=60)`; public name `__`. `McpResource(name, handler()->str|json, description, mime_type)`; URI `autobangumi://plugins//`. + +## Hooks + +- `torrent.filter`: `check(torrent: TorrentInfo, release: Release | None, bangumi: BangumiInfo) -> Verdict` (`Verdict.ok()` / `Verdict.reject(reason)`; bool also works). +- `title.parsed`: `fix(release)`; readable fields `raw, title_en, title_zh, title_jp, group, season, episode, resolution, source, subtitle, year`. +- `torrent.adding`: `adjust(AddRequest(bangumi, torrents, save_path, category, tags)) -> AddRequest | None`. Keep the `/Season N` save-path shape; keep category `Bangumi` or AB stops organizing the torrent; the `ab:` tag is restored if removed. +- `http.request`: `adjust(HttpRequest(method, url, headers))`; only header changes apply. Called for every GET: keep it cheap, no network inside. +- `message_template`: `(RenderedMessage(title, body), event, channel) -> RenderedMessage | None`; external push only; "new episode" notices are not covered. + +## Events + +`@subscribe(kind)` handlers get the event dataclass. System kinds: `rss_failure`, `download_failure`, `offset_review`, `downloader_unavailable`, `update_available`, `update_applied`, `update_failed`, `llm_auth_failure`, `llm_plugin_install_failed`, `rename_conflict`, `rename_skipped`, `plugin.loaded`, `plugin.disabled`. Organize kinds: `file.renamed` (`bangumi_id, old_path, new_path, file_kind, downloader_id`), `torrent.organized` (`torrent_hash, bangumi_id, files: tuple[OrganizedFile(path, kind)], downloader_id`). + +Custom event: +```python +@dataclass(frozen=True, slots=True) +class Done(Event): # SystemEvent for a notifiable event (add describe()) + kind: ClassVar[str] = "my-plugin.done" + count: int +``` +Publish with `self.ctx.bus.publish(Done(count=1))`. diff --git a/skills/autobangumi-plugin/references/frontend.md b/skills/autobangumi-plugin/references/frontend.md new file mode 100644 index 00000000..115bab42 --- /dev/null +++ b/skills/autobangumi-plugin/references/frontend.md @@ -0,0 +1,34 @@ +# Frontend (Web Component) slots + +Plugin UI is a custom element defined by an ES module under `web/`. AB sets `element.host` (AbHost) and `element.context` before inserting it. + +## Manifest + +```toml +[[plugin.ui]] +slot = "bangumi.detail.tab" # settings.section | bangumi.detail.tab | bangumi.card.action | page | dashboard.widget +element = "ab-plugin-" # must equal or start with ab-plugin-- +entry = "web/index.js" # under web/ +title = { zh-CN = "…", en-US = "…" } # at least one language +``` + +`context`: `{ bangumiId }` for `bangumi.detail.tab` and `bangumi.card.action`; `{}` otherwise. + +## AbHost + +- `host.pluginId` +- `host.api.get/post/put/delete(path, ...)`: JSON in/out, rejects on non-2xx. Relative path = `/api/v1/plugins//…` (the plugin's own `api_router`). Paths starting `/api/v1/` are host APIs, GET only. +- `host.i18n.locale`, `host.i18n.t(key)` +- `host.theme.mode`, `host.theme.tokens` (`--ab-*`) +- `host.toast(message, "info" | "error")` +- `host.events.on(kind, cb) -> unsubscribe` (event bus; callback gets the event fields) + +## Rules + +- CSP is `script-src 'self'`: no inline scripts, `eval` or CDN scripts; bundle everything into the module. Images, styles and fetches are free. +- Use a Shadow DOM and `var(--ab-color-primary)`, `--ab-color-surface`, `--ab-color-text`, `--ab-radius-sm`, `--ab-font-mono` for theme-aware styling. +- Clean up in `disconnectedCallback` (call the unsubscribe function). +- Static files are served at `/api/v1/plugins//web/…` (login required, enabled plugins only). Do not use `web/` as an `api_router` path. +- An import failure or uncaught error shows "plugin component failed to load" for that plugin only. +- Build template: `webui/packages/plugin-ui/template/` in the AB repo (Vite library mode, one self-contained ES module to `web/index.js`). Types: `@autobangumi/plugin-ui` (`AbHost`, `AbPluginElement`, `AbSlotContext`); a workspace package, not on npm. +- Reference implementation: `examples/plugins/manual-pick/` (no build step). diff --git a/skills/autobangumi-plugin/references/testing.md b/skills/autobangumi-plugin/references/testing.md new file mode 100644 index 00000000..8444eaf5 --- /dev/null +++ b/skills/autobangumi-plugin/references/testing.md @@ -0,0 +1,56 @@ +# Testing, CLI and manifest + +## Manifest (`plugin.toml`) + +```toml +[plugin] +id = "my-plugin" # ^[a-z0-9]+(-[a-z0-9]+)*$ ; not core/local +name = "My plugin" +version = "0.1.0" # PEP 440 +sdk = ">=0.5,<1" # must include the installed SDK version +entry = "my_plugin:MyPlugin" # module:Class, relative to the plugin dir +description = "" +authors = [] +permissions = ["network"] # display only +extension_points = ["notifier"] # display only (catalog) + +[[plugin.ui]] # optional, see frontend.md +slot = "page" +element = "ab-plugin-my-plugin" +entry = "web/index.js" +title = { en-US = "My plugin" } +``` + +Layout: `plugin.toml`, `my_plugin/__init__.py` (or `my_plugin.py`), optional `web/`, `vendor/`, `tests/`, dev-only `pyproject.toml` (not packed). + +## ab-plugin CLI + +| Command | Effect | +| --- | --- | +| `ab-plugin new [--kind rename\|notifier\|search] [--dir .]` | Skeleton with a contract test | +| `ab-plugin validate [path]` | Manifest, sdk range, entry module, web entry, native-file check; exit 1 on problems | +| `ab-plugin pack [path] [-o dist]` | Deterministic `dist/-.zip` (content at zip root; excludes tests, pyproject, caches) | +| `ab-plugin dev [path] [--config-dir config]` | Symlink to `config/plugins/local/`, write `dev_mode`, `allow_unsigned`, `enabled.`; needs an existing `config.json`/`config_dev.json`; restart AB once, then file changes reload the plugin | + +## Tests + +```python +import asyncio +from ab_sdk.testing import create_plugin + +def test_x(tmp_path): + plugin, ctx = create_plugin(MyPlugin, {"url": "https://x"}, data_dir=tmp_path) + asyncio.run(plugin.on_event(event)) # call decorated methods directly + assert ctx.bus.published == [] # events the plugin published +``` + +- `create_plugin` validates options through `config_model`, builds the plugin, does not call `setup()`. `ctx.kv` is an in-memory store; `await ctx.bus.deliver(event)` feeds the plugin's own subscriptions. +- Keep tests synchronous with `asyncio.run` so plain pytest works (no pytest-asyncio needed). +- Network: replace the HTTP client with `httpx.MockTransport` (expose a `client_factory` attribute on the plugin). Cover the backend-refusal path. +- Contract suites (subclass, implement `create()`): `DownloaderContract`, `RenameStrategyContract`, `NotifierContract` (+ `create_failing()`), `SearchSiteContract`. Run them through the real provider method, e.g. `create_plugin(Cls)[0].strategy()`. +- Write the failing test first; a contract failure usually points to a real bug (e.g. non-ASCII header values). + +## Packaging and signing + +- `ab-plugin pack` output is the unit for the signed catalog. Maintainers run `scripts/build_plugin_catalog.py` (ed25519 `.sig` for `catalog.json` and each zip) and upload to the `plugins` release. Authors cannot self-sign into the official catalog. +- pip distribution: entry point group `autobangumi.plugins`, with `plugin.toml` in the top-level package.