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
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
Post-rebase rework addressing review findings on #1051 (the branch
predated the component-system migration):
- Replace retired ab-popup confirm with the useConfirm() promise hook
- Replace NButton and bare <button> with ab-button / ab-icon-button;
badge pills with ab-tag / ab-badge marks; empty state with ab-empty;
drop hardcoded colors for theme variables
- Make the per-bangumi page's route param reactive with a NaN guard,
and remount the list on param change
- Add a "View torrents" entry point in the rule edit dialog
- Reload the torrent list in finally so partial batch-delete failures
don't leave stale rows
- Align the orphan card title across locales; regenerate dts files
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
Review findings on #1056, verified against upstream NexusPHP source:
- torrentrss.php only recognizes per-category flags (cat402=1); the
bare cat=402 form was silently ignored. Emit cat<ID>=1 and accept
comma-separated IDs.
- Validate that category IDs are numeric, with a warning and Apply
gating in the dialog.
- Stock NexusPHP hardcodes $searchstr = null (search unsupported), so
the template only filters on sites/forks that kept search support —
the hint now tells users to verify this on their site instead of
implying it always works.
- Mask the passkey in the dialog's URL preview (stored URL unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
- The login button was type="button", so the form had no submit button
and Enter in the password field did nothing (no implicit submission
with two inputs). The button is now the form's submit button and the
handler guards re-entry.
- Auto-trigger the passkey prompt on the login page for browsers that
have successfully used a passkey before (hasPasskey localStorage
flag, set on passkey login/registration). A cancelled or unavailable
silent ceremony (DOMException) disarms the flag so a stale entry
can't doom every visit; API errors keep it (interceptor already
toasts). An explicit logout suppresses the next auto-prompt via
sessionStorage so a reflexive Touch ID confirm can't log straight
back in.
- Extract the shared withSetup test helper instead of a third copy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
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
- Nest ab-edit-rule's delete confirmation inside the main ab-modal so
headlessui suspends the outer dialog's Escape/outside-click while the
inner one is open (clicking the delete-files checkbox no longer
closes both layers); regression-tested with a nested-modal vitest.
- Restore autocomplete passthrough (ab-input prop + ab-setting wiring)
so downloader/proxy password fields keep autocomplete=off.
- Guarantee 44px touch targets for ab-button sm on touch; repoint or
remove the dead .n-button deep selectors in wizard/config/downloader/
log left over from the sweep.
- a11y labels: modal X and search closes announce 'Close', tag remove
announces 'Remove' (new common.close/common.remove i18n pairs);
ab-field renders label[for] only when the control adopts the id,
span + aria-labelledby otherwise.
- Convert the last NPopconfirms (passkey/search-provider/update-card)
to useConfirm with danger icon buttons; drop legacy
.notification-action CSS that fought ab-button variants.
- Fix missing homepage.rule.offset key (→ episode_offset), normalize
test names to the should-convention, run prettier over the branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
- ab-status: back to the ring form (thin border circle, lamp inside)
per review; keeps the 4-state API and pulse.
- Select widths: drop the competing 140px floors in ab-setting and
ab-select; ab-field's 200px desktop floor now applies, so config
dropdowns stop collapsing to arrow-width.
- Modal actions standardized into ab-modal's footer slot (right-
aligned, consistent padding): search-provider, notification,
passkey dialogs; cancel buttons demoted from danger to secondary.
- ab-add-rss and ab-edit-rule converted from hand-rolled dialogs to
ab-modal (focus trap, unified header/footer; stray divider above
the add button gone); edit-rule's inner delete dialog is a nested
ab-modal, enable-rule dialog gains a proper footer.
- ab-modal: showClose prop + bare mode — bodyless confirms render
compact (no empty body strip, no doubled hairlines, no X); the
notification provider row uses ab-icon-button with a red remove.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Migrate the remaining surfaces: all seven ab-popup call sites to
ab-modal (footer slots, sm sizing), every ab-label to ab-field, all
raw ab-input-shortcut inputs to the ab-input component (textareas get
local Soft Ink styles), rss mobile list to ab-list with loading
skeleton, and de-tint the hand-rolled warning callouts (hairline +
ink, per the no-tinted-boxes rule). Wire aria-live into pull-refresh.
Delete dead code: ab-popup, ab-adaptive-modal, ab-label, ab-data-list,
ab-add, ab-button-multi, ab-image, ab-rule, ab-swipe-container (+
orphaned stories); drop the ab-input/input-error UnoCSS shortcuts.
Add stories for the full universal set and document the final
22-component catalog in DESIGN.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
Sweep every NButton and styled bare button to ab-button /
ab-icon-button (variant map: primary→primary, secondary→secondary,
error→danger with hover escalation); route the five NPopconfirm sites
and the config leave-guard through useConfirm(); migrate ab-tag call
sites to semantic types (primary→info, active→success, warn→danger,
inactive→neutral); wire ab-badge into the notification bell and
ab-progress into the downloader table; first two ab-popup call sites
moved to ab-modal.
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