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
The dev-branch CI added in this cycle runs pnpm lint, so the
pre-existing eslint errors (import sorting, unused imports, a
message-less Error in a test) started failing the pipeline. Auto-fixed
plus one manual message; remaining items are warnings only.
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
- RSS: parser/aggregate chips move next to the feed name where they
describe the source; Status keeps only Connected/Active state; URL
column left-aligned with correct "URL" header; chip labels localized
- Log: "Contact Infomation" typo, "Update Now"→"Refresh" and
"Reset"→"Clear log" (the buttons refresh/clear the log view, not the
app), empty state message instead of a bare box
- Sidebar/mobile nav: "HomePage"→"Home", "RSS Manager"→"RSS" — labels
no longer truncate in the mobile bottom nav
- Bangumi cards: titles clamp to two lines instead of one and carry a
full-title tooltip
- Calendar: drop the redundant "Schedule" heading under the page h1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
- Calendar cards without a poster now show the series title on the
placeholder (was an anonymous gray box until hover)
- Downloader: Seeds/Peers column no longer wraps mid-word; progress
percentage moved outside the bar where it is readable at 0%
- RSS: Refresh All icon uses NButton's icon slot (was glued to label)
- Setup wizard: progress bar counts the current step (was empty on
step 1), animates via transform with reduced-motion fallback,
description text raised to AA contrast
- Card titles use the primary text color — headers were lighter than
body text (inverted hierarchy) across every card
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Two regressions from the component-system unification left the Config
page blank or control-less:
- App.vue passed var(--color-danger) as common.errorColor*; naive-ui
runs JS color math (seemly rgba) on these tokens and threw
"Invalid color value var(...)" while rendering NSelect, killing the
whole page subtree. Error colors are now per-theme literals.
- ab-setting.vue still referenced the deleted AbSwitch/AbSelect
primitives, which auto-resolution silently rendered as nothing —
every switch/select in settings vanished. Replaced with themed
NSwitch/NSelect plus an items→options adapter for the old API.
- Bumped naive-ui 2.39→2.44: 2.39 predates Vue 3.5 and crashed NTag
rendering inside NDynamicTags ("theme.self undefined").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
up/down/seed/status/reset/shot subcommands: reuses the e2e docker
services (qBittorrent + mock RSS), starts backend with AB_DEV_NO_AUTH=1
and the webui dev server, pins a stable qB password, completes the
setup wizard through the API, and subscribes the mock feed — a seeded,
login-free dev stack in one command. shot provides a headless-Chrome
screenshot fallback (the bundled Chrome-for-Testing hangs on
Page.captureScreenshot on macOS).
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
Add Unreleased entries covering the aria2 first-class downloader, movie/
special episode recognition, per-bangumi release preference dedup, and
the webui component-split/naive-ui/SSE work landed this phase.
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
Drop python-jose and passlib in favor of direct pyjwt (with pinned
decode algorithm) and bcrypt 4.x hashpw/checkpw calls. Existing
passlib-generated $2b$ hashes keep validating unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Add per-row and 'refresh all' buttons to the RSS page, wired through
new store actions refreshRSS/refreshAllRSS backed by apiRSS.refresh(All).
Fix vitest.config.ts's auto-import dirs being out of sync with
vite.config.ts, which silently made every src/api/*.ts contract test a
no-op (bare axios/apiXxx identifiers threw under vitest, so the specs
never called real code). Add a shared axios mock helper and give every
src/api/*.ts module a real contract test asserting path + method against
the mocked axios instance and cross-checked against the FastAPI routers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Derive the release changelog path from the release version instead of
hardcoding 3.2.md, so the workflow keeps working after 3.3 ships. Add
docs/changelog/3.3.md summarizing the 3.3.0 section of CHANGELOG.md.
Fix docker-compose.yml's PGID/PUID falling back to shell substitution
that never runs in a compose file, using numeric defaults instead, and
drop the obsolete top-level 'version' key. Fix README's Python badge to
3.13 to match backend/pyproject.toml's requires-python.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Add GET /health mounted outside the /api prefix so it bypasses
per-route auth and is reachable as a plain liveness/readiness probe.
Returns {status, version, db_ok}, degrading to db_ok=false with a 200
(not a raised exception) on database failure so the probe stays useful
even when the DB is unreachable. Add a Docker HEALTHCHECK that curls it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
Setup wizard and config reload now funnel through a single
AppContext.reload_settings() entry point instead of mutating settings
directly. Wrap remaining sync file I/O (image cache, settings load/save,
log tail read) in asyncio.to_thread so async request handlers stop
blocking the event loop. Add reset_cache() to each cache-owning parser/
searcher module (tmdb, mikan, title parser, poster cache) and wire them
into reload_settings() so a base-URL/API-key config change invalidates
stale cached results immediately. Fix BangumiDatabase.add_all's N+1 query
by batching semantic-duplicate candidate lookups into one SELECT for the
whole batch instead of one query per candidate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
- Apply the docker-actions/cache fix from 4a513df so 3.3 releases build; fix buildx outputs typo
- Run CI on PRs targeting 3.*-dev; add ruff + frontend lint/vitest steps
- .dockerignore excludes tests and __pycache__ from the image
- test_tmdb mocks the HTTP layer (was hitting the live TMDB API in the default suite)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
- Clear needSetup after completion so the wizard stops bouncing back to itself
- ab-select watches modelValue (config selects showed stale defaults); notification-type dropdown renders options
- Search modal Escape handler no longer opens the modal from anywhere; ab-button defaults type=button (no double login)
- axios surfaces 400/404/422; background polls suppress the network-error toast storm; calendar re-reads locally on activate
- Search subscribe uses the active provider + i18n; provider settings go through axios; dead search-store code removed
- Bump axios ^1.7 (CVE-2023-45857) and vue-i18n ^9.14.2 (XSS); add missing i18n keys
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
- Discord/Webhook/Gotify/Bark POST real JSON via RequestURL.post_json (was form-encoded and always rejected)
- Route through a distinct _post_json to avoid the form-encoding post_json shadowed on RequestContent
- Poster fields use settings.notification.base_url when set, otherwise omitted (local paths were never reachable)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
- qB rename verification uses for/else so it actually polls (was returning True after one check)
- move_torrent pipe-joins hashes so qB applies the move (was silently dropped)
- request_url no longer aclose()s the shared client from the request path (killed concurrent in-flight requests); add post_json
- Single-flight qB 403 re-login behind a lock; ref-guard the cached client on teardown
- ab_decorator re-raises after retries and caps delay; collector add/update fixed; renamer offset-lookup failures skip instead of defaulting to 0; rule delete/disable no longer requires a live downloader
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
- OpenAI Episode model uses int season/episode (was str -> silently swallowed TypeError on every parse)
- Mark torrents downloaded only on a successful add so transient failures retry
- Mikan no-poster branch keeps the parsed official_title; set bangumi_id for empty-filter matches; set rss_link on aggregate discovery
- TMDB: guard None results, include test flag in cache key, TTL negative results, skip poster on failed fetch
- Empty filter means no filtering; provider config read at call time; OpenAIParser built once; bounded search enrichment/cache
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
- cache_image/save_image skip None bytes; cross_version caches posters per-item best-effort (one bad URL no longer aborts boot)
- run_migrations re-raises after rollback so boot aborts on a half-migrated schema; run migrations before legacy data_migration
- AppContext.start_tasks() is non-blocking; startup task is cancelled+awaited on shutdown
- Settings.save/init create config/ dir; robust version.info parse; AB_SOCKS writes proxy type "socks5"
- checker delegates downloader health to the client (aria2 can pass); add webauthn/notification settings fields
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
- CSRF: reset/all, enable/disable, log/clear, program start/stop/restart/shutdown, rss refresh are now POST; frontend api calls updated to match
- Batch delete/disable: type list[int], guard empty input, aggregate response, register /many before /{id}
- auto_tag_torrents: load bangumi once and match in memory (was N+1 per torrent)
- Type bangumi_id path params as int; correct 406->404/400; drop false SSE response_model; remove dead imports/handlers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
- Gate dev auth bypass on AB_DEV_NO_AUTH=1 (was: any non-CI build), warn loudly when on
- WebAuthn rp_id/origin from settings when set; LRU-cap the service cache; match challenge by clientDataJSON
- Passkey: enforce login-IP whitelist, remove user enumeration, stop echoing str(e) in 500s
- Auth: migrate session/JWT on username change; guard decode_token(None); logout is POST + samesite=strict cookie
- Setup: require default admin account before pre-auth /setup/complete
- JWT secret written 0600; move IP-allowlist helper into module/security (new ip_allowlist.py)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Refresh the module map and migration instructions, and document the new
conventions (async DB + session-per-operation, constructor-injected
services, downloader Protocol + capabilities, reload_settings) so the old
Program/sub_thread/combine.py references don't mislead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Drop the rss/torrent/bangumi @property proxies that RSSEngine and
TorrentManager exposed so the migration could keep the old
Database-is-a-Session call style working. Services now use self.db.<repo>
internally, and call sites reach repos through the Database they already
hold: pure-repo API routes use the injected db directly (dropping a
pointless RSSEngine/TorrentManager construction), and internal callers use
db.<repo> / engine.db.<repo>.
Contract-preserving: the black-box API tests are unchanged; white-box tests
that mocked the service repo-proxies now mock at the Database seam.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Add a Network config section with tmdb_base_url and bgm_base_url, defaulting
to the official endpoints and read live by the TMDB/bgm.tv parsers. Users
behind a GFW can point them at a reachable mirror without patching code or
restarting. Old config files pick up the defaults automatically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
build_rss_rule joined the comma-separated filter string with '|' via
str.join, which splits it into characters ('720,480' -> '7|2|0|,|4|8|0')
instead of alternating whole terms ('720|480') the way every other filter
site does with .replace(',', '|'). Latent since the rule dict was first
written; low real-world impact (the download path uses add_torrent, not
qB-native RSS rules) but the extracted helper is now correct.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
No production code imports the sync `engine` after the async flip. Remove it
and its foreign_keys connect listener; the migration tests build their own
throwaway sync engine via create_engine and the sync create_tables/
run_migrations helpers in migrations.py, which are kept for that purpose.
DATA_PATH stays only to derive ASYNC_DATA_PATH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Convert the whole DB stack from a sync Session to AsyncSession in one atomic
change. The async boundary cannot be split into individually-green commits:
async repositories and sync consumers cannot coexist, and the API service-mock
tests break the moment the consumers flip, so the planned repos/consumers/test
commits land together here.
- Repositories (bangumi/rss/torrent/user) take an AsyncSession; every method is
async. user.py drops sqlmodel session.exec for session.execute().scalars().
- Database is an async context manager over async_session_factory (with an
optional engine= DI seam for tests); add stays sync, commit/rollback/refresh/
close and create_table/run_migrations/migrate are async. get_db is async.
- migrations.py gains create_tables_async / run_migrations_async wrappers that
drive the existing sync-Connection bodies via async_engine.begin() + run_sync.
- Delete the module-level bangumi TTL cache entirely. Its real job — matching
every torrent in an RSS refresh without re-querying per call — is now done by
loading the active bangumi list ONCE per refresh cycle in RSSEngine.refresh_rss
and matching in memory via match_bangumi_in_list().
- Flip every consumer to `async with Database()` + awaits: rss engine/analyser,
managers (torrent/renamer/collector), core loops/context/offset_scanner,
notification manager (dropping the asyncio.to_thread poster shim for a direct
async query), security/api auth helpers, all api routes, mcp tools/resources,
and the update/* startup chain.
- Fix two pre-existing latent bugs: update/rss.py::update_main_rss and
update/data_migration.py::data_migration called the async engine.add_rss(...)
WITHOUT await, so the calls were silently never executed. Both are now awaited
and their enclosing functions made async up to AppContext.startup.
- RSSEngine/TorrentManager lose their transitional __enter__/__exit__; routes
construct them plainly. Test fixtures go async (in-memory StaticPool engine;
bare Database() rebound to a per-test temp-file DB; get_db overridden).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Concurrent aiosqlite connections replace the single serialized sync Session,
so add PRAGMA journal_mode=WAL and PRAGMA busy_timeout=5000 to the async
engine's connect listener (keeping foreign_keys=ON). Extract the listener as
_set_sqlite_pragmas_async so tests can attach it to a throwaway engine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
AppContext is built in create_app and owned by the lifespan: startup() is
awaited before serving (errors abort boot), and start_tasks() runs as a
supervised background task. A generic PeriodicTask/Scheduler replaces the four
Thread mixins, with per-tick bodies moved to plain functions in loops.py. The
MCP app now takes the context instead of importing the api.program global.
Config updates go through reload_settings(), restoring the RSS re-application
that was previously commented out. SessionStore adds session expiry and
constant-time token comparison, and downloader shutdown() is wired into app
shutdown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Cache one concrete client per connection-settings key at module level and reuse
it across `async with DownloadClient()` blocks instead of logging in on enter and
out on exit every operation (~1/min from the rename loop). QbDownloader.auth is
now idempotent — a live, authenticated client short-circuits without re-posting
login — and data operations go through a _request wrapper that re-authenticates
once and retries on a 403 (expired session). __aexit__ no longer logs out; a new
shutdown() module function tears the cached client down (wired up by the Phase 5
composition root). MockDownloader/Aria2Downloader gain the same auth idempotency.
When connection settings change, the stale client is retired and closed lazily on
the next enter. A failed fresh login still closes the pool and drops the cache
entry before raising (leak fix, #1043).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Introduce a DownloaderClient Protocol with per-backend DownloaderCapabilities;
the DownloadClient facade now consults capabilities and skips-and-logs
unsupported operations instead of relying on NotImplementedError stubs. Aria2
advertises only its core surface (auth/logout/add_torrents) and its ~20
NotImplementedError bodies are deleted; qB and Mock advertise the full surface
(Mock gains get_torrents_by_tag).
Extract the qB RSS rule dict into rules.build_rss_rule and route both the
facade set_rule and manager.update_rule through a new DownloadClient.set_rss_rule
guarded by can_rss_rules. Convert TorrentPath methods into module-level
functions in downloader.path and update all callers.
Recompose Renamer, SeasonCollector, and SearchTorrent to take/hold their
collaborators instead of inheriting DownloadClient / RequestContent / RSSAnalyser,
and update their call sites (sub_thread, api.rss, api.search, mcp.tools).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Break the DB-side inheritance chains so no service IS-A Session:
- Database no longer subclasses sqlmodel.Session; it now owns a Session
(self.session) and constructs the repos from it. It keeps its
context-manager role plus thin add/commit/rollback/refresh/close delegates
for the call sites that relied on Database being a Session, and retains
create_table/run_migrations/drop_table/migrate.
- RSSEngine and TorrentManager are plain services that take a Database in
__init__ and reach the repos through self.db. They expose rss/torrent/bangumi
as read-only properties over self.db and keep a trivial __enter__/__exit__ so
the existing `with Service(db) as x:` call sites and their black-box tests
stay valid.
- RSSAnalyser no longer subclasses TitleParser; it calls TitleParser.raw_parser /
mikan_parser / tmdb_parser statically. SearchTorrent keeps composing
RSSAnalyser and works unchanged because those methods are now static calls.
- API routes obtain a request-scoped Database through a new get_db FastAPI
dependency (module/database/__init__.py) and build the service from it.
- Lifecycle-only call sites that previously went through an RSSEngine (create
table / run migrations / migrate / default user) now use Database directly.
No behavior change; the REST API contract is frozen. Added minimal black-box
tests for the previously-uncovered routes (/rss/analysis, /rss/collect,
/rss/subscribe, PATCH /bangumi/{id}/weekday) before refactoring. Full suite:
741 passed / 71 skipped / 2 xfailed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Move the migration machinery out of the Database session class into
module/database/migrations.py. Each Migration entry now carries a
declarative already_applied guard (column_exists / table_exists),
replacing the hardcoded per-version if-ladder in run_migrations().
Version numbers 1-9 and their SQL are unchanged — existing databases
upgrade identically. CURRENT_SCHEMA_VERSION is derived from the list, so
adding a migration is one tuple entry with its guard. The runner works on
a single sync Connection (each migration in a SAVEPOINT, stop on first
failure), which the async flip can later drive via conn.run_sync().
Database.create_table()/run_migrations() stay as thin delegates for
existing callers; they will move to the composition root in a later phase.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Post-review fixes for the batch-3.2.8 branch:
- auth() and the setup wizard again require a positive success marker on
200 responses ('Ok.' body) so a proxy or non-qB service answering
200 + HTML is not mistaken for a login; 204 stays the qB >= 5.2 success.
- delete_rule no longer wraps a failed torrent deletion into a 200 reply,
and the WebUI torrents-delete endpoint reports the failure instead of
answering 'Torrents deleted' unconditionally.
- The close-on-failed-auth teardown now lives once in
DownloadClient.__aenter__ (client.logout() before raising) instead of
being copy-pasted into each concrete client.
- search_all() records a cache generation before querying and skips the
cache write if an invalidation landed in between, so a stale snapshot
can no longer overwrite a newer invalidation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
Delete the legacy notification stack (notification.py + plugin/, five
providers duplicated by the active providers/ registry, including a broken
copy-pasted slack plugin), the unused SIGALRM timeout decorator, the unused
ConflictError, the unused bgm_parser, the 0-byte transmission client stub,
unused Lock/Event state in ProgramStatus, the fully-skipped legacy RSS
engine test module, and the dev_server/test_passkey_server scratch entry
points that duplicated app assembly.
The zh notification message template now exists in exactly one place
(NotificationProvider._format_message) — the other seven copies lived in
the deleted legacy plugins.
No behavior change; full suite stays green (726 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh