Replaces the monolithic regex-based parser with a multi-stage tokenizer
pipeline (normalize, tokenize, classify, resolve). Fixes multiple parsing
bugs: Atlas format (#986), VTuber/Lv2 title splits (#798, #766),
Pre-episode format (#794), western S01E05 format (#764), and separator
dash leaking into titles (#819).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add detection and handling for anime movies (剧场版/劇場版) as a
separate entity from TV series. Previously, movies in RSS feeds were
either parsed as episode 0 or skipped entirely.
- Add TokenKind.MOVIE and movie keyword/pattern detection in tokenizer
- Add is_movie flag to Episode dataclass
- Create Movie SQLModel, MovieDatabase with CRUD + TTL cache
- Add migration v10 to create movie table
- Route TitleParser.raw_parser() to return Movie or Bangumi
- Update RSS analyser/engine for movie-aware parsing and downloads
- Add movie save path (no Season subfolder) and rename logic
- Add Movie REST API endpoints (get/update/delete/disable/enable)
- Add 11 tests for movie marker detection and non-movie regression
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Merging PR #1063 ("Doc update", 3.3-dev -> main) triggered the stable
release path, which fell back to the raw PR title as the version string
and shipped a Docker image tagged Doc-update as :latest — every
container pulling latest then crashed on SemVer parsing (#1065).
The release trigger is now explicit:
- bare X.Y.Z tag -> stable release, refused unless the tagged commit is
an ancestor of main
- tag containing alpha/beta -> pre-release (unchanged)
- PR merges only run tests and a docker build test
Also pass head.ref through an env var instead of inlining it in the
script (injection hardening), and take release notes solely from the
changelog file since tag pushes carry no PR body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jejs2dffkscAbhZkcjZ1oK
Add anibt.net as a first-class default search provider alongside
mikan/nyaa/dmhy, using its RSS search endpoint
(https://anibt.net/rss/magnets.xml?q=%s) with the tmdb parser.
load_provider() now merges newly added built-in sites into an
already-saved config/search_provider.json, so existing installs pick
up new default providers on restart while keeping user-customized
URLs and parsers untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUaTRVgAxPq6zEiwJHnbiC
open() never closed the previous EventSource, so re-searching leaked
the old stream: it kept appending the same results (duplicate
variants/tags in the list), its onerror closed the NEW stream's
handle, and the leaked stream auto-reconnected forever — an idle
spinner even with an empty input.
- close the previous stream before opening a new one
- stale-stream handlers close themselves and never touch shared state
- de-duplicate appended results by rss_link as a safety net
- clearing the input stops an in-flight search
- eventSource must be a shallowRef: ref() proxies the EventSource and
breaks the same-stream identity check
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
qB <=5.1 answers every rejected add with an opaque 200 "Fails.". For
magnet adds we already confirm "actually a duplicate" via the btih
hash, but Mikan torrents are uploaded as .torrent files, so the check
never matched and the add was classified FAILED — and since failed
adds are deliberately not persisted (kept for retry), the same
torrents error-looped every RSS tick ("Fails." ... "no matching
torrent found") forever, e.g. after purging orphan records while the
torrents still exist in qB.
Compute the v1 infohash (SHA-1 of the bencoded info dict, minimal
cursor — no new dependency) from the uploaded bytes and confirm
existence in qB; corrupt files still fail loudly and keep retrying.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
Codex review follow-up: a soft-deleted exact duplicate still orphaned
torrents (add() refuses the insert, the deleted row was refused for
linking, and download proceeded with no id) and the collect path
could link torrents to a still-disabled row. Explicitly subscribing
or collecting a disabled rule can only mean the user wants it back:
restore_one() re-enables the row (keeping its tuned config) and
torrents link to it. eps_complete is unaffected (not_complete already
filters deleted rows).
Also document the ab:renamed tag semantics (top-level media files;
subtitles follow in the same tick, nested extras never rename).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
- renamer: drop retroactive sanitization in gen_path — titles parsed
from on-disk files are already legal; sanitizing them would batch-
rename existing libraries containing ":" on upgrade. Sanitization
stays in gen_save_path (new rules only), with an "Unknown Bangumi"
fallback when a title is all reserved characters.
- renamer: tag failures no longer break the rename flow (notification
still returned, cycle continues); collections with unparseable
media files are not marked ab:renamed.
- collector: duplicate resolution no longer clobbers user-tuned
fields (eps_collect is set via a targeted update); failed collects
roll back the freshly inserted row; soft-deleted rules are never
linked; stale ids pointing at removed rows are cleared.
- database: find_duplicate is deterministic (oldest row) and warns on
legacy duplicate keys.
- api: auto_tag_torrents matches ab:<digits> exactly so the
ab:renamed marker doesn't block re-linking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
bangumi.add() returns False for exact (title_raw+group_name) and
semantic duplicates without backfilling the id, so re-subscribing an
already-known bangumi still orphaned every torrent (found in live
verification against the seeded dev stack). Add find_duplicate() to
the repo and resolve the existing row's id in subscribe/collect
before downloading.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
Backend: add network.tmdb_api_key (empty = fall back to the built-in
shared key, read live like tmdb_base_url); the config API masks it
automatically.
WebUI: the network section existed backend-only since #1042 — expose
it as a Network settings panel (TMDB API URL, TMDB API key,
Bangumi API URL) with types, initConfig defaults, i18n, and mocks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
After a torrent's media files are renamed (or already conform to the
target naming), add an ab:renamed tag in the downloader so external
scripts (filebot, hlink, ...) can filter tasks AutoBangumi has
finished processing. Skips the API call when the tag is already
present, skips failed renames, and skips the none/normal
pass-through methods. Capability-guarded via the facade, so
aria2/mock downloaders are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
The filename parser already yields 12.5 as a float, but gen_path and
rename_file int()-floored it to 12, overwriting the real episode 12.
Extract the duplicated offset logic into _adjust_episode and add
_format_episode (fraction preserved, integer part zero-padded:
"09.5"); widen Notification.episode to int | float so notifications
carry the fraction too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
Titles containing Windows/qB reserved characters (e.g. "Fate/Zero")
were joined raw into save paths and filenames, letting qBittorrent
split them into extra directories or silently truncate. Replace
<>:"/\|?* and control chars with a space in the save-path folder and
in every rename method's title component; collapse whitespace and
strip trailing dots/spaces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
Torrents added via search/subscribe/collect were persisted with
bangumi_id=NULL, so every matched torrent from those flows landed in
the orphan list regardless of the track_orphans setting, and the
ab:<id> qB tag was silently skipped (bangumi.id did not exist yet).
- subscribe_season/collect_season: insert the bangumi row before
downloading so its id exists for the qB tag and the FK
- collect_season/download_bangumi: set torrent.bangumi_id before
persisting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
- 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