- Disable (not delete) orphaned per-sub RSS feeds on bangumi deletion:
a hard delete could not distinguish search-created subscriptions from
user-added non-aggregate feeds, and RSSDatabase.delete's cascade also
wiped torrent dedupe records that other bangumi may rely on. Disabled
feeds keep their history and can be re-enabled; batch-disabled in one
commit via disable_batch.
- Never remap known parser types (mikan/tmdb/parser) through the
search-provider config in /rss/subscribe; only genuine site names
(nyaa/dmhy) are mapped. Prevents parser='mikan' from being rewritten
when the user customized the mikan provider's parser.
- RSSDatabase.search_url tolerates duplicate-URL rows (first() instead
of scalar_one_or_none) - url has no DB unique constraint.
- _retry_after_delay rejects NaN Retry-After values, which would
propagate through min/max into asyncio.sleep(nan) and never wake.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhTduCYBGeHETU7dSWoh37
Batch-collecting a season fired all .torrent file downloads at once via
an unbounded asyncio.gather, tripping nyaa's rate limiter; get_url then
gave up on the first 429 with no retry, so the whole batch silently
failed.
- add_torrent now fetches torrent files serially per host with a 1s
delay between requests (parallel across hosts), mirroring the
RSS_PER_HOST_DELAY pattern from #1026
- get_url retries 429 responses within the existing retry budget,
honoring a numeric Retry-After header (clamped to 60s) and falling
back to a 10s delay otherwise
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhTduCYBGeHETU7dSWoh37
Deleting a bangumi left its individually-subscribed (aggregate=False)
RSSItem enabled, so the WebUI kept showing the feed as active and the
RSS loop kept polling it. delete_rule now removes per-sub feeds that no
remaining bangumi references, splitting comma-joined rss_link values and
matching URLs exactly. Aggregate feeds and feeds still referenced by any
bangumi (including soft-disabled ones) are never touched.
Adds RSSDatabase.search_url for exact-URL lookup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhTduCYBGeHETU7dSWoh37
Search-subscribe sent the provider site name (nyaa/dmhy) as the RSSItem
parser, but the analyser only understands mikan/tmdb, so those
subscriptions silently skipped TMDB title/poster enrichment. Resolve the
site name through the search-provider config at the subscribe boundary;
values that are already parser types pass through unchanged.
Note: the WebUI RSS tag for search-subscribed nyaa/dmhy feeds now reads
"tmdb" (the parser type) instead of the site name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhTduCYBGeHETU7dSWoh37
New bangumi_manage.track_orphans toggle (default on = current
behavior). When off, refresh_rss no longer persists unmatched torrents
(bangumi_id NULL orphan records): they are re-matched in memory each
tick (cheap, no network — the analyser dedups via bangumi rules, not
torrent records), and a rule added later can retroactively pick up
episodes still in the feed, which persisted orphans used to block
forever. Season-collect flows still persist their NULL records for
feed dedup and are not gated.
WebUI: "Track Unmatched Torrents" switch in Settings -> Manage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
3.3 port of #1020 (orphan-torrent DB methods, 7 API endpoints, torrent
management pages), rebased onto 3.3-dev and reworked to the Soft Ink
ab-* component system per code review. Merged locally because the
rebased branch could not be force-pushed to the PR head.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
The log page intermittently showed nothing because the file lifecycle
destroyed history at both ends:
- setup_logger(reset=True) deleted log.txt on every process start
(container restart, online update), wiping history exactly when the
user wants to inspect what happened before the restart. Boot now
rotates the previous run into log.txt.1-3 via the stdlib
RotatingFileHandler.doRollover (delay=True, rename-only), matching
runtime rotation naming; failures warn instead of aborting boot.
- At rotation, the UI collapsed to a near-empty log.txt: both readers
(GET /log and the SSE log event) only read log.txt. The tail read
now stitches log.txt.1 in front when the current file fits inside
the 512 KB budget — and never when the read was truncated, which
would fake a time-jump above a silent gap. A file renamed mid-read
(rollover race) reads as empty instead of raising, which previously
could kill the whole SSE stream.
- Rotation cap tightened to 2 MB x 3 backups (~8 MB total); the UI
renders at most 512 KB so nothing visible is lost.
- POST /log/clear also removes rotated backups (stitching would
resurrect them) and runs its file I/O in a thread like get_log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
Port zhushanwen321's torrent-management feature (#1020) from the 3.2 sync
architecture to the 3.3 async design.
Backend:
- TorrentDatabase: search_orphans / count_orphans / delete_orphans / delete_obj
- bangumi API: list/delete endpoints for per-bangumi torrents and orphan
torrents (bangumi_id IS NULL); literal /torrents/orphans routes registered
before /{bangumi_id}/torrents
WebUI:
- Others card on the bangumi list with orphan count badge
- Torrent list pages (per-bangumi and orphans) with multi-select,
batch delete, and clear-all confirm dialog
The PR's mikan_parser and rss/analyser fixes are not ported: 3.3-dev
already handles failed poster downloads and missing elements.
Co-authored-by: zhushanwen321 <zhushanwen321@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Verified against qB source at release tags 4.6.7/5.0.0/5.1.0/5.2.0:
- torrents/add: accept qB 5.2 JSON response (200/202) — partial success
and pending URL fetches count as ADDED, matching legacy "Ok."
semantics; 409 Conflict is DUPLICATE for synchronous file uploads and
hash-confirmed magnets, otherwise raised for retry. Send both paused
and stopped form params (renamed in 5.0, each era ignores the other).
- torrents/pause|resume: renamed to stop|start in qB 5.0 with no
aliases; try the modern name first, fall back on 404, remember which
era worked.
- torrents/info: filter=paused was renamed and unknown filter values
silently mean All — filter paused/stopped client-side by state.
- torrents/delete, torrents/renameFile: treat any 2xx as success
(qB 5.2 returns 204 for empty-body responses).
- webui: map qB 5.0+ stoppedDL/stoppedUP states to the paused label.
- .gitignore: exclude local runtime data (backend/data), agent
workspace (.claude), and task notes (tasks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
Add PT (private-tracker) support via the existing RSS-based search
pipeline. The Add Search Provider dialog gains a "PT Site (NexusPHP)"
mode that builds a torrentrss.php search template from base URL +
passkey + optional category ID (e.g. Audiences.me anime = 402), so
search -> subscribe -> auto-download -> rename works end-to-end on
NexusPHP trackers with no backend changes.
- webui/src/utils/nexusphp.ts: URL-template builder (vitest-covered)
- config-search-provider.vue: segmented custom/NexusPHP add dialog
with live URL preview; en + zh-CN strings
- backend test_nexusphp_rss.py: regression tests proving NexusPHP
torrentrss output (enclosure and linktype=dl variants) parses
through the generic rss_parser path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhTduCYBGeHETU7dSWoh37
Two changes, verified with local builds:
- Drop UV_COMPILE_BYTECODE=1: shipped .pyc files were 51MB. The venv is
root-owned and the app runs long-lived as 'ab', so bytecode only saved
a few seconds of import time once per container start. This also
matches the boot_overlay uv-sync path, which never compiled bytecode.
- mcp[cli] -> mcp: the app only imports mcp.server; the cli extra pulled
typer/rich/pygments/markdown-it (~14MB) for an unused console script.
python-dotenv, the extra's other member, is already a direct dep.
Compressed pull size drops 64.2MB -> 48.8MB. Image verified: packages
absent, no __pycache__, 'import main' succeeds; pytest and mypy green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEnmRvBJL93fAURbRf1koK
Ship-readiness review of main...3.3-dev (multi-agent + codex verify pass)
surfaced 10 confirmed defects; all fixed with red-first tests (+24):
- config: masked list secrets restored by identity match, not index —
deleting/reordering a notification provider no longer corrupts the
survivor's credentials; unresolvable masks return 400 instead of guessing
- statics: mount dist/fonts — self-hosted Inter 404'd in every production
build and the whole UI fell back to OS fonts
- boot_overlay: chown replaced trees to the app user (crash-loop under
UMASK=077); snapshot-and-restore around the EXDEV in-place fallback so a
mid-copy failure can't gut /app/module
- updater: rollback refuses to swap without the retained signed backup
bundle — reverts to image version honestly instead of half-swapping
- qbittorrent: credential-failure latch (cleared on settings save) plus
single-flighted auth sharing the negative result with queued waiters —
failed logins no longer accumulate into a WebUI IP ban
- download_client: holder refcount registered before auth (no mid-login
close), stale clients accumulated in a list (no pool leak on rapid
settings saves), holder released on cancellation during __aenter__
- entrypoint: ownership marker written only after chown -R succeeds
- aria2: torrents_info provides num_seeds/num_leechs/eta/added_on and maps
states to qB vocabulary (UI showed undefined / NaNhNaNm / raw states)
- program API: deprecated GET aliases for /restart /start /stop /shutdown
(3.2 automation compatibility)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
CI runs `mypy src` (not part of the local pytest/ruff/black gate); the
feature commit tripped 22 type errors. No behaviour change:
- events.py: rename the SSE notification payload local so it no longer
collides with the str-typed `payload` earlier in the generator.
- inbox.py: type: ignore on the `update().where(read == False)` DML
predicate and the CursorResult.rowcount reads.
- tests: narrow Optional returns (a typed `_upsert` helper for
InboxDatabase, `assert ... is not None` for credential loads) and
annotate a bare dict.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Two 3.3 platform features sharing core infra (notification events, DB
migrations/wiring, config model, API registration), committed together
because they cannot be split at file granularity without broken
intermediate builds.
Notification center:
- New `inboxmessage` table (migration 16) + repo with coalescing
(unread rows with the same dedup_key increment a count; `once` kinds
like update_available notify per-version) and 500-row pruning.
- SystemEvent contract (kind/severity/dedup_key/payload) with new
DownloaderUnavailable / UpdateAvailable / UpdateApplied events;
NotificationManager.send_event persists to the inbox first, then
broadcasts to external providers only when notification.enable.
- SSE `notification` frames driven by an in-process revision counter;
REST endpoints for list/unread-count/read/read-all/delete/clear.
- Wire-ups: qBittorrent auth now reports last_auth_error
(credentials|banned|unreachable) and stops retrying rejected
credentials to avoid a WebUI IP ban; the startup wait loop aborts
early on credential errors and emits a notification; a 24h
update-check tick surfaces new versions; apply/rollback emit results.
- WebUI bell + unread badge + dropdown inbox (severity, coalesce count,
relative time, deep links), SSE-driven with a 30s poll fallback.
LLM provider plugin framework + subscription auth:
- LLMParser refactored to a thin facade over a provider adapter
contract + registry; the three built-ins (openai/anthropic/gemini)
move to adapters with zero behaviour change (existing tests unchanged).
- base_url presets for zai/minimax/deepseek/kimi (mainland + overseas)
reusing the OpenAI-compatible adapter, with a json_object structured-
output fallback for providers without json_schema.
- Downloadable signed plugins: catalog + installer clone the update
system's ed25519 verify / zip-slip-guarded extract pipeline (unsigned
refused, catalog signed against downgrade); versioned-module-name
loader hot-loads without restart.
- Subscription auth: `llmcredential` table (migration 17) stores OAuth
tokens server-side only (never sent to the frontend); device-flow
auth runs a server-side polling task keyed by an opaque handle so the
device_code stays off the wire; AuthExpiredError trips the breaker and
notifies. Two grey-area device-flow plugins (github-copilot,
codex-chatgpt) under plugins/, flagged at-your-own-risk.
- WebUI: grouped provider select (built-in/preset/subscription/
downloadable), install with risk confirmation, device-flow connect
dialog, connected-account chip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
When a new bangumi entry is created for an already-known show (same
official_title, different group/quality so no semantic merge), it now
inherits the existing entry's year. The save path is built as
`title (year)/Season N`, so a Mikan-parsed entry (which never fills
year) previously diverged into a separate library folder from a
TMDB-parsed sibling that had one. `_inherit_year` in add()/add_all()
copies the year from any non-deleted same-title row when the incoming
entry has none; a parsed year is never overwritten.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Bump version, uv lock, and CHANGELOG for 3.3.0-beta.5 — ships the
overlayfs EXDEV fix for in-app updates (needs a fresh image to take
effect) and moves the Software Update card into Settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
In-app updates verified and staged fine but silently stayed on the old
version: boot_overlay swaps the code with os.rename('/app/module',
'/app/module.ab_old'), and /app/module comes from the image's overlayfs
lower layer. Some overlay/kernel combos refuse to rename a lower-layer
directory across the mount boundary and raise EXDEV (Errno 18), so the
swap failed on every boot ("Applying overlay ... / Failed to overlay
module tree: Cross-device link") while apply reported success — the user
just saw the frontend restart watch time out.
_replace_tree now catches EXDEV (dst is untouched, rename is atomic) and
falls back to replacing the directory's contents in place, which triggers
overlayfs copy-up per file and works regardless of mount/overlay topology.
Same fix covers the dist tree. Reproduced with a test that forces EXDEV on
the module/dist rename and asserts the overlay still lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Clicking "check for updates" felt like a no-op: the only signal was a
static one-line "up to date", already shown from the on-mount auto-check,
and the /check endpoint served a 15-minute cache so repeated clicks did
nothing server-side.
- Expose `force` on GET /update/check; the button passes force=true to
bypass the cache and re-fetch, while the on-mount auto-check stays cached
(no extra GitHub calls). Updater-level force was already implemented and
is now locked by a test.
- Active feedback on a user-initiated check: a success toast when already
current, an info toast naming the new version when one is found, and a
"Last checked HH:MM:SS" line that updates every click. The check-failed
toast now includes the backend's reason (rate limit / no release) instead
of a generic message.
Verified end-to-end: the endpoint reaches GitHub and finds the latest
release (beta.3, whose signed update bundle is attached), so in-app update
is functional; the timestamp advances and the toast fires on each click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Two things dominated container start:
- The LLM SDKs (anthropic + openai + google-genai) imported at module load
via parser.analyser/__init__ → llm.py, costing ~0.7s on every boot even
though the LLM parser is off by default. Move those three heavy imports
into the provider branch of __init__ and each _parse_* method, so they
load only when an LLMParser is actually built. Measured: importing
parser.analyser drops 782ms → 203ms; none of the SDKs are in sys.modules
after the startup import chain. httpx/httpx_socks stay module-level (cheap,
and the proxy tests patch them by module path).
- entrypoint.sh ran `chown -R /app/data /app/config` unconditionally every
start — O(files), which a large poster cache on a NAS turns into the
dominant startup cost. Walk the tree only when ownership needs fixing
(first run, changed PUID/PGID, or a wrong-owned volume root), tracked by a
marker; the legacy bangumi.json migration chowns its one file explicitly so
the gate can't miss it. /home/ab (image-internal, tiny) is still chowned
every time.
Also regenerates the auto-imports dts for onResponseError (exported for the
401 test in the previous commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Config page redesigned to a section-rail layout (search + per-section
dirty dots, single column), a save bar that names the restart and
double-confirms save/discard, and a navigate-away guard. Log page
redesigned with a health strip (+ jump-to-first-problem), a unified
toolbar (level chips with counts + text search), severity-only row
colour (fixes the dark-mode 3.53:1 contrast failure), an extracted
module column, and overflow-wrap word handling.
Log deduplication: fix the queue log handler using Python's default
formatter (source of the "INFO::module.x:" duplicate prefix on every
line), strip 245 hand-written [Tag] prefixes, drop double-logged
offset-scan/calendar completion lines, demote "Config loaded" to DEBUG.
3.3 feature/UI alignment: aria2 selectable in Config + wizard (with an
aria2 JSON-RPC connection test), preferred group/resolution and
episode_type surfaced in the rule editor (migrations v10/v12), update
auto_check switch with immediate persistence (no restart).
LLM: model input is now a searchable/free-text select that fetches the
provider's model list on demand (new POST /api/v1/config/llm/models);
timeout/cache/concurrency/circuit-breaker tuning fields; default model
gpt-4o-mini -> gpt-5-mini (discontinued).
Accessibility: real <label for> on config controls, aria-labels on
switches/selects, aria-pressed on log chips, 44px touch targets.
Unify delete confirms to NPopconfirm; password inputs; copy fixes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
CHANGELOG: fold the unreleased in-app updater + multi-provider LLM
entries into 3.3.0-beta.2 and add the WebUI UX/a11y/perf pass and
post-review backend fixes. docs/changelog/3.3.md (the release body)
gains the beta.2 highlights.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
The verify loop treated "neither old nor new name in the file list" as a
successful rename. A qBittorrent 200 that never materializes (target name
taken by a duplicate from another source, file list anomaly) was reported
as success, so the renamer re-renamed and re-notified every rename cycle.
Now only an observed new_path returns True; everything else returns False
and backs off via _PENDING_RENAME_COOLDOWN.
Regression tests adapted from PR #1037 by cyber-ayi, which diagnosed the
false-positive contract.
Co-authored-by: cyber-ayi <cyber-ayi@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
- boot_overlay/signing: narrow load_pem_public_key result to
Ed25519PublicKey before verify; a non-ed25519 PEM is now rejected
explicitly instead of via TypeError caught by the broad handler
- test_context: drive the scheduler mock through a local variable so
mypy does not see an assignment to the read-only Scheduler.running
- test_migrations_module: _make_versioned_engine returns Engine
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Closes the update-chain trust gap: the sha256 came from the same GitHub
release as the bundle (integrity, not authenticity), and the boot overlay
re-applied ab-writable content as root — including uv sync on an
attacker-controllable lockfile.
- signing: new module/update/signing.py verifies an ed25519 signature;
public key embedded in the image at /app/ab_update_pubkey.pem (outside
the overlay-replaceable tree, so it is the trust root). Private key lives
only in CI (UPDATE_SIGNING_KEY secret).
- updater: discover the .zip.sig asset, verify the signature after the
sha256 check, reject any release without a signature, retain the verified
bundle.zip+.sig for boot re-verification, and swap them on rollback.
- boot_overlay: re-verify the signed bundle at every boot and extract the
module tree / webui dist FROM the verified zip — never from the
ab-writable current/ tree; read the version from the zip manifest, not
the ab-writable applied.json; drop to the ab user before any fallback
uv sync so lockfile build hooks can't run as root; zip-slip guard uses
is_relative_to (also fixed in updater._safe_extract).
- CI: build.yml signs the bundle with UPDATE_SIGNING_KEY and publishes the
.zip.sig; the build fails if the secret is unset.
- deps: cryptography made an explicit dependency.
Tests cover bad-signature reject, unsigned-release reject, legacy overlay
without a signed bundle reject, verified-bundle retention, and boot-overlay
signature verification. Full backend suite: 1079 passed.
Ops: run `gh secret set UPDATE_SIGNING_KEY < ~/.autobangumi/update-signing-key.pem`
before the 3.3 release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
- update: snapshot DB before promote; rollback restores it only when the
schema actually advanced, never from a stale on-disk fallback; snapshots
are standalone (no WAL sidecars); dependency sync moved to apply time
with a staged venv, plus boot-time venv reconcile after rollback;
entrypoint restart loop forwards signals so docker stop stays graceful
- downloader: AddResult carried through the client protocol; qB "Fails."
only classified duplicate when the magnet hash is confirmed present;
aria2 partial-success batches report ADDED, magnet followedBy gids are
migrated and metadata stubs skipped in torrents_info; renamed paths
persisted per gid so the rename loop converges
- core: AppContext lifecycle serialized under one lock with running state
derived from the scheduler; setup wizard starts background loops on
completion
- parser: LLM path gains episode_type classification (LLM results only),
timeout/cache/concurrency/breaker controls, proxy-aware SDK clients
closed after a grace period on config reload
- webui: useApi returns an {ok} discriminant; config save and RSS refresh
surface failures instead of false success
- migrations: per-statement guards so a crash mid-migration recovers
Implemented via Codex (P2-P10 from tasks/todo.md), then reviewed with a
multi-agent verify pass; 10 confirmed review findings fixed on top.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Adds one-click update from the settings page so routine updates no
longer require pulling a new Docker image.
- CI attaches update-bundle-<version>.zip (+ .sha256) to each release:
backend module tree, built webui, and a manifest; the image bakes
/app/IMAGE_VERSION as the baseline
- boot_overlay.py (shipped in the image, outside /app/module so it can
never overwrite its own deciding logic) runs from entrypoint before
the app starts: if a persisted overlay in /app/config/updates is
newer than the image it tree-replaces /app/module and /app/dist and
runs `uv sync` when the lockfile changed; if the image is newer it
clears the stale overlay. Applied via the config volume, so updates
survive container recreate; a newer pulled image always wins
- GET/POST /api/v1/update/{check,apply,rollback} (auth-gated, repo
pinned to EstrellaXD/Auto_Bangumi): check queries GitHub releases
with a stable/beta channel; apply downloads, verifies sha256, stages,
promotes current->backup, then exits the process so Docker restarts
into the overlay; rollback swaps the backup back. Progress streams
over the existing SSE channel
- Settings card (Log page) shows current/latest, release notes, channel
toggle, update + rollback, with a restart:unless-stopped requirement
note; compose examples updated
Requires restart: unless-stopped for self-restart (documented; no
Docker-socket fallback). 1043 backend tests, mypy 0, ruff clean; 135
frontend tests, vue-tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Generalizes the OpenAI-only title parser into a provider-dispatching
LLMParser (openai-compatible / Anthropic / Gemini) with a parse-mode
switch:
- fallback (default): regex parser runs first, LLM only rescues titles
the regex can't handle — minimal LLM spend
- primary: LLM parses every title first, regex is the safety net so no
title is dropped on LLM failure
The openai provider takes a base_url, covering any OpenAI-compatible
endpoint (DeepSeek, Ollama, LM Studio, OpenRouter, OneAPI). Anthropic
uses structured JSON output with refusal handling; Gemini uses
response_mime_type=application/json.
Config auto-migrates the legacy experimental_openai section into a new
llm section on load (migrated users get mode=primary, preserving their
original LLM-first behavior); the old section is kept for downgrade
safety and llm.api_key is redacted by the config API. Settings page
swaps the OpenAI panel for an LLM panel with provider + mode selects.
1018 backend tests, mypy 0, ruff clean; 131 frontend tests, vue-tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Audit of the Jan perf pass (cba4988) against the 3.3 rewrite found two
regressions and applied new profile-guided fixes:
- raw_parser: all static regex precompiled — 16% faster wall, re._compile
calls down from 2.06M to 89k per 100k parses
- Renamer untagged-fallback offset lookup batched (same idiom as
engine/auto-tag): 215ms/201 queries -> 19ms/2 queries at 200 torrents
- Notification dispatch across items re-parallelized (lost in the
rewrite), after fixing the underlying RequestURL re-entrancy race
that made naive gather unsafe
- Migrations 13/14: index backfill for upgrading databases — index=True
only materializes via create_all on fresh DBs, so every upgraded
install was full-scanning title_raw/url/rss_id lookups; new
ix_torrent_bangumi_id flips offset-scanner and preference-dedup
queries from SCAN to SEARCH
990 tests green (+6 regression tests), mypy 0 errors, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
- bcrypt 5.x raises on >72-byte passwords where passlib silently
truncated; hash and verify now truncate to 72 bytes so pre-upgrade
users with long (e.g. CJK) passwords can still log in instead of
hitting HTTP 500
- The SSE downloader payload is fetched under a 3s asyncio.wait_for
(task cancelled cleanly on timeout) and emits an explicit null on
failure, so a dead qBittorrent can no longer freeze the entire
event stream while the UI shows connected
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
- Multi-file movie torrents: the largest file keeps "Title (Year).ext",
extras get "Title (Year) - <original-stem>.ext" (idempotent across
rename cycles) — no more per-cycle qB conflicts or aria2 collisions
- Reverted the "[group] " filename prefix: group_tag historically only
named the qB RSS rule; prefixing renames would mass-rename existing
seeding libraries on upgrade
- Season floor restored for regular episodes (season+offset<1 reverts
to the original season); only specials may land in Season 0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
- On a dedup-key hit the gid is verified via tellStatus; rows for
downloads removed outside AutoBangumi are deleted so the torrent can
be re-added (unreachable aria2 conservatively keeps the row)
- JSON-RPC numeric strings are parsed before math; totalLength "0" no
longer raises ZeroDivisionError in torrents_info
- Filesystem renames refuse to overwrite an existing destination and
fail loudly instead of destroying data via os.replace
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
- Failed torrent adds are no longer persisted, so the feed item
re-enters the pipeline on the next tick — the previous
downloaded=False row blocked retry forever via check_new's URL dedup
- add_torrent returns AddResult (ADDED/DUPLICATE/FAILED); healthy
duplicates no longer emit download-failure notifications
- bangumi_id is set only on torrents that pass the exclusion filter,
so OffsetScanner no longer computes suggestions from excluded
episodes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
The e2e suite never runs in CI and drifted from three intentional changes:
- destructive endpoints (start/stop/restart, log/clear, logout, bangumi
reset) were converted from GET to POST for CSRF hardening
- disabling a nonexistent RSS now returns 404 instead of 406
- newer qBittorrent images return 204 with no body on login
Full e2e run now green: 67 passed, 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Extract useBangumiRuleForm + shared preview/tag/rss-link/filter/offset
components out of ab-add-rss.vue and ab-edit-rule.vue, and split
calendar.vue into calendar-board/calendar-day-column/calendar-card/
calendar-mobile-list/calendar-rule-list-popup. Replace the bespoke
ab-button/ab-select/ab-switch/ab-checkbox primitives with themed
NButton/NSelect/NSwitch (naive-ui) across the app and delete the now-
unused wrappers. Add a backend SSE endpoint (GET /api/v1/events/stream)
streaming status/downloader/log ticks, and a shared useEventStream
composable that the status/downloader/log polling loops prefer over
their interval fetch, falling back to polling when SSE is unavailable
or the tab is hidden.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Teach raw_parser to detect 剧场版/劇場版/电影版/Movie tokens (episode_type
"movie") and OVA/OAD/SP/Special tokens (episode_type "special", season
forced to 0) instead of returning None. Add episode_type to Episode/
Bangumi/BangumiUpdate/EpisodeFile/SubtitleFile (migration v12), fall
back to TMDB's search/movie endpoint when search/tv finds no animation
match, and rename/organize movies with a flat "Title (Year)" layout
(no Season subfolder) while specials use Season 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Replace the aria2 client's stub RPC layer with a typed, per-call-timeout
JSON-RPC implementation and real add/query/rename/move/delete support:
honest add-result tracking with local dedup (url/file-hash) plus
RPC duplicate-error detection, torrents_info/torrents_files mapped into
the qB-shaped dict the rest of the app expects, and a new aria2_gid
table (migration v11) persisting the "ab:<bangumi_id>" tag association
so Renamer's tag-based offset lookup works unmodified. Capability flags
(can_query/can_rename/can_manage) now reflect what is actually
implemented; can_rss_rules stays False (no qB-native RSS-rule surface
to honestly back it with).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Add preferred_group/preferred_resolution fields on Bangumi (migration v10)
and wire RSSEngine.refresh_rss to skip strictly-worse candidate releases
within a batch, or against an already-downloaded episode, when a
preference is set. Bangumi without a preference are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Add mypy>=2.1.0 as a dev dependency with a check_untyped_defs baseline
config and per-module ignore overrides (module.__version__, httpx_socks,
aiohttp, test.* method-assign), and run it in CI right after ruff.
Convert positional Field(value, ...) defaults to Field(default=value, ...)
across models/config.py so pydantic v2 stops treating aliased fields as
required constructor args. Fix real bugs the sweep surfaced: unguarded
None bangumi_id in TorrentManager.refind_poster, RSSAnalyser.torrent_to_data
silently returning None past its declared type, missing __aenter__ guards
on RequestURL/downloader clients, database/engine.py's sync sessionmaker
on an AsyncEngine, and RequestContent.post_json shadowing RequestURL.post_json
(renamed to post_form_json). Remove a dead, type-unsafe str_to_list() helper
in api/bangumi.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Store {url, parser} per search provider (backward-compatible with
legacy url-only configs) and use it in search_url() instead of the
hardcoded mikan-vs-tmdb rule.
TitleParser.raw_parser() now always tries the regex parser first and
only falls back to OpenAI when regex parsing fails and
experimental_openai.enable is set.
Renamer.gen_path() prefixes filenames with "[Group] " when
bangumi_manage.group_tag is enabled and a group tag was parsed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Replace the offset scanner's hardcoded parsed_episode=1 placeholder with
_get_latest_parsed_episode(), which parses a bangumi's actual torrent
names via raw_parser() and skips the suggestion when nothing parses.
Add BangumiDatabase.apply_offset() to copy suggested offsets into the
live season/episode offsets and clear the review flag, plus
POST /api/v1/bangumi/apply-offset/{bangumi_id} and .../apply-offset/many,
each triggering a rename pass afterward.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Introduce SystemEvent (RssFailureEvent/DownloadFailureEvent/OffsetReviewEvent)
raised by refresh_rss() on healthy->error transitions and add_torrent()
failures, and by the offset scanner; wire them through NotificationManager.send_event()
and the rss/offset-scan loops (gated by settings.notification.enable).
Extend the {{title}}/{{season}}/{{episode}}/{{poster_url}} template mechanism
from webhook-only to all providers via NotificationProvider._format_message(),
falling back to each provider's original default message when unset.
Replace NotificationProvider's multiple inheritance from RequestContent with
composition (self._http), removing the post_json self-shadowing workaround.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z