Commit Graph

1888 Commits

Author SHA1 Message Date
Estrella Pan
e67dff8f6b chore: bump version to 3.3.0-beta.1, full release notes for the 3.3 beta
docs/changelog/3.3.md rewritten to cover the complete 3.3 scope (it is
the release body the tag build publishes): architecture, aria2
first-class, movies/specials, preference dedup, notifications, security
stack, PyJWT migration, performance pass, WebUI/SSE work, upgrade notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
3.3.0-beta.1
2026-07-03 08:10:47 +02:00
Estrella Pan
a6d0ac8718 perf: precompiled parser regex, batched renamer fallback, restored lost optimizations
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
2026-07-03 08:03:09 +02:00
Estrella Pan
e5e8c91699 chore(webui): clear baseline lint errors now gating CI
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
2026-07-03 07:11:20 +02:00
Estrella Pan
edbc734d08 fix(security,sse): restore 72-byte password semantics, bound SSE downloader fetch
- 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
2026-07-02 22:07:12 +02:00
Estrella Pan
d422e615b1 fix(renamer): distinct names for multi-file movies, revert group-tag prefix, season floor
- 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
2026-07-02 22:07:12 +02:00
Estrella Pan
1f5a6195d8 fix(aria2): reconcile stale dedup rows, guard zero-length progress, never overwrite on rename
- 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
2026-07-02 22:07:12 +02:00
Estrella Pan
eada4e7210 fix(rss): retry failed adds, stop spurious failure alerts, unpollute offset scan
- 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
2026-07-02 22:07:12 +02:00
Estrella Pan
8597e3d64f polish(webui): declutter RSS table, log page copy, nav labels, title clamps
- 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
2026-07-02 21:46:09 +02:00
Estrella Pan
ddd618b733 polish(webui): calendar card titles, table/header fixes, wizard and contrast
- 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
2026-07-02 21:33:02 +02:00
Estrella Pan
d80bf89cfa fix(webui): restore config page — naive-ui theme crash and missing controls
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
2026-07-02 21:33:02 +02:00
Estrella Pan
a630d10cde feat(dev): one-command dev environment via scripts/dev.sh
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
2026-07-02 21:33:02 +02:00
Estrella Pan
ddc3717121 test(e2e): update stale expectations for POST conversions and 404 semantics
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
2026-07-02 20:02:35 +02:00
Estrella Pan
76c4f72a95 docs(changelog): summarize big-rocks phase (aria2, movies/specials, release preference, webui refactor)
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
2026-07-02 19:49:51 +02:00
Estrella Pan
3baf1c72b3 refactor(webui): split oversized components, adopt naive-ui primitives, add SSE
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
2026-07-02 19:48:46 +02:00
Estrella Pan
590220abfe feat(parser): recognize movies and specials as non-episodic bangumi
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
2026-07-02 19:48:31 +02:00
Estrella Pan
27049dedc5 feat(downloader): make aria2 a first-class downloader backend
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
2026-07-02 19:48:16 +02:00
Estrella Pan
cbab57cd05 feat(rss): add per-bangumi release group/resolution preference with dedup skip
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
2026-07-02 19:48:06 +02:00
Estrella Pan
66dea1af0f chore(types): add mypy to CI and fix ~20 bugs it surfaced
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
2026-07-02 19:00:37 +02:00
Estrella Pan
72c4a040e4 feat(parser): per-provider parser choice, OpenAI as raw_parser fallback, honor group_tag
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
2026-07-02 19:00:18 +02:00
Estrella Pan
ca58fdc50e feat(offset): compute offset suggestions from real parsed episodes and expose apply endpoints
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
2026-07-02 19:00:11 +02:00
Estrella Pan
bf84d91926 feat(notification): typed system events, per-provider templates, composition-based base class
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
2026-07-02 19:00:00 +02:00
Estrella Pan
820ff29537 refactor(security): migrate JWT/password hashing from jose+passlib to pyjwt+bcrypt
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
2026-07-02 18:58:22 +02:00
Estrella Pan
92eb224011 feat(webui): add RSS refresh buttons, fix vitest API contract tests going stale
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
2026-07-02 17:39:59 +02:00
Estrella Pan
c2261ece0a docs(ci): make release changelog lookup version-aware, add 3.3 changelog, fix docker-compose PGID/PUID
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
2026-07-02 17:39:49 +02:00
Estrella Pan
9fafaef494 feat(backend): add unauthenticated /health liveness endpoint
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
2026-07-02 17:39:39 +02:00
Estrella Pan
d9f41f4b11 refactor(backend): route config reload through AppContext, fix blocking I/O, dedupe N+1 query
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
2026-07-02 17:39:29 +02:00
Estrella Pan
42418d5b18 ci: restore release build fix, gate dev-branch PRs, mock the live TMDB test
- 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
2026-07-02 17:05:50 +02:00
Estrella Pan
7733bd18f7 fix(webui): setup loop, reactive selects, escape handler, axios/i18n and dep bumps
- 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
2026-07-02 17:05:50 +02:00
Estrella Pan
04c913fdf7 fix(notification): send JSON bodies as JSON, gate poster URLs on a public base_url
- 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
2026-07-02 17:05:50 +02:00
Estrella Pan
dc786a29e3 fix(downloader): real rename verification, pipe-join move hashes, stop closing shared client
- 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
2026-07-02 17:05:50 +02:00
Estrella Pan
f00d39b990 fix(rss/parser): repair OpenAI parser, retry failed adds, stop wiping titles
- 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
2026-07-02 17:05:50 +02:00
Estrella Pan
4bab9d00c8 fix(core): best-effort poster cache, abort boot on migration failure, non-blocking start
- 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
2026-07-02 17:05:34 +02:00
Estrella Pan
9f9c7042dc fix(api): convert destructive GETs to POST (CSRF), fix batch routes and N+1
- 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
2026-07-02 17:05:34 +02:00
Estrella Pan
0f44efa2af fix(security): env-gated auth bypass, WebAuthn hardening, passkey/JWT fixes
- 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
2026-07-02 17:05:34 +02:00
Estrella Pan
4d7104ca7f chore: bump version to 3.3.0 and add changelog for the architecture refactor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
2026-07-02 15:06:42 +02:00
Estrella Pan
5f22d22a9f docs: update CLAUDE.md for the 3.3 async/composition architecture
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
2026-07-02 15:04:43 +02:00
Estrella Pan
526f2bed5d refactor: remove transitional service repo-proxies from Phase 3
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
2026-07-02 15:03:14 +02:00
Estrella Pan
fd47bdcd9a feat(config): configurable TMDB and bgm.tv base URLs (#1040, #1042)
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
2026-07-02 14:47:46 +02:00
Estrella Pan
758cc986bb fix(downloader): build qB rule filter as regex alternation, not char-join
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
2026-07-02 14:47:22 +02:00
Estrella Pan
939ba5b6ab refactor(database): drop the now-unused sync engine
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
2026-07-02 14:35:31 +02:00
Estrella Pan
1cd740657a refactor(database): flip repositories, Database, and all consumers to async
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
2026-07-02 14:34:17 +02:00
Estrella Pan
6c73226826 refactor(database): enable WAL + busy_timeout on the async engine
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
2026-07-02 13:50:10 +02:00
Estrella Pan
efdfacfbae refactor(core): replace Program god-object with lifespan composition root
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
2026-07-02 13:39:24 +02:00
Estrella Pan
5c73aa0013 feat(downloader): reuse qBittorrent session across operations (#1039, #900)
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
2026-07-02 13:08:19 +02:00
Estrella Pan
f363fcb9ef refactor(downloader): protocol + capabilities, module-level path helpers, composed services
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
2026-07-02 13:07:38 +02:00
Estrella Pan
731830cf86 refactor(database): composition over inheritance for Database, RSSEngine, TorrentManager, RSSAnalyser
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
2026-07-02 12:32:18 +02:00
Estrella Pan
0b4cbf4744 refactor(database): extract table-driven schema migrations
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
2026-07-02 11:58:46 +02:00
Estrella Pan
1b3216fe1b Merge branch 'fix/batch-3.2.8-issues' into 3.3-dev 2026-07-02 11:57:49 +02:00
Estrella Pan
fe7298689a fix(review): tighten qB login check, propagate delete failures end-to-end, close cache race
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
2026-07-02 11:57:35 +02:00
Estrella Pan
f3634baaee refactor: remove dead code before the 3.3 architecture work
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
2026-07-02 11:48:45 +02:00