Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

All notable changes to AoNarr, newest first. See README.md's Verification section for the full
build/test log behind each round.

## Round 372 — Selectable UI designs (Classic + six redesigns)

Added an in-app design picker. **Classic is the default and is unchanged** — nothing looks different
until a user picks another design. The choice is per-browser (`localStorage`, key `aonarr_design`),
like the light/dark theme and layout-width toggles, and every design supports both light and dark.

### Added

- **Six designs** — Midnight (refined slate-blue), Aurora (violet/teal, large rounded cards), Ember
  (warm charcoal + amber), Noir (true black + gold, posters first), Paper (editorial ink, serif
  headings, square corners), Terminal (monospace, dense, green). Source in `web/src/designs/`:
  `modern.css` is the shared structural layer (every rule scoped under `:root[data-design]`, all
  colors/radii/fonts/density from tokens), and `<id>.css` holds one design's tokens + signature rules.
  Uses native CSS nesting, which Vite/esbuild lowers to plain selectors in the build.
- **Picker** — palette button next to the theme toggle in the sidebar footer and the top bar
  (`DesignToggle` → modal), plus an **Appearance** tab on the Account page (reachable by household
  accounts too). `DesignPicker` is a native radio group with a live mini-preview per design, drawn
  from that design's own tokens.
- **`theme-init.js`** applies the saved design before React renders (no flash), and accepts
  `?design=<id>` (e.g. `/?design=classic`) as a recovery link.

### Fixed (in the new designs only — Classic is untouched)

- Filled buttons and poster status strips now meet 4.5:1 text contrast (the original is 3.2:1 on
  primary buttons, 2.3:1 white-on-green).
- Visible `:focus-visible` rings on every control (the original has none).
- Hover/pressed states use theme-aware tokens, so they show in light mode too.
- 12px minimum text, 44px touch targets on coarse pointers, `prefers-reduced-motion` respected.
- Palette contrast for all 12 design/theme combinations verified numerically (text ≥7:1, muted,
  accent text, on-accent, status colors ≥4.5:1).

### Known limitation

- An admin's custom theme (Settings → General, free-form CSS served from `/api/theme.css`) is applied
  on top of everything, but a selected design's rules are scoped under `:root[data-design]` and so
  outrank plain `:root { --var }` or single-class overrides. Custom themes are reliable under
  Classic; under another design they may need higher specificity. Not changed in this round.

## Round 371 — Fixed a duplicate-season episode bug (multi-provider episode merge)

Reported: "A Pup Named Scooby-Doo"'s season 4 was the same 5 episodes as the end of season 3, just
reordered — one metadata provider splits a show's episodes into a different season count than
another, and AoNarr's merge logic had no way to notice the two lists overlapped.

### Fixed

- **`mergeEpisodesIntoItem()`** (the shared insert-if-missing logic behind Refresh,
  Match All Providers, and a new show's first episode sync) deduped an incoming episode only by
  season+episode number, never by title — so a second provider's own season split for the same
  real episodes always looked like new ones. Added a title-based dedup check (the same
  exact-normalized-match convention already used elsewhere in this file): a non-placeholder title
  that already matches a tracked episode is now treated as the same episode under different
  numbering and backfills its air date instead of duplicating it. Two different episodes that both
  happen to carry a generic placeholder title ("Episode 5") are explicitly excluded from the check,
  so they're never wrongly merged into each other.
- **A library-wide sweep found the same pattern in 158 shows.** After separating real bugs from
  coincidence (course lecture titles and talk-show segment titles both naturally recur across
  different real episodes and needed excluding as false positives — see the CHANGELOG-adjacent
  session notes / project memory for the classification method), **4,065 phantom duplicate episode
  rows were removed across 91 shows**, backfilling 2,431 air dates onto the surviving rows first.
  42 pairs across 9 shows turned out to be two genuinely separate downloaded files for the same
  episode (real wasted disk space, not a database bug) — left untouched pending a decision on
  whether to recycle the extra file.

### Tests

- `mergeEpisodesIntoItem`: the exact "A Pup Named Scooby-Doo" regression (a second provider's
  differently-numbered episode list gets treated as duplicates, not new episodes, backfilling air
  dates onto the existing rows) and a negative case (two different episodes sharing only a generic
  placeholder title are never merged into each other).

## Round 370 — Duration column, a real Background Jobs widget, bulk-selected actions, icon-only
topbar controls, and a Missing page broken down by library

### Added

- **Duration column** on the Library table/poster views for every type where a row's own runtime
  is actually known and meaningful (Movies, Sports PPV) — deliberately excluded from TV Shows/Anime
  (their `runtimeMinutes` is a single EPISODE's length, not the whole show's) and from ROMs (no game
  metadata provider supplies a runtime at all). Shows in the default column set on fresh installs.
- **A real Background Jobs widget** — Scan & Import, Refresh, Match All Providers, and Organize &
  Rename were all fire-and-forget before this ("check the Logs page later"); they now report live
  progress through a new server-side tracker (`services/backgroundJobs.ts`), shown in a floating
  panel that can be minimized and stays visible across page navigation. The Media Analyzer's own
  "Analyze Now" progress is folded into the same widget. A job whose total item count isn't known
  yet shows an animated indeterminate bar instead of a misleading static full one.
- **Bulk-selected Refresh / Match All Providers / Organize & Rename** — selecting items on a Library
  page now offers these three actions scoped to just the selection instead of the whole library,
  sharing the same background-job tracking/progress and the same "only one run at a time per type"
  guard as the whole-library buttons.
- **Icon-only theme/layout/log-out topbar controls** — new hand-drawn Sun/Moon, Maximize/Minimize,
  and Log Out icons (`components/NavIcons.tsx`) replace the text-only buttons from an earlier round.
- **Missing page broken down by library** — previously grouped by shape (three lumped sections that
  mixed Online Videos/Books/Music together, and TV Shows/Anime/Sports/Adult together); now one
  section per actual library type, in the same order as the sidebar.

### Fixed

- **Previous/Next pagination no longer jumps to the top of the page.** Clicking Prev/Next at the
  bottom of a list (Library pages and 8 other paginated pages sharing `components/Pagination.tsx`)
  used to leave the viewport wherever it landed once the new page rendered — usually the top —
  forcing a full re-scroll to get back to the pagination controls. Root cause was two-fold: a
  React rendering race (a page's new rows and its `loading` flag commit in separate renders, so
  scrolling the instant `loading` clears could still measure the old page's layout) and a `useEffect`
  cleanup-timing bug (a second, unrelated `loading` flicker was canceling the pending scroll via
  React's automatic effect cleanup). Fixed by tracking the scroll timer in a ref instead of a
  cleanup function.

## Round 349 — Fixed 3 of Round 348's known limitations, corrected an undocumented Round 348 fix

An investigation into Round 348's documented limitations found that 3 of the 4 items were actually
fixable, not inherent — and that one item's own note was wrong about what had shipped. Each fix was
independently adversarially reviewed; the review caught two real regressions before they shipped
(a broken test-mock seam, and a concurrency race), both repaired and re-verified.

### Fixed

- **A pathological PDF could stall the whole server, not just the scan.** The 15-second PDF-parse
  timeout was a same-thread `Promise.race`, which cannot interrupt pdf.js if it gets stuck in a
  synchronous loop on a malformed or malicious file — so the "timeout" did nothing and the entire
  process (every other in-flight request, not just the scan) could freeze for as long as pdf.js kept
  running. The parse now runs in a `worker_thread` that is force-`terminate()`d on timeout, a real
  OS-enforced interrupt (`services/bookFileMetadata.ts`, new `services/pdfInfoWorker.ts`).
- **Hand-picking disc 2 of an album after disc 1's folder was deleted outright no longer numbers its
  tracks from 1.** The per-disc track-number offset for the CD1/CD2-subfolder layout was computed by
  a folder's position in the current directory listing, not by the disc number in its own name — so
  a missing disc 1 folder made disc 2 look like disc 1 and could overwrite disc 1's already-imported
  track 1. Offsets are now indexed by each folder's own disc number and refuse to guess (rather than
  silently mis-numbering) when an earlier disc isn't present, matching the single-folder Picard/
  iTunes layout's existing behaviour (`services/importer.ts`).
- **The Missing/Cutoff-Unmet auto-search cycle had no cap.** Every cycle (default every 30 minutes)
  searched every monitored item lacking a file, with no ceiling — for a large library, thousands of
  full multi-indexer searches per cycle. `AONARR_AUTO_SEARCH_MAX_PER_CYCLE` (default 500; 0/unset =
  unlimited) now caps it, with candidates ordered oldest-searched-first (a new `last_auto_searched_at`
  column) so a backlog larger than the cap rotates across cycles instead of starving anything
  (`services/scheduler.ts`).

### Correction to Round 348's notes

- The storage forecast's free-space fix (reading `bavail` instead of `bfree`, which excludes the
  filesystem blocks reserved for root that AoNarr can never actually write into) shipped in Round 348
  (`e533944`) along with a one-time wipe of old `disk_usage_samples` rows — this was never written
  up, and Round 348's "a wipe was judged worse" note was wrong: a wipe is exactly what shipped.
  Expect the Storage forecast to show "-" for a day or two after upgrading past that commit while new
  samples accumulate; it resolves on its own once two `bavail`-based samples span 12+ hours
  (`services/storageForecast.ts`).

### Tests

- New regression tests: a real worker_thread killing a genuine synchronous busy-loop within ~300ms
  (the PDF fix), disc 1's folder deleted outright before hand-picking disc 2 (the multi-disc fix), and
  7 tests covering the auto-search cap/ordering/starvation behaviour. The full suite passes on SQLite
  (2,845 passed, 13 skipped) and Postgres 15 (2,845 passed, 14 skipped).

### Known limitations (carried over from Round 348, still true)

- An audio-only fansub title with no video marker (e.g. `[Group] Show - 05 [AAC]`) still doesn't
  report its leading group. Traced to the exact guard responsible: it's indistinguishable from a
  music/audiobook title using the identical `[X] Y - NN [AUDIO_TAG]` convention without a media-type
  hint threaded through parseReleaseTitle's ~25 call sites — a real trade-off, not a fixable bug.

## Round 348 — Loose books in library roots, second full audit (~180 fixes), and the deferred features

A second multi-agent audit pass (14 cross-cutting lenses: SQLite/Postgres parity, permissions,
injection/SSRF, concurrency, filesystem, errors/resources, React state, metadata providers, the
release decision engine, data integrity, download clients, integrations, the import pipeline per
media type, and a regression review of Round 347). 182 of 183 findings were independently confirmed.
Every fix was adversarially reviewed, re-fixed and re-reviewed, and the five items previously
deferred are included.

### Behaviour changes to know about

- **Cross-origin API use needs an explicit allow-list.** With "Allowed CORS origins" empty, only
  AoNarr's own web UI (same origin) can call the API from a browser; `*` restores the old allow-any
  behaviour. The setting can also be given as the `AONARR_CORS_ALLOWED_ORIGINS` environment variable
  (comma-separated), which is the recovery path if a reverse proxy rewrites the Host header. A
  cross-site form-style request that could act without a CORS preflight is refused (403).
- **Credentials are no longer accepted in URLs.** `?apikey=` and `?sessionToken=` are gone; use the
  `X-Api-Key` / `X-Session-Token` headers. The web UI's live streams use short-lived single-use
  tickets, and avatars are fetched with headers, so no long-lived credential lands in proxy or
  container logs.
- **A Postgres backup restore restarts the app** (as a SQLite restore already did), so migrations
  and in-memory caches match the restored database.
- **First-run setup only opens on a pristine instance.** Once any root folder, media item, indexer or
  download client exists, creating the first admin requires the API key.
- **Hardlink/symlink imports no longer remove the torrent from the client** after import (they exist
  to keep seeding).
- **Season packs never overwrite a better existing episode**; only real upgrades are placed.

### Books, comics and audiobooks sitting directly in a library root folder

- **Fixed: book files sitting at the top of the Books root folder never showed up.** The scan skipped
  every file without an `Author/` folder before reading any metadata. A loose book is now identified
  from its own `<name>.opf`, then its embedded metadata (EPUB OPF, MOBI/AZW/AZW3 EXTH header, PDF
  info), then an `Author - Title` filename, and otherwise lands under an unmonitored per-root
  "Unknown Author" (never searched, renamed or enriched), so it always appears in the library. Junk
  embedded values (default account names like "Windows User", download-site stamps like
  "www.it-ebooks.info", "Microsoft Word - x.docx" titles) are ignored.
- **Comics and Manga** in the root use ComicInfo.xml's series, then the series parsed from the
  filename, then "Unknown Series". **A single-file audiobook** (e.g. `Author - Title.m4b`) in the
  root is imported from its tags or filename. **Music** still needs `Artist/Album/`, and the scan now
  says so.
- **A book folder directly under the root** (`Root/<Book Title>/book.epub`) is filed under the
  book's real author when its metadata names one, instead of an "author" named after the book.
- **A stray `metadata.opf` or `ComicInfo.xml` no longer gives every book or issue in a folder the
  same title** (it only applies to a folder holding a single book; `<name>.opf` always applies).
- The same book in several formats stays one book; macOS `._` companion files are ignored; the MOBI
  reader is bounds-checked and never throws on truncated files.

### Security

- **With authentication disabled, any website you visited could drive the API** — fixed by the
  same-origin default above.
- **Path escapes:** a `/` or `..` in any title could create extra folders or write outside the root
  folder; every templated destination is now confined to its root.
- **SSRF:** a household podcast request could make the server fetch arbitrary URLs.
- **ScreenScraper credentials** were embedded in stored artwork URLs; artwork is now proxied
  server-side and existing rows are migrated.
- Remote-instance API keys and friend Plex/Jellyfin tokens are now encrypted at rest; IRC announces
  are only accepted from the configured announcer nick(s); live streams close when a session is
  revoked, a password changes or the API key rotates; `/api/metrics` no longer exposes root-folder
  paths or runs a full-library scan per hit; the login lockout can't be bypassed with parallel
  requests on Postgres; Send to Kindle encodes non-ASCII subjects and filenames.

### Data safety (filesystem)

- **The weekly Corrupt Media Check could wipe the whole library when a mount was down**, and the
  deleted-file check mass-flagged symlinked libraries when a debrid/rclone mount blinked — both now
  skip unavailable roots and link targets.
- **Post-import cleanup could delete a shared category folder**, including other downloads.
- **Multi-disc albums in one folder overwrote their own tracks** on import.
- Cross-device moves and upgrades are atomic (temp file + rename), so an interrupted copy never
  destroys the existing library file; Organize & Rename never overwrites a different file and moves
  AoNarr's own subtitles/NFOs along; auto-archival no longer overwrites same-named archived files;
  free-space checks use space available to AoNarr; folder permissions apply to every created level;
  the recycle bin recovers entries left "restoring" by a restart.
- **Archives:** only the download being imported is unpacked (not the whole downloads tree), ROM
  zips are never unpacked, extraction has size/entry limits, and failed extractions aren't retried
  on every import. **The Docker images now include `unrar` and `7z`**, so RAR and 7z releases unpack.
- A sample is never imported as the episode or movie (Sample folders and scene-style `-sample` names),
  while an episode merely titled "…The Sample…" still imports.

### Grabbing and the decision engine

- **Automatic grabbing never worked for Music, Books, Comics, Audiobooks or ROMs** (their releases
  have no video quality tiers) — they're now ranked by custom-format score, indexer priority and
  seeders.
- **Indexers added in the UI were never searched for most library types** (anime, sports, comics…);
  new and existing indexers now default to every indexer-searchable type, with a Media types picker
  on the Indexers form.
- The quality tier is chosen from releases that survive every rejection; a release whose protocol
  has no enabled client is skipped instead of blocking the grab; bulk and retry grabs only take real
  upgrades; "Search selected" on a show/artist/author searches its missing children; 0-seeder
  torrents are avoided; IRC instant grabs respect delay profiles, years and simultaneous announces.
- **Release parsing:** anime `Show S2 - 05` / `Season 2 - 05` numbering, ranged fansub batches (only
  covering their own episodes), multi-season collections (`Season 1 - 9 Complete`), `10bit` tags,
  SD releases, and leading/trailing release-group edge cases.
- **Online Videos (yt-dlp) downloads could never be imported** (`.webm` output) — the output path is
  now reported and `.webm` is a recognised extension.
- A book/album grab only accepts releases naming the right title (no "The Annotated Hobbit" for "The
  Hobbit", no other Ed Sheeran symbol album for "÷").

### Download clients and the queue

- **Download clients are polled concurrently**, so one slow client no longer delays the rest (imports
  still run one at a time).
- **Imports cut off by a restart are retried once automatically**; imports deliberately skipped
  (waiting for a manual import) are never re-run.
- Stalled detection now covers downloads that never made progress or vanished from the client;
  library-side failures (disk full, read-only destination) no longer blocklist a good release;
  in-process (HTTP/debrid/yt-dlp) jobs cut off by a restart are re-grabbed without blocklisting;
  qBittorrent "moving"/"checking" torrents aren't imported mid-move; SABnzbd connection tests check
  the API key; Retry/Manual import can't run on top of the poller's import of the same row; deleting a
  download client no longer strands its queue rows.

### Metadata, lists and integrations

- Long child lists are paged (books, comics, manga, artist albums over 100); currently-airing
  AniList anime get their episodes; Trakt id lookups use the right endpoint; podcast episodes survive
  enclosure URL changes; Refresh keeps each item on its own provider's episode list (and air dates)
  instead of a merged second provider's; a manual "Fetch from provider" verifies by id.
- **Import lists, Trakt sync and Plex watchlist sync created items with no root folder**, so their
  downloads could never import. **Each import list can now have its own root folder** (default: the
  type's root folder with the most free space); lists that skip items show a warning instead of an error.
- **TRaSH-Guides sync results are shown in Settings** (last run, counts, and every failed or
  unsupported format with its reason); a second sync of the same app joins the running one.
- IMDb list bot-challenge responses, Trakt's required User-Agent, Jellyfin webhooks, Prowlarr/
  Jackett-synced indexer edits, and Overseerr/Jellyseerr duplicate approvals are handled.

### SQLite/Postgres parity

- Postgres queue progress is a double (stalled detection works), paginated lists have a unique
  tiebreaker (no repeated/skipped rows across pages), global search is no longer capped before
  filtering on SQLite, and non-integer values no longer fail Postgres inserts after a download was
  already sent.

### Web UI

- Date-only release dates no longer show a day early west of UTC; remaining server timestamps are
  shown in local time; Media Detail refreshes in place instead of blanking; stale-response guards on
  the remaining pages; pages show errors instead of "Loading…" forever; the What's New page renders
  the changelog correctly; the generated invite link is visible; Watchlist Import sorts IMDb TV
  Movies/Specials correctly; library Back/Forward between types keeps the right page.

### Tests

- The server suite passes on SQLite and Postgres. A test-isolation bug that let some test files
  write into the real `data/config` folder (and caused occasional full-suite-only failures) is
  fixed: every test file now gets its own temporary config and downloads directory.

### Known limitations

- A pathological PDF that makes pdf.js loop synchronously can't be interrupted (it is only read once
  per scan and cached afterwards).
- Picking disc 2 of an album by hand after disc 1's folder was deleted outright numbers its tracks
  from 1.
- An audio-only fansub title with no video marker (e.g. `[Group] Show - 05 [AAC]`) doesn't report its
  leading group.

## Round 347 — Full-codebase bug audit (security, downloads, scans, settings, UI)

A 12-partition multi-agent audit of every server and web file (plus a dedicated pass over the
last twelve rounds' changes), with every finding independently re-verified against the code before
anything was touched. Each fix was then adversarially reviewed, and the review's own findings were
fixed and reviewed again.

### Security

- **Fixed an unauthenticated arbitrary-file read.** The public local-artwork route served whatever
  path an NFO's `<thumb>` (or a poster/fanart name) resolved to, so `<thumb>../../config/aonarr.db`
  exposed the database. Artwork now only resolves to a regular image file (.jpg/.jpeg/.png/.webp/.gif)
  whose real path stays inside the sidecar's own folder — traversal, absolute paths and symlinks
  pointing outside are refused.
- **Media-server posters no longer embed your Plex token / Jellyfin-Emby API key.** Poster URLs used
  to carry the credential in their query string (visible to every household account and in page
  source). They are now proxied through AoNarr with the credential sent as a header; existing
  posters are converted on startup (only same-origin artwork paths of the configured server,
  sub-path installs such as `http://host/jellyfin` included).
- **Settings template import can no longer overwrite `authRequired`/the API key** — only naming
  templates are imported from a template's naming section.
- **Household accounts no longer see indexer download URLs/magnets/API keys** in an item's history.
- **Login lockout fixes:** failures are bucketed per IP *and* username (a successful login to one
  account no longer clears failures against another); a successful TOTP step no longer resets its
  bucket; a valid API key/session is still accepted while an IP is locked out (only failures are
  blocked).
- **First-run setup can't be re-run once any user exists** (setup-status only reports "needs setup"
  for an empty users table; `POST /auth/setup` then requires the API key).
- **Requests:** a household account can no longer request a media type it isn't allowed, and only a
  pending request can be rejected.
- **The large (10 MB) JSON body limit for template import now applies only after authentication**,
  and both bundled nginx configs allow it.
- **Notification errors no longer write webhook URLs / Telegram bot tokens into the logs** (only
  the host is named), and every notification provider call has a 15 s timeout so an unresponsive
  target can't stall grabs/imports.
- **HTTP metrics no longer record raw unmatched paths**, so an unauthenticated scanner can't grow
  the in-memory metrics map one junk URL at a time.
- **The encryption key is never regenerated over an unreadable key file** (only when it truly
  doesn't exist); an invalid key file is set aside as `encryption.key.bad-<ts>` instead of being
  overwritten.
- **Postgres restore keeps credentials readable:** the backup's key is installed only when it's the
  one that decrypts the restored data, and `pg_restore` runs with `--no-owner --no-privileges` so a
  restore from another install no longer fails on role/ownership lines.

### Downloads & grabbing

- **qBittorrent: downloads are now tracked by their real info-hash** (parsed from a magnet, or found
  through a unique per-add tag for a .torrent URL), so status, priority and remove work from the
  first poll; older queue rows keyed by a magnet URL are migrated automatically. A download that
  can't be resolved within 30 minutes is matched to the torrent by name (qBittorrent silently drops
  a duplicate add of something it's still seeding), and otherwise failed and retried instead of
  sitting "queued" forever. Hybrid v1/v2 torrents are matched on their v1 hash too.
- **qBittorrent seed-goal cleanup only removes AoNarr's own torrents** (tagged `aonarr`, or in its
  configured category) — never Sonarr/Radarr's or your manual ones on a shared client.
- **SABnzbd:** a job still post-processing (verifying/repairing/extracting/moving) is no longer
  imported early.
- **slskd:** Rejected/TimedOut/Aborted/other non-Succeeded terminal states now count as failed
  instead of "downloading" forever.
- **Direct HTTP downloads get the right file extension** (from Content-Disposition, the final or
  original URL, or the content type) instead of `.bin`, so the importer finds them.
- **Real-Debrid/AllDebrid/TorBox multi-file jobs are saved into their own folder** instead of loose in
  the downloads root.
- **Season packs:** a queued season pack now counts as covering that season's episodes (no more
  grabbing each episode on top of it), and a pack grabbed for one episode now imports the whole
  season; a single-episode grab from a season search (or another season's pack) no longer blocks
  the whole season, and a failed pack is only replaced by another full pack of that season.
- **Daily shows** are searched by air date in bulk search, auto-upgrade and failed-grab retries.
- **Online Videos/Podcasts:** an automatically-failed yt-dlp/RSS grab is no longer retried every
  30 minutes (a 24-hour cooldown per title); a video/episode you blocklisted yourself stays
  blocked.
- **Auto-search intervals of 60+ minutes now actually run at that interval** (cron steps above 59
  used to collapse to hourly/irregular runs).

### Importing

- **A shared downloads folder is no longer treated as one release:** an album or season pack loose
  in the downloads root or a client category folder only takes files that belong to it (other
  shows' episodes and other albums' tracks stay put), and the download's data is kept whenever
  anything was left behind; a partially-imported album raises a "manual interaction" notification.
- **Season packs laid out as per-episode folders, or extracted from per-episode RARs, import every
  episode** (recursive, samples skipped); extras/NCOP/NCED/specials no longer block cleanup.
- **Upgrades recycle the replaced file** instead of leaving it orphaned in the library.
- **Scene-numbered episodes no longer mark the wrong TVDB episode downloaded.**
- **An episode is never imported from another show's download** that merely has the same episode
  number or air date; an obfuscated file inside a properly named release folder is still found.
- **Hardlink upgrades land atomically** over the existing library file.

### Library scan, refresh & metadata

- **Same-titled items (remakes/reboots) are told apart by year** on scan, and a per-item scan no
  longer claims a different year's file; loose title matching is whole-word ("Go" no longer matches
  "Django for Beginners").
- **Root folders that share a prefix** (`/data/music` vs `/data/music-lossless`) and roots saved with
  a trailing slash are handled correctly.
- **Refresh no longer lets a stale NFO undo a "Different Match"** — a matched item is refreshed by
  its own ids; the title-search fallback is year-checked ("Halloween" 1978 stays 1978).
- **Course/Adult shows keep their folder/NFO title on Refresh** (a ThePornDB scene hit no longer
  renames the show and spawns duplicates), and a course's `tvshow.nfo` is read from its top-level
  folder for lessons in any module subfolder.
- **Multi-episode files** keep all their episodes on scan and no longer get one NFO title copied
  onto every episode on Refresh.
- **Specials:** NFO season 0 is honoured, a `Specials` folder counts as season 0, and specials
  merged from a second provider are added unmonitored.
- **Matching additional providers** verifies title and year before merging ids, and only merges
  Season 0 specials (AniList-numbered anime is never given another provider's episode list).
- **A show created by Scan & Import is only enriched from a search hit whose title and year agree
  with it** (punctuation-tolerant: "Greys.Anatomy" still matches "Grey's Anatomy"), instead of
  taking whatever ranked first — a daily show's air-date year is no longer used as its premiere
  year, and a Specials file's own title is never mistaken for the show's.
- **Books/Comics:** a Calibre `metadata.opf` or `ComicInfo.xml` no longer renames the author/series to
  one book's/issue's title on Refresh.
- **TVDB long-running shows get every season** (the episode list is paged); **Deezer multi-disc
  albums** number tracks continuously (and page past 25 tracks).
- **ISBN detection** survives a printer's key or other numbers after the ISBN.
- **NFO content ratings** such as "Rated R" or "US:PG-13" are normalized so household rating limits
  apply to them.
- **Archived items are unmonitored**, and **the deleted-file check skips an unavailable (unmounted
  or empty) root folder** instead of flagging its whole library missing.

### Library data & routes

- **Duplicate merge** respects legacy (not yet converted) Adult/Course items, recycles files only
  after the database commit, tells you when an item couldn't be merged, and moved episodes/tracks
  stay findable in search.
- **Library pages sort deterministically** on both SQLite and Postgres (no rows repeated/skipped
  across pages).
- **Bulk delete** no longer holds the database transaction open while moving files.
- **Rematch** resets content rating/genres and refreshes the item.
- **Exports:** non-Latin titles no longer 500, and same-titled items no longer overwrite each
  other inside the zip.
- **Household accounts can view an album's/audiobook's tracks**; Discover no longer reveals titles
  above an account's rating limit.
- **Orphaned-file scan** understands music/audiobook track files.
- **Root-folder "move to"** reverts an item whose files couldn't be moved, and refuses Music/
  Audiobooks.
- **Deleting a tag or indexer** removes it from release profiles' restrictions.
- **Library groups created before Round 344** can be opened again.
- **Users:** renaming a user works (409 on a duplicate name) and a rejected edit no longer applies
  half of it.
- **Quality tiers:** deleting one moves every affected profile's cutoff to the nearest allowed tier
  below it; renaming a tier is refused (profiles reference them by name); 480p/576p releases map to
  real tiers and CAM/Telesync/Telecine/Workprint parse as Unknown.
- **The default "Any" quality profile is no longer recreated** after you delete or rename it.
- **Settings template import** inserts new quality tiers after their nearest template neighbour
  instead of colliding on rank.
- **Upgrade candidates** skip unmonitored items and items whose quality tier no longer exists.
- **TRaSH/Starr custom formats import with the right logic** (specs of one kind are OR'd, kinds are
  AND'd, required specs are honoured; live-API field arrays are understood).
- **Integrations:** media-server watch sync no longer re-records old watches every run; Plex
  "mark watched" finds unwatched items; Jellyfin/Emby stop events only count when played to
  completion (add `"PlayedToCompletion": "{{PlayedToCompletion}}"` to a Jellyfin webhook template);
  Plex watchlist sync resolves TMDB/IMDb ids and pages; DDL indexer health checks send the API key;
  IRC announces check the year; Whisparr re-imports no longer renumber/duplicate scenes; Starr
  quality-profile group cutoffs map correctly.
- **Dashboard:** an unreachable media server no longer breaks the dashboard; music/audiobook library
  sizes are counted; recently-watched ordering is correct; the health check no longer re-probes
  every indexer/client every minute.
- **Calendar feed** skips year-only release dates and no longer drops tonight's items after UTC
  midnight. **OPDS** links keep the port/scheme behind the bundled proxy. **Remote libraries** and
  the MCP `list_media` tool page past 60 items; MCP `add_media` populates episodes/albums.
- **Remote (S3) backups** only rotate this instance's own backups; SMTP bodies are sent with proper
  line endings; audiobook chapter marks no longer drift; the media-analysis run can't start twice.

### Web UI

- **Returning from an item to its library restores your scroll position** (the Round 346 fix saved
  the position too late, after the page had already scrolled to the top).
- **Timestamps** (history, blocklist, recycle bin, import review, audit log, sessions, invites,
  dashboard) are shown in your local time instead of being read as local when they're UTC.
- **Stale-response guards** on Media, Episode, Sub-item and Track pages, Add Media search, Media
  Analyzer, Calendar, History, IPTV playlists and Remote Library — a slow earlier request can no
  longer overwrite what's on screen.
- **Bulk search** (Library, Missing, Cutoff Unmet, Media Analyzer) is sent in batches of 100 instead
  of failing silently above the server's limit; failures now show an error everywhere.
- **Manual Import for movies** no longer requires a target, and imports one file at a time.
- **Settings:** score inputs follow the selected profile; the media-server type, default metadata
  provider and quiet-hours/search-window/permission defaults are saved as shown; regex terms with
  commas/colons survive in release profiles and custom formats; regenerating the API key no
  longer logs out a password session; profile cutoffs stay within the allowed qualities; Starr
  imports go to the instance the preview came from.
- **Wrong login password / TOTP code shows an error** instead of reloading the page.
- **Household accounts** see read-only monitored icons, and admin-only collection/Discover actions
  are hidden from them; pages show the server's error instead of "Loading..." forever.
- Also: pagination steps back when the last row of a page is removed (Activity, Import Review,
  Blocklist), the Duplicates page keys groups correctly, a modal no longer closes when a text
  selection drag ends outside it, the sidebar recovers if the first media-types request fails, the
  Account page shows an admin's real username, and System's backup list/log level filter refresh
  when changed.

### Deferred (needs a decision or a larger change)

- Trusting `X-Real-IP` from any private-network peer (affects rate limiting when AoNarr is exposed
  without the bundled proxy) — needs a deployment decision (a "trusted proxies" setting).
- Loose audio files directly in an artist folder (no album subfolder) during a music scan.
- Mapping XEM scene numbering onto TMDB-numbered shows.
- A process-wide lock around async SQLite transactions (the known file-I/O-inside-transaction
  cases were moved out of their transactions this round instead).

## Round 346 — Bug fixes + Custom Format/Release Profile parity with the *Arr apps

- **Fixed: deleting a media item from its own page sent you to the Dashboard.** Now navigates back
  to that item's own library page instead.
- **Fixed: Course library still showed a course's own subfolders (Module 1, Week 2, ...) as separate
  courses.** The folder-to-show resolver only special-cased a literal "Season NN" parent folder,
  treating any other subfolder name as its own show — a course's arbitrarily-named/nested category
  folders were never covered by that. It now walks up to the true top-level folder under the
  library root, no matter the nesting depth or naming, so every file under `Root/CourseA/**`
  correctly becomes part of "CourseA" regardless of how it's organized underneath.
- **Dashboard: moved the Health Issues box's "View System" arrow** next to the expand/collapse
  chevron in its header, instead of on its own line below the (possibly collapsed) issue list.
- **Custom Format conditions — added Edition, Quality Modifier, and Release Type**, and extended the
  Source (Cam/Telesync/Telecine/Workprint) and Resolution (480p/576p) vocabularies — closing the
  verified gap against Radarr/Sonarr/Lidarr/Readarr/Whisparr's combined condition set (confirmed via
  each app's actual source: Lidarr/Readarr genuinely have nothing beyond title/group/size/indexer-
  flag, so there's no further parity gap for them specifically).
- **Release Profiles — added regex support, an Indexer restriction, and a Tags restriction.** A term
  wrapped as `/pattern/flags` is now treated as regex (same auto-detection Radarr/Sonarr/Lidarr use),
  and a profile can be scoped to specific indexers/tags instead of always applying globally —
  confirmed via source that every app with this feature (Radarr, Sonarr, Lidarr, the now-retired
  Readarr, and Whisparr-Eros) has exactly these two restrictions; none of them still have a
  "preferred term + score" field (that moved to Custom Formats everywhere), so AoNarr's own
  `preferred` list stays as a deliberate superset rather than a gap to close.
- **The base quality tiers (SD, HDTV-720p, ...) can now be deleted**, not just reordered/resized —
  there was no delete route for them at all before. Deleting one drops it from every quality
  profile's allowed list (falling back to the highest-ranked remaining quality if that empties the
  list) and moves any profile's cutoff down off of it.
- **Fixed a spacing gap** between the Metadata Providers tile grid and the "Default provider per
  type" panel below it on the Settings page — they had zero margin between them.
- Investigated the reported "browser Back from a media page loses the list's scroll position"
  bug extensively, including live end-to-end testing (poster grid, table view, and mobile layout, on
  a freshly rebuilt image) — could not reproduce it; the existing scroll-save/restore logic already
  in `LibraryType.tsx` worked correctly in every configuration tried. No code change made for this
  one; revisit with exact repro steps if it recurs.

Requested: a way to import Quality Profiles, Qualities, and Custom Formats from Sonarr, Radarr,
Lidarr, Whisparr, and Readarr, plus a working library-import path for each of those apps. Turned
out library import already existed for Radarr/Sonarr/Lidarr/Readarr (`services/starrImport.ts`) —
only Whisparr was missing — and Custom Format translation already existed too, reused from the
TRaSH-Guides importer (its condition-group shape is exactly a Radarr/Sonarr CustomFormat export,
which is what TRaSH itself mirrors). Quality Profile import didn't exist for any app.

- **Whisparr library import** (Settings → each Adult library page → "Import from Whisparr"): its
  current (V3/"Eros") API is a flat Movie/Scene list with no season/episode structure of its own —
  each item carries its Studio denormalized on itself rather than nesting the way Sonarr nests
  episodes under a series. Scenes are grouped by studio into one AoNarr "show" per studio, numbered
  season 1 + sequentially (the same synthesized-numbering fallback a locally-scanned Adult folder
  with no real episode numbers already uses); a scene with no studio at all becomes its own
  single-episode show.
- **Quality Profile import** (Settings → Quality → "Import from Radarr/Sonarr/Whisparr"): fetches a
  live instance's quality profiles, maps each one's allowed qualities/cutoff onto AoNarr's own
  `qualities` table by name (a quality with no AoNarr equivalent — Radarr's `Raw-HD`, for instance —
  is left out of that profile rather than failing the whole import), and carries over per-format
  scores for any of its formats that already exist in AoNarr under the same name. A profile whose
  cutoff is a Radarr/Sonarr "quality group" (an admin-picked bundle with its own arbitrary name, not
  a real quality) falls back to the highest-ranked quality it could actually map. Preview-then-pick,
  not an unconditional sync — a live instance's profiles are rarely all wanted.
- **Custom Format live import** (Settings → Custom Formats → "Import from a live Radarr/Sonarr/
  Whisparr instance"): same preview-then-pick flow, pulling straight from `GET .../customformat`
  instead of requiring a copy-pasted export or a TRaSH-Guides sync.
- **Lidarr and Readarr are deliberately left out of both Quality Profile and Custom Format import.**
  Their quality tiers are audio/ebook format names (Lidarr: `MP3-320`, `FLAC`, `ALAC`, ...) or (for
  the now-officially-retired Readarr) presumably ebook/audiobook formats — nothing in AoNarr's own
  video-resolution-based `qualities` table has an equivalent, so an import would either create an
  empty, unusable profile or need to invent a whole second quality vocabulary AoNarr doesn't have.
  Their library import (artists/albums, authors/books) is unaffected — that never depended on the
  quality-profile model at all.

## Round 344 — Flatten Course/Adult/ROM/Online Videos browsing, remove "Ungrouped"

Requested: each Course folder should be one course, browsed as a flat list — not nested under
extra Site/Creator "folder" tiles — with no "N item(s) haven't been matched to a group yet"
Ungrouped nag, and the same for Adult/ROMs/anything else that had it. Confirmed a course folder was
already correctly treated as exactly one course with Season NN/episode structure (the scanner's
folder-depth logic never used `groupLevels` at all — that's a completely separate, purely
admin-curated browse taxonomy layered on top via `group_id`, unrelated to how items are actually
scanned from disk), so the only real change needed was removing that taxonomy:

- Removed `groupLevels` from all four types that had it — Courses (`site`/`creator`), Adult
  (`site`/`maker`/`series`), Online Videos (`site`), and ROMs (`system`/`maker`). Since the
  Ungrouped tab/banner and the grouped tile-browse view are both driven entirely by whether a
  type's `groupLevels` is non-empty, every one of these now goes straight to its own flat item
  grid — the exact same experience Movies/TV/Music/etc. already have — with zero frontend code
  changes needed.
- ROM's grouping was the one exception worth calling out: System/Maker groups there were the only
  ones actually auto-populated (from RAWG/IGDB/etc. platform data during Refresh, never from folder
  structure), not a manual admin chore like the other three. Removed alongside the others per an
  explicit choice to keep all four consistent — the auto-assignment code (and its now-dead
  `findOrCreateLibraryGroup` helper) is removed too, since it would otherwise keep doing a real
  provider lookup on every ROM refresh for a `group_id` nothing displays anymore.
- The underlying `library_groups` table, its CRUD routes, and the frontend's generic grouped-browse
  code are all left intact and untouched — genuinely dead for every type today, but available
  as-is for a future type that wants this kind of manual multi-level taxonomy again.

## Round 343 — Local poster/backdrop artwork (Kodi poster.jpg/fanart.jpg convention)

Sparked by a peer session generating custom cover art for ~416 restructured Course folders and
asking whether a `<thumb>` tag naming a local file would actually display — it wouldn't have:
`posterUrl`/`backdropUrl` were rendered directly as `<img src>`/CSS `background-image` in the
browser, which can't resolve a bare local filesystem path at all, and AoNarr had no route to serve
one over HTTP either. Rather than leave that a dead end, added real local-artwork support:

- **Kodi's own convention** — a bare `poster.jpg`/`folder.jpg` (poster) or `fanart.jpg`/
  `backdrop.jpg` (backdrop) sitting next to a `movie.nfo`/`tvshow.nfo`/`artist.nfo`/`album.nfo` is
  picked up automatically, no NFO edits required. A `<thumb>` value that's a relative filename
  (rather than a real URL) is also resolved locally, and — since it's a more explicit signal —
  takes priority over the bare-file convention when both exist. A *working* remote `<thumb>` URL is
  never overridden by a same-folder `poster.jpg` some other tool happened to leave behind; this only
  fills in the cases Round 340 couldn't handle at all (a relative path, or no `<thumb>` at all).
  Applies to every `kodi-video`/`kodi-music` type (Movies, TV Shows, Anime, Sports, Sports PPV,
  Courses, Adult, Music) — Comics/Manga/Books/Audiobooks are out of scope for this round.
- New `GET /api/media/local-artwork/:token` route streams the resolved local file — looked up by
  its own opaque per-item token (same "capability URL" pattern `/api/share/:token` already uses,
  and exempt from the normal API-key/session auth for the same reason: an `<img src>` can't carry
  either), so `poster_url`/`backdrop_url` just point at it like any other URL and every existing
  page that already renders those needed zero changes. The token is stable across repeated
  scans/refreshes of the same item rather than rotating every time.
- Refresh re-checks a sidecar's local artwork the same as everything else Round 341 already made it
  re-check, and a manual "pick a different poster" or "Different Match" now clears any stale
  local-artwork pointer it's replacing instead of leaving it orphaned.
- Bulk NFO/Calibre-OPF export (the .zip download buttons) now embeds the actual local image bytes
  for a local-artwork item instead of trying to re-fetch AoNarr's own internal route URL, which
  can't be resolved outside a browser — previously would have silently produced no poster.jpg at
  all for these. Re-exporting an item's own `.nfo` also no longer writes that internal route URL
  back into the `<thumb>` tag, which would have been meaningless to anything reading the file later.

## Round 342 — Fix missing nginx logs in the combined (Unraid CA) image

Reported: "no logs being reported in container logs of unraid." Traced to the `combined` image
(the single-container nginx+node build meant for Unraid CA deployments) installing nginx from
Debian's own `apt` package rather than the official nginx Docker image — Debian's package logs to
real files under `/var/log/nginx` by default, which never reach `docker logs`/Unraid's log viewer,
unlike the official nginx image (used by AoNarr's own split `web` image), which symlinks those
files to stdout/stderr at build time. Every nginx-side request and error was completely invisible
in Unraid's UI, even though the Node app's own log lines showed up fine right next to the gap.
Fixed by symlinking `access.log`/`error.log` to `/dev/stdout`/`/dev/stderr` the same way the
official image does. Live-verified: rebuilt the image, confirmed the symlinks, and watched real
`GET` request lines land in `docker logs` that were previously silent. Dockerfile-only change — no
app code affected, split `server`/`web` images were never affected (the `web` image was already
using the official nginx base, confirmed unaffected before shipping this fix).

## Round 341 — Fix incomplete NFO sync (Round 340 follow-up)

Reported after Round 340 shipped: "the reader of nfo is not updating all the information ... seems
to be only updating the poster." Live-reproduced against a real Course and Series fixture and found
three real gaps, all in `libraryScan.ts`:

- **Per-episode sidecar data was silently discarded.** A per-episode `<basename>.nfo`'s `<plot>`
  was parsed but never written anywhere — only its `<title>` made it into the episode row, both on
  first import and (for an already-tracked episode whose file path changed) on a later rescan.
- **Refresh never revisited an already-downloaded episode's own sidecar at all**, for *every*
  episodic type — Scan & Import only ever walks paths it doesn't already know about (so an admin
  editing an episode's `.nfo` *after* the episode had already been scanned had no way to get that
  change into AoNarr short of deleting and re-adding it), and Refresh's own provider-driven episode
  sync (`syncMissingChildren`) only ever pulls a *new* episode's title/overview from a metadata
  provider, never re-reads a file already on disk. Refresh now makes a dedicated pass over every
  already-downloaded episode of a kodi-video episodic show (Series/Anime/Sports/Course/Adult),
  independent of the file-walk, and applies its own `.nfo` if one exists — this is the fix behind
  the reported symptom: editing a lesson/episode's `.nfo` and hitting Refresh now actually updates
  that episode's title and overview, not just the show's own poster.
- **A new show's or artist's first-scan enrichment silently dropped content rating and genres** —
  only overview/poster/year/external_ids were written on creation; a follow-up Refresh happened to
  paper over this for the show/artist itself (its own update path already included every field), but
  the first scan alone under-enriched a brand-new item. Fixed to match the same full field set every
  other enrichment-on-creation path already writes.

## Round 340 — Automatic sidecar-metadata matching (NFO / ComicInfo.xml / OPF)

The scanner never read a local metadata sidecar for *any* library before this — `.nfo` parsing
existed, but was wired to exactly one place, the manual "Add Media" prefill button. Scan and
Refresh now check for one first, for every library type that has a real sidecar convention, before
falling back to today's filename/folder guessing — and always prefer it over a plain title search
when present.

- **Kodi-convention NFO/XML** for Movies, TV Shows, Anime, Sports, Sports PPV, Courses, Adult, and
  Music: `movie.nfo`/`<basename>.nfo` next to a single-shape file; `tvshow.nfo` in the show folder
  (season-folder aware — works whether episodes sit directly in the show folder or under their own
  `Season NN` subfolder) plus a per-episode `<basename>.nfo` for season/episode/title; `artist.nfo`
  and `album.nfo` for Music. Notably applies to Courses and Adult too, which have no metadata
  provider at all today (no viable public API for either) — previously these two library types
  could *never* get an overview/poster from anything but a filename guess; a `tvshow.nfo` now
  enriches them the same way a provider match enriches everything else.
- **ComicInfo.xml** for Comics/Manga — checked inside the `.cbz` first (the real-world convention,
  reusing the same `adm-zip` dependency `comicImageConvert.ts` already uses to look inside a
  comic archive), falling back to an external sidecar file next to it otherwise. Its `<Series>`
  tag corrects the series/parent title, distinct from `<Title>`/`<Number>`, the issue's own.
- **Calibre's metadata.opf** for Books/Audiobooks.
- When a sidecar carries a provider id (a Kodi `<uniqueid>`, a Calibre ISBN/Goodreads identifier),
  fetches the full live record for it — richer overview/poster/genres/content rating than the bare
  sidecar fields — falling back to the sidecar's own fields directly (fully offline-capable) when
  there's no id, its provider isn't lookup-capable, or the live call fails.
- Fixed a real, previously-unreachable bug in the existing `.nfo` parser while extending it: a
  genuine Kodi `artist.nfo`'s title field is `<name>`, not `<title>` — every real artist.nfo parsed
  to a null title before this.
- Left out (no real-world sidecar convention exists for these, flagged rather than silently
  dropped): ROMs, Online Videos, Podcasts.

## Round 339 — Actually fix the toolbar wrap this time

Round 338's toolbar fix ("Filters" dropdown consolidation) only helped at very wide desktop
widths — reported back as still broken after updating, and reproduced live at a completely
ordinary 1366px laptop width across several library types, not an edge case. The real cause
wasn't insufficient width reduction — it was two nested `flex-wrap` containers (the toolbar's own
left/right split, and each side's own internal wrapping): once a side wrapped onto its own line,
the browser computed its *available* width as only about two-thirds of the row's actual free
space, so it kept wrapping its own children internally long before genuinely running out of room,
even at 1600px. Confirmed by measuring real layout rects live rather than guessing from a
screenshot at one width.

- **Fixed properly**: flattened the toolbar into a single flex-wrap row instead of nesting two
  independent ones — every left/right control is now a direct sibling, so there's only one
  layout pass deciding what fits, and it correctly uses the entire row. The previous
  left-actions/right-controls visual split (right side flush to the edge when there's room to
  share a line) is preserved with a single `margin-left: auto` instead of the two-group
  `justify-content: space-between` that caused it, which also fixes a second thing that
  fix would have caused on toolbars with nothing on the right (Indexers, Import Lists) — the
  left-side buttons themselves spreading out across the whole row instead of staying clustered.
- **The Theme/Layout toggle buttons weren't actually missing** (reported as "not present" on the
  top-bar nav layout) — reproduced at the exact reported config (top nav, 1920x1080) and found
  them present, clickable, and correctly typed as `<button>`s, but with zero padding sitting
  directly against their padded `<a>` neighbors (Enable notifications, Log out) with only a 4px
  gap between everything — visually indistinguishable from one run-on line of body text instead of
  separate controls, easy to miss even though nothing was actually broken. Gave them the same
  padding/hover treatment as their sibling links in each context (block rows in the sidebar footer,
  padded pills in the topbar) instead of a bare reset to zero padding everywhere.
  Verified live at 1366px, 1600px, and mobile width, and confirmed pages with a short/empty right
  side aren't affected.

## Round 338 — Multi-episode files, genres, toolbar fix, a real profile page, and a popup audit

Nine more requests, three of which referenced Round 337 work as allegedly still broken. Two of
those turned out to be a stale browser tab still running the pre-Round-337 JS bundle from before
the container was last updated (confirmed by asking exactly what happened on click — "still plain
text, not a button" is the fingerprint of the old markup, not a code defect); the third was an
already-understood, by-design behavior (bulk-imported ROM/Adult/Course items sitting in
"Ungrouped" until matched) that just needed better in-app visibility, not a behavior change.

- **Multi-episode files** ("S01E01-E02", Sonarr-style) now import correctly. The filename parser
  already extracted every episode number in a range/chain — the actual bug was three separate
  downstream consumers (the directory scan, the queue importer's single-file path, and its
  season-pack path) each keeping only the first one. A single multi-episode file now marks every
  episode it covers as Downloaded, with the same file path on each row, and "Organize & Rename" no
  longer double-moves a file shared by two episode rows (a second bug found while fixing the first).
- **Genres** are now a first-class field: auto-pulled from TMDB (movie/series) and RAWG (ROMs) with
  no extra API calls needed (both already returned a genre list this codebase was previously
  discarding), shown as a pill on the backdrop, and filterable/sortable/searchable on the library
  page — typing a genre name into the library search box now finds it, same as a title match would.
- **Fixed the library toolbar wrapping onto two rows** on real desktop widths, not just narrow
  windows — confirmed live via the page's own layout measurements before attempting a fix. The
  accumulated width of every dropdown this page can show genuinely doesn't fit one row once a
  library has tags, content ratings, and now genres too. Consolidated Status/Tags/Content
  rating/Genre/System into one "Filters" popup (the same pattern already used for Poster info/
  Columns), which reliably keeps the row on one line instead of trying to force-fit an
  ever-growing set of always-visible selects.
- **The Account page is now an actual full-page profile**, not a settings-form box: a banner,
  a large avatar overlapping it, the display name/username/bio front and center, and Profile/
  Security tabs — defaults to a read-only profile view with an Edit-profile toggle for the form
  fields, instead of always showing the edit form.
- **Recycle Bin gained Restore All / Delete All**, scoped to each media type's own section (an
  admin restoring Movies doesn't also restore TV Shows) rather than one global action.
- **Audited every settings popup for missing Save/Test/Delete affordances.** Root folders (and
  ~15 similar tiles) already auto-save on every change but never confirmed it — added a save
  toast. Fixed the real gaps: Indexers and Import Lists had no way to edit an existing entry at all
  (delete-and-recreate was the only fix for a typo'd URL); Quality Profiles' Name/Allowed
  qualities/Cutoff and Custom Formats' Name/Conditions were hardcoded read-only even though their
  save routes already accepted edits; Subtitle Providers had no edit route at all; Media Server
  Sync had no way to verify a URL/token actually work before something depends on them silently
  failing later — each of these now has the affordance it was missing.
- Clarified (not changed): ROMs/Adult/Course items only ever join a System/Site group once a
  metadata match succeeds — a bulk import that's never been individually refreshed/matched stays
  in "Ungrouped" by design. Added a dismissible banner at the top of the library page pointing
  admins at Refresh/Match All Providers (or Convert to Episodic, for old-format Course/Adult items)
  instead of leaving a large silent bucket with no obvious next step.

## Round 337 — CI fix, Jump-to-letter fix, content ratings, backups, profile page, and UI polish

Ten more requests plus a recurring CI failure. Two items turned out smaller than they looked once
investigated (Backups was ~90% already built; the Media Analyzer's backend was already running
fully in the background), and one turned into a real, previously-invisible bug — the reported
symptom ("only # and A light up") was the exact fingerprint needed to find it after an initial
smaller-scale repro came back clean.

- **Fixed the GitHub Actions "Server tests / test-postgres" job**, broken on every push. Pulled the
  actual failure logs and found three real root causes, none of them flaky: (1) Postgres returns
  `BIGINT`/`COUNT()`/`SUM()` results as strings, not numbers — 48 call sites across the codebase
  relied on them being numbers; fixed once, at the driver level, by registering a bigint type
  parser, rather than patching each call site (this was also a real, currently-live bug for anyone
  running the Postgres driver in production, not just a test artifact). (2) A release-group
  success/failure counter's `ON CONFLICT DO UPDATE SET x = x + 1` was ambiguous under Postgres
  (`x` matches both the existing row and the proposed one) — every other such query in the
  codebase already qualifies this correctly; this one didn't. (3) A test file hardcoded raw SQLite
  `datetime()` syntax instead of using this codebase's own existing dialect-aware helper. Verified
  by running the full suite against a real local Postgres container before pushing, not just
  trusting the fix in theory.
- **The Library page's A-Z "jump to letter" sidebar was broken on any library over 500 items** —
  confirmed via the reported symptom (only "#" and "A" enabled): the sidebar's index fetch asked
  for "every matching item" via a very large `limit`, which the shared list endpoint's safety cap
  silently truncated to 500, permanently graying out every letter past wherever the 500th item fell
  alphabetically. Fixed with a new lightweight endpoint built specifically for "every id+title, no
  cap" rather than reusing the capped one.
- **Content rating now shows as a badge on the backdrop** (Radarr-style) instead of a separate row
  further down the page, and is **auto-populated from TMDB** (movie certifications, and a new call
  for TV content ratings that didn't exist before) on match and refresh — previously this field was
  manual-entry-only, end to end, with no automated path ever writing to it.
- **Backups gained a Radarr-style file list.** The backend (scheduled backups, S3 upload, dialect-
  aware backup/restore) already existed and was more capable than expected — the actual gap was no
  way to see what backup files already exist on disk. "Backup Now" now also saves into the
  configured backup directory (in addition to still downloading immediately), and a new table lists
  every existing backup with Download/Delete actions per row.
- **The Account page is now an actual profile**, not just "Signed in as X": a photo (upload +
  displayed in the nav sidebar/topbar next to the Account link), a display name, a bio, and a
  free-form list of links — self-service, alongside the existing two-factor setup.
- **Media Analyzer**: traced the reported "stops when I navigate away" — the backend was already a
  true fire-and-forget background job, unaffected by which page is open. The actual gap was 100%
  frontend: progress polling lived in the page component itself and was torn down on navigation.
  Moved it into an app-level context (mounted once, like the auth context) so a run's progress and
  its completion notice now survive navigating anywhere in the app.
- Collapsed the Dashboard's Health Issues box by default (with an expand toggle) — a household with
  several unreachable indexers could produce a list long enough to dominate the whole dashboard.
- The Theme and Layout-width toggles in the sidebar/topbar footer are now real `<button>`s instead
  of plain clickable text.
- The Dashboard's "Customize" button and the nav's gear/"Customize sections" control now open as
  popups (matching the Metadata Sources/Export & Bulk pattern) instead of an inline panel and a
  dropdown respectively — the same underlying nav-customize feature had two different, inconsistent
  treatments depending on which entry point (sidebar link vs. topbar button) opened it.
- Fixed episode-table column misalignment on multi-season pages: each season renders its own table,
  which previously auto-sized its own columns independently, so Title/Air date/etc. visibly drifted
  out of line between seasons whenever one season's episode titles were longer than another's.

## Round 336 — Library page fixes/polish, crash hardening, MediaDetail overhaul, smarter search, multi-date releases

A large batch of 14 user-reported bugs/requests across the Library list page, media detail page,
config modals, bulk delete, and core search/matching/release-date logic, shipped together. Two
turned out to be confirmed systemic bugs (not isolated cosmetic issues) found via code audit;
two more genuine bugs — one causing real data corruption — were only caught during live browser
verification after the initial fix.

- **Bulk-delete crash hardening.** `POST /media/bulk/delete` now wraps its whole loop in a single
  `db.transaction(...)` and reuses the existing single-item `deleteMediaItemCascade()` helper
  instead of duplicating its logic, so a mid-batch failure can no longer leave the library half
  deleted. The app also gained its first-ever React error boundary
  (`web/src/components/ErrorBoundary.tsx`, wrapping the whole route tree in `main.tsx`) so any
  single uncaught render error shows a "Something went wrong" screen with a Reload button instead
  of a permanently blank page needing a container restart. The Library page's own bulk-delete
  handler now also catches and reports request failures instead of leaving the selection silently
  stale.
- **Library dropdown/filter state was leaking across library types**, confirmed as a systemic bug:
  view mode, poster size, visible columns, and poster-info fields were all persisted to *global*
  (non-per-type) localStorage keys with no reset when switching library types, and tag/system
  filters and the search box weren't reset either. All of these are now namespaced per type and
  reset on type change, matching the status/sort/page-size pattern already in place. Live testing
  after the initial fix caught a second, deeper bug in that *existing* pattern itself: because the
  "write on value change" and "read on type change" effects both listed the shared `type` value as
  a dependency, switching library types made the write-effects fire first using the outgoing type's
  stale state, corrupting the new type's storage key before the reset effect could even run. Fixed
  by removing `type` from every persistence effect's dependency list.
- **Config popups could render behind the config modal that opened them** — confirmed systemic:
  `ConfirmModal`/`PromptModal` share the base `Modal` component's hardcoded z-index with every other
  page modal, so plain DOM order decided which one visually won. `Modal` now accepts an optional
  `zIndex` prop, and confirm/prompt dialogs always render above whatever opened them (e.g. deleting
  a root folder from inside its own settings modal).
- **Metadata Sources fetch/apply errors** now render inside that popup itself instead of a
  page-level banner hidden behind it.
- **Export & Bulk** is now a toolbar button that opens a popup, matching the Metadata Sources
  pattern, instead of a plain dropdown menu.
- **Toolbar layout**: the Library page's view/sort/filter controls and the search box now stay
  visually grouped as one row, with a new save-icon button (replacing the old checkmark) and the
  search box anchored to the right edge.
- Media page: root folder now shows its configured **name** (falling back to its path only when
  unnamed), with the full path still available as a hover tooltip.
- Episode table gained a toggle-able **File Path column**, and a **season Expand All/Collapse All**
  button next to the existing List/Tile toggle — both Sonarr-style conveniences that didn't exist
  before.
- **Cast** and **Alternate Titles** switched from an unbounded horizontal scroll strip / run-on line
  to a capped, wrapping list with a "+N more" button to expand the full list.
- Single-file media pages (movies, ROMs, PPVs, legacy adult/course items) replaced the old top path
  pill and separate "File details" toggle panel with a single **Radarr-style Files table**
  (relative path, size, quality, video, audio, subtitles, date added) — everything that panel used
  to show now lives in one place.
- The "Type / status" pill was always showing "Unknown" because nothing ever wrote a real value
  into it. Movie/series metadata lookups now capture the provider's real status (Released, Ended,
  Continuing, etc.) and it's written back into the library on scan/refresh.
- **Search/grab matching** now also considers year and external ids (IMDb/TMDB), not just title
  text: indexer queries append `imdbid`/`tmdbid` params when known, Torznab responses' own
  `imdb`/`tmdbid` attributes and title-embedded IMDb ids are parsed, and a confirmed-identity or
  matching-year release now wins as a tiebreaker among releases already tied on format score —
  purely an additional ranking signal, never a hard filter, so nothing is ever excluded from
  results.
- **Release dates** now capture the actual day, not just the year, and movies additionally get real
  TMDB digital/physical release dates (previously only approximated as "release date + 90 days").
  The "released" availability gate now prefers a real digital/physical date when TMDB has one.

## Round 335 — Courses/Adult work like TV Shows (folder = show, files = episodes); ROM filename cleanup + system filter

Two related asks: Courses and Adult should organize like TV Shows instead of one flat file/list per
item — a folder becomes a *show* whose title comes straight from the folder name, and the files
inside it become that show's *episodes*, so a complete lesson/clip list survives even with no
"season" markers in the filenames at all. Separately, ROMs should match Movies' filename-to-title
behavior, plus gain a "filter by game system" dropdown.

- `services/mediaTypes.ts`: `course` and `adult` switch from `shape: "collection"`/`"single"` to
  `shape: "episodic"` — this already works with zero metadata provider (proven: it's exactly how
  `series` scanning already behaves whenever a provider search fails, and `course` already ran
  provider-less under its old shape). A new `sequentialEpisodeFallback` flag (set only on these two
  types) lets a file with no `S01E02`-style marker still become an episode — season defaults to 1,
  episode number comes from a leading number in the filename ("01 - Intro.mp4") or otherwise
  appends after the show's current highest episode — instead of being skipped, which is what every
  *other* episodic type (series/anime/sports) still does unchanged.
- `services/libraryScan.ts`: the "Refresh" button now re-scans a course/adult show's own folder for
  new files (there's no episode-listing provider for either that could ever backfill one the way
  `series`/tmdb already does), so it actually picks up new lessons/clips instead of silently no-op'ing
  past the overview/poster re-pull. ThePornDB stays enrichment-only for Adult, the same role
  TMDB plays for Series — the existing "never overwrite an already-matched item's title" logic
  already covers this with no new code.
- **Existing data isn't touched automatically.** Every existing Adult/Course item is stamped with a
  new `media_items.legacy_shape` column the moment it's added (a one-time, non-repeatable migration)
  and keeps rendering/behaving exactly as it did before — right down to Missing/Downloaded status,
  child counts, and the Wanted/calendar lists — until an admin clicks the new **"Convert to
  Episodic"** library-toolbar button, which restructures every item of that type in one transaction
  (course's `sub_items` become `episodes`; an adult item's own file becomes its one episode) and
  clears the stamp. A brand new library with nothing to convert never shows the button at all.
- ROMs: a new `cleanRomTitle()` strips No-Intro/GoodTools/TOSEC-style filename cruft (region tags
  like "(USA)", revision tags like "(Rev 1)", language lists, "[!]"/translation-patch tags) before
  it becomes the searchable game title — matching stays fully on-demand (Refresh / Add Media), same
  as Movies, never automatic at scan time. A ROM now also auto-assigns itself a System/Maker
  `library_groups` entry the moment a *real* match returns platform data (via Refresh, not just the
  guided Add Media flow) — never guessed from the file extension, and never overwriting a group set
  by hand. A new "filter by game system" dropdown lives on a new flat "Browse all ROMs" page
  (`/library/rom/all`), since the System → Maker tile-browsing tree has nowhere to filter across an
  entire System's Makers at once; unmatched ROMs group under "Unknown system".

Verified: `tsc --noEmit` clean on both projects; full server suite passes including new coverage for
the folder-as-show fallback numbering, the legacy-shape scan-time/status/child-count carve-outs, the
Convert to Episodic transaction, `cleanRomTitle`, the new recursive System filter, and the ROM
group-auto-assignment. Live-verified against the running stack: scanned a real course folder with no
provider configured and confirmed the folder name became the show title with sequentially-numbered
episodes; ran Convert to Episodic against real pre-existing data; matched a real messy ROM filename
and confirmed the cleaned title drove the search and a System group got created automatically.

## Round 334 — fix Refresh reverting a corrected match back to the wrong one

User-reported bug: fix a wrong match via "Different Match," then click "Refresh," and the item's
overview/poster/backdrop/rating silently reverted to the *old, wrong* show's data — even though the
title text and `external_ids` (correctly updated by the rematch) never changed, so it looked like
nothing had happened until you looked past the title.

Root cause: `services/libraryScan.ts`'s `refreshOneItem` (shared by the per-item "Refresh" button and
the whole-library Refresh job) always re-ran a plain **title search** to get its data, regardless of
whether the item already had a real provider id — it only used that id to decide whether to skip
overwriting `title`/`external_ids` themselves. For a shared/ambiguous title (a US vs. UK version, a
reboot, a franchise entry), that fresh title search can easily rank a completely different show
first. Since only the title/id columns were protected, every *other* field (overview, poster,
backdrop, rating, runtime, release date) still got silently overwritten with that wrong show's data.

- `refreshOneItem` now looks an already-matched item up **by its own stored id** via
  `fetchByExternalId` (tmdb, tvdb, tvmaze, trakt, anilist, imdb, igdb, rawg — tried in that order,
  whichever the item actually has) instead of re-searching by title. Falls back to the existing
  title search only for an item that's never been matched at all (a Scan & Import guess), or when
  none of the item's ids are ones `fetchByExternalId` supports.
- Extended `fetchByExternalId` (`services/metadata.ts`) with `tvmaze` and `trakt` cases (a real
  by-id show lookup for each) — it previously only covered tmdb/imdb/tvdb/anilist/igdb/rawg/isbn/
  youtubePlaylist, which would have left a TVMaze- or Trakt-matched series still exposed to this
  exact bug even after the `refreshOneItem` fix.

Verified: `tsc --noEmit` clean; full server suite (90 files / 1400 tests) passes, including two new
regression tests (an already-matched item is looked up by id and never even calls `searchMetadata`;
an item whose only id isn't one `fetchByExternalId` supports correctly falls back to a title search)
and new `fetchByExternalId` coverage for the tvmaze/trakt cases. Live-verified end to end against the
real running server using TVMaze (the one provider with a free, keyless API, so genuinely reachable
here): created an item, "rematched" it to the real UK version of The Office (tvmaze id 1292, a
deliberately different show from the US version TVMaze's own title search for "The Office" ranks
first), then hit Refresh — the item's overview correctly stayed the UK show's real content (mentions
Slough, Gareth, David Brent, the BBC) and its poster updated to the UK show's real artwork, instead
of drifting to the US version the way a plain title re-search would have. Fixture cleaned up
afterward.

## Round 333 — multi-provider metadata matching, episode-union merge, external-id dedup, provider icons

The root cause of duplicate TV shows from mixing metadata providers: `services/duplicateCheck.ts`
matched purely on normalized title + year, never on external provider IDs — so the same show added
once via a provider reporting one year (a regional premiere) and again via a provider reporting
another (a US air date), or with a slightly different title, sailed straight past the duplicate
warning. Fixing that properly meant also fixing why a show would end up matched to only one
provider's episode list in the first place, since providers disagree on whether Season 0/specials
even exist (TMDB and Trakt exclude them, TVDB and TVMaze include them, AniList has no seasons at
all) — so this round is one connected piece of work, not several small ones.

- **`services/duplicateCheck.ts`**: `findPossibleDuplicates`/`findDuplicateGroups` now also flag a
  match when two items share an external provider id, in addition to the existing title/year check
  — still just a warning, still overridable via `confirmDuplicate`, same as before. The Duplicates
  page now also groups already-existing rows that share an id even when their title/year drifted,
  surfacing duplicates the old title-only sweep could never find.
- **`services/metadata.ts`**: `fetchSeriesEpisodesFor`'s one-provider-by-priority dispatch is now
  built from a shared lookup table, and a new `fetchSeriesEpisodesForProvider(provider, id)` lets
  code fetch a *specific* provider's episode list instead of whichever one wins priority order.
- **`services/libraryScan.ts`**: extracted the existing non-destructive "insert if missing, patch a
  placeholder title" episode merge into a reusable `mergeEpisodesIntoItem`. New
  `matchAdditionalProviders(mediaItemId)` searches every other configured provider for an item's
  type, merges in each hit's id (never overwriting one the item already has) and full result
  (staged for the Metadata Sources popup below), and — for an episodic item — merges in any episode
  that provider lists but this item doesn't have yet (e.g. TVDB's Season 0 specials on a
  TMDB-matched show). Runs providers sequentially, not in parallel (several of these APIs expect
  roughly one request per second). New `matchProvidersForLibrary(type)` runs it across a whole
  library — the one-time backfill for shows imported before this feature existed.
- **Wired up in three places**: `POST /metadata/import` (the real Add Media flow) now fires
  `matchAdditionalProviders` as a background follow-up right after a new item is created — "once a
  show is imported, it tries to match every metadata source" — without slowing the add itself down.
  The existing manual "Fetch from X" button (`POST /media/:id/metadata/fetch`) now also merges in
  that provider's missing episodes immediately (non-destructive, so no source-picking needed the
  way the 4-field merge table requires) and folds its id into `external_ids`. A new "Match All
  Providers" library-wide action (`POST /media/match-providers`, admin-only, fire-and-forget same
  as Scan & Import/Refresh) is the one-time backfill for already-imported shows.
- **UI**: the always-inline "Additional Metadata Sources" section on a media page moved behind a
  new toolbar button (globe icon) opening it in a popup instead, with a "+N episodes added" toast
  when a fetch merges in missing episodes. A new "Match All Providers" toolbar button appears on
  each Library page (gated on the type actually having 2+ providers). The external-id pills
  (`tmdb: 590223`, browser-default blue link text) are now small colored per-provider icon badges
  instead — new `web/src/components/ProviderIcons.tsx` covers all 27 providers this app supports.
- **Stats**: `Dashboard.tsx`/`LibraryHome.tsx` only ever fetched their counts/sizes once on mount,
  so a tab left open never updated on its own — both now also refresh every 60s. `LibraryType.tsx`'s
  Scan & Import/Refresh/Match All Providers actions are fire-and-forget background jobs that never
  refetched the list/stats once the job actually finished — added a bounded poll (every 5s for up
  to a minute) after starting one. (`routes/dashboard.ts`'s `library-sizes` key-parsing, suspected
  as a second stats bug going in, turned out to already correctly use a real `` delimiter on
  both the write and read side — invisible to a plain-text read, the exact class of false positive
  Round 323 warned about. No change needed there; confirmed by hexdumping the actual bytes before
  touching anything.)
- **Two bugs found live while verifying the above, fixed in the same pass**: `POST /media/:id/metadata/fetch`
  built its merged `external_ids` via `{ ...item.externalIds }` — since the mapper leaves
  `externalIds` as a raw JSON *string* (unlike `extraMetadata`, which it already parses), spreading
  it fanned the string out into one object key per character instead of parsing it. And
  `POST /metadata/import`/`POST /media`/`PATCH /media/:id` all bound `monitored`/`protected` to
  SQLite as a raw JS boolean when a caller (anything sending idiomatic JSON `true`/`false` — an MCP
  tool, a script) didn't pre-convert it to 0/1 — better-sqlite3 can't bind a boolean at all, so any
  such call crashed with a 500. Both were caught by live-testing this round's new endpoints with a
  real curl payload rather than only through the frontend, which always happened to send 0/1
  already.

Verified: `tsc --noEmit` clean on both projects; full server suite (90 files / 1396 tests, one new
file) passes — new coverage for the external-id dedup match, `fetchSeriesEpisodesForProvider`,
`mergeEpisodesIntoItem`, `matchAdditionalProviders`/`matchProvidersForLibrary`, and the
`externalIds`-corruption regression. Live-verified end to end against the real running server using
TVMaze (the one metadata provider with a free, keyless API, so genuinely reachable in this
environment): imported "Chernobyl" for real, confirmed the background all-providers match fired and
logged its outcome; deleted 2 of its 5 real episodes and confirmed a manual "Fetch from tvmaze"
correctly re-added exactly those 2 (`episodesAdded: 2`) without duplicating or disturbing the other
3; confirmed the dedup fix live — importing a second item titled completely differently but sharing
the same `tvmaze` id correctly triggered the duplicate warning; drove the real Settings/Library UI
in the browser (Metadata Sources popup open → fetch → merge table renders correctly; external-id
pills render provider-icon badges instead of blue text; "Match All Providers" button present on a
Library page and confirmed via direct API call against real (pre-existing) library items). All
fixtures cleaned up afterward.

## Round 332 — infra sweep: db layer, migrations, MCP server, app bootstrap, web api client

Closes out the codebase-wide bug-audit sweep by covering everything that wasn't routes/services/
pages/components: `server/src/db/` (mappers, the SQLite client + migrations, Postgres schema),
`server/src/middleware/`, `server/src/mcp/server.ts`, `server/src/app.ts`, and the frontend's
`web/src/api/client.ts`/hooks/utils/context. 14 confirmed and fixed:

- `episodeFromRow`/`subItemFromRow` returned `size_bytes` unconverted, unlike every other mapper in
  the file — under the Postgres driver this column comes back as a string (BIGINT), so summing an
  episode's size (`MediaDetail.tsx`'s season-size total) silently string-concatenated instead of
  adding. Now wrapped in `Number(...)` like `mediaItemFromRow`/`queueItemFromRow` already are.
- Three of `client.ts`'s CHECK-constraint-removal/dangling-reference-repair table rebuilds
  (`indexers`, `queue`, `import_lists`) were missing columns that `ensureColumn` already adds to the
  live table before the rebuild runs — on a database that still needed one of these migrations, the
  rebuild's `INSERT ... SELECT` would fail outright ("no such column"), crashing startup with that
  table stuck mid-migration. All three rebuilt `CREATE TABLE` statements now match the live schema.
- A comment introducing the users-FK dangling-reference repairs claimed blocklist's repair was
  "above" when it's actually further below — reworded so a future repair call doesn't get misplaced
  relative to it.
- The `add_media` MCP tool forwarded its `monitored` field as a raw JS boolean straight into
  POST /api/media, which better-sqlite3 can't bind — every call failed with a 500. Now coerced to
  1/0 first, matching `set_monitored`'s own existing convention for the identical field.
- The `grab_release` MCP tool's schema had no `indexerId` field, so an MCP-grabbed release's queue
  row always got `indexer_id = NULL` — a later "remove + blocklist" on that download couldn't scope
  the block to the offending indexer the way a web-UI grab always can. Added the field.
- `indexers.api_key` and `subtitle_providers.api_key` were the only two credential-shaped columns
  never covered by encryption-at-rest (`download_clients`/`irc_feeds`/`ai_providers` all are) —
  neither table's write path encrypted, neither table's real read paths decrypted. Added encryption
  on every write (the two routes' POST/PATCH, and the Prowlarr/Jackett sync services' own raw
  UPDATE/INSERT calls), decryption on every real read (the two mappers, plus the three raw-row
  subtitle-search call sites in `subtitles.ts`/`importer.ts`/`subtitleRescan.ts` that don't go
  through a mapper), and both tables to `app.ts`'s existing legacy-plaintext backfill sweep.
- Two stale doc comments caught making a claim the same file's own code already contradicts:
  `asyncDb.ts`'s header said appending `RETURNING id` was "safe unconditionally" right next to the
  `TABLES_WITHOUT_ID` exception list it's actually gated on; `postgresSchema.ts`'s header said only
  2 SQLite→Postgres substitutions were ever needed, ignoring its own `TYPE_MIGRATIONS` block (a
  3rd: widening 4 byte-count columns to `BIGINT` after the literal `INTEGER` translation overflowed
  on anything past ~2.1GB).
- `web/src/api/client.ts`'s `downloadFile`/`uploadRaw`/`uploadFormFile` didn't special-case a 401
  the way `request()` does — an expired session hitting a backup download/restore or CSV import
  just showed a generic error toast instead of logging the user out, leaving stale credentials in
  `localStorage` failing every subsequent call. Now all three force the same logout. `ApiError`'s
  doc comment was also stale in the same area (claimed to cover every non-2xx response, but 401
  throws a plain `Error` instead, before `ApiError` is ever constructed).
- The calendar's per-type release labels (`describeCalendarEntry`) predate two media types added in
  later rounds — `podcast` (collection-shape) and `ppv`/Sports PPV (single-shape) — so both silently
  fell back to a generic "New release"/"Release" instead of a type-specific label. Added `podcast` →
  "New episode" and `ppv` → "Movie release" (matching the near-identical `movie` type).

Verified: `tsc --noEmit` clean on both projects; full server suite (89 files / 1381 tests) passes,
with `jackettSync.test.ts`/`prowlarrSync.test.ts` updated to assert the now-encrypted `api_key`
column instead of the old plaintext-equality check. Live-verified against the running server: created
a real indexer and subtitle provider via the API with a plaintext key, confirmed the raw DB column is
ciphertext while the API response and a direct `decryptValue()` call both correctly recover the
original plaintext, and confirmed a PATCH re-encrypts a rotated key; hit the real `/subtitles/search`
route and confirmed it reached OpenSubtitles' real API using the decrypted key (a genuine HTTP 403 on
the fake test key, not a local decryption crash); drove the real Calendar page with a fixture podcast
episode and a fixture PPV release and confirmed both new labels render; and reproduced the 401 fix
live in the browser — deleted a valid session server-side, clicked "Download backup", and confirmed
it now force-logs-out to the login screen with `localStorage` cleared, instead of the old silent error
toast. `client.ts`'s three migration-rebuild fixes and the MCP `monitored`/`indexerId` fixes were
verified by code review and the passing test suite rather than live-triggered, since exercising them
needs either a pre-migration legacy database or a real MCP client, neither practical to fake safely
against this shared persistent environment.

## Round 331 — per-client download type selection for debrid clients, real TorBox Usenet support

New feature: Settings -> Download Clients now lets a **TorBox** client declare which release
protocol(s) it should be used for — Torrent/Magnet, Usenet, or both — instead of every debrid
client silently being torrent-only. Real-Debrid and AllDebrid don't get this control since neither
provider's API supports Usenet at all; TorBox is the one debrid client here that genuinely caches
both, via its own separate `/usenet/...` API namespace (mirroring `/torrents/...` almost exactly:
same `createusenetdownload`/`mylist`/`requestdl` shape, `usenet_id` in place of `torrent_id`).

- `DownloadClient` gained a `downloadTypes: string[] | null` field (new `download_types` TEXT
  column, migrated in for existing installs). Null means "unconfigured", which
  `pickClientForProtocol` treats as the historical torrent-only default so nothing changes for
  existing TorBox setups until an admin opts in.
- `pickClientForProtocol` now also considers TorBox for `usenet` releases (previously only
  SABnzbd/Blackhole), gated on the client's own `downloadTypes` — a TorBox client scoped to
  Usenet-only no longer matches a torrent release, and vice versa.
- `TorBoxAdapter.addDownload` branches on the grabbed release's protocol: a torrent release still
  goes through `/torrents/createtorrent` exactly as before; a Usenet release now goes through a new
  `/usenet/createusenetdownload` call (handing TorBox the indexer's NZB URL directly via its `link`
  param, the same "let the provider fetch it" shape AllDebrid's magnet upload already uses for an
  http(s) URL) and polls/downloads via the mirrored `/usenet/mylist`+`/usenet/requestdl` endpoints.
- The manual grab route and the scheduler's automatic grab path both now thread the release's
  protocol through to `adapter.addDownload` so TorBox knows which endpoint family to use; every
  other adapter ignores the added parameter.
- Settings -> Download Clients' TorBox form gained a "Download types" checkbox pair, and each
  TorBox tile shows which type(s) it's scoped to.

Verified: `tsc --noEmit` clean on both projects; full server suite (89 files / 1381 tests) passes,
including new coverage for `pickClientForProtocol`'s TorBox gating and `TorBoxAdapter`'s Usenet
branch. Live-verified end to end against the running server: created a TorBox client scoped to
Usenet-only via the real API, confirmed a manual torrent-protocol grab correctly refuses (no
enabled client can take it) while a usenet-protocol grab correctly routes to it and reaches
TorBox's real `/usenet/createusenetdownload` endpoint (a genuine HTTP 403 from TorBox on the fake
test API key, not a 404, confirming the endpoint path itself is correct); also drove the Settings
UI directly in the browser (add a TorBox client, toggle both checkboxes, save, confirm the tile's
"torrent + usenet" summary). All fixtures cleaned up afterward.

## Round 330 — nine bugs closing out the services layer

Finished the services-layer sweep with the ~39 remaining files under 100 lines each
(`settingsStore.ts`, `encryption.ts`, `totp.ts`, `rateLimiter.ts`, `overseerrWebhook.ts`,
`duplicates.ts`, `sceneNumbering.ts`, `libraryValidation.ts`, `jackettSync.ts`, `nfoParser.ts`,
`audit.ts`, `bootstrapAdmin.ts`, and 27 more), in small groups. Nine confirmed and fixed:

- A season-pack import wrote one summary `history` row for the whole season instead of one row
  per episode (with that episode's id and quality) the way every other import path does — every
  season-pack-imported series collapsed to the same "no episode/sub-item" grouping key in the
  repeated-imports check, so importing two different, entirely legitimate seasons of the same show
  got misreported as a repeated/duplicate import.
- The weekly scheduled TheXEM scene-numbering sync hardcoded `type IN ('series', 'anime')` —
  the only place in the codebase enumerating episodic types by a literal list instead of deriving
  them from the type registry's `shape` field — silently and permanently excluding Sports items
  from the recurring re-sync that every other scene-numbering code path (add-time sync, the manual
  per-item button) already treats Sports as eligible for.
- The library-validation report's episode query was missing the `file_path IS NOT NULL` guard its
  own sibling query (and every analogous has_file=1 query elsewhere in the codebase) already has —
  a has_file=1 episode row with a NULL path crashed the entire `/system/library-validation` request
  with a TypeError instead of just being skipped.
- The Jackett indexer sync unconditionally force-enabled an existing indexer row on every sync
  (recurring every 6 hours, and on a manual "Sync Jackett" click) — since Jackett's own API has no
  per-indexer enable/disable concept to mirror the way the parallel Prowlarr sync correctly does,
  this silently reverted an admin's "Disabled" toggle back to enabled, making that control a
  no-op for any Jackett-sourced indexer.
- Remote (S3) backup rotation decided which objects count as "a backup eligible for deletion"
  using only a file-extension check, while local rotation (and this very function's own comment)
  requires the `aonarr-backup-` filename prefix too — an S3 prefix is often shared with other
  files, and the weaker remote check could delete unrelated `.db`/`.dump` files sitting under it.
- The "Import from .nfo file" control was offered unconditionally for every media type, but the
  parser only recognizes the Kodi/Jellyfin movie/tvshow/episodedetails/album root tags (now also
  `artist`) — picking an .nfo for Comics, ROMs, Podcasts, or any of the other unsupported types
  silently prefilled nothing instead of erroring. Now hidden for types with no real NFO convention
  to parse.
- `metadata.ts` hand-copied `audit.ts`'s exported `auditActor(req)` helper inline instead of
  importing and calling it — a second copy of the same "who's the actor for this audit-log entry"
  logic that could silently drift from the original.
- A malformed-but-present `encryption.key` file (truncated, corrupted, hand-edited) silently
  triggered the same "generate and overwrite with a fresh key" fallback as a genuinely missing key
  file — permanently and silently locking an admin out of every already-encrypted credential with
  no log at the moment it happened. Now logs loudly for that specific case before regenerating.
- A typo'd or unreadable `AONARR_ADMIN_USERNAME_FILE`/`AONARR_ADMIN_PASSWORD_FILE` Docker-secrets
  path silently skipped admin-account bootstrap with zero log output, unlike the very next
  rejection check three lines later (password too short) which does log a warning.

Verified: `npx tsc --noEmit` clean in both `web/` and `server/`. Full server test suite (89 files /
1377 tests) passes with no regressions or needed updates. Live-verified against the real, rebuilt
server: the Add Media page's "Import from .nfo file" control now correctly disappears for Comics
and reappears for Music/Movies; a fixture second admin account confirmed `/metadata/import`'s
audit-log entry now shows the real actor instead of the old hardcoded "admin". The remaining fixes
(Jackett/Prowlarr-style sync, S3 rotation, the scheduled scene-numbering job, a crafted
malformed-key-file scenario) depend on external services or would risk corrupting real state in
this shared test environment to exercise live — verified by code review, structural comparison
against each fix's already-correct sibling implementation, and the passing test suite instead. All
fixtures (sessions, a second admin account, a media item, an episode) were removed afterward.

## Round 329 — forty-six bugs from a full sweep of the services layer

The largest audit pass this series: with every route file done, extended the sweep to all 44
`server/src/services/*.ts` files of 100+ lines (~16,300 lines — `metadata.ts`, `scheduler.ts`,
`importer.ts`, `downloadClient.ts`, `libraryScan.ts`, `mediaServer.ts`, `importLists.ts`,
`starrImport.ts`, `notifications.ts`, `indexerClient.ts`, `mediaAnalysis.ts`, and 33 more), each
given its own dedicated review (the 11 largest solo, the rest in small groups). Forty-six confirmed
and fixed:

**Missing metadata on auto-added items** — the recurring theme this round. Six different add
paths were each missing fields every *other* add path already includes, so an item's completeness
in the library silently depended on which feature added it:
- Trakt/TMDB import-list sync, the standalone Trakt List Sync feature, and auto-request-from-
  watch-history all skipped `overview`/`poster_url` on insert; Trakt-sourced items also never
  captured an IMDb id, permanently blanking their IMDb/RT/Metacritic ratings. Trakt's list endpoint
  also wasn't being asked for the extended data (`?extended=full`) that contains the overview.
- The Discord bot's `/request` add path dropped `release_date`/`backdrop_url`/`rating`.
- Lidarr/Readarr-imported albums and books never got a `poster_url`, despite Starr's own API
  providing one and this file already using it for the parent artist/author.

**Correctness bugs that silently produce wrong results:**
- MusicBrainz's release-group search rejects AoNarr's own multi-type album filter with HTTP 400
  (repeated `type=` params instead of one pipe-separated value) — configuring more than one album
  type (the feature's whole point) broke adding *any* new MusicBrainz artist outright.
- Anime items matched via TMDB (rather than AniList) got no Cast or Alternate Titles — the type
  dispatch for those two lookups omitted "anime" while every other TMDB dispatch in the file
  already treats it as tv-shaped.
- A `Season <N>` release title (space-separated, no "S03" abbreviation) matched as a full season
  but with no season number extracted, so it could never satisfy a request for that season.
- Automatic download retries after a failure skipped Delay Profile gating entirely (protocol
  enable/disable, per-protocol delay, bypass-if-highest-quality) — a retry could grab a
  disabled-protocol or too-recent release the identical initial search would have refused.
- The one-shot on-demand "Check Corrupt" button always deleted the file immediately, even with
  "Hold for review" turned on — it reimplemented detection/handling inline instead of sharing the
  scheduled job's logic, silently ignoring a setting the admin explicitly enabled. Now shares the
  same code path and tells the frontend which branch happened.
- IRC instant-grab ignored a scored release's `rejected` flag (a Release Profile must-not-contain
  hit, or an over-max-size release) and never passed scene/absolute-episode numbers into episode
  matching — a release the scheduled search path would refuse, or an anime episode it would match,
  behaved differently for a grab triggered via IRC.
- A season-pack import checked its season number with a plain truthy test, so a Season 0
  (specials) pack was silently misrouted and always failed to import.
- `Skip Free Space Check` was only enforced for single-file imports — a multi-GB season pack or
  whole album import could still fill a drive to zero with the safety setting left at its default.
- The automatic downloaded-file matcher (the common case — most imports never open the manual
  picker) ignored scene numbering and the anime absolute-episode fallback that every other
  release-matching call site in the codebase already uses, so a scene-numbered release correctly
  grabbed could fail to be matched back to the right file at import time.
- TorBox and AllDebrid's polling loops had no timeout, unlike the identical Real-Debrid loop right
  next to them — a truly dead/stuck torrent on those two providers polled forever instead of
  failing after 6 hours.
- A media library scan's exclusion filter for already-known multi-disc albums compared the wrong
  directory level, so every track in a CD1/CD2-subfoldered album was silently re-walked and
  re-processed on every single future scan; the separate one-time "backfill missing track listings"
  fixup also listed a multi-disc album's folder non-recursively, so it could never actually find
  those albums' tracks to backfill (its whole reason for existing).
- Jellyfin/Emby's own per-user library-access restrictions meant three functions that only ever
  queried `users[0]` (movie import, series import, the manual watch-toggle's item lookup) could
  silently see a smaller library than the watch-status sync (which correctly loops every user) —
  now prefers an administrator account when the API reports one.
- Recording a webhook-driven watch event resolves a path-tail collision by keeping the *first*
  matching library row; the scheduled watch-status sync resolved the identical lookup by keeping
  the *last* one — the same watched file could be attributed to a different item depending on which
  path recorded it. The sync's own "don't skip past a still-unmatched file" cursor logic also had a
  gap: it only tracked a running max over *matched* files, so a later-played-but-matched file in the
  same batch could still push the cursor past an unrelated unmatched file's timestamp.
- Sort-by-Quality and Sort-by-Content-Rating on the Library page both sorted their column as a
  plain string (alphabetical), not by the app's own configured rank/severity order — quality sort
  put "Bluray-1080p" before "DVD", and content-rating sort put "NC-17" second, right after "G".
  Sort-by-Status used the raw `has_file` flag instead of the same "fully complete" definition the
  Downloaded/Missing *filter* on the same page already uses for episodic/collection items.
- Filtering the Library page by both a tag and a library group silently dropped the group half of
  the filter whenever a tag was also selected, returning every tagged item in the whole type instead
  of just the ones in the browsed group.
- Two `parseInt(...) || N` / `Number(...) || N` fallbacks (recycle-bin retention days, auto-request-
  from-watch-history's limit) treated an explicitly configured `0` as invalid input and silently
  substituted the unrelated default instead of the requested near-zero value.
- `sendEmail`'s SMTP dot-stuffing escaped the message body in isolation before splicing it into the
  full DATA payload, so a body starting with "." was left unescaped — `sendEmailWithAttachment`
  right next to it already escapes the fully-assembled message and gets this right.
- A DDL indexer's health/reachability check fetched its raw, un-substituted `{query}` URL template
  instead of a real request, and DDL searches used a plain `fetch()` with no retry-on-transient-
  failure wrapper, unlike every other indexer protocol.
- The one-shot audiobook chapter-merge tool could run before every chapter finished downloading
  (silently dropping the unfinished ones from the merged book forever) and hardcoded the merged
  row's track number to a value that could collide with a still-downloading track under the same
  unique constraint.
- A watched/aged-file "Leaving Soon" preview didn't check the same `archiveEnabled`/archive-folder
  gates the real scheduled archival run does, so it could list items as scheduled for a specific
  date when no run would ever actually touch them (feature off, or on with nowhere configured to
  put the files).
- `corruptReason()`'s "file is missing" check trusted a single `fs.existsSync()` with no retry,
  while the ffprobe-failure check two lines below it already guards against the identical class of
  transient network-mount hiccup — a brief SMB/NFS disconnect could get a perfectly healthy file
  permanently recycled and its DB row wiped.
- A friend's-library "already owned" comparison only checked local items typed `movie`/`series` —
  Plex/Jellyfin/Emby have no separate "anime" category of their own, so a locally anime-typed item
  the friend also has was always reported as missing.
- Soulseek search results stored a fabricated `0`/`1` in the generic `seeders` field (based on
  upload-slot availability, not a real peer count), corrupting real cross-protocol sort/tiebreak
  logic that treats `seeders` as a comparable count; now `null`, matching every other seederless
  protocol.
- A GitHub-sourced TRaSH-Guides format sync silently dropped which condition types got skipped for
  a partially-translated format — the identical paste-JSON import path already surfaces this to the
  admin directly.
- `runJobNow()` (the "Run Now" trigger, e.g. System page's "Scan Library Now") always reported a
  job as started even when the job's own `execute()` silently skipped it for already running —
  the caller was told "started" while nothing new happened.
- Manual-Starr-import album/book matching dropped an already-tracked-but-still-undownloaded child
  from every result counter instead of counting it as matched, understating the "matched X, created
  Y, skipped Z" summary Radarr/Sonarr-style Lidarr/Readarr imports log.
- Two orphaned doc comments (`notifications.ts`, `media.ts`) sat above the wrong function, having
  been left behind when something was inserted between them and their real target.
- Three stale comments corrected: `metadata.ts`'s `backdropUrl`/`rating` field docs claimed "only
  TMDB populates this" (AniList/RAWG/IGDB have for a while); `logger.ts`'s ring-buffer comment said
  "last 500 lines" against an actual 2000-entry cap; `downloadClient.ts`'s `remotePath` comment
  claimed it's "only set on completed" when the adapters that populate it do so at other statuses
  too and the only caller never gated on status anyway.
- `mediaAnalysis.ts`'s `CompatibilityNote` type declared an "incompatible" severity level that
  nothing ever actually produced — the whole "With incompatible notes" filter/badge on the Media
  Analyzer page was a dead control that always showed a zero count and matched nothing. Removed the
  unreachable level from the type and the dependent UI. Separately, its DTS-HD compatibility note
  was keyed on `"dts-hd"`/`"dts_hd"` strings ffprobe never actually emits (its `codec_name` for
  every DTS variant is the bare string `"dts"`) — now reads ffprobe's separate `profile` field to
  tell DTS-HD apart from core DTS.
- `identifyMediaFile()`'s "used a video frame vs. embedded tags" result was computed on every
  return path but never reached the user — the MediaDetail/EpisodeDetail AI-guess UI now shows it.

Verified: `npx tsc --noEmit` clean in both `web/` and `server/`. The full server test suite (89
files) had 7 pre-existing tests fail after these fixes — all 7 were asserting the *old, buggy*
behavior (the MusicBrainz multi-type param shape, Soulseek's fabricated seeders, Trakt's URL
missing `extended=full`, and `getUpcomingArchivals` returning real candidates without the
archival-enabled gates configured) — updated all 7 to assert the corrected behavior, plus extended
`archival.test.ts`'s gate coverage to two tests that had gone from "passing for the right reason" to
"passing for the wrong reason" once the gate landed. Full suite now 89 files / 1377 tests, all
green. Live-verified a representative, high-risk sample against the real, rebuilt server: quality
and content-rating sort now return rank/severity order instead of alphabetical; a combined tag+group
filter now correctly scopes to the group; the on-demand Check Corrupt route now recycles
immediately with the setting off and correctly holds for review (file/DB row untouched, queued)
with it on. All fixtures (sessions, media items, a library group, tags, settings) were removed
afterward.

## Round 328 — ten more backend bugs from the remaining smaller route files

Finished the server-route sweep by auditing the 25 remaining, smaller route files (`librarySearch.ts`,
`qualityProfiles.ts`, `importReview.ts`, `qualities.ts`, `delayProfiles.ts`, `mediaServerWebhook.ts`,
`plexAuth.ts`, `friendLibraries.ts`, `ircFeeds.ts`, `watchlistImport.ts`, `releaseProfiles.ts`,
`mediaAnalysis.ts`, `aiProviders.ts`, `metrics.ts`, `remoteInstances.ts`, `userInvites.ts`,
`importLists.ts`, `indexers.ts`, `downloadClients.ts`, `calendarFeed.ts`, `wanted.ts`, `users.ts`,
`libraryGroups.ts`, `rootFolders.ts`, `discordInteractions.ts`) with the same backend bug classes
used in Round 327. Ten confirmed and fixed:

- Watchlist Import's `media_items` INSERT never set `root_folder_id`, unlike every other add path —
  every item added this way silently could never have a downloaded file imported (`importer.ts`
  throws "has no root folder configured" for any import attempt against it). Now auto-selects one
  per row, the same way Add Media/Requests/Discord/Overseerr-webhook already do.
- `users.ts` hardcoded every admin-audit-log write to the literal actor `(null, "admin")` instead
  of using the `auditActor(req)` helper every other admin route file already uses — on any instance
  with more than one admin account, every create-user/reset-password/permissions-change/
  session-revoke/delete-user entry in the Audit Log was misattributed to a generic "admin" instead
  of the specific admin who did it.
- The root-folders list endpoint rounded `percentUsed` to an integer before the frontend compared
  it against the quota threshold, while the real grab-pausing check computes the same percentage
  unrounded — near a threshold, the "over quota" badge and the actual pause decision could disagree.
  Now returns the unrounded value (rounding only for the badge's display text).
- `remoteInstances.ts`'s Remote Library browse proxy forwarded the remote instance's raw
  `{ items, total }` response envelope verbatim, but the frontend expects (and calls `.map()`/
  `.length` on) a bare array — breaking the feature with a `TypeError` any time it was used. Now
  unwraps `body.items` before forwarding.
- The Discord bot's `/request` add path dropped `release_date`, `backdrop_url`, and `rating` from
  its `media_items` INSERT even though the TMDB search result it just fetched already carries them
  — every other add path (Overseerr webhook, the web Add Media flow) persists these. Discord-added
  titles were missing their release-date "Upcoming" badge and backdrop image.
- `ircFeeds.ts`'s create route only treated the literal value `false` as "off" for `useSsl`/
  `enabled`, while its own update route (and the field's own GET response shape) treats any falsy
  value as off — creating a feed with `useSsl: 0` (exactly what a round-tripped GET response would
  send back) silently created an SSL-*enabled* feed instead. Also, the named-capture-group regex
  validation enforced on create was never enforced on update, so editing an existing feed could
  silently save a regex that would never match anything, with no error.
- Two stale comments corrected: `downloadClients.ts`'s "Test" route comment cited a nonexistent GET
  sibling route (the real one is a POST); `importLists.ts` had a comment describing an in-progress
  COALESCE-to-explicit-fields rewrite that was, on inspection, already fully complete — every field
  in the handler already uses the same explicit pattern the comment claimed only some fields lacked.
- `watchlistImport.ts`'s docstring overstated parity with the single Add Media pipeline ("same
  pipeline as a single Add Media import") — corrected to note the real, narrower behavior (root
  folder auto-selection, yes; absolute-episode numbers and a monitor-strategy choice, no) after the
  missing-root-folder bug above turned out to be exactly the kind of drift an overstated comment lets
  go unnoticed.

Verified: `npx tsc --noEmit` clean in both `web/` and `server/`; full server test suite (89 files,
1377 tests) passes with no regressions. Live-verified against the real, rebuilt server: a fixture
root folder confirmed the quota badge and the real enforcement check now agree on the same
unrounded percentage; a second fixture admin account confirmed the Audit Log now records the real
actor instead of a generic "admin"; a fixture IRC feed confirmed `useSsl: 0`/`enabled: 0` now
persist correctly and an invalid regex is now rejected on update; a fixture root folder and a live
Open Library author search confirmed a Watchlist Import add now gets a real `root_folder_id`; a
fixture remote instance pointed at the server's own API confirmed the Remote Library proxy now
returns a bare array. The Discord bot fix was verified by code review only (correctly wired to match
the established `overseerrWebhook.ts` INSERT pattern, `tsc`-clean) since exercising it live requires
a real Discord interactions webhook round-trip, which isn't reproducible in this environment. All
fixtures (sessions, users, a root folder, an IRC feed, a media item, a remote instance) were removed
afterward.

## Round 327 — fifteen backend correctness bugs found by extending the audit to server routes

With every page and shared component now given a dedicated pass, extended the audit to the largest
server-side route files (`media.ts`, `system.ts`, `iptv.ts`, `metadata.ts`, `collections.ts`,
`requests.ts`, `search.ts`, `opds.ts`, `activity.ts`, `customFormats.ts`, `dashboard.ts`,
`settings.ts`, `authRoutes.ts`, `import.ts`) using bug classes adapted for backend correctness
(inconsistent duplicate logic, stale comments, dead parameters, calculation errors, and missing
response-mapping) instead of the frontend-specific ones used through Round 326. Fifteen confirmed
and fixed:

- `media.ts`'s "Edit Metadata" PATCH updated `title` without recomputing `sort_title` — every other
  writer of this table (create, rematch, split, every sync service) keeps them in sync; only editing
  a title through the normal Edit dialog left it stale, silently breaking that item's sort order.
- `media.ts`'s bulk monitor/edit/tag endpoints reported the size of the request's id array as the
  number of items "updated"/"tagged", regardless of how many ids didn't actually exist or get
  changed — now read the real affected-row count off the query result, matching how bulk/delete and
  the season-monitor route already do it.
- `media.ts` had an orphaned doc comment describing the "second opinion" metadata-provider endpoint
  sitting above an unrelated corrupt-file-check route 30 lines above where it belonged — moved back
  to its actual endpoint.
- Anime absolute-episode-number release matching only worked for episodes added via
  `/metadata/import` — every other way an episode can be added (Trakt/Plex/watchlist sync, import
  lists, library scan, a manual add) left `absolute_episode_number` NULL, silently breaking
  bare-absolute-numbered fansub matching depending purely on how a show was added. Switched
  release-matching to compute it live from the episodes table (the same COUNT approach
  `importer.ts` already uses for naming templates) instead of trusting a column only one insert
  path populates.
- `collections.ts`'s "up to 4 member posters" for a collection card sliced to the first 4
  members *then* dropped the ones without a poster, instead of the other way around — a smart
  collection (any viewer) or a fixed collection viewed by a non-admin household user could show
  blank poster-grid squares even when the collection had 4+ posters available, just because the
  poster-bearing items weren't among the first 4 in sort/position order.
- Per-user storage attribution (admin Users page) statted a Music/Audiobook sub-item's `file_path`
  directly — but that path is a *folder* (one file per track) for these types, and `fs.stat` on a
  directory returns its own tiny metadata size, not a recursive sum of its contents. Storage from
  approved music/audiobook requests showed as near-zero. Now prefers each row's already-correct
  stored `size_bytes` over statting the path.
- The manual-grab route silently rewrote any Soulseek ("slskd") release's protocol to "torrent"
  before picking a download client, so grabbing a Soulseek search result either went to the wrong
  client type or failed with a message claiming no client could handle a "torrent" release. Added
  "slskd" as its own protocol → client-type mapping.
- Search-result deduplication keyed on normalized title + exact size, but several indexer adapters
  default a missing size to the literal number `0` rather than null — two different, unrelated
  releases that both lack size data collided on that shared "unknown size" sentinel and got wrongly
  merged, silently discarding one. Falls back to the download URL to tell size-less releases apart.
- The Activity page's merged timeline fetched `requests` ordered by `created_at` only, but a
  request's "approved"/"rejected" entry is timestamped by the separate, later `resolved_at` column
  — an old request resolved moments ago could be excluded from the fetched window entirely, silently
  dropping a real, recent event from the page. Now orders by `COALESCE(resolved_at, created_at)`.
- Three stale comments corrected to match actual behavior: `customFormats.ts`'s TRaSH-import
  docstring omitted `ResolutionSpecification`, which the route's own error message and the
  underlying translator already support; `dashboard.ts`'s library-sizes comment claimed file size
  is never stored separately, when `size_bytes` has been a real, populated column for a while.
- The Settings page's portable config template (export/import) silently dropped three fields on
  every round-trip: a custom format's media-type scoping, a quality profile's max-size cap, and a
  quality's preferred-size hint — importing a template onto another instance (or re-creating a
  format from one) silently unscoped/uncapped/unset them instead of preserving the original config.
- The manual-import folder browser's hardcoded extension list omitted Comics/Manga's `.cbz`/`.cbr`
  and every ROM extension, so those files were invisible to manual import even though automatic
  import fully supports them.

Verified: `npx tsc --noEmit` clean in both `web/` and `server/`; full server test suite (89 files,
1377 tests) passes with no regressions. Live-verified the highest-risk fixes against the real,
rebuilt server: a template export/import round-trip confirmed all three previously-dropped fields
now persist; a fixture 5-item smart collection and a fixture non-admin-visible fixed collection both
confirmed a poster beyond the first 4 (by sort/position) now surfaces instead of a blank slot; a
fixture Music sub-item with a real `size_bytes` and a directory-shaped `file_path` confirmed
per-user storage now reports the correct value instead of near-zero; editing a title confirmed
`sort_title` now updates with it; a bulk-monitor call with one real and one nonexistent id confirmed
the response now reports the true affected count. All fixtures (sessions, media items, collections,
a request) were removed afterward.

## Round 326 — four bugs found by extending the audit to shared components

With every page now given a dedicated audit pass, extended the same review to the 20 substantive
shared components in `web/src/components/` (excluding the two pure-icon files) — code reused across
many pages, so a bug here can affect more than one screen at once. Four confirmed and fixed:

- `MediaDetail.tsx`'s "Move to Group" dialog never passed `GroupPicker`'s `initialChain` prop, even
  though the component was built specifically to accept it ("Pass `initialChain` ... to preselect an
  existing item's current location") and `MediaDetail.tsx` already fetches that item's exact
  root-first group-id chain for its own "Location: ..." breadcrumb — it just discarded the ids and
  kept only the display names. Moving an already-grouped item meant every level had to be re-picked
  from scratch, with the current location's breadcrumb also hidden while the dialog was open. Now
  threads the fetched ids through as `initialChain`, matching how `AddPreview.tsx` already does it.
- `RenamePreviewModal.tsx`'s skipped-file note hardcoded "music track(s)" and claimed track
  filenames are "never templated" — but the same `skippedMusic` count also covers Audiobook chapter
  files (this modal's opener is gated only on admin, not media type), and Music track filenames
  *are* templated at import time via a setting added since this text was written (Round 173's
  `namingArtistTrackTemplate`); only this retroactive bulk-rename tool skips already-placed ones.
  Reworded to "track/chapter file(s) skipped — this tool doesn't retroactively rename already-placed
  track/chapter files (new imports already follow the naming template)."
- `RenamePreviewModal.tsx` declared a per-item `errors` array on its preview response — the same
  shape callers already render via `notify.error` after a *real* rename fails — but never read or
  displayed it during the preview itself, so an admin previewing a rename got no warning about items
  that would fail until after committing to the real operation. Added a red-highlighted list of
  failing items to the preview, shown before the Rename button.
- `Requests.tsx`'s "N request(s) total" line above the table counted only the current page's
  fetched items (capped at the selected page size), while the correct grand total was already shown
  moments later by the shared `Pagination` component on the same page — two different numbers
  presented as the same fact once a household user has more requests than fits on one page. The
  approval-rate percentage next to it had the same page-scoped bug. Added a narrow `GET
  /requests/stats/me` endpoint (self-scoped version of the existing admin-only `/requests/stats`
  per-user counts) and switched the summary line to it instead of deriving from the paginated array.

Verified: `npx tsc --noEmit` clean in both `web/` and `server/`. Live-verified against the real,
rebuilt server: a fixture session for the existing non-admin test user confirmed `/requests/stats/me`
and the rendered summary line both report the true count/rate; a fixture single-level "site" group
and a fixture Online Videos item confirmed the Move to Group dropdown now preselects the item's
current site instead of starting blank; the Rename preview modal was re-opened on a live item to
confirm no regression in the ordinary (no-error) case. All fixtures (a session token, a media item,
a library group) were removed afterward.

## Round 325 — thirteen more bugs across the next tier of pages

Audited the next 24 not-yet-solo-reviewed pages (`RemoteLibrary.tsx`, `RecycleBin.tsx`,
`IrcFeeds.tsx`, `CutoffUnmet.tsx`, `CollectionDetail.tsx`, `Collections.tsx`, `AiProviders.tsx`,
`Recommendations.tsx`, `ImportReview.tsx`, `HistoryPage.tsx`, `CustomColumns.tsx`, `Jobs.tsx`,
`AuditLog.tsx`, `Blocklist.tsx`, `NetworkStats.tsx`, `Account.tsx`, `Person.tsx`, `CalendarDay.tsx`,
`InviteAcceptPage.tsx`, `ApiDocs.tsx`, `Changelog.tsx`, `LibraryHome.tsx`, `TrackDetail.tsx`,
`SharePage.tsx`, `LibraryUngrouped.tsx`) in groups of 3-4, each independently adversarially verified.
Thirteen confirmed and fixed:

- `RemoteLibrary.tsx`'s browsed-remote-item tiles used the shared `.card` hover-lift styling with no
  click handler at all, implying clickability they don't have — missing the `static` modifier
  (`.card.static`) other pages already use for exactly this situation. Same bug found and fixed on
  `Person.tsx`'s not-yet-in-library filmography credit cards, whose `onClick` is a no-op for them.
- `CollectionDetail.tsx`'s "Archival retention" control was shown for every collection, but setting
  it on a *smart* collection silently does nothing — the server only ever applies a collection's
  retention override by joining through `collection_items`, and smart collections never have rows
  there (writes to that table are blocked for them). The control is now hidden for smart collections.
- `CollectionDetail.tsx`'s "No items yet" empty state told the admin to use "Add to collection" to
  add one "here" — impossible for a smart collection, whose membership can't be manually edited, as
  the page's own notice directly above already says. Now shows "This smart filter currently matches
  nothing" instead when a smart collection's filter is empty.
- `ImportReview.tsx` showed the raw internal `type` key ("movie", "artist", ...) instead of a label,
  and the raw unformatted SQLite datetime string for "Queued" instead of a formatted date — both
  fixed via `useMediaTypes()` and `toLocaleString()`, matching sibling pages.
- `HistoryPage.tsx`'s event-type label map was missing `auto_archived`, the one history event type
  the watch-status archival job actually logs beyond the four already covered — it fell through to
  the raw snake_case string with no badge color and no filter-dropdown option. Added the label, badge
  color, and filter option.
- `Jobs.tsx`'s "Last result" column printed the raw `lastStatus` enum ("success"/"error"/"cancelled")
  instead of a label, unlike the adjacent Status column two cells over which already converts its
  value to "Running"/"Idle". Added a label map.
- `CustomColumns.tsx` let an admin attach a Quality or any `mediaInfo.*` field preset to any library
  type, including episodic/collection-shaped ones (TV shows, music, books, ...) where that data is
  never populated on the parent row — the exact SINGLE_SHAPE_ONLY_FIELDS bug `LibraryType.tsx`
  already found and fixed for its own built-in toggles, reintroduced here for this admin screen. The
  Field dropdown now hides those presets once an incompatible library type is selected.
- `AuditLog.tsx`'s `EVENT_LABELS` map was missing seven event types the server actually logs
  (duplicate merge/dismiss, media split, invite-created users, root-folder move/remove, a skipped
  auto-approval) — all fell through to their raw snake_case string. Added all seven.
- `NetworkStats.tsx` printed the raw queue status string and the raw download-client type key
  directly, even though this exact codebase already has label maps for both fields elsewhere
  (`Activity.tsx`'s `QUEUE_STATUS_LABELS`, `DownloadClients.tsx`'s `TYPE_LABELS`) — duplicated both
  maps locally, matching this codebase's established small-duplicated-map convention.
- `LibraryHome.tsx`'s Recently Added cards could show a raw internal type key instead of a label on
  a render where the independent `/dashboard/recently-added` fetch resolves before the separate
  `/media-types` fetch does — added a loading guard.

Verified: `npx tsc --noEmit` clean. Live-verified every fix against the real, rebuilt server: a
fixture queue row showed "Downloading" instead of the raw status, and the existing fixture download
clients now show "qBittorrent" instead of the raw type key on Network Stats; a fixture Import Review
row showed "Movies" and a formatted date; a fixture `auto_archived` history row showed the new
label, badge color, and filter option; a fixture audit-log row showed "Merged duplicate media"
instead of the raw event key; the Custom Columns Field dropdown was confirmed to hide Quality/
mediaInfo presets for "TV Shows" and show them again for "Movies"; a fixture smart collection with a
zero-match filter showed the corrected empty-state text with no retention control, while a fixture
plain collection kept both unchanged. All fixtures (queue row, import-review row, history row,
audit-log row, two collections) were removed afterward.

## Round 324 — six more raw-value/dead-option bugs from the next tier of pages

Continued the deep-dive audit (3-4 files per reviewer, up from the original 8-9-file bundles) across
`Indexers.tsx`, `ImportLists.tsx`, `Missing.tsx`, `WatchlistImport.tsx`, `AddPreview.tsx`,
`Requests.tsx`, `GlobalSearch.tsx`, `Duplicates.tsx`, `Discover.tsx`, and `FriendLibraries.tsx`. Six
confirmed and fixed — all but one the same raw-value-leak/dead-option classes found in every prior
round of this sweep:

- `Indexers.tsx`'s table showed the raw protocol enum ("torznab", "newznab") instead of a label,
  even though this exact field already has a proper label map (`PROTOCOL_LABELS`) in `Activity.tsx`
  for the identical value.
- `Requests.tsx`'s Status column showed raw lowercase "pending"/"approved"/"rejected" as plain text
  — every comparable status field elsewhere in the app (Activity's queue, WatchlistImport's results,
  Duplicates, HistoryPage) renders through a labeled, color-coded badge; Requests never got the same
  treatment.
- `Duplicates.tsx`'s "Matched to" column showed raw provider ids ("tmdb", "mangadex") instead of the
  labels `AddMedia.tsx`'s own `PROVIDER_LABELS` already defines for the identical values.
- `AddPreview.tsx`'s Monitor dropdown offered all nine Sonarr-style strategies on the add-a-new-show
  screen, but the server's `episodesToMonitor()` — which only ever runs against a brand-new add,
  nothing downloaded yet — treats "Existing Episodes" identically to "None" (nothing exists yet) and
  "Missing Episodes" identically to "All Episodes" (everything fetched is "missing"), by its own
  explicit doc comment. Two of nine options could never behave differently from two others already
  in the same list. Narrowed this add-only dropdown to the seven options that are actually distinct.
- `Missing.tsx`'s Movies section showed the same title twice, once in "Media" and once in "Item" —
  the server's `/wanted/missing` query aliases both `mediaTitle` and `label` to the same `title`
  column for single-shape items (there's no separate "item" below a movie itself, unlike an episode's
  "S01E02" or an album's own title). The shared `Section` component now hides the redundant "Item"
  column whenever every row's label matches its media title, which only ever happens for this bucket.
- `ImportLists.tsx` had a subject-verb agreement error: "1 need review" instead of "1 needs review".

Verified: `npx tsc --noEmit` clean. Live-verified every fix against the real, rebuilt server: a
fixture Usenet indexer showed "Usenet" not "newznab"; the existing Approved request fixture rendered
as a proper green badge; the Monitor dropdown on a fresh TV-show add showed exactly seven options;
the Missing page's Movies section dropped its duplicate Item column while Albums & Books (genuinely
different label per row) kept both columns; a fixture external-id set on a duplicate movie showed
"TMDB, MangaDex" instead of raw provider keys. Fixtures (an indexer, an external-id patch) were
removed/reverted afterward.

## Round 323 — seven more bugs from the next tier of pages, plus a caught false positive

Continued the deep-dive audit into the next tier of pages by size (`IptvPlaylists.tsx`,
`DownloadClients.tsx`, `Users.tsx`, `EpisodeDetail.tsx`, `Dashboard.tsx`, `Calendar.tsx`,
`AddMedia.tsx`) — 2-3 files per reviewer instead of the original 8-9-file bundles. Seven confirmed
and fixed:

- `DownloadClients.tsx` showed a "Category" field for Soulseek (slskd) download clients, but
  `SlskdAdapter.addDownload()` doesn't even take a category parameter — Soulseek has no
  categorization concept at all, so a value entered there silently did nothing. Split the field's
  visibility into its own `supportsCategory` check (qBittorrent/SABnzbd only), separate from
  `needsHost` (which still legitimately includes slskd for the host/port fields).
- `Users.tsx`'s Invite Links table showed the raw role value ("admin"/"user") instead of the same
  "Admin"/"Household user" wording the invite-creation form on the same page already uses.
- `Dashboard.tsx`'s Upcoming widget mixed real media items with admin-added custom calendar events
  from the same `/wanted/calendar` endpoint, but — unlike `Calendar.tsx`, which already guards this
  identical case — never checked which was which: clicking a custom event within the next 14 days
  navigated to `/media/<event's own id>`, a nonexistent or unrelated item, and its Type column
  printed the raw pseudo-type "custom" instead of a proper label. Added the same `kind` check and
  "Custom date" label `Calendar.tsx` already uses.
- `Dashboard.tsx`'s Recently Changed widget had no label for `event_type = 'failed'` history rows
  (written on every automatic-retry blocklist), showing raw "failed" while three sibling pages
  (Activity, History, MediaDetail) already map it to "Failed".
- `AddMedia.tsx`'s Metadata provider dropdown had no label for "mangadex", the second of Manga's two
  providers — picking Manga showed "AniList" and a raw unlabeled "mangadex". Separately, and more
  significantly: the dropdown's *initial* selection just always picked `metadataProviders[0]`, which
  for Manga is "anilist" — but the server's own `defaultProvider` for Manga is "mangadex", with a
  comment explaining AniList has no per-chapter listing, so a manga matched through it silently sits
  at "0 total" chapters forever. Every other type's provider list happens to start with its real
  default, which is what let this go unnoticed. Added a new `GET /api/metadata/default-providers`
  endpoint (kept separate from the existing `/metadata/providers`, which four other pages already
  consume in its simpler shape) and had `AddMedia.tsx` prefer it over guessing index 0.

**One flagged finding turned out to be a false positive, caught before touching any code**: the
audit reported `server/src/routes/dashboard.ts`'s library-size cache-key parsing as broken (writing
`${type}${contentRating}` with no separator, then reading it back with `key.split("")` — which in
JS explodes a string into every individual character, not into two parts). Both the reviewing agent
and its independent adversarial verifier confirmed this as a real bug. Direct byte-level inspection
(`cat -A`, and a Node script printing the lines as JSON) showed the actual file already joins and
splits on a literal `` control character on both sides — correct, working code. The control
character is invisible in normal file-reading output, which is exactly what fooled both agents into
"seeing" a missing separator that was actually there. No change was made to `dashboard.ts`.

Verified: `npx tsc --noEmit` clean in both projects (also caught and fixed a real type mismatch of
my own — `defaultProvider` is `string | null` server-side, not `string | undefined` — before it
reached the Docker build). Live-verified every fix against the real, rebuilt server: Manga's
provider dropdown now shows "MangaDex" as a label and pre-selects it by default (not AniList); a
fixture admin invite showed "Admin" instead of raw "admin"; the slskd client-type modal no longer
shows a Category field; a fixture custom calendar event 3 days out showed "Custom date" in the
Upcoming widget and clicking it stayed on the Dashboard instead of navigating anywhere. All fixtures
(the invite, the calendar event) were deleted afterward via the API.

## Round 322 — six more bugs from a solo deep-dive on the next-largest pages

Rounds 319-321 bundled `LibraryType.tsx` (1765 lines) and `System.tsx` (1628 lines) with 7-8 other
files per review agent — the same under-coverage risk that made `Settings.tsx`/`MediaDetail.tsx`
worth a dedicated pass in Round 321. Gave these two the same solo treatment, checking the full set
of bug classes found this session (dead toggle options, raw/duplicate values, inconsistent
clickability, positional references, grammar), plus a bundled check on three mid-size pages. Six
more confirmed:

- `LibraryType.tsx`'s Poster info/Overview info/Columns menu offered "Studio" on every library type,
  but `media_items.studio` is only ever populated for movies (TMDB) and adult (ThePornDB) —
  `libraryScan.ts`'s refresh path gates the studio-fetch on `type === "movie"` specifically, and no
  other type's search/refresh path ever writes it. Same class of bug as Round 317's Quality/Path/
  Size-on-disk fix; added a `STUDIO_FIELD_TYPES` allow-list (movie/adult, plus PPV since it can pick
  one up via a rare manual TMDB-id paste even though normal search never sets one).
- `LibraryType.tsx`'s Poster/Overview views duplicated the monitored state: the MonitorToggle icon
  already overlaid on every poster/thumbnail shows monitored/unmonitored regardless of the Poster
  info toggle, but the "Monitored" field's suppression only blanked itself for *monitored* items —
  an unmonitored item still printed a redundant plain-text "unmonitored" right next to the icon (and,
  when also missing, next to the "Unmonitored" banner too). "Status" already got full suppression for
  the identical reason (Round 318) — extended the same treatment to "monitored", and removed the now
  provably-dead `fieldValue()` branches for both fields (every caller special-cases them to a badge/
  icon before ever reaching that function).
- `System.tsx` had three tables (Disk Space, Cleanup Suggestions' "unmonitored + no file" list, and
  the "Leaving Soon" upcoming-archivals list) printing the raw internal media-type key ("series",
  "artist", "rom", ...) in their Type column instead of the friendly label ("TV Shows", "Music",
  "ROMs") — the component already loads the key→label mapping via `useMediaTypes()` for a filter
  dropdown elsewhere in the same file, just never applied it to these three tables.
- `Activity.tsx`'s Queue table printed the raw lowercase status enum ("downloading", "completed", ...)
  next to its status icon, while the same file already does the correct thing for the History
  table's event types (`TIMELINE_LABELS`) and protocol column (`PROTOCOL_LABELS`) a few lines away —
  added the equivalent `QUEUE_STATUS_LABELS` map.

Verified: `npx tsc --noEmit` clean. Live-verified every fix against the real, rebuilt server: the
Studio option confirmed present on Movies and absent on TV Shows; toggling a real movie unmonitored
via the API confirmed the poster sub-line no longer duplicates "unmonitored" next to the banner;
the same movie, made unmonitored-with-no-file, showed "Movies" (not "movie") in Cleanup Suggestions;
a fixture queue row with `status='completed'` showed "Completed" (not "completed") in the Activity
Queue table. All test fixtures (monitored-state changes, the queue row) were reverted/deleted
afterward.

## Round 321 — copy sweep, part 2: more dangling cross-tab/cross-modal references

Follow-up to Round 320: pulled every remaining "above"/"below" occurrence in the two largest, most
candidate-dense pages (`Settings.tsx` at ~3300 lines and `MediaDetail.tsx` at ~2200) — both were
bundled with 7-8 other files in Round 320's page-group review, which likely under-covered them —
plus a handful of scattered candidates elsewhere, and had each one individually traced against the
actual tab/tile/modal structure. Six more confirmed:

- `Settings.tsx`'s Trakt List Sync tile said it "reuses the Trakt Client ID set **above** under
  Metadata Providers" — Trakt List Sync lives under the Library Sync tab, Metadata Providers is a
  completely different tab (mutually exclusive, same bug class as Round 320's SMTP/Plex-token
  fixes). Dropped the positional word, matching the fix style already used there.
- `Settings.tsx`'s Tags tile said a tag's retention override falls back to "Watch-status
  Auto-Archival **above**" — that section is on the Media Management tab, Tags is on Library Sync.
  Now names the actual location.
- `Settings.tsx`'s "Sync from TRaSH-Guides" panel said newly-synced custom formats need "a score for
  them **below**" — scoring happens in the separate Format Scores tile, not anywhere in this tile's
  own render (which ends with just the sync buttons and a JSON-paste panel). The very same render
  function already correctly says "from the Format Scores tile" a few lines earlier with no
  positional word — this occurrence just hadn't caught up to that phrasing.
- `Settings.tsx`'s "Unmonitor Deleted Files" description had a duplicated clause, not a positional
  bug: both halves of a sentence meant to contrast "stay monitored" vs. "unmonitor" said "left
  monitored" and described the same outcome, never actually saying what the Unmonitor option does.
- `MediaDetail.tsx`'s Edit Metadata dialog said you could set a poster/backdrop "via the Artwork
  picker **below**" — the Artwork picker is a separate, mutually-exclusive full-screen modal (opened
  from the page toolbar), not something reachable or visible from inside this dialog. Reworded to
  say to close the dialog and use the toolbar button instead.
- `IptvPlaylists.tsx`'s Playlist Token panel said it's "shared by every feed URL **above**" — a
  playlist's feed URL only ever renders inside that one playlist's edit modal (a full-screen overlay
  that covers this exact panel while open), so it's never actually visible "above" this text; also
  overstated "every" when only one URL is ever shown at a time. Reworded to describe the
  relationship conceptually instead of claiming visibility.

Verified: `npx tsc --noEmit` clean. Live-verified the two cross-tab Settings.tsx fixes (Trakt Client
ID, Tags/Archival) directly against the running dev server by switching tabs and expanding each
tile; the other four are pure text changes confirmed correct via careful tracing (both by the
auditing agents and independently by direct source reading before editing), with no logic or layout
touched.

## Round 320 — copy sweep: dangling "above"/"below" references and grammar fixes

User-reported: Media Analyzer's truncation notice said "summary above still covers everything" right
next to the truncation badge, but the actual stat-summary panels render in a later block — i.e.
below that text, not above. Fixed that one directly, then ran a multi-agent proofreading pass over
every user-facing string in all 50 pages plus the 20 shared components that carry text, checking
specifically for (1) grammar/spelling errors and (2) positional references ("above"/"below"/"here"/
"left"/"right") that don't match where the referenced element actually renders — every reviewer
finding was independently re-verified by tracing the real JSX before being treated as confirmed.

Six more confirmed and fixed:
- `Settings.tsx`'s Fanart.tv provider description said "see Artwork below" — no "Artwork" section
  exists anywhere on the page (it's a dangling reference, not just misplaced); reworded to actually
  say what Fanart.tv does (powers a media item's own "Artwork" picker for extra posters/backgrounds).
- `Settings.tsx`'s Plex watchlist sync text said it "uses the same server token above" — the token
  field is defined later in the same modal, not earlier. Now says "below."
- `Settings.tsx`'s Send to Kindle text said it "reuses the Email (SMTP) notification settings above"
  — SMTP fields live on the separate Notifications tab, never visible at the same time as the
  General tab this text is on. Now names the tab directly, matching how this file already does it
  elsewhere (e.g. "the Trakt Client ID set above under Metadata Providers").
- `System.tsx`'s Media Server Library Validation panel said "Needs a media server configured above"
  — media server setup lives entirely on the separate Settings page, not anywhere on System. Now
  says "in Settings."
- `System.tsx`'s log-verbosity note ("Controls what's kept... — always still goes to the container's
  own stdout/stderr...") had a dangling verb phrase with no subject. Restored the missing subject
  ("everything still always goes to...").
- `LibraryType.tsx`'s Starr-import description had a comma splice joining two independent clauses;
  split with an em dash to match the rest of the file's punctuation style.
- `Onboarding.tsx` said "this is where new files get organized to" — "organize" doesn't take a
  locative "to" (unlike "moved to"); dropped the stray preposition.

Verified: `npx tsc --noEmit` clean. Live-verified the Settings (Fanart.tv, Send to Kindle) and System
(Media Server Library Validation, log verbosity) corrections directly against the running dev
server; the LibraryType.tsx and Onboarding.tsx wording fixes were confirmed correct by direct
source reading (pure text changes, no logic or layout risk).

## Round 319 — similar-bugs sweep: 14 confirmed defects across 12 pages

Following up on Round 317/318's Poster-info/Overview-view bugs, ran a multi-agent audit (6 reviewers
reading all 50 pages in full, each finding independently re-verified against the actual source)
specifically hunting for the same three bug classes — dead toggle options, raw internal values
leaking into user-facing text, and inconsistent card clickability — plus any other concrete
correctness bug in conditional rendering. All 14 findings were confirmed and fixed:

**Dead toggle options** (an option offered everywhere, but only ever works in some contexts):
- `LibraryType.tsx`'s Status filter offered "Cutoff unmet"/"Filename doesn't match title" on every
  library type, but both are computed from `media_items.quality`/`.path`, which — same root cause
  as Round 317's Poster-info fix — are only ever populated for "single"-shape types. Hidden for
  episodic/collection types now, matching the Sort dropdown's existing gating.
- `Duplicates.tsx`'s duplicate-group table showed a "Quality" column unconditionally; always `-` for
  episodic/collection types for the identical reason. Hidden the same way the file already
  conditionally hides its "Children" header.
- `System.tsx`'s "Rename Files" type dropdown offered "Music" and "Audiobooks", both of which
  (`multiFilePerChild` shapes) are unconditionally skipped by the rename routine — picking either
  always renamed zero files. Also fixed the result banner, which reported every skip as "Music file(s)
  skipped" even when the library being renamed was Audiobooks-only.
- `WatchlistImport.tsx`'s single-title Type dropdown included "Courses", which has no metadata-search
  provider configured — searching always silently failed and queued the row as "No metadata match
  found" with no indication the type itself could never work (`AddMedia.tsx` already guards this via
  `hasMetadataSearch`; this flow didn't).

**Raw internal values shown to users** instead of the proper label:
- `MediaDetail.tsx`'s Type/status pill printed the raw database key (`item.type`) instead of
  `typeInfo.label` — e.g. a Music item's own detail page read "artist · Continuing" instead of
  "Music · Continuing", even though `typeInfo` was already computed on the same page.
- `SharePage.tsx` — the fully public, unauthenticated `/share/:token` page — had the identical bug:
  the server now resolves and sends `typeLabel` (`getMediaTypeConfig(type).label`) instead of the
  client guessing at a capitalized raw key.
- `MediaAnalyzer.tsx`'s "HDR format" stat panel (and the "Filtered to..." caption it drives) printed
  the raw internal slug ("dolby-vision", "hdr10plus") even though the main file table right next to
  it correctly used `HDR_LABELS` for the same field on the same data. Both now share one
  `labelForStat()` helper so they can't drift apart again.

**Inconsistent card clickability** (styled/hoverable like every other clickable card, but only part
of it actually was):
- `FriendLibraries.tsx` and `CollectionDetail.tsx` both had their card's `onClick` on an inner text
  wrapper instead of the outer `.card` div — hovering the card's padding or whitespace between
  elements showed the lift/border-highlight every clickable card gets, but clicking there did
  nothing. Moved `onClick` to the outer card (existing action buttons already/now call
  `stopPropagation()`), matching how equivalent cards elsewhere in the app are built.
- `MediaDetail.tsx`'s Artwork-background tiles are deliberately non-clickable (only their two
  buttons are), but kept the shared `.card` class, whose `:hover` rule still fired regardless of the
  inline `cursor: default` override (a CSS pseudo-class isn't neutralized by an inline style).
  Added a `.card.static` modifier that actually suppresses the hover lift/border for tiles like this.

**Other**:
- `Calendar.tsx`'s day-detail panel and Agenda table showed a "Missing" item's status badge in plain
  gray, while the identical fact renders red (danger) in the month-grid mini-pills and on
  `CalendarDay.tsx`'s own page — a copy-paste omission now made consistent everywhere.
- `Activity.tsx`'s Queue table hardcoded `(size / 1e9).toFixed(2)} GB` instead of using the file's
  own KB/MB/GB-scaling `formatSize()` helper (already used correctly a few hundred lines later) —
  any queue item under ~5MB (a small ebook/comic/sample release) rendered as an uninformative
  "0.00 GB".
- `RecycleBin.tsx` (both tables), `Users.tsx`'s Active Sessions Device column, and `Duplicates.tsx`'s
  file-path cell all truncated long text (paths, user-agent strings) with no `title` attribute to
  recover the full value on hover — `MediaDetail.tsx` already has this exact pattern with the
  tooltip; these didn't.

Verified: `npx tsc --noEmit` clean in both projects. Live-verified against the real, rebuilt server —
the Status/Sort/Rename-type/Watchlist-type dropdowns' shape-based filtering (Movies keeps every
option, TV Shows/episodic types don't); a Music item's detail page and its public share link both
now read "Music" instead of "artist"; a fixture Media Analyzer response confirmed "Dolby
Vision"/"HDR10+" render instead of raw slugs in both the stat table and its tooltip; a fixture
Friend Library tile's previously-dead whitespace now opens its edit modal on click. The remaining
findings (Duplicates/Activity/RecycleBin/Users/Calendar/Artwork-modal/CollectionDetail) are small,
independent, single-line-or-few-line changes confirmed correct by direct code reading against the
audit's exact citations; live fixture data wasn't set up for each given the volume of confirmed
findings in this round.

## Round 318 — Overview view mode duplicated the Status field as raw text next to its own badge

Checking "Status" in Poster info (on by default) and switching to **Overview** view mode showed
each item's status twice: the colored "Missing"/"Downloaded"/etc. badge Overview already renders
for every row, immediately followed by the same status again as plain lowercase text (`missing`,
`downloaded`, `unmonitored`, ...) — literally the CSS class name (`fieldValue()`'s `"status"` case
returns `posterBanner(item).cls`, not a human label). Poster view already excludes "status" from its
extra-fields line for exactly this reason (its own poster-banner strip shows it), but that exclusion
was only ever applied to Poster view — Overview view has had its own identical badge the whole time
and was never given the same exclusion, so it double-printed on every row for every library type.

Fixed by applying the same `f !== "status"` filter to Overview view's field list that Poster view
already had, and corrected the stale comment above Poster view's filter (it claimed Overview "has no
banner of its own," which stopped being true once Overview's badge was added). Table/List view was
already correct — it renders "Status" as its own dedicated badge cell only when explicitly added as
a column, with no separate always-on indicator to duplicate against.

Verified: `npx tsc --noEmit` clean. Live-verified on the running dev server — Overview view now
shows just the badge + year (no duplicate lowercase status text) for the same items that showed the
bug before the fix; Poster and Table views re-checked and unaffected.

## Round 317 — Poster info: three options were dead weight on most library types

**"Quality," "Path," and "Size on disk" in the Poster info/Overview info/Columns menu (and the
matching Sort options) never did anything on any episodic (TV/Anime/Sports/PPV) or collection
(Music/Books/Comics/Manga/Audiobooks/...) library** — checking the box or picking the sort option
looked like a real setting but silently produced no visible change. Root cause: `media_items
.quality`/`.path`/`.size_bytes` are only ever populated when a file is placed directly against the
*parent* row (`importer.ts`'s `typeConfig.shape === "single"` branch) — for episodic/collection
shapes a file always belongs to a child episode/sub-item instead, and (per `schema.sql`'s own
comment on `size_bytes`) there was never any per-child size/quality/path tracking to roll up from,
so those three columns are simply always `NULL` for every non-"single" media type. `fieldValue()` in
`LibraryType.tsx` correctly returned `""` for them, which then got filtered out of the display —
correct behavior, but the picker still offered them everywhere regardless, so a Series/Album/Book
library's Poster info menu had three checkboxes that could never do anything.

Fixed by hiding those three options from Poster info/Overview info/Columns (`allFieldKeys`, one
shared list already backing all three) and from the Sort dropdown whenever the current library
type's shape isn't "single" — the options now only ever appear where they can actually show a
value, matching how Sonarr's own series list has no per-series "current quality" column for the
same underlying reason. Movies/ROMs/Videos/Courses/Adult (single-shape) are unaffected — all ten
options still show exactly as before.

Verified: `npx tsc --noEmit` clean. Live-verified against the running dev server across all three
affected surfaces (Poster info, Table view's Columns, and the Sort dropdown) on both a single-shape
type (Movies — all ten options still present) and episodic/collection types (TV Shows, Music — the
three dead options are gone from all three surfaces, table header row matches).

## Round 316 — live-tailing System → Logs, Radarr/Sonarr-style

**System → Logs now live-tails**, matching how the other Starr apps' own Logs page behaves, instead
of only ever showing a snapshot from whenever you last clicked "Load logs"/"Refresh". A new SSE
channel (`GET /api/system/logs/stream`, `services/logger.ts`'s `push()` now broadcasts every entry
the moment it's logged to any open connection) pushes new lines to the page as they happen; the page
now also auto-loads the first batch the moment you open the Logs tab instead of starting empty. A
"● Live"/"Pause" control lets you freeze the view mid-read without losing the connection — pausing
just drops incoming lines rather than queuing them, so resuming picks up from "now," not from where
you paused. A live entry is filtered against whatever level/search filter is currently set, same as
the existing "Load logs" fetch, so switching to "Error only" mid-session stops new warn/info lines
from appearing without needing a manual refresh to take effect.

This did **not** need any change to get log lines into `docker compose logs` — `logger.ts`'s
`log.info/warn/error` already unconditionally call `console.log/warn/error` before anything else
(persisting to the in-memory buffer/daily log file is the only thing the configurable "Log
verbosity" setting gates), so every line the new live view streams was already going to the
container's own stdout/stderr regardless. Confirmed this explicitly during verification rather than
assuming it, since it's exactly the kind of invariant a change like this could accidentally break.

Verified: `npx tsc --noEmit` clean in both projects. Live-verified against the real, rebuilt
server — opened the Logs tab and confirmed it auto-loaded and connected ("● Live"); the container's
own periodic IRC-reconnect warning (fires every 30s) appeared in the UI within a second of each
firing, with no manual refresh; clicking "Pause" and waiting through two more 30s cycles confirmed
nothing new appeared, then "Resume" picked the next one up immediately; setting the level filter to
"Error" and waiting through two more cycles confirmed the live warn-level lines were correctly
withheld from the view while `docker logs aonarr-server` kept receiving them the whole time,
unaffected.

## Round 315 — trending/recommended items were missing their backdrop image in the new preview

Round 314 sent Discover/Recommendations cards to the same `/add/preview` hero page Add Media and
Global Search already use, but the poster showed while the big backdrop image behind the title
never did — `fetchTrendingMovies`/`fetchTrendingSeries` (`services/metadata.ts`) and
`recommendMovies`/`recommendSeries`/`recommendArtists` (`services/recommendations.ts`) built their
own result objects straight from TMDB's/Last.fm's raw response instead of going through the shared
search helpers, and never carried over `backdrop_path` or `vote_average` — so `MetadataSearchResult
.backdropUrl`/`.rating` were always `null` for a trending or recommended item, even though the exact
same TMDB response includes them (every other metadata path already captures both, e.g.
`searchMoviesTmdb`). Fixed by mapping `backdropUrl`/`rating` the same way those other paths already
do (`TMDB_BACKDROP_BASE = ".../t/p/w1280"` + `backdrop_path`), and threading both fields through
`DiscoverItem`/`Recommendation` on the client so `openPreview()` now hands them to `/add/preview`
along with everything else.

Verified: `npx tsc --noEmit` clean in both projects. Live-verified against the rebuilt real server —
patched `window.fetch` to serve a fixture Discover/Recommendations result with a `backdropUrl` and
`rating` set, clicked through to `/add/preview`, and confirmed via the DOM (`.media-backdrop`'s
`background-image` style) that the backdrop image URL and the rating pill both actually render,
for both entry points.

## Round 314 — Discover/Recommendations cards open a real preview, plus a "View more" per section on Discover

**Clicking a Discover or Recommendations card now opens a preview** instead of doing nothing (the
card previously had no click handler at all — only the small Add/Request icon-button worked).
Mirrors exactly how `GlobalSearch.tsx`'s "Add new" results already behave: a result already in the
library navigates straight to its real `/media/:id` detail page, and anything else navigates to
`/add/preview` (the same Radarr/Sonarr-style "Add New" page reached from Add Media/Global Search)
pre-filled with the title/year/overview/poster/external ids already in hand — so you can review
root folder, quality profile, and monitoring before committing, rather than the old one-click
direct-add/request. The existing Add/Request/"Not interested" icon-buttons inside each card now
`stopPropagation()` so clicking them still does its own immediate action without also firing the
card's navigation.

To make the "already in library" branch possible, `GET /discover` now resolves each trending
result against the library by its real `media_items.id` (previously it only tracked a boolean
membership flag, discarding the id) and returns it as `mediaItemId`.

**"View more" per section on Discover**, matching the pattern `Recommendations.tsx` already used —
each of "Trending Movies"/"Trending TV" now shows its first 12 results with a "View more (N more)"
button revealing the rest of that already-fetched batch, instead of dumping the whole list (or,
previously, having no way to see past whatever fit on screen at once).

Verified: `npx tsc --noEmit` clean in both projects. Live-verified against the real server —
patched `window.fetch` in the running dev server to serve fixture Discover/Recommendations
responses (real network calls need a configured TMDB/Last.fm key this test instance doesn't have),
then drove real clicks: an "in library" card landed on the real `/media/:id` page, a new card
landed on `/add/preview` with the right title/year carried over, the Add/Request icon-button's
`stopPropagation` was confirmed by watching the final URL land on the newly-created item's own
detail page rather than getting diverted to `/add/preview`, "Not interested" stayed on
`/recommendations` and correctly removed only that card, and "View more" on both pages correctly
revealed the remaining items with the right "(N more)" count. Test fixtures created along the way
(a temporary media item, an import exclusion) were deleted afterward via the API.

## Round 313 — toolbar button labels were getting clipped mid-word

**Icon-over-label toolbar buttons (`PageToolbar.tsx`'s `ToolbarButton`, rolled out in Round 310)
truncated longer labels mid-word** — e.g. "Organize & Rename" rendered as "Organize & Rena...".
`.toolbar-button-label` had a hardcoded `max-width: 96px` with `overflow:hidden;
text-overflow:ellipsis`; most labels fit under that, but a two-word label like "Organize & Rename"
(99px at the toolbar's font) overflowed it by just enough to get sliced. Fixed by removing the fixed
max-width/ellipsis entirely and letting each label size to its own content — `.toolbar-group`
already wraps to a new row when a toolbar is crowded (the same behavior Sonarr/Radarr's own toolbars
use), so there was never a need to truncate text to begin with. Also added `flex-shrink: 0` to
`.toolbar-button` within `.toolbar-group` so a crowded row wraps a button to the next line instead of
silently shrinking its label narrower than its content, mirroring the identical fix `.toolbar` (the
older, unrelated toolbar row) already had for the same class of bug.

Verified: `npx tsc --noEmit` clean; checked every current `ToolbarButton` label across the app
(measured against the real rendered font) and confirmed only "Organize & Rename" was actually
overflowing the old 96px cap. Live-verified against the running dev server at both normal and a
narrowed (850px) viewport width — the label now renders in full with no truncation at any width.

## Round 312 — Media Analyzer: live progress, search-for-upgrade, pagination/CSV export, and a real language/resolution dedup bug
Three requested improvements plus a real bug fix to the same page.

**Duplicate resolution/language buckets, fixed.** The Resolution/Subtitle languages/Spoken
languages summary tables grouped by the raw, unnormalized values — a language tag could be
2-letter ("en"), 3-letter ("eng"), or either the bibliographic or terminology ISO 639-2 form
("ger"/"deu", "fre"/"fra", "chi"/"zho" — muxers aren't consistent), so the same actual language
showed up as 2-3 separate rows; resolution grouped by exact `WxH` ("1920x1080" vs. a cinematically
-cropped "1920x804" of the same 1080p source) for the same reason. Fixed with two new pure
functions in `services/mediaAnalysis.ts`: `normalizeLanguage()` (uses `Intl.DisplayNames`, built
into Node/every browser — no new dependency — to resolve any of those forms to one canonical name,
falling back to the raw code only when it's genuinely unrecognized) and `resolutionTier()`
(classifies into the same named tiers the rest of the app already uses for the quality ladder and
release-name parsing — 480p/576p/720p/1080p/2160p — by the *longer* edge, since a cinematic crop
shrinks height, not width). Audio/subtitle tracks with no language tag at all now bucket as an
explicit "Unknown" instead of being silently omitted. Both functions are duplicated client-side
(same technique already used for `clampLimit`/`csvEscape`-style small utils) so a stat-table click
still matches the same items it's grouping. New tests cover the exact "en"+"eng" and letterboxed
-1080p dedup cases directly, and the one pre-existing test that asserted the old raw-string
behavior is updated to the new (correct) grouping.

**Live analysis progress**, replacing the old fire-and-forget "click Analyze Now, then go check
the Logs page" experience: `runLibraryAnalysis()` now maintains a module-level progress state
(`{running, total, done, failed}`) polled every 1.2s by a new `GET /media-analysis/progress`
endpoint, rendered as a live progress bar with a running "N of M probed" count. `POST /run` now
refuses to start a second run while one is already in flight (`{started:false, reason:
"already-running"}`) instead of letting two overlapping probes race each other. Loading the page
while a run is already active (e.g. started from another tab) picks up its progress immediately
rather than only ever noticing a run this page itself started.

**"Search for a better release"**, reusing the exact same `POST /search/bulk` automated search
-and-grab endpoint Cutoff Unmet already uses for this identical "found something suboptimal, try
to upgrade it" case — per-row and bulk (checkbox selection + "Search all currently shown,"
respecting whatever filters are active) actions, chunked into batches of 100 to respect that
endpoint's per-request cap regardless of how many rows are selected.

**Pagination and CSV export**, matching every other list page's now-standard pattern. Since the
whole analysis result is already fetched in one (fast, ffprobe-free — it only reads already-stored
`media_info`) request, pagination here windows the already-loaded, already-filtered array
client-side via the shared `Pagination` component rather than adding new server round-trips.
"Export CSV" hits a new `GET /media-analysis/export.csv` endpoint (matching `/api/media/export.csv`'s
existing convention) with one row per analyzed file — title, path, codec, resolution tier, HDR,
audio/subtitle languages, and compatibility notes.

Verified: `npx tsc --noEmit` clean in both projects; the full server test suite (89 files, 1377
tests, including 20 new tests for `normalizeLanguage`/`resolutionTier`/the dedup regression) passes
in a disposable Linux container. Live-verified against the real server with inserted fixture files
covering "en" vs. "eng" vs. "fre" vs. "fra" audio tracks and a letterboxed-1080p file — confirmed
they collapse into single "English"/"French"/"1080p" buckets and that clicking a bucket still
finds the same items; ran a real analysis pass and confirmed the progress bar's poll-and-detect
-completion cycle end to end (including the already-running guard); confirmed bulk search-selected,
"Select all on this page", and CSV export (checked the actual response body) all work with no
console errors.

## Round 311 — toolbar alignment fix, a real "Downloaded" status bug, and pagination for six more pages
Three separate fixes/additions from user-reported issues.

**Toolbar alignment.** `.toolbar-group` (the new `PageToolbar`'s left/right containers from Round
310) never got the height-normalization rules `.toolbar` has always had for its own button/select/
input/dropdown children — so a `DropdownMenu` trigger (View mode, Columns, ...) sitting in a
`PageToolbar`'s right-hand group rendered ~15-20px taller than the plain `<select>`s next to it,
inheriting the base `button` rule's `margin-top: 16px`/`padding: 8px 16px` unreset. Fixed by adding
the same `.toolbar-group > select/input { height: 38px }` and `.toolbar-group > .dropdown > button
{ height: 38px }` reset `.toolbar` already had — a pure CSS fix, affects every page using
`PageToolbar` at once.

**"Downloaded" status was wrong for partially-downloaded TV shows and Music.** For episodic
(series/anime/sports) and collection (music/books/comics/...) shapes, a media item's own `has_file`
only ever meant "at least one episode/track has a file" (see `services/childCounts.ts`) — but
`LibraryType.tsx`'s status badge/poster-banner and the server's `status=downloaded`/`status=missing`
library filter (`services/mediaQuery.ts`) both checked that flag directly, so a series with 1 of 10
episodes downloaded showed "Downloaded" and could never match a "Missing" filter. Fixed on both
sides: `posterBanner()` now uses the already-fetched `childCount`/`childHaveCount` to show
"Downloaded" (all children present), a new neutral "Partial" state (some present — new
`.poster-banner.partial`/`.pill.partial`, reusing `var(--muted)`, no new colors), or "Missing" (none)
for those two shapes; the server's status filter now requires ALL episodes/tracks present for
`downloaded` and treats anything short of that as `missing`, via new `typeKeysByShape()` (derived
from `MEDIA_TYPES`, not hardcoded) and a `downloadStatusCondition()` helper in `mediaQuery.ts`.
Single-file shapes (movies, ROMs, ...) are unaffected — this only changes behavior for shapes with
children. Added a real regression test (`mediaQuery.test.ts`) covering fully/partially/never
downloaded series, replacing an old test that asserted the exact literal SQL string.

**Pagination for six more list pages**, matching the page-size dropdown (30/60/100/250) + "Page X
of Y (N total)" + Prev/Next pattern `LibraryType.tsx` and `AuditLog.tsx` already used — extracted
into a new shared `web/src/components/Pagination.tsx` component (both of those now use it too,
replacing their own hand-rolled copies) and reusing `mediaQuery.ts`'s existing `clampLimit`/
`clampOffset` helpers server-side. Added real server-side `limit`/`offset` (previously: no limit at
all, or a hardcoded window with no way to page further) to: `Activity.tsx`'s Queue and History
sections and the standalone `HistoryPage.tsx` (all three backed by `routes/activity.ts` — the
History section's old fixed top-200 merge-and-slice and `HistoryPage`'s hard 500-row cap are both
gone, replaced by real paging), `Blocklist.tsx`, `CutoffUnmet.tsx` (paginated over the resolved
candidate-id array, since `findUpgradeCandidates()` has no SQL WHERE to attach a COUNT to), and
`ImportReview.tsx` and `Requests.tsx`. Every affected endpoint's response shape changed from a bare
array to `{ items, total }` (or `{ rows, total, page, pageSize, totalPages }` for `cutoff-unmet`,
matching `audit-log`'s existing shape) — updated the one other caller this broke
(`server/src/mcp/server.ts`'s `get_queue` tool, now requests `?limit=500` and unwraps `items` to
keep returning a plain array) and a second one missed by the first pass (`Settings.tsx`'s Blocklist
tile, which fetched `/blocklist` independently of the dedicated page and needed the same unwrap).
A "select all visible"/bulk-selection feature on a paginated list now naturally scopes to the
current page's loaded rows, matching how `LibraryType.tsx`'s own bulk-select already worked — not a
regression, the expected behavior once paging exists.

Deliberately NOT paginated, each for a specific reason (flagging so these aren't mistaken for
oversights): `Missing.tsx`'s episode section and `RecycleBin.tsx` group their rows into collapsible
per-series/per-type sections — naive row-level pagination would break that UX, and doing it right
needs group-aware pagination on the server, a bigger, separate change. `Duplicates.tsx` computes
its (typically small) result via a full in-memory scan/group rather than a single filtered SQL
query, so `LIMIT`/`OFFSET` doesn't attach cleanly. `Collections.tsx` is admin-curated and stays
small in practice, not library-scaled. `WatchlistImport.tsx` renders the one-shot result of a
single POST (already capped at 500 rows), not a persisted, growable list with a GET endpoint to
paginate.

Verified: `npx tsc --noEmit` clean in both `web/` and `server/`; the full server test suite (89
files, 1367 tests, run in a disposable Linux container per this repo's usual native-binary-on-
Windows workaround) passes, including the new regression test. Live-verified against the real
`aonarr-server`/`aonarr-web-dev` stack with temporary DB fixtures (inserted, checked, deleted) for
every one of the six newly-paginated pages/sections plus the two fixed status-badge/toolbar issues
and both broken callers' fixes — no console errors on any of them.

## Round 310 — Starr-style page toolbars, detail-page pills, and popup-ified forms across the app
Layout-and-placement pass matching how Sonarr/Radarr/Lidarr/Readarr/Whisparr actually organize a
page — verified against Sonarr's own frontend source (GitHub), not guessed. Colors are completely
unchanged; the sidebar/topbar toggle and the centered/full-width layout toggle both work exactly
as before (neither was touched).

Two new shared pieces: `web/src/components/PageToolbar.tsx` (`PageToolbar`, `ToolbarButton` —
icon-over-label, for page-level actions — and `ToolbarSeparator`) and a handful of new CSS classes
in `styles.css` (`.page-toolbar`, `.toolbar-group`, `.toolbar-button`, `.detail-pills`/`.pill`,
`.spin`), plus five new icons in `ActionIcons.tsx` (`RowsIcon`, `TableIcon`, `FilterIcon`,
`SortIcon`, `RefreshIcon`). `.page-toolbar` bleeds to the edges of `.content` the same way
`.media-backdrop` already does, so it automatically respects the layout-width preference. The
existing icon-only `.icon-button` convention (Round 305) is untouched and still owns every
per-row/in-context action (a table row's Search/Delete icon, a card's remove icon) — the new
labeled `ToolbarButton` is only for the page-level toolbar bar.

Rolled out across every admin page with a real page-level action (29 page files): library list
pages consolidated their two stacked ad hoc toolbars into one left/right `PageToolbar`; every
detail page (`MediaDetail`, `EpisodeDetail`, `SubItemDetail`, `TrackDetail`, `AddPreview`) moved
its action-icon row above the poster/backdrop hero (previously it sat awkwardly mid-page) and
replaced the inline paragraph-of-badges-and-a-table with a proper `.detail-pills` fact row;
`MediaDetail`'s five inline toggle-panels (Edit Metadata, History, Move to Group, Split, Artwork)
are now real `Modal` dialogs instead of inline divs, and its two genuinely-editable fields
(Minimum availability, Series type) moved into the Edit Metadata modal alongside title/year/
overview/poster/backdrop, with a read-only pill showing the current value on the page itself.
Several pages that used an always-visible inline form for their one "create X" action (Collections,
ImportLists, Requests' non-admin submission form) now use an Add-button-opens-Modal pattern
matching Indexers.tsx, which was built first as the reference implementation every other agent
worked from. Pages with no real page-level action (Person, RecycleBin, AuditLog, ApiDocs,
Changelog, NetworkStats, and others) were deliberately left alone rather than growing an invented
toolbar with nothing meaningful in it.

Verified live against the real running `aonarr-web-dev`/`aonarr-server` containers: the reference
page (Indexers) and the two highest-risk conversions (`LibraryType`'s consolidated toolbar,
`MediaDetail`'s full hero/toolbar/modal rework, including opening the new Edit Metadata modal and
confirming Minimum availability still saves) both in top-bar and collapsed-sidebar layouts, plus
Collections' new Add-modal and Activity's nested toolbar buttons. Caught and fixed one real bug in
review: `.toolbar-button.danger` didn't reset `background`, so the pre-existing global
`button.danger { background: var(--danger) }` rule (matched by class alone, regardless of a
button's other classes) painted a solid red block over the Remove button's icon and label instead
of the intended transparent-until-hover treatment — fixed by explicitly setting
`background: transparent` on the danger variant. `npx tsc --noEmit` and `npm run build` both pass
clean across the whole `web/` project.

A few deliberate scope boundaries, all noted so they aren't mistaken for oversights: `Settings.tsx`
and `System.tsx` (large, tab-based pages) had their header/tab-bar placement confirmed already
correct and were left otherwise untouched — restyling their tab *content* would be a much bigger,
separate effort. `AddMedia.tsx`'s search/lookup step keeps its existing segmented type/provider
picker as-is (an in-form tab switcher, not a page-level action row). A small, known inconsistency:
the read-only "Downloaded/Missing" status fact renders as the original colored `.badge ok/danger`
in some pages and stays that way deliberately (`.pill` has no green success variant), which is a
reasonable follow-up if a future round wants to add one.

## Round 309 — the last native popup: window.prompt() replaced too
Closes the one gap Round 308 deliberately left open. New `web/src/utils/promptDialog.ts` +
`web/src/components/PromptModal.tsx` (same architecture as `confirmDialog`/`ConfirmModal`: a
promise-based singleton, mounted once in `App.tsx`, built on the shared `Modal.tsx` shell) replace
every `window.prompt()` in the app — `promptDialog({title, label, defaultValue?}): Promise<string |
null>` for a single field (same null-vs-string contract `prompt()` itself has), or pass `fields:
[...]` for more than one, resolving `Record<string, string> | null` instead.

Two sites used to chain two `prompt()` calls to fake a multi-field form — both now show a single
dialog with both fields together instead: `SubItemDetail.tsx`'s series editor (name + position,
position only meaningful when name is set) and `GroupPicker.tsx`'s "+ New" group creation (name,
plus a website field shown only for a "site"-kind group). The other 7 sites (`LibraryType.tsx` ×3,
`MediaDetail.tsx` ×2 — including the Share-link clipboard-copy fallback, which was never really
collecting new input, just using `prompt()` as a copy-paste field — and `SubItemDetail.tsx` ×2)
convert 1:1.

Verified live: the single-field case (`LibraryType.tsx`'s "Save view", including cleanup via the
also-newly-converted "Delete view" confirm), the two-field case (`SubItemDetail.tsx`'s series
editor, against the same sub-item fixture used in earlier rounds — reverted after), and the
conditional-field case (`GroupPicker.tsx`'s "+ New Site" showing both Name and Website, versus a
plain kind showing just Name). One finding worth remembering, not a real bug: a synthetic Enter
keypress from browser automation didn't trigger the form's implicit submission even though focus
and the DOM were verified correct — calling `form.requestSubmit()` directly confirmed the wiring
was right all along; this is an automation-layer quirk, not an app bug, and real keyboard input in
a real browser is unaffected.

Every native browser popup (`alert`, `confirm`, `prompt`) is now gone from the app.

## Round 308 — popup rollout complete: LibraryType, MediaDetail, Settings
Finishes the `alert()`/`confirm()` → `notify`/`confirmDialog` conversion started in Round 306 —
**every native browser popup in the app is now gone**, across all three of the largest remaining
files (`LibraryType.tsx`, `MediaDetail.tsx` — the biggest file in the app, `Settings.tsx`), on top
of their checkbox-chain functions already converted in Round 306. ~45 more call sites, all the
same established patterns: error/success/info toasts, and plain yes/no confirms for token/API-key/
webhook-URL regeneration, group/saved-view deletion, root-folder moves, and a blocklist release.
One post-action navigation choice (`MediaDetail.tsx`'s split-item "go to the new show now?") uses
`confirmDialog` with custom `confirmLabel`/`cancelLabel` ("Go to new show"/"Stay here") instead of
generic Confirm/Cancel, since it isn't a safety gate — the split already happened, this is just
picking where to go next.

`window.prompt()` (a handful of free-text inputs — `SubItemDetail.tsx`/`MediaDetail.tsx`'s cover-
art/series/narrator editors, `GroupPicker.tsx`'s "+ New" group creation, `MediaDetail.tsx`'s Share-
link clipboard-copy fallback) is deliberately left alone — a different native-popup category
(text input, not alert/confirm) that would need its own new modal component, not built as part of
this effort.

Verified live: `Settings.tsx`'s "Regenerate API key" confirm dialog rendered and cancelled cleanly;
`LibraryType.tsx` and `MediaDetail.tsx` both load without any new console errors after their edits.
A final app-wide grep (`grep -rn "alert(\|confirm(" web/src/pages web/src/components`) turned up
only doc-comment mentions of the old behavior — no real call sites left.

## Round 307 — popup rollout continues: Activity, IptvPlaylists, System, SubItemDetail
Continues Round 306's `alert()`/`confirm()` → `notify`/`confirmDialog` conversion. All four files
are now fully converted (44 combined call sites: `Activity.tsx` 5, `IptvPlaylists.tsx` 10+1,
`System.tsx` 9+3, `SubItemDetail.tsx` 16+2) — all straightforward error/success/info toasts and
plain yes/no confirms, no new multi-checkbox chains found (`System.tsx`'s restore-backup and
rename-files confirms were each already a single question, not a chain, despite being high-stakes
enough to double-check for one).

Fixed a real gap the conversion surfaced: `.toast-message` had no `white-space: pre-line`, so a
multi-line message (e.g. `System.tsx`'s "N item(s) failed to delete:\n<list>") would have silently
collapsed onto one line instead of showing each failure separately — `ConfirmModal.tsx`'s message
paragraph already had this, the toast just hadn't needed it until this batch.

Verified live: `System.tsx`'s Trakt sync produced a real `.toast.success` ("Trakt sync added 0 new
item(s)."), confirmed via the DOM (a fast robot click checks the DOM before React's batched state
update flushes — a ~1s wait was needed to see it, worth remembering for future toast checks so a
timing artifact doesn't get mistaken for a real bug). `SubItemDetail.tsx`'s "Mark as missing"
confirm dialog rendered correctly against a temporary sub-item fixture (has_file flipped to 1 and
back after). Remaining large files (`LibraryType`, `MediaDetail`, `Settings` — each has real
alert/confirm sites beyond the checkbox-chain function already converted in Round 306) continue
next.

## Round 306 — Servarr-style popups (toast + confirm dialog) + Add Media redesign
Two related pieces of new infrastructure, plus a first rollout batch of each.

**Toast notifications and a confirmation dialog, replacing the browser's native `alert()`/
`confirm()`.** New `web/src/utils/notify.ts` (`notify.success/error/info(message)`, a tiny
pub-sub any file can call directly) + `web/src/components/Toaster.tsx` (stacked, auto-dismissing,
top-right, new slide-in `@keyframes` in `styles.css` — the first animation in the app). New
`web/src/utils/confirmDialog.ts` (`confirmDialog({title, message, danger?}): Promise<boolean>`,
or pass `options: [{key, label}]` for a richer Sonarr/Radarr-style checkbox dialog, resolving
`{confirmed, values} | null` instead) + `web/src/components/ConfirmModal.tsx`, a thin wrapper
around the existing `Modal.tsx` shell (reuses its focus trap/Escape/stacking for free). Both are
mounted once in `App.tsx`, next to the existing `CommandPalette`.

Three multi-`confirm()`-chain call sites — each used to simulate a 2-3-way choice by stacking
native confirms, since a single native dialog can't have checkboxes — now use one `confirmDialog`
call with `options` instead, matching how Sonarr/Radarr's own delete dialogs actually look:
`LibraryType.tsx`'s bulk delete (delete files? add exclusion?), `MediaDetail.tsx`'s remove-item
(same two questions — previously an awkward "Cancel this dialog, then OK the next one" dance to
fake a third option, now just an unchecked checkbox), and `Settings.tsx`'s root-folder delete
(remove media too? delete their files too?). The duplicate-add retry shape (`if (confirm(x)) {
retry(true) }`, found in `AddMedia`→now `AddPreview`, `Discover.tsx`, `Requests.tsx` ×2) uses the
plain boolean form.

Every `alert()`/`confirm()` in `Blocklist`, `Calendar`, `CalendarDay`, `CollectionDetail`,
`Collections`, `CutoffUnmet`, `DownloadClients`, `GlobalSearch`, `Indexers`, `IrcFeeds`, `Jobs`,
`MediaAnalyzer`, `Missing`, `Recommendations`, `RecycleBin`, `Users`, `WatchlistImport`,
`Discover`, `Requests`, `ImportReview`, `Duplicates`, `EpisodeDetail`, and the components
`NamingSetupModal`/`NotificationsToggle` is now converted (~24 files). The remaining large files
(`Activity`, `IptvPlaylists`, `System`, and the rest of `LibraryType`/`MediaDetail`/`Settings`/
`SubItemDetail` beyond the checkbox chains already done) continue in follow-up rounds, same
incremental rhythm as the icon-button rollout. `window.prompt()` (a few free-text inputs in
`SubItemDetail.tsx`/`GroupPicker.tsx`) is a separate, smaller residual native-popup category not
covered by this round.

**Add Media now previews like a real detail page before creating anything**, matching Sonarr/
Radarr's "Add New Series/Movie" convention. New `web/src/pages/AddPreview.tsx` (routed at
`/add/preview`) renders a `MediaDetail.tsx`-style hero (same `.media-backdrop` CSS, poster, title,
overview — deliberately reimplemented rather than extracted from `MediaDetail.tsx`, since a
pre-add item has no numeric id yet for the real page's cast/ratings/trailer/file-status sections
to key off, and extracting a shared component would mean threading "is this real or a preview"
conditionals through the largest file in the app for ~100 lines saved) below which the actual
root-folder/quality-profile/monitor-strategy/group-picker configuration and "Add to library"
button live. It's reached via React Router navigation state — no new backend endpoint, since every
caller already has the full search-result payload in hand client-side.

`AddMedia.tsx` (616 → ~330 lines) keeps everything with no equivalent elsewhere — type/provider
picker, title search, ID/URL match, .nfo import, course-URL scrape — but every path now hands its
result to `AddPreview.tsx` instead of expanding an inline form; it has no more knowledge of root
folders, quality profiles, or monitor strategy at all. `GlobalSearch.tsx`'s "Add new" results
already fetched the same rich `/metadata/search` payload but were discarding it and making
`AddMedia.tsx` redo the search from scratch (`/add?type=X&q=title`) — now it navigates straight to
`AddPreview.tsx` with the result already in hand, both fixing the redundant re-search and getting
the same detail-page-styled preview. `FriendLibraries.tsx`'s "Add" link (title/type hint only, no
full result) still goes through `AddMedia.tsx`'s own search first, same as before. The
`MetadataSearchResult` shape all of these share is now one exported interface in `web/src/types.ts`
instead of three near-duplicate local ones.

Verified live against the running dev server: a temporary movie fixture confirmed the new
checkbox-based remove dialog; a real manual-entry add (no external metadata dependency, since this
sandbox's outbound network access can't reach TMDB/Open Library) round-tripped through
`AddPreview.tsx` into a real library item, and adding the same title again correctly triggered the
duplicate-confirm dialog and completed on retry; the toast system confirmed rendering a real
in-app notification (`MediaAnalyzer.tsx`'s "Analysis started" message). Both test items and the
movie fixture were deleted after verification.

## Round 305 — the last 9 components: icon-button rollout is complete
Closes out the icon-button effort that started at Round 294. `Modal.tsx`'s "✕" close button —
used by literally every modal in the app — is now a real `XIcon`, the last raw-glyph-as-button-
child holdout (`DropdownMenu.tsx`'s "▾"/"▸" disclosure suffix is a small state indicator glued onto
caller-controlled text, not a standalone button, so it stays as-is, same call already made for the
identical pattern in `RecycleBin.tsx`/`LibraryType.tsx`). `FolderPicker.tsx`'s "Create" (paired
1:1 with its own "New folder name" input, a genuine mini-toolbar) converts to `PlusCircleIcon`;
its "Select this folder"/Cancel footer stays text — that pairing is this modal's actual primary
confirm/cancel action, the same shape every modal-footer pair in this rollout has kept as text.

The other 7 needed zero changes, each a shape an earlier round already named: `ApiKeyGate.tsx` (9
buttons — Continue/Back TOTP pairs, a lone primary "Create admin account", and the account-mode
selector the original plan explicitly called out as a stays-text tab strip) and
`SearchMatchModal.tsx` (4 — the by-title/by-ID toggle, ditto, plus two lone primary form submits)
needed nothing beyond what Round 294's plan already decided for them by name.
`NamingSetupModal.tsx` (4) is a modal-footer Save/Reset-to-default/Cancel trio (extends the
established 2-button-footer-stays-text rule to 3) plus a row of token-insert buttons whose LABEL
*is* the token being inserted (`{title}`, `{season:00}`) — can't be iconified, the text is the
payload, same reasoning as LibraryType's letter-index strip. `RenamePreviewModal.tsx` (2) is a
count-bearing "Rename N file(s)"/Cancel footer. `SettingsProviderTiles.tsx` (1, "Send test
notification") is the tile-click-to-Modal shape with one lone action at the bottom — stays text
regardless of ZapIcon being established elsewhere, per Round 299's rule that the shape decides,
not icon availability.

**All 49 `web/src/pages/*.tsx` files (Round 294-304) and all 16 `web/src/components/*.tsx` files
(this round) are now fully resolved.** The Servarr-style icon-button rollout that began at Round
294 is complete.

Verified live: reopened the real `Scheduled Backups` → `Browse for a folder` nested-modal flow
(System.tsx → FolderPicker.tsx) against the running dev server — both dialogs' close buttons and
the "Create folder" icon button all render and label correctly, confirmed via the accessibility
tree; closed both without changing any real setting.

## Round 304 — 13 smaller pages converted to icon buttons, 6 needed zero changes
`TrackDetail.tsx` (Back), `CalendarDay.tsx` (Back, Open/Remove), `Blocklist.tsx` (row Remove —
deliberately not `.danger`, since un-blocklisting is reversible and the original used `secondary`
not `danger`), `AuditLog.tsx` (Previous/Next pagination), `Recommendations.tsx` (Add, Not
interested), `Jobs.tsx` (inline schedule Save, Run now, Cancel), `Collections.tsx` (card Delete),
`CollectionDetail.tsx` (Up/Down reorder, Remove), and `RecycleBin.tsx` (Recycle/Dismiss on the
corrupt-media review table, Restore/Delete forever on the recycled-files table) all converted
cleanly using icons already established in earlier rounds.

`FriendLibraries.tsx`, `WatchlistImport.tsx`, and `MediaAnalyzer.tsx` needed one conversion each
once a closer look turned up a fitting established icon: the missing-items table's "Add" (now
PlusCircleIcon, matching Recommendations.tsx), "Choose CSV file..." (now FolderIcon, matching
every other folder-browse trigger in the app), and "Analyze now" (now ZapIcon, the same
trigger-a-background-job icon `Jobs.tsx`'s "Run now" uses) respectively — everything else on those
three pages stayed text: mode choosers, modal-footer Save/Delete pairs, and count-or-loading-label
buttons ("Merge N into the kept item", "Import N title(s)") where the label itself carries
information an icon would drop. `Duplicates.tsx`, `InviteAcceptPage.tsx`, `Onboarding.tsx`,
`Account.tsx`, and `CustomColumns.tsx` needed zero changes, each matching a shape an earlier round
already decided stays text (count-bearing Merge, primary-CTA-only, first-run welcome screen,
Settings.tsx's 2FA section mirror, and AiProviders.tsx's tile-and-modal pattern).

`SubItemDetail.tsx` got the full toolbar treatment (Search, Download for YouTube items, Mark as
missing, Back, Grab in the search-results modal, Fetch tracks) plus two new icon choices: CpuIcon
for "Scan for ISBN" (reading a file's own contents to identify it, the same idea as MediaDetail's
AI Identify) and ShareIcon for "Send to Kindle" (sending the file to an external destination, same
idea as Calendar's calendar-app subscribe). Its toolbar's plain-text "Monitor"/"Unmonitor" button
was also replaced with the actual `MonitorToggle` component already used for the same field two
rows up in this same page's own details table — `EpisodeDetail.tsx`'s toolbar already does this
rather than duplicating the action as a second, differently-styled control. "Convert to
chapterized M4B" stayed text: standalone (not in the toolbar row), no established icon fits, and
its loading label doubles as an important warning ("this can take a while").

Verified live against the running dev server: real data for Duplicates/Jobs, a temporary
friend-library fixture, and temporary media-item/sub-item fixtures (an audiobook chapter and a
standalone book, inserted and removed via direct DB access) for SubItemDetail's fuller
has-file/parent-type conditional toolbar, including one under an "author" parent to reach Scan for
ISBN and Send to Kindle. No regressions; light theme spot-checked on Jobs.tsx.

## Round 303 — AddMedia (no changes) + 8 Configuration-adjacent pages converted to icon buttons
`AddMedia.tsx` (616 lines) needed zero changes — every one of its 8 buttons is either a segmented
mode toggle (Search by title/Match by ID, Add manually/Search metadata instead) or a primary form
submit (Search, Match, Load NFO, Fetch course info, Add to library), and its search results render
as clickable cards, not buttons. `AiProviders.tsx` and `IrcFeeds.tsx` also needed zero changes —
both use a "click a tile, edit in a Modal" pattern structurally identical to `Settings.tsx`'s
tile-panels (just Modal-based instead of inline), so the same "lone panel action stays text"
reasoning applies directly: Test connection, Save/Add, and Delete are all exactly the shape Round
299 established as text.

The other six DID have real toolbar/table shapes to convert: `Indexers.tsx` (Add indexer + Test
all in the top toolbar, row-level Test/Delete — "Sync from Prowlarr"/"Sync from Jackett" stayed
text, same distinguishing-label reasoning as Round 299's "Sync Radarr/Sonarr formats"),
`DownloadClients.tsx` (a standalone "Test all" converted since ZapIcon=Test is now established
from Indexers.tsx moments earlier in this same round; the paired "Test connection"/"Check health"
inside the edit modal stayed text since forcing only one of a two-button row to convert would look
worse than either extreme, and "Check health" has no established icon), `ImportLists.tsx` (Sync
now/Delete row pairing), `IptvPlaylists.tsx` (Attach paired with its picker select, Detach,
Up/Down/Remove in the items table), `RemoteLibrary.tsx` (Browse, paired with its instance/type
selects), and `Users.tsx` (a standalone "Dismiss" now converts too, since XIcon=Dismiss was
established by `ImportReview.tsx` in Round 302; Revoke row actions in both the Invites and Active
Sessions tables).

Verified live against real fixtures (a temporary indexer, inserted and removed) and real data
already in the instance (existing download clients, real active sessions) — confirmed the
Indexers row Test/Delete icons, the paired-text Test-connection/Check-health modal, and both
Revoke icons in Users.tsx's session/invite tables. No regressions.

## Round 302 — ImportReview, Discover, Requests, GlobalSearch, EpisodeDetail converted to icon buttons
Five more pages, continuing the icon-button rollout beyond the original priority list.
`ImportReview.tsx` (Match.../Dismiss row actions) and `Requests.tsx` (Approve/Reject/Cancel row
actions, a new `CheckIcon` use for Approve) were both clean, small tables — straightforward
conversions. `Discover.tsx`'s poster-grid Add/Request buttons (one per card, same shape as
`MediaDetail.tsx`'s TMDB-collection-part Add button from Round 295) both use `PlusCircleIcon` —
they're mutually exclusive per card (admin sees Add, everyone else sees Request) so reusing one
icon for both creates no ambiguity. `GlobalSearch.tsx` needed no changes at all — its only button is
the primary search-submit (stays text) and "Clear" (next to Recent searches) turned out to already
be a styled `<span>` link, not a `<button>`, so it was never in scope.

`EpisodeDetail.tsx` — flagged during Round 296's live-testing as missing from the original survey —
turned out to closely mirror `MediaDetail.tsx`'s own manual-import/search-results panels, so every
icon choice reused directly: the main toolbar (Search/Manual-Import/Mark-as-missing/Back-to-show),
and found the SAME "component already imported but not used for the main toggle" pattern Round 295
fixed in MediaDetail — `MonitorToggle` was already imported and used inline elsewhere on the page,
but the main toolbar had its own separate plain-text Monitor/Unmonitor button doing the identical
toggle; replaced it with the existing component instead of converting it to a new icon button.

Verified live: ImportReview and Requests confirmed against temporary fixture rows (inserted and
removed after), EpisodeDetail confirmed against a temporary test episode showing every toolbar icon
including the file-gated "Mark as missing". Discover couldn't be verified against real data (no
TMDB key configured in this instance) but uses an already-proven pattern. No regressions.

## Round 301 — Missing.tsx, CutoffUnmet.tsx, Calendar.tsx, Dashboard.tsx converted to icon buttons
Four smaller, clean-shaped pages in one round — all genuinely toolbar/table-shaped throughout (no
`SettingsSectionTiles`-style lone-panel buttons to filter out this time). `Missing.tsx` and
`CutoffUnmet.tsx` share the same layout (a bulk-select bar, a per-section "Search all", and
per-row Search/Open) and got identical treatment: SearchIcon for every search action (including
the per-series "Search all missing in this series" in Missing's episode grouping), and the new
`ArrowRightIcon` (added in Round 300) for every "Open" row action. `Calendar.tsx` got chevrons for
month/agenda navigation, `CalendarIcon` for "Today" (jump to the current date on the calendar
you're already looking at), `PlusCircleIcon` for "Add custom date", `ShareIcon` for "Subscribe from
calendar app..." (generating an external feed URL is the same "give this to something else"
concept as `MediaDetail.tsx`'s Share button), and `TrashIcon`/`ArrowRightIcon` for the day-detail
table's Remove/Open row actions — "Regenerate URL" for the ICS token stayed text, matching the
established "consequential, standalone security-token action" exception. `Dashboard.tsx` got a new
`SlidersIcon` for "Customize layout" (established here as this rollout's "adjust settings/layout"
convention) and upgraded its two reorder buttons from raw ↑/↓ text glyphs to real `ArrowUpIcon`/
`ArrowDownIcon` SVG components, matching the same upgrade already done for `LibraryType.tsx`'s
group-delete glyph in Round 297.

Verified live: Missing's bulk-select bar and per-section search icons work against real data,
Calendar's month navigation and day-detail panel render correctly, Dashboard's customize-layout
panel shows correctly disabled up/down arrows at the first/last row. No regressions.

## Round 300 — System.tsx: icon buttons, refined by a "does this icon read on its own" test
`System.tsx` (27 buttons) mixes both shapes seen so far — some tabs (Backups) use
`SettingsSectionTiles`, others (Overview/Health/Maintenance/Insights/Logs) use plain tables and
toolbar rows closer to `LibraryType.tsx`'s shape. Converting it surfaced a sharper version of the
rule Round 299 established: a button converts when it's either (a) part of a genuine multi-button
row/pairing, or (b) a LONE button whose icon is already an established, unambiguous convention
elsewhere in this rollout (Refresh/Download/Delete) — since users have already learned that icon's
meaning. A lone button with a RARE, specific action and no established icon (Check for updates, Run
archival now, Load reputation stats, Run validation) stays text even when nothing else is nearby,
since forcing an icon there would just mean "guess, or hover for the tooltip every time."

Converted: Health's lone "Refresh" (established icon, safe alone), Backups' "Download backup"/
"Restore from backup..." pairing (Download/Inbox icons — Inbox since restoring is literally
uploading a backup file, matching the existing Manual-Import convention) and its "Browse..."
button, two "Open" row-actions in result tables (a new `ArrowRightIcon` added to `ActionIcons.tsx`,
pairing with the existing `ArrowLeftIcon`), Cleanup Suggestions' row-level "Delete", and the Logs
tab's "Load logs"/"Refresh"-toggling button + "Download .log" (a real toolbar row, select+input+
buttons together) plus Log Files' own lone "Refresh" and row-level "Download".

Stayed text: the Maintenance tab's 6-button cluster (Run archival now / Scan library for existing
files / Scan for orphaned files / Full orphaned-file scan / Run Trakt sync now / Run Plex watchlist
sync now) — six mutually-confusable, rare, high-consequence actions bunched together with no clean
1:1 icon mapping, the same "label carries essential distinguishing info" reasoning applied
repeatedly this rollout, just at a larger cluster size than before. "Find unmonitored + no file"/
"Find duplicate files" (both would need the same Search icon, indistinguishable from each other).
"Delete all {N}" (the count in the label is meaningful safety information an icon would discard).

Verified live against the real running server: backup/restore icons render with correct danger
styling, the Logs tab's button correctly toggles its aria-label between "Load logs" and "Refresh
logs" as state changes, real log data loads and displays correctly. No regressions.

## Round 299 — Settings.tsx: icon buttons where they actually fit
Continued the Phase 2 rollout, but `Settings.tsx` (41 buttons in the original survey) turned out to
need a genuinely different treatment than `Activity.tsx`/`MediaDetail.tsx`/`LibraryType.tsx`. Those
three pages are built around dense toolbar/table rows — several actions sitting side by side, where
icons read cleanly as a group. `Settings.tsx` is built around `SettingsSectionTiles`: click a tile,
get a dedicated, mostly-textual form panel for that one root folder/tag/quality/quality-profile/
delay-profile/release-profile/custom-format/subtitle-provider, with usually exactly ONE action
button at the bottom (Delete, Regenerate, Test, Sync, Import, Set up 2FA, ...). Converting a single
lone button to an icon in the middle of an otherwise all-text, all-label form panel doesn't read as
"organized" the way a row of several icons does — it reads as an orphaned glyph with no siblings to
give it context, which is worse than what it replaced. Real Sonarr/Radarr keep exactly this shape
of button (a lone destructive/consequential action at the bottom of a settings panel) as text too.

So this round converted only what's genuinely row/toolbar-shaped: the three list-style tables at
the top of the file (Blocklist/Tags/Import-Exclusions, each a real multi-column table with a
row-level Remove/Delete in the last column — TrashIcon), the root-folder path picker's "Browse..."
button (paired with an input, same shape already converted in `MediaDetail.tsx` — FolderIcon), and
the quality ladder's "Move up"/"Move down" pair (a genuine 2-button mini-toolbar with an
unambiguous icon pair — ArrowUpIcon/ArrowDownIcon). Everything else — every "Delete {X}" at the
bottom of its own settings panel, every Regenerate/Test/Sync/Import/Show-URL/Register-command
button, and "Sync Radarr formats"/"Sync Sonarr formats" (same "label carries essential
distinguishing info" reasoning as `MediaDetail.tsx`'s "Fetch from {provider}") — stays text.

Verified live: the quality reorder icons show correct disabled state at the first/last rank, the
folder-browse icon opens the picker correctly, and no regressions elsewhere in the tab strip or
panel forms.

## Round 298 — audit web/ for the stray-"0" JSX conditional bug
Follow-up to the bug found in Round 296: any `0 | 1`-typed field (not `boolean`) used bare in a
JSX `&&` chain — `{subItem.hasFile && (...)}` — renders the literal number `0` as a visible text
node when the field is 0, since `&&` returns the first falsy operand rather than coercing to
`false`. Enumerated every `0 | 1`-typed field across `web/src/types.ts` and every page/component
file with its own locally-defined interface (`monitored`, `hasFile`, `protected`, `enabled`,
`useFlareSolverr`, `useSsl`, `audioOnly`, `pauseGrabsAtQuota`, `autoApprove`, `isDefault`,
`require_review`, `insertAfterEachItem`), then grepped every `web/src/pages/*.tsx` and
`web/src/components/*.tsx` file for bare usage. Found two more genuine instances in
`SubItemDetail.tsx` (the "Scan for ISBN" and "Send to Kindle" button conditionals, both keyed off
`subItem.hasFile`) and fixed both with `!!subItem.hasFile`. Confirmed every other match already
used a safe form (`!!field`, or `!field` — logical NOT always produces a real boolean regardless of
operand type, so that pattern was never actually at risk). Verified live against a real sub-item
with `hasFile: 0` — no stray "0" renders, and the file-gated buttons correctly stay hidden.

## Round 297 — LibraryType.tsx converted to icon buttons
Continued the Phase 2 rollout — `LibraryType.tsx` (39 buttons in the original survey) is now fully
converted: the group-browse level (Edit/Add description, Add group, and upgrading the existing
raw "✕" glyph delete button to a real `XIcon` for visual consistency with the rest of the icon
set), Save/Delete view, the quick-add button, Scan & Import/Refresh/Organize & Rename, pagination
(Previous/Next → chevrons), and the whole bulk-action bar (Monitor/Unmonitor/Search selected/
Remove/Apply/Apply tag).

Left as text, deliberately, extending the established exceptions: `DropdownMenu`'s own menu items
(View: Posters/Overview/Table, Export CSV/.nfo/JSON/.plexmatch/Calibre, Bulk edit via CSV) stay
text — a vertical menu list needs readable labels the way any app's dropdown does, unlike a row of
standalone icon buttons; converting only the dropdown *trigger* to icon-only while keeping menu
items textual would also lose the trigger's "shows the current selection" affordance (e.g. "View:
Table") that a bare icon can't convey. "Import from Media Server" and "Import from {StarrApp}" stay
text since which external system is being imported from is essential, not decorative, information
— same reasoning as `MediaDetail.tsx`'s "Fetch from {provider}". "Select"/"Done selecting"/"Select
all on page"/"Select none"/"Clear selection" all stay text, matching the precedent already set by
`Activity.tsx`'s own selection-composition controls in Round 294.

Verified live: main toolbar renders correctly, the bulk-action bar's icons and Apply/Apply-tag
flow work, and the group-browse level's "Add System" (ROMs, a grouped type) renders with the
correct dynamic label. No regressions.

## Round 296 — MediaDetail.tsx: the rest of the file converted to icon buttons
Finished what Round 295 started — every remaining row/toolbar action button in `MediaDetail.tsx`
is now icon-only: the season toolbar (Sync scene numbering, List/Tiles view toggle — a new
`GridIcon` added to `ActionIcons.tsx` for "tiles"), each season's own action row (Search season,
Scan & Import, Manual Import, Organize & Rename, Refresh), episode row actions (Search, Manual
Import), collection-shape sub-item row actions (Download, Search, Manual Import), the search-
results modal's Grab/Blocklist buttons, and the manual-import browse panel's navigation (Back to
downloads, Browse this folder, Up, AI Identify, per-row folder Open). Left as text, deliberately:
primary form-submit CTAs (Save metadata, Save location, Split N episodes, Apply merged metadata,
Import N checked files) and the per-provider "Fetch from {provider}"/"Set as backdrop"/"Set as
poster" buttons, where the label itself carries essential information an icon can't — matching the
conventions from Round 294.

`MediaDetail.tsx` (53 buttons in the original survey) is now fully converted. Verified live against
three different item shapes — single (a movie), episodic (a temporary test series with 2 seasons/3
episodes, inserted and removed after), and collection (an existing audiobook with a sub-item) — via
the accessibility tree, confirming every button's `aria-label` and every shape-conditional
(Move to group/Split/Check for corruption/Artwork only showing where they always did) survived the
conversion unchanged. No regressions found this round.

## Round 295 — MediaDetail.tsx: main header toolbar converted to icon buttons
Continued the Phase 2 icon-button rollout — `MediaDetail.tsx` is the largest file in the survey
(53 buttons), so this round scopes to its highest-visibility section: the header (Share, File
details toggle, the 3 small "Add" buttons next to collection/tag/TMDB-collection-part pickers) and
the full main action toolbar (Monitor, Protect, Mark watched, Search now, Manual Import, Scan &
Import, Refresh, Edit metadata, History, Organize & Rename, Check for corruption, Export .nfo,
Search for a different match, Export for Plex, Move to group, Split, Artwork, Remove — 18 buttons
total). The plain-text Monitor/Unmonitor button was replaced with the existing `MonitorToggle`
component (already icon-only, already used elsewhere, just not here) rather than reinventing it.
Toggle-state buttons reflect state the same way `MonitorToggle` already does — an accent-colored
icon when active (Protect, Mark watched) — and panel-open/close buttons (Edit metadata, Move,
Split) swap between their action icon and an X when open, matching their "Cancel X" text before.
Added a new `EyeIcon` to `ActionIcons.tsx` for the watched toggle.

Found and fixed a genuine pre-existing bug while verifying live: two conditionals used
`item.hasFile` (typed `0 | 1`, not `boolean`) bare in a JSX `&&` chain — `{item.hasFile && (...)}`
— which renders the literal number `0` as a stray text node when `hasFile` is 0, since JS's `&&`
returns the first falsy operand rather than coercing to `false`. Confirmed visually (a stray "0"
appeared on the page for a missing/no-file item) before fixing both with `!!item.hasFile`. The same
pattern likely recurs elsewhere in the frontend (any `0 | 1`-typed field used bare in `&&`) —
flagged as a follow-up task rather than chased site-wide in this round.

Verified live against the running `aonarr-server` container in both themes: icon-swap on
open/close, accent-color on toggle-state buttons, and the stray-"0" fix all confirmed working.
Remaining in this file for future rounds: the season/episode table's own row actions, the
search-results table, the manual-import browse panel, the artwork picker, and the metadata-merge
table.

## Round 294 — Servarr-style icon buttons + Activity page overhaul (Phase 1)
First round of a visual pass matching Sonarr/Radarr/Lidarr/Readarr/Whisparr conventions more
closely: icon-only action buttons instead of text-labeled ones, and a fully reworked Activity
page. Added `web/src/components/ActionIcons.tsx` (Trash/X/Check/Pencil/ArrowLeft/ChevronLeft/
ChevronRight/ArrowUp/ArrowDown/Folder — the same 18px stroke-SVG style as the existing
`NavIcons.tsx`, reusing several of its icons too: Search/Download/RotateCcw/PlusCircle/Zap/Slash/
Inbox/etc.) and a new `button.icon-button`/`button.icon-button.danger` CSS class formalizing the
ad hoc inline styles `MonitorToggle.tsx` used before this class existed. Convention going forward:
row/toolbar action buttons (Delete, Edit, Search, Refresh, Sync, Retry, Prioritize...) go
icon-only with a tooltip; primary form-submit CTAs and tab strips stay text, matching what real
Sonarr/Radarr actually do; view-mode switchers go icon-only.

The Activity page (`web/src/pages/Activity.tsx`) got the deeper rework: every Queue action button
is now icon-only, and the Queue table gained Season/Indexer/Protocol/Download Client columns and a
per-status icon alongside the existing text badge — all from data the backend was already
returning (`indexerId`/`downloadClientId`/`episodeId`/`seasonNumber` were already present in
`queueItemFromRow` but untyped/unrendered on the frontend) plus a client-side join against the
already-existing `/indexers` and `/download-clients` endpoints — no backend changes needed. Added
a Protocol filter alongside the existing Status filter, and Sonarr-style bulk selection (mirroring
`LibraryType.tsx`'s established `selectMode`/`selected: Set`/bulk-action-bar pattern): a checkbox
column, select-all-visible/clear-selection, and a bulk action bar for Remove/Remove & Blocklist/
Retry import across every selected item at once. Both the Queue and History sections were migrated
off their own hand-rolled sort state onto the shared `useSortableTable` hook (already used by
`HistoryPage.tsx`), extending the sortable columns to cover everything new. Verified live against
the running `aonarr-server` container (temporary queue fixture rows inserted directly, removed
after) via the dev-server preview: all new columns render correctly (including real download-client
name resolution), bulk-select and the bulk action bar work, sorting works across every column, and
both light and dark theme render the new icon buttons cleanly.

This is Phase 1 of a larger effort — the remaining ~46 page/component files still have
text-labeled buttons and will be converted in priority-ordered batches in future rounds, each
verified the same way before moving to the next.

## Round 293 — encryption.ts: proving key persistence on disk, not just in-process round-trips
Continued down the fresh ratio-scan list. `encryption.ts` (0.72, 93 lines) already had solid
round-trip/mismatch/reload coverage, but every existing test only proved behavior WITHIN one
process — `cachedKey` masks whatever actually landed on disk, so nothing previously confirmed the
generated key FILE itself is valid, which is what actually matters for surviving a restart or a
database restore onto a different config volume (the exact scenario this file's own module comment
is written around). Added a test that deletes the key file, triggers creation via `encryptValue`,
and reads the file back directly to confirm it holds a genuine 64-hex-character key rather than
just something that happens to round-trip in memory. Also added the corrupted-key-file branch — a
key file that EXISTS but fails the hex-format check takes a different code path (falls through
normally, no exception) than a missing file (caught via `try`/`catch`) though both end up
regenerating a fresh key; and `decryptValue`'s behavior on a truncated/malformed encrypted value
(not just a wrong-key one) — a genuinely different real-world failure mode, e.g. settings-table
corruption, still throws rather than producing something undefined. No source bug — every branch
already worked correctly. Full suite: 1362 → 1365 tests, 89 files.

## Round 292 — recommendations.ts: the series pipeline and a documented-but-unproven dedup guard
Continued down the fresh ratio-scan list. `recommendations.ts` (0.67, 309 lines) had reasonable
movie-recommendation coverage, but `recommendSeries` — TMDB's TV-shaped sibling of the well-tested
`recommendMovies`, reading different field names (`name`/`first_air_date` instead of
`title`/`release_date`) — had never been exercised by a single test, and neither had
`recentlyWatchedLibraryItems`'s series branch (joining `episodes` to `media_items` to find the
most-recently-watched episode per show) or `runAutoRequestFromWatchHistory`'s series-episode
insertion path. Added one end-to-end test covering all three at once: a watched series correctly
produces a TMDB TV-shaped recommendation. Also added the DB-level proof for
`runAutoRequestFromWatchHistory`'s `insertedThisRun` guard — its own comment explains it exists
because `getRecommendations()` computes "added" and "watched" (and each individual source item)
independently, so the exact same TMDB id can legitimately appear twice in one batch from two
different source items, but nothing previously proved the guard still prevents a double-insert. Two
source movies engineered to both recommend the same target confirmed exactly one row (and one log
line) resulted, not two. Rounded out with `tmdbSimilar`'s `!res.ok` branch (a failed TMDB request
degrades to no results, not a throw). No source bug — every branch already worked correctly,
including the duplicate-prevention guard its own comment specifically calls out. Full suite: 1358 →
1362 tests, 89 files.

## Round 291 — releaseParser.ts: source-detection branches masked by an untested title, plus dedup/fallback edges
Continued down the fresh ratio-scan list. `releaseParser.ts` (0.62, 261 lines) was already well
tested overall, but re-reading `detectSource`'s if-chain (Remux checked before Bluray before
WEBDL/WEBRip/HDTV/DVD) against the existing fixtures turned up a real blind spot: the only
BluRay-tagged title in the suite also contains "REMUX" in the same string, so Remux (checked first)
always wins there — that existing test could pass whether or not Bluray-alone detection actually
worked, since nothing exercised a BluRay title without a Remux tag alongside it. Added a
Bluray-only title, its "bdrip" spelling, a WEBRip title (previously zero coverage; every existing
WEB-flavored fixture happened to use WEBDL), and a DVD-only title (proving `detectQuality`'s
special-cased bare `"DVD"` return, since a DVD release has no resolution tag to combine with a
source). Also added the documented-but-never-asserted "resolution present, no recognized source at
all → assume WEB-DL" fallback, a language-dedup test proving two different literal tokens
("TrueFrench" and "French") that map to the same canonical tag collapse to one result instead of
two, a null-release-group case, leading-zero stripping in the `1x01`-style fallback notation, and
`releaseMatchesEpisode`'s scene-numbering guard requiring BOTH `sceneSeasonNumber` AND
`sceneEpisodeNumber` together (supplying only one must be ignored, not partially applied). No
source bug — every branch, including the ones masked by a previously-untested title shape, already
worked correctly. Full suite: 1349 → 1358 tests, 89 files.

## Round 290 — quality.ts: the fire-and-forget cache invalidator and two boundary shapes
Continued down the fresh ratio-scan list from Round 289. `quality.ts` (0.57, 139 lines) was already
solidly tested overall, but `invalidateQualityRankCache` — the one exported function with zero
coverage — is the file's fire-and-forget cache-refresh entry point (its own doc comment names it as
one of exactly three call sites in the whole codebase): it fires `loadQualityCaches()` and attaches
`.catch()` without awaiting it. Added a success-path test (a newly inserted quality becomes visible
to `qualityRank` shortly after calling it, without the caller awaiting anything) and a failure-path
test proving the `.catch()` genuinely swallows a rejection rather than leaking an unhandled
rejection — forced a real DB read failure by renaming the `qualities` table out from under it for
the duration of one call (restored immediately after), then listened for `process`'s
`unhandledRejection` event, the same technique validated on `ircFeedManager.ts`'s announce-handler
wiring. Also closed two narrower gaps: `sizeWithinQualityBounds`'s min-only and max-only bound
configurations were previously only ever tested together (both set on the same row), never proving
the two `!= null` guards are actually independent; and `pickBestAllowedQuality` never had a test
proving a candidate sitting exactly *at* the cutoff (not just strictly below it) is included, nor
that its below-cutoff fallback correctly ranks among *multiple* eligible candidates rather than just
returning whichever one happened to be present. No source bug — every branch already worked
correctly, including the cache-invalidation failure path. Full suite: 1343 → 1349 tests, 89 files.

## Round 289 — mediaAnalysis.ts: `runLibraryAnalysis`, the only file-mutating function, had zero coverage
Re-ran the ratio-scan against the current codebase now that the original candidate list (Rounds
283-288) is closed. `mediaAnalysis.ts` (0.54, 334 lines) turned out to be another instance of the
"only the pure/read-only surface is tested" pattern: `analyzeCompatibility` (10 tests) and
`getLibraryAnalysis` (6 tests) were both already well covered, but `runLibraryAnalysis` — the
function that actually re-probes files with ffprobe and writes the result back into
`media_items`/`episodes`/`sub_items` — had never been called by a single test. Added 8 tests
mocking `ffprobe.js`'s `probeMediaInfo` via the established closure-indirection pattern: a
successful probe updates the right row's `media_info`, a non-probeable extension is skipped without
calling the probe at all, a null probe result counts as `failed` without touching the row, episodes
and sub_items are each updated in their own table (not their parent's), a single call's
`probed`/`failed` counts aggregate correctly across all three tables, and — the two branches most
worth proving given this function does real, repeated DB writes — an already-aborted `AbortSignal`
returns immediately without probing anything, and a signal that aborts mid-scan stops before the
next row rather than finishing the batch. Every test used its own never-reused synthetic `type`
value (this file's tests otherwise share one un-cleaned DB) so none of the eight could pick up
another test's leftover rows. No source bug — every branch, including cancellation, already worked
correctly. Full suite: 1335 → 1343 tests, 89 files, stable across the run (no recurrence of Round
288's full-suite-only flakiness).

## Round 288 — mediaQuery.ts: 3 of 8 status filters, and their underlying heuristics, untested
Last candidate from the original ratio-scan list (0.47, 213 lines). `buildMediaQuery`'s existing 15
tests covered most of its filter surface, but three of its eight `status` values —
`cutoffUnmet`, `filenameMismatch`, and `unmatched` — were never exercised, including the two real
algorithmic heuristics behind the first two (`findCutoffUnmetIds`'s quality-vs-profile-cutoff
comparison, `filenameLooksMismatched`'s significant-word-overlap check) — the same kind of "the
public function's own real logic was untested" gap found repeatedly this phase, just scoped to a
few branches of an otherwise well-tested function rather than the whole file. No source changes;
every branch worked correctly once exercised.

- `tests/mediaQuery.test.ts` — 15 → 23 tests. `status:unmatched` (null/empty/`{}` `external_ids`);
  a specific (non-`"all"`) `contentRating` filter; `status:cutoffUnmet` (an item below its quality
  profile's cutoff included, one at or above it excluded, and the `where:null` short-circuit when
  nothing qualifies); `status:filenameMismatch` (a file whose name shares too few significant words
  with its title flagged, a well-matched one not, and its own `where:null` short-circuit); the
  free-text search actually wiring `toFts5Query`'s output into the SQLite FTS5 subquery (the
  transform itself was already unit-tested, but not that `buildMediaQuery` actually uses it
  correctly) and a blank search term adding no condition at all; `tagId` combined with an explicit
  `type` (both conditions present, tags still joined); and a plain (non-`"none"`) `groupId`.

  A genuinely puzzling test-only issue surfaced on the first full-suite Docker run (isolated to
  this file, and not reproducible when running it alone): the `cutoffUnmet`/`filenameMismatch`
  short-circuit tests found non-empty results, and a fixture's `INSERT` into `quality_profiles` hit
  a `UNIQUE` constraint on a name only that one `it` block ever inserts — both symptoms consistent
  with those specific tests somehow executing more than once against the same database under a
  full-suite run, despite `vitest.config.ts`'s `fileParallelism: false`. The exact mechanism wasn't
  fully pinned down, but the fix is correct regardless of cause: `findCutoffUnmetIds`/
  `findFilenameMismatchIds` scan every `media_items` row with no type filter at all (`allowedTypes`
  only affects a separate, later-joined condition), so their own tests can't assume a clean
  precondition just from being declared "first" — each now does its own explicit cleanup of the
  exact criterion it depends on before asserting anything, making both self-contained and
  idempotent against re-execution regardless of order or repetition. Verified stable across two
  consecutive full-suite runs after the fix.

Test count: 1327 → 1335 (89 files, no new files this round).

## Round 287 — starrImport.ts: 3 of 6 exported functions had zero coverage
Last candidate from the Round 284 ratio scan. `starrImport.ts` (468 lines) migrates an existing
Radarr/Sonarr/Lidarr/Readarr library into AoNarr — 6 exported functions, one pair per *Starr app.
Only `fetchRadarrMovies` (partially) and `importArtistsFromLidarr` (thoroughly, from an earlier
round) had tests; `importMoviesFromRadarr`, `fetchSonarrSeries`, `importSeriesFromSonarr`, and
`importAuthorsFromReadarr` — more than half the file's public surface — had never been exercised
at all. No source changes; every branch worked correctly once exercised.

- `tests/starrImport.test.ts` — 7 → 17 tests. `fetchSonarrSeries` gets direct coverage of its own
  real logic (previously completely untested): mapping a series plus its per-series episodes/files
  (Sonarr's v3 API has no bulk endpoint, so this is an N+1-per-series fetch — proven with two
  independent series in one call), resolving a real file path only for an episode Sonarr reports as
  downloaded, and skipping a title-less series entirely. `importMoviesFromRadarr`/
  `importSeriesFromSonarr` each get one delegation-proof test — the shared match-or-create logic
  they call into (`importMovieItems`/`importSeriesData`) is already thoroughly proven by
  mediaServerImport.test.ts, so these just confirm the fetch-then-delegate wiring itself works.
  `importAuthorsFromReadarr` — the single most under-tested app in the file — gets parity with
  Lidarr's own treatment: a new author+book creation test (confirming the `goodreads` external-
  provider wiring specifically, Readarr's own detail) and an existing-author-matched-by-external-id
  test; `importCollectionData`'s deeper branches (skip-by-known-path-tail, COALESCE metadata fill,
  never-downgrading a downloaded child) are shared code already exhaustively proven via Lidarr, so
  they aren't re-proven a second time for Readarr specifically. Also added one more edge case to
  the existing `importArtistsFromLidarr` coverage: when Lidarr reports more than one track file for
  an album, only the *first* one's directory is used as the album's folder path.

Test count: 1320 → 1327 (89 files, no new files this round).

## Round 286 — duplicateCheck.ts: mergeMediaItems's less-common branches, and the scheduled job
Next candidate from the Round 284 ratio scan. Unlike the last three rounds, `duplicateCheck.ts`'s
existing 8 tests already covered real orchestrator logic (not just pure helpers) — `findDuplicateGroups`,
`mergeMediaItems`'s single/episodic-shape happy paths, `dismissDuplicateGroup`. The gap here was
narrower but still real: several of `mergeMediaItems`'s less-common branches, and
`runScheduledDuplicateCheck` (the scheduled counterpart to the on-demand Duplicates-page sweep),
had zero coverage. No source changes; every branch worked correctly once exercised.

- `tests/duplicateCheck.test.ts` — 8 → 23 tests. `mergeMediaItems` gaps closed: the "collection"
  shape (sub_items, e.g. Music/Books) had never been exercised at all, unlike its single/episodic
  siblings; `deleteFiles: true`'s actual `recycleFile` dispatch was never proven for any shape
  (single-item both-have-files, episodic collision, or collection collision) — every existing test
  only ever passed `false`; tag and collection-membership reassignment (dedup via `INSERT OR
  IGNORE`) was untested; the `REASSIGN_TABLES` loop was only ever proven for `history`, never for
  `blocklist`/`queue`; and the guard branches (empty/keeper-only `loserIds`, a missing keeper
  throwing, a missing or wrong-type loser silently skipped) plus a genuine multi-loser merge in one
  call. `runScheduledDuplicateCheck` gets its first coverage at all: no groups found; a newly-found
  group recorded and notified with its year-formatted title; a group already recorded from an
  earlier run correctly *not* re-notified (needed its own scoped `beforeEach` table wipe — this
  function scans literally every `media_items` row with no filter, and every other describe block
  in this file deliberately accumulates state across tests, so without the wipe a duplicate pair
  from the very first test in the file was still sitting there polluting a "zero groups" assertion —
  the identical `runAllImportLists` shared-table issue from Round 270, recurring in a new file);
  the notified title list capped at 5 while the true count is still reported accurately; and a
  notification failure swallowed rather than thrown. Also added direct tests for
  `findPossibleDuplicates` (previously only exercised indirectly via importLists.test.ts),
  including its deliberately looser year-matching than its `mediaServerImport.ts` cousin
  `titleAndYearMatch`: either side missing a year still allows a title-only match here, where the
  cousin function requires an exact title when either year is unknown.

Test count: 1304 → 1320 (89 files, no new files this round).

## Round 285 — archival.ts: the file-moving/deleting logic itself was completely untested
A third instance of the Round 283/284 pattern, and the most consequential one: `archival.ts`'s
existing 8 tests only covered its small pure/DB-read helpers (`pathTail`, `findWatchedMatch`,
`effectiveRetentionDays`) — `getUpcomingArchivals` (the "Leaving Soon" preview) and, more
importantly, `runAutoArchival` itself — the function that actually **moves or permanently deletes
real media files** off disk once they're watched and past their retention window — had never been
exercised at all. No source changes; every branch, including the cross-filesystem `EXDEV` fallback
and the destructive delete path, worked correctly once actually tested against real files.

- `tests/archival.test.ts` — 8 → 26 tests (pre-existing pure-helper tests untouched). Real
  temp-directory files are used throughout (not mocked fs) so the actual move/delete/copy behavior
  is genuinely verified end to end; only `mediaServer.js`'s `fetchWatchedFiles` (kept real:
  `getMediaServerConfig`, already covered by mediaServer.test.ts) and `recycleBin.js`'s
  `recycleFile` (has its own dedicated test file) are mocked. `getUpcomingArchivals`: no-op without
  a configured media server or on a fetch failure; a watched item's `scheduledFor` computed
  correctly from its real last-played time plus the effective retention window; a never-archive
  override excludes an item; episode/sub-item candidates get their own composed labels; sort order.
  `runAutoArchival`: every no-op gate (not enabled, no media server, no archive folder *and* no
  permanent-delete opt-in); a watched+aged file actually gets moved to the real archive folder on
  disk, the DB row's `has_file`/`path`/`quality` are cleared, and a `history` row is logged; a
  watched-but-not-yet-aged file is left alone; a never-archive override is respected; permanent
  delete routes to `recycleFile` instead of a real move; **one item's archive failure (a real
  `ENOENT` from a file that no longer exists on disk) is caught and logged without aborting the
  rest of the run** — proven with a second, healthy item in the same batch that still gets
  archived correctly; the same shape for episodes and sub-items; the roll-up `UPDATE` that flips a
  show's own `has_file` back to 0 only once *every* one of its episodes has been archived, not
  before; and the `EXDEV` (cross-filesystem) fallback to copy+delete, forced via `vi.spyOn(fsp,
  "rename").mockRejectedValueOnce(...)` since a same-filesystem temp dir can't produce a genuine
  cross-device error naturally.

Test count: 1288 → 1304 (89 files, no new files this round).

## Round 284 — mediaServerImport.ts: the real import logic had the same gap as notifications.ts
A systematic check for more files with this shape (source-to-test line ratio) surfaced
`mediaServerImport.ts` as the next real match: 332 source lines, but its existing tests only ever
exercised the small pure matching helpers (`titlesMatch`, `exactTitlesMatch`, `titleAndYearMatch`,
`externalIdsOverlap`) — the actual `importMovieItems`/`importSeriesData` orchestrators, which do
every bit of the real database matching/creation/update work for importing an existing Plex/
Jellyfin/Emby library into AoNarr, had zero coverage. No source changes; every branch worked
correctly once exercised.

- `tests/mediaServerImport.test.ts` — 12 → 35 tests (the pre-existing pure-helper tests are
  untouched). `importMovieItems`: creates a new row with every field populated correctly
  (`has_file` driven by path presence); skips a title-less item and one whose path-tail is already
  known; matches an existing row by external-id overlap or by title+year, filling in only
  currently-null fields via `COALESCE` — proving a match's title is never renamed and its metadata
  is never overwritten; a match reached via a path-less (Starr-sourced) item never downgrades an
  already-downloaded row's `has_file`/`path`; matching runs against *every* movie, not just
  not-yet-downloaded ones (a documented past-fix, now regression-proof); two media-server items for
  the same new movie in one batch — the second matches the first instead of creating a duplicate
  (also a documented past-fix); a genuine mid-loop `AbortSignal` stop; and a malformed
  `external_ids` JSON on an existing row doesn't crash the match loop. `importSeriesData` gets the
  equivalent coverage one level deeper — the same match/create/`COALESCE`-update shape for shows,
  plus per-episode matching by season+episode under the resolved show, the show-id memoization that
  keeps a multi-episode show from being resolved (or created) more than once, and the roll-up
  `UPDATE` that flips a show's own `has_file` once any of its episodes has one. The two thin
  `import*FromMediaServer` wrapper functions get one delegation-proof test each, with
  `mediaServer.js`'s fetch functions mocked (inert for every other test in the file, since the core
  functions take already-fetched items directly).

Test count: 1267 → 1288 (89 files, no new files this round).

## Round 283 — notifications.ts: the whole notification pipeline had essentially no coverage
While looking for the next file to deepen, `tests/notifications.test.ts` turned out to be only 3
tests covering one small helper (`isEventEnabledFor`) — a narrow Round-229 regression test for one
specific bug fix, not a real test suite. Checking the CHANGELOG confirmed this: `notifications.js`
has been *mocked* as a dependency in dozens of other files' tests across the whole 282-round
history (scheduler.test.ts and others), but never given its own comprehensive coverage — a genuine
gap the file-level "every file has a test file" milestone didn't actually catch, since the file
technically already had one. `notifications.ts` (446 lines) is the entire outbound notification
pipeline: 10 provider sinks (Discord, Slack, a generic webhook, Telegram, Pushover, Matrix, Twilio
SMS, SMTP email, web push, and an admin-configured custom script), 8 event-trigger functions, a
per-provider "Test" dispatcher, and {token}-based template rendering — none of which had ever been
exercised directly. No source changes; every branch behaved correctly once actually exercised, but
in a genuinely close call, see below.

- `tests/notifications.test.ts` — 3 → 40 tests. `push.js`/`smtp.js`/`mediaServer.js` are mocked
  (each already has its own dedicated test file covering its real internals); `node:child_process`'s
  `execFile` is mocked at the raw callback level so `runCustomScript`'s `promisify(execFile)`
  wrapper (built once at module-load time) picks it up correctly. Covers: every sink's request
  shape (URL, headers, body) when configured, and that it's skipped entirely when it isn't; that
  each sink's own `<providerKey>Events` gate is actually wired into `fanOut` (not just that the
  `isEventEnabledFor` helper works in isolation); that one sink failing doesn't stop the others
  (`Promise.allSettled`) and never rejects the calling `notify*` function; `renderTemplate`'s
  settings-override and unknown-`{token}`-passthrough behavior; all 8 `notify*` functions' title/
  color/payload/template, including `notifyImported`/`notifyUpgraded`'s opt-in media-server-refresh
  side effect (and that a refresh failure is caught, not surfaced); `sendTestNotification`'s all 10
  provider branches (throws a specific message when unconfigured, sends when configured, and
  genuinely ignores the event-filter setting — proven by explicitly silencing a provider's events
  and confirming a test-send still goes through); and `runCustomScript`'s env-var construction
  (`AONARR_`-prefixed, camelCase→SNAKE_CASE) plus its two different failure-propagation shapes
  (`fanOut` swallows and logs; `sendTestNotification` lets it throw straight through).

  Two self-inflicted test bugs caught on the first Docker run, both worth remembering: (1) the
  `beforeEach` settings-reset initially used `setSetting(key, "")` for every `<providerKey>Events`
  key too — but `isEventEnabledFor` treats a *truly-unset* setting as "every event enabled" and an
  *explicitly-saved empty string* as "every event disabled" (that distinction is the entire point of
  the Round-227 bug this file's original 3 tests already guard), so the reset was silently
  disabling every provider before each test even started, exactly the `defaultProviderFor`/`??`
  class of mistake from Round 276, recurring in a new file — fixed with `deleteSetting` for those
  keys specifically, `setSetting(key, "")` for everything else. (2) A Matrix test asserted on
  `fetchMock`'s recorded call arguments, which are captured regardless of whether the mocked
  response ever actually matched — the route's own matcher used `%21room%3Aexample` for the room id
  `!room:example`, but `encodeURIComponent` leaves `!` unescaped (it's in the unreserved set), so
  the real URL never matched and the "successful" response was never actually returned; the
  assertions on call *arguments* still happened to pass since those are recorded independent of the
  mock's outcome, silently masking that the "success" path wasn't actually proven end-to-end.

Test count: 1230 → 1267 (89 files, no new files this round).

## Round 282 — deepen test coverage: mediaServer.ts's resilience-branch divergences
Continues the deepening phase, moving to `mediaServer.ts` (its own original round found "the real
Plex-skips-vs-Jellyfin-throws asymmetry on section-fetch failure" — a strong signal this file has
more than one such divergence worth surfacing). No source changes this round — every branch found
was real and already behaved sensibly, just unexercised and, in one case, un-*documented*; no
Docker image rebuild needed.

- `tests/mediaServer.test.ts` — 33 → 38 tests. The clearest find: `fetchMediaServerSeries` (both
  Plex and Jellyfin) silently continues with a partial result when its shows or episodes
  sub-request fails, unlike `fetchMediaServerMovies`'s Jellyfin path, which explicitly throws on
  the same kind of failure — a real, previously-untested (and previously undocumented in a comment)
  divergence between the two "fetch full library details" functions, now pinned down by a test for
  each provider rather than left to be rediscovered by surprise later. Also added: `fetchPlexItems`
  (used by `pushWatchState` to resolve which Plex item to scrobble) skipping a section whose own
  items request fails rather than aborting the whole match attempt; and `refreshMediaServerLibrary`/
  `triggerFullMediaServerScan`'s per-section `.catch(() => {})` on each individual Plex refresh
  call — previously only proven for a rejection at the *sections* list fetch (caught by the
  function's outer try/catch before ever reaching the per-section loop), never for one specific
  section's own refresh call rejecting while its siblings still succeed.

Test count: 1226 → 1230 (89 files, no new files this round).

## Round 281 — deepen test coverage: importLists.ts's four sync-provider parity gaps
Continues the deepening phase, moving to `importLists.ts` (Round 270, Trakt/IMDb/Last.fm/TMDB
list-sync). Applied the same "sibling functions should share the same tested behavior" lens that
found real gaps in Rounds 279-280: the four `sync*List` functions all share the identical
try/catch-per-entry resilience shape, an in-library dedup check, an exclusions check, and (where
applicable) genre filtering — but each function's *own* test coverage of these shared behaviors had
grown unevenly across the 11 prior rounds this file has passed through. No source changes this
round — every gap was a real branch that already behaved correctly; no Docker image rebuild needed.

- `tests/importLists.test.ts` — 35 → 42 tests. Genre filtering had only ever been proven
  end-to-end for TMDB lists (and only via `TMDB_MOVIE_GENRES`, never `TMDB_TV_GENRES` — a TV-only
  genre id like "Kids" has no entry at all in the movie map, so excluding it only proves something
  if the *TV* map is what's actually being read); added the equivalent end-to-end proof for Trakt's
  `entry.genres` and IMDb's CSV `Genres` column, plus a TMDB test that specifically excludes a
  TV-only genre. The in-library dedup check (`existingTmdbIds`, by TMDB id) was proven for Trakt but
  never for TMDB despite sharing the identical mechanism, and its own malformed-JSON tolerance (a
  pre-existing row with corrupt `external_ids`) was untested for either. Last.fm shares IMDb's
  `findPossibleDuplicates` dedup and the standard `isExclude`-based exclusions check, but had never
  had either path exercised — only its happy-path adds. Also added a negative-assertion test proving
  `insertArtistAlbums` never calls `fetchAlbumTracksFor` for an album the provider returned with no
  `externalId` (the field that gates whether a track fetch is even attempted).

Test count: 1219 → 1226 (89 files, no new files this round).

## Round 280 — deepen test coverage: downloadClient.ts's three debrid adapters
Continues the deepening phase, moving to `downloadClient.ts` (Round 274, already yielded one real
shipped bug — the NULL-byte one — so a second, more careful look felt warranted). Focused on the
three structurally-similar "debrid" adapters (Real-Debrid, TorBox, AllDebrid), reading all three
side by side the same way the Last.fm bug was found in Round 277: AllDebrid's own source comments
document two *previous* real bugs in its polling loop (a v4.1 API field removal, and `data.magnets`
sometimes coming back as a bare object instead of an array) — a strong signal this general area of
the file (JSON-shape assumptions in these three providers' polling/upload code) was worth a closer
pass. No source changes this round — every gap found was a real branch that behaved correctly, just
completely unexercised; no Docker image rebuild needed.

- `tests/downloadClient.test.ts` — 54 → 64 tests. The clearest gap, present in *both* Real-Debrid
  and TorBox: every existing test only ever fed the adapter a literal `magnet:` URI or a URL that
  redirects to one — the raw-.torrent-bytes upload branch (`PUT /torrents/addTorrent` for RD, a
  multipart `file` field for TorBox) had **zero** coverage in either adapter, despite being a real,
  regularly-hit code path (an indexer's proxy "get" endpoint that serves torrent bytes directly
  rather than redirecting). Also added: Real-Debrid's documented-but-unverified `selectFiles`
  202-already-selected special case (and its real-error sibling), its 6-hour polling deadline
  actually firing (proven cheaply via `vi.setSystemTime`+`vi.advanceTimersByTimeAsync` rather than
  waiting out thousands of real 5-second poll intervals), and its in-progress percentage reporting
  (previously proven for TorBox's polling loop but never for Real-Debrid's near-identical one);
  TorBox's `body.data` coming back as an array rather than a bare object during polling (mirroring
  the exact defensive-but-unverified pattern already fixed once for AllDebrid); TorBox's
  `download_state` failure match, empty-files case, and a `createtorrent` response with no
  torrent id anywhere; and AllDebrid's generic `call()` helper's `status:"error"` branch (used by
  4 different endpoints in the adapter, and — despite AllDebrid's polling-loop-specific
  `statusCode`-based failures being well tested — never itself exercised), its rejected-magnet
  error-message extraction (both the specific and generic-fallback cases), and `link/unlock`
  returning no direct link.

Test count: 1209 → 1219 (89 files, no new files this round).

## Round 279 — deepen test coverage: indexerClient.ts network retry, FlareSolverr, scene variants
`metadata.ts` (Rounds 276-278) has reached diminishing returns for further deepening, so this round
moves to a different file with the same "several provider/protocol adapters of varying depth"
shape: `indexerClient.ts` (Round 269, Torznab/Newznab/RSS/DDL). No source changes — purely
additional test coverage; no Docker image rebuild needed.

- `tests/indexerClient.test.ts` — 34 → 44 tests. The biggest gap: `withNetworkRetry`/
  `isTransientNetworkError`'s actual retry-then-succeed behavior had **never** been proven — every
  existing rejection test used `mockRejectedValue` (rejects every call identically), which can't
  distinguish "retried and recovered" from "never retried at all." New tests use
  `mockRejectedValueOnce` + `mockResolvedValueOnce` to prove a transient failure (message-matched
  `"fetch failed"`, an `err.code` like `ECONNRESET`, and the nested `err.cause.code` shape some
  runtimes wrap errors in) gets exactly one real retry and recovers — both through
  `checkIndexerHealth` directly and through a full `searchIndexer` torznab search, confirming the
  retried request's data actually flows through to a parsed result. Also proved a non-transient
  error is never retried, and that a *repeatedly*-failing transient error still only gets the one
  retry (not a loop) before giving up. Two other completely untested areas: the FlareSolverr
  proxying path (`fetchIndexerText`'s alternate branch for indexers behind Cloudflare) — POSTs to
  `{url}/v1` with the right request body, strips a trailing slash from the configured URL, only
  applies to an indexer that opted in via `useFlareSolverr` even when the instance-wide URL is
  configured, and surfaces both FlareSolverr's own "could not resolve" message and a non-OK HTTP
  response from FlareSolverr itself; and the query-limit's rolling 1-hour window, which was only
  ever proven to *block* once hit, never proven to actually *reset* once the blocking request ages
  past an hour (via `vi.useFakeTimers()`/`vi.setSystemTime()`). Rounded out
  `generateSceneVariants`'s coverage from 1 of its 4 transform branches to all 4 (and→&,
  drop-leading-article, space→dot, on top of the already-tested &→and), plus the "every variant
  also comes back empty" and "the query has no applicable variant at all" cases.

Test count: 1199 → 1209 (89 files, no new files this round).

## Round 278 — deepen test coverage: metadata.ts token expiry, episode aggregation, tie-breaks
Continues Round 277's "deepen already-tested files" phase, working through the specific gap list
sketched at the end of that round: IGDB token expiry, TVDB/Trakt/AniList episode edge cases, and
MusicBrainz's `pickBestRelease` tie-break logic. No source changes this round — purely additional
test coverage; no Docker image rebuild needed.

- `tests/metadata.test.ts` — 124 → 129 tests. New coverage: IGDB's cached-token *expiry* actually
  triggering a re-authentication (Round 276/277 only ever proved caching, never that an expired
  token gets refreshed) using `vi.useFakeTimers()`/`vi.setSystemTime()` to jump past a short-lived
  token's expiry without needing to fake any real timers; TVDB search/episode results defaulting
  every optional field to null when absent; Trakt's episode fetch aggregating across multiple real
  seasons (previously only ever exercised with one) and defaulting title/overview to null;
  AniList's episode-count lookup returning `[]` for a null or zero count rather than throwing or
  generating a placeholder list; and `pickBestRelease`'s full three-tier sort — Official status,
  then preferred-country, then earliest date — proven as a genuine *tier* ordering (a later date in
  a preferred country beats an earlier date in a non-preferred one; a missing date always loses to
  a real one) rather than just the single-tier case Round 276 covered, using releases where only
  the intended winner's own `/release/{id}` endpoint is stubbed at all — the wrong pick would fail
  on "unmocked fetch call," not silently return wrong data.

  One test-authoring bug self-caught on the first Docker run, itself an instance of a pattern this
  project's memory already flags: the new IGDB-expiry test initially ran *after* the existing
  "authenticates once and reuses the cached token" test in the same `describe` block, so the
  already-warm, still-real-time-valid `igdbToken` module cache from that earlier test meant the new
  test's own auth stub was never even reached (`authCalls` stayed 0). Reordering to run first
  wasn't enough on its own — the fix under fake timers also had to leave its *own* leftover cached
  token in an unambiguously-expired state (a `expires_in: 0` second response) so it couldn't go on
  to poison whichever IGDB test runs after it, regardless of how little real wall-clock time
  elapses between tests.

Test count: 1194 → 1199 (89 files, no new files this round).

## Round 277 — deepen test coverage: metadata.ts edge cases
With the untested-file backlog cleared (Round 276), this round shifts to deepening coverage in
already-tested files rather than starting new ones. `metadata.ts` was the freshest and most
clearly under-tested-by-design target: Round 276 deliberately gave ~25 "secondary" providers only
one happy-path test each. This round went back through that list and added the missing-field
fallback branches (a provider's own JSON commonly omits a field entirely, not just sets it to a
falsy value) each of those functions actually has.

- `tests/metadata.test.ts` — 119 → 124 tests (several existing tests also gained extra assertions
  without adding a new `it`). New coverage: Trakt search omitting the `imdb` key entirely (not
  just `undefined`) when Trakt has none; TVMaze with no image/summary/premiered date; Open
  Library/ComicVine/RAWG/ThePornDB search with every optional field absent; Deezer's
  `cover_medium` → `cover` poster fallback; Goodreads skipping a row with an empty title or no
  author link, and leaving `releaseDate` null when there's no "published YYYY" text; Audible's
  missing `product_images`/`release_date`; RAWG's and IGDB's maker resolution falling back to
  publisher when there's no developer entry; ScreenScraper's region/language helper falling back
  to the first entry when the preferred one is absent; TheGamesDB's `base_url` fallback chains
  (search: medium → original; artwork: large → original → medium); `fetchPlaylistByIdYoutube`
  throwing for a playlist that doesn't exist; confirming `searchMangaAnilist` never sets
  `runtimeMinutes` (unlike the anime/series AniList search, which does); Fanart.tv's `movielogo`
  fallback when `hdmovielogo` is absent; the podcast RSS parser's 500-episode safety cap (proven
  end-to-end with a 501-item generated feed, not just asserted from reading the source); and
  iTunes podcast search falling back to `trackName`/null when `collectionName`/`artistName`/
  artwork are absent.

  One more genuine source bug surfaced — this time caught by inspection before even writing the
  test, while comparing `fetchArtistAlbumsLastfm` against `searchArtistsLastfm` right above it in
  the file: `searchArtistsLastfm` unwraps Last.fm's `artistmatches.artist` field with
  `Array.isArray(matches) ? matches : [matches]`, because Last.fm's API (translated from XML) is
  well known to collapse a single-item list field down to a bare object instead of a 1-element
  array. `fetchArtistAlbumsLastfm`, immediately below it, read `topalbums.album` the exact same
  way but had no such guard — meaning any artist with exactly one top album would crash the whole
  album fetch with `albums.map is not a function`. Fixed with the identical one-line guard already
  used a few lines above it, and reproduced first with a failing test before the fix (rather than
  fixing blind) to confirm the bug was real. Docker `server`/`combined` images rebuilt and pushed
  (`web` untouched, no frontend changes).

Test count: 1189 → 1194 (89 files, no new files this round).

## Round 276 — test coverage complete: metadata.ts, the last untested file
Every service file in the codebase now has a test file. `metadata.ts` (2551 lines, the single
largest file in the codebase by a wide margin) is a huge but structurally flat collection of
~50 private per-provider fetch functions (TMDB, OMDb, Trakt, TVDB, TVMaze, AniList, MusicBrainz,
Deezer, Discogs, Last.fm, Open Library, Google Books, iTunes, Hardcover, Goodreads, AudNexus,
Audible, Comic Vine, RAWG, IGDB, ScreenScraper, TheGamesDB, YouTube, Vimeo, ThePornDB, MangaDex,
Fanart.tv) reachable only through ~20 exported dispatcher functions — none of it touches the
database at all, just `fetch()` and the settings cache. A deliberate, disclosed scoping decision
given the sheer breadth: **full branch coverage on every exported dispatcher** (every provider-id
routing branch, every error path) **and on every function with genuine logic** (year-based
re-ranking and the MusicBrainz→Deezer poster backfill in `searchMetadata`, `pickBestRelease`'s
official/country/date sort and the multi-disc continuous track numbering in
`fetchAlbumTracksFor`, TVDB/IGDB token caching including a forced 401 re-login, YouTube/Vimeo
pagination and their 500-item safety caps, Goodreads' HTML scraping, AudNexus's exact-match sort
with partial-failure resilience, the TMDB collection/person-credits dedup+sort); **one solid
happy-path test per "fetch JSON, map fields" provider function** otherwise, since most of the ~50
share that same trivial shape and differ only in field names.

- `tests/metadata.test.ts` — 119 tests covering `parseProviderUrl`, `searchMetadata` (every media
  type's provider routing including both real `TYPE_SPECIFIC_SEARCH_FNS` overrides — AniList
  manga vs. anime, iTunes podcast vs. author search — plus dispatch-error paths, year re-ranking,
  and the Deezer poster backfill), `fetchByExternalId` (all 9 provider branches), `fetchSeriesEpisodesFor`/
  `fetchSeriesSeasonsFor`/`fetchArtistAlbumsFor`/`fetchAlbumTracksFor`/`fetchCollectionChildrenFor`/
  `fetchArtworkFor`/`fetchRomDetailsFor` (every routing branch + priority order + the empty-id
  fallback), `fetchCastFor`/`fetchAlternateTitlesFor`/`fetchTmdbCollectionFor`/`fetchPersonDetails`/
  `fetchTrailerFor`/`fetchOmdbRatings`/`fetchTrendingMovies`/`fetchTrendingSeries`/
  `fetchMovieByTmdbId`/`fetchSeriesByTmdbId`. Only `settingsStore.js` (via a real `setupTestDb()` —
  this file has no other DB dependency) and `global.fetch` are involved; `cheerio` and `xml2js` run
  for real against fixture HTML/RSS, matching this project's existing scraping-test convention.

  One genuine source bug surfaced: `parseProviderUrl`'s ISBN regex was `/(\d{9}[\dXx]|\d{13})/` —
  since JS regex alternation takes the first branch that matches at a position rather than the
  longest one, a real 13-digit ISBN-13 pasted into a URL always matched the shorter `\d{9}[\dXx]`
  branch first and got silently truncated to its first 10 digits (an invalid id, sent straight to
  Open Library's API). Fixed by trying `\d{13}` first. Two other apparent failures on the first
  Docker run turned out to be my own fixture mistakes, not source bugs: Goodreads' `authorId` is
  the *entire* `/author/show/<id>` path segment including the slugified name (matching real
  Goodreads URLs like `/author/show/153394.Chuck_Palahniuk`), not just its leading digits; and
  MangaDex's cover URL convention appends `.256.jpg` after a cover filename that already ends in
  `.jpg` (a genuine, intentional double extension in their CDN's own URL scheme).

Test count: 1070 → 1189 (88 → 89 files).

## Round 275 — more test coverage (scheduler & auto-search/grab pipeline)
No behavior changes (the exports below add no new logic — see "How to apply" in this project's own
test-writing conventions). The central orchestrator tying together nearly every other service in the
codebase (1474 lines) — the auto-search/grab pipeline is the app's core value proposition, and until
this round none of it had any test coverage at all.

- Exported twelve previously-private functions purely for direct testability (an established,
  low-risk pattern already used elsewhere in this codebase — no logic changed): `isWithinTimeWindow`,
  `isReleaseAvailableForSearch`, `runAutoSearch`, `runAutoUpgrade`, `checkVideoChannels`,
  `checkPodcastFeeds`, `retryFailedGrab`, `pollQueue`, `cleanupStalledDownloads`,
  `pruneOldFailedQueueItems`, `checkHealthAndNotify`, `runSeedGoalCleanup`.
- `tests/scheduler.test.ts` — `isAlreadyQueued`, `pickClientForProtocol`, and `grab` directly; then
  `searchAndGrabTargets` (exercising `chooseBestResult`'s real ranking algorithm indirectly: the
  blocklist gate, the quality-upgrade gate, missing-client/no-results errors, a seeders tie-break
  between two otherwise-identical releases, and one target's exception not aborting the batch);
  `runAutoSearch` across all three shapes (movie/series/collection) plus every one of its skip gates
  — quiet hours, the search window, no enabled clients, a root folder over its configured disk quota,
  already-has-a-file-or-queued, minimum availability — the YouTube/podcast direct-grab special cases
  that bypass indexer search entirely, a future-dated daily-series episode never being searched, and
  a genuine mid-loop `AbortSignal` stop; `runAutoUpgrade`'s enabled-gate and already-queued skip;
  `checkVideoChannels`/`checkPodcastFeeds`'s new-item detection and conditional auto-grab;
  `pollQueue`'s full state machine (progress/remote-path-mapping updates, completed → import,
  `ImportSkippedError` → manual-interaction notification with no retry, a real import failure →
  retry, a client-level failure → remove + retry); `cleanupStalledDownloads` and
  `pruneOldFailedQueueItems`'s threshold-based cleanup; `retryFailedGrab`'s blocklist-then-retry
  chain (respecting `blocklistOnly` and the configured retry cap); `checkHealthAndNotify`'s dedup-
  against-the-last-notified-summary behavior; `runSeedGoalCleanup`; and `startScheduler`'s job-
  registration wiring (30+ unique job keys, started exactly once). `indexerClient.js`,
  `downloadClient.js`, `notifications.js`, `metadata.js`, `upgradeCandidates.js`, and `jobRegistry.js`
  are mocked (the last one specifically to prevent `startScheduler()`'s test from starting real cron
  timers); `importer.js` is partially mocked via `importOriginal` to keep the real `ImportSkippedError`
  class for `instanceof` checks; everything else (quality scoring, release parsing, blocklist,
  release-group stats, root-folder-quota checking) runs for real.

  Six distinct bugs surfaced via the first Docker run, all in the test's own fixtures, none in the
  source: a hardcoded `indexerId: 1` on the shared fake-result fixture violated `queue.indexer_id`'s
  real FK (nothing in these tests needed a real indexer row, so it's `null` now); three assertions
  expected a download-client's `category` field as `undefined` where the real DB-mapped client
  actually has `null` (and `expect.anything()` explicitly excludes `null`, so it silently masked the
  same mismatch rather than catching it); the root-folder-quota test assumed `isRootFolderOverQuota`
  reads the `disk_usage_samples` table, when it actually calls `fs.statfsSync` on the folder's real
  path directly and additionally requires `pause_grabs_at_quota` to be set — an entirely different
  mechanism from `checkHealthAndNotify`'s own (real) low-disk-space check, which does read that
  table; a stalled-download fixture compared a JS `toISOString()` timestamp against SQLite's own
  `datetime()`-formatted column, two different string formats that don't reliably compare
  lexicographically; a `mockRejectedValueOnce`/`mockResolvedValueOnce` pair assumed call order would
  match the input array's order, but the array's first target (a nonexistent media item) never
  reaches the mock at all — the same ordering pitfall this project's own memory already flagged from
  Round 243, now hit a second time and fixed by branching the mock on the actual query content
  instead of call order; and a test asserting `sizeWithinQualityBounds` rejects an implausibly small
  "1080p" release turned out to test a scenario that can't happen with no size bounds configured for
  that quality (a settings-driven cache) — rewritten to assert the real, opposite default instead.

Test count: 1011 → 1070 (87 → 88 files).

Verified: `tsc --noEmit` clean, all 1070 server tests passing. No Docker rebuild — test-only change
(the new `export` keywords add no behavior).

## Round 274 — more test coverage (download clients) + a real Soulseek bug fix
The third-largest file tackled this session (1203 lines, 9 distinct download-client backends
behind one shared interface) — and this round did find a real, shipped bug, not just add tests.

- **Fixed**: `SlskdAdapter`'s `downloadId` (in both `addDownload` and `getStatus`) joined the
  Soulseek username and filename with a literal embedded NULL byte (`\x00`) instead of a space —
  confirmed via a hex dump of the actual source file, not a guess (`grep` had been silently
  reporting `downloadClient.ts` as a "binary file" for several rounds now, which was the real tell
  in hindsight). Both sides of the adapter's own internal comparison used the same corrupted
  template, so it likely went unnoticed in casual use — but a `TEXT` column bound with an embedded
  NULL byte is exactly the kind of value some SQLite drivers silently truncate at the C-string
  boundary, which would have desynced a stored `queue.download_id` from what `getStatus` computes
  on every poll, breaking progress/completion tracking for Soulseek downloads specifically. Caught
  only because this round's test asserted the exact string rather than a looser shape.
- `tests/downloadClient.test.ts` — `applyRemotePathMapping` (longest-prefix-wins, case-insensitive
  and mixed-slash-tolerant matching, unmapped passthrough); `testDownloadClientConnection`'s per-
  type checks; `removeQueueItemDownload`'s best-effort contract; and, for each of the 9 adapters
  behind `getDownloadClientAdapter`: qBittorrent (session-cookie caching and its drop-and-retry-once
  on a 403, `content_path`/`save_path` fallback, health-stats ratio math, and `removeSeededTorrents`'
  state+goal filtering), SABnzbd (its whole reason for existing — a 100%-in-queue job reported as
  "downloading", never "completed", until it actually reaches history — and trying both the queue
  and history locations to remove a job), the in-process Http and yt-dlp adapters (background
  download/spawn, stdout progress parsing, exit-code and spawn-error handling, and yt-dlp's opt-in
  flags), the three debrid adapters — Real-Debrid, TorBox (including its 0–100-vs-0–1 progress-
  normalization quirk), and AllDebrid, which got the most attention given its rich documented bug
  history: reading an accepted upload's id from `magnets[]` for a magnet vs. `files[]` for a
  `.torrent`-bytes upload, `data.magnets` coming back as a bare object instead of an array on the
  v4.1 status endpoint, and the dedicated `/magnet/files` recursive file-tree walk (a real file
  nested one folder deep, alongside a malformed sibling entry with neither a link nor children,
  proving both the recursion and the "just skip it" tolerance) — Blackhole's magnet/NZB/torrent
  content-sniffing, and Slskd's transfer-matching (the bug above, plus state-string mapping).
  `node:child_process` is partially mocked (`importOriginal`, overriding only `spawn` — a full
  replacement broke `ffprobe.ts`'s unrelated `execFile` import, loaded transitively via the full
  app); everything else runs for real. Several of this round's own test-fixture bugs surfaced via
  the first Docker run too: a `remote_path_mappings` FK violation from literal (nonexistent) client
  ids, a fake file response missing `.headers` that `HttpDownloadAdapter` reads for content-length,
  output-file assertions checking the wrong directory (Real-Debrid/AllDebrid/Http all write to the
  real `config.downloadsDir` directly, never the `test-fixtures` subfolder used for input fixtures),
  a multi-file AllDebrid fixture reusing one mocked `Response`'s already-consumed stream for a
  second download, and an AllDebrid `/link/unlock` mock not wrapped in the `{status, data}` envelope
  `this.call()` actually unwraps.

Test count: 957 → 1011 (86 → 87 files).

Verified: `tsc --noEmit` clean, all 1011 server tests passing. Docker images rebuilt and pushed
(`server` and `combined` — the fix is server-only; `web` is unaffected).

## Round 273 — more test coverage (file placement & import engine)
No behavior changes. Continues the test-coverage push. The second-largest file tackled this
session (1194 lines) — the engine that actually moves a downloaded file into the library, names it,
and updates the database, shared by the automatic post-download path and every manual-import route.

- `tests/importer.test.ts` — `removeEmptyParents` (walks upward removing now-empty directories,
  stopping at the root folder); `createLibraryFolderSkeleton`'s never-throws contract;
  `findDownloadedFile`'s full matching engine (plain token-overlap scoring and its 0.4 confidence
  floor, narrowing to a specific season/episode or air date in a season-pack download, falling back
  to plain overlap when nothing parses to the exact target, and `searchRoot` — a single file used
  directly, a directory scoped and scored on its own, and falling through to the full downloads
  directory when a stale mapping no longer exists) and `listDownloadedFileCandidates`'s newest-first
  listing; `placeFile` across all three shapes it handles (single, episodic — including the
  absolute-episode-number count that deliberately excludes season 0 specials — and single-file
  collection), its guards (no root folder, the free-space check), imported-vs-upgraded notification
  choice, conditional NFO sidecar writing, comic image conversion, and video-only subtitle
  downloading (proven for both directions — a provider IS queried for a video file, and never for a
  non-video one, rather than only checking the negative case, which a provider-less first draft would
  have left vacuously true either way); `placeAlbumFiles` (leading-number track matching, an
  unmatched file keeping its original name, the CD1/CD2 multi-disc collapse into one album with a
  continuous cross-disc track-number offset, and conditional audio-tag writing); `placeSeasonPackFiles`
  (importing every file it can match to a known episode, leaving an unmatched one in place rather
  than guessing, and throwing when nothing in the pack matches at all); `importQueueItem`'s dispatch
  to the right placement function by shape, its manual-source-file path-containment validation, and
  its conditional download-client removal / source-folder cleanup (never touching the source for a
  hardlink/symlink strategy); and `renameLibraryFiles`/`renameOneMediaItem` (a real rename when the
  computed destination differs, a no-op when it's already correct, `dryRun` touching neither the
  filesystem nor the database, skipping items with no file, counting-but-not-renaming Music, and one
  item's rename failure not aborting the batch). `notifications.js`, `metadataExport.js`,
  `audioTagWriter.js`, `archiveExtract.js`, `downloadClient.js`, `subtitleSync.js`,
  `comicImageConvert.js`, and `ffprobe.js` are mocked; `subtitleClient.js` is partially mocked
  (keeping the real, already-tested `pickBestSubtitleForLanguage`); everything else (naming,
  mediaTypes, settingsStore, releaseParser, `libraryScan.js`'s `detectSeasonEpisode`,
  `releaseGroupStats.js`) runs for real.

  The most consequential discovery this round wasn't in the source: `config.downloadsDir` is a
  `const` resolved once at `config.ts`'s module-load time from `AONARR_DOWNLOADS_DIR` — reassigning
  that env var per test after the module has already loaded (as the first draft did) has no effect
  at all, so every `findDownloadedFile`-family test silently searched the wrong directory and found
  nothing. Worse, `setupTestDb()` points `AONARR_CONFIG_DIR` and `AONARR_DOWNLOADS_DIR` at the exact
  same temp directory, so a naive "clear everything in the downloads dir between tests" fix deleted
  the app's own `logs/` folder and crashed the logger mid-suite. The real fix: read the actual
  resolved `config.downloadsDir` once in `beforeAll` and use a dedicated subfolder under it for every
  test's fixtures — but even that subfolder isn't safe to merely *clear*, since
  `cleanupDownloadSourceFolder`'s own `removeEmptyParents` call walks upward from a just-removed
  release folder toward the real `config.downloadsDir` and will happily remove that subfolder too, as
  a legitimate "now-empty parent" — so each test recreates it outright (`rmSync` + `mkdirSync`) rather
  than assuming it still exists.

Test count: 910 → 957 (85 → 86 files).

Verified: `tsc --noEmit` clean, all 957 server tests passing. No Docker rebuild — test-only change.

## Round 272 — more test coverage (library scan & import engine)
No behavior changes. Continues the test-coverage push. The single largest and most historically
bug-dense file tackled this session (963 lines) — the core Scan & Import engine behind every media
shape (movie, series, author/book, audiobook).

- `tests/libraryScan.test.ts` — the pure helpers (`titlesMatch`'s exact-only matching, specifically
  re-proving the documented "Extraction"/"Extraction 2" and "The Office"/"The Office UK" regression
  it exists to prevent; `guessTitleFromText`'s cut-pattern precedence; `detectSeasonEpisode`'s
  filename-first/season-folder-fallback chain), then `scanAndImportLibrary` across all three shapes:
  movie (create, match-and-fill an existing missing item, and — the exact historical "duplicate
  movies" bug the source comments describe — never duplicating or overwriting an already-downloaded
  movie when a second file guesses the same title), series (new-show creation with best-effort
  metadata enrichment, matching an existing show instead of duplicating it, the Season-folder+bare-
  E-marker fallback, not overwriting an episode that already has a different file, and — another
  named historical bug — the has_file rollup that flips a parent series from "Missing" to correct
  even on a run that finds zero new files), author/book (parent+child creation, the "sits directly in
  root with no parent folder" skip), and audiobook (per-file track upserting, a flat Artist/track.mp3
  layout falling back to a self-titled album, and the multi-disc "Album [2CD]/CD1,CD2" layout being
  recognized as ONE album with continuous track numbering — proving the exact disc-restart-collision
  logic `upsertTrackFromFile`'s own comment describes); the `onlyTitle`/`onlySeasonNumber`/
  `onlyMediaItemId` per-item scoping (including the documented "The Office" vs "The Office (US)"
  loose-vs-strict-match scenario); the overlapping-whole-library-scan guard (and its deliberate
  exemption for a scoped per-item scan); a genuine mid-scan `AbortSignal` stop; and one file's
  exception not aborting the rest of the batch. Then `refreshLibraryMetadata`/`refreshOneMediaItem`
  (the already-matched-vs-never-matched title-overwrite gate, episode/child backfilling, placeholder-
  episode-title replacement, a movie's studio backfill, and `onlySeasonNumber` leaving the show's own
  fields untouched) and the two startup data-fix backfills. `metadata.js`'s six network-calling
  exports and `ffprobe.js`'s `probeMediaInfo` are mocked; everything else runs for real against a
  real temp-directory filesystem tree and a real DB, wiped between tests since this file's every
  function re-scans the whole table for its type.

  Four bugs caught via the first Docker run, none in the source: three tests used a fictional
  `'book'` media type key — the real registered key is `'author'` (Books shape, per
  `mediaTypes.ts`) — the exact same mistake this project's own memory already flagged from an
  earlier round, now recorded more prominently since it recurred independently; and one test's raw
  SQL `INSERT` had its column list and `VALUES` list out of alignment (`overview`'s value landed in
  the `has_file` column), caught by an assertion receiving `"1"` where a string overview was
  expected.

Test count: 856 → 910 (84 → 85 files).

Verified: `tsc --noEmit` clean, all 910 server tests passing. No Docker rebuild — test-only change.

## Round 271 — more test coverage (Plex/Jellyfin/Emby media server client)
No behavior changes. Continues the test-coverage push.

- `tests/mediaServer.test.ts` — `getMediaServerConfig`'s missing-field guard and trailing-slash
  normalization; `fetchWatchedFiles`/`fetchAllLibraryFiles` for both Plex (movie/show section
  filtering, the watched-state gate, a failed section's items request being skipped rather than
  fatal) and Jellyfin/Emby (collapsing the same shared-household file across multiple users to the
  most-recently-played entry, the `/emby` base path); `parsePlexExternalIds`'s new-agent `Guid`-array
  vs. legacy `guid`-string parsing and their precedence; `fetchMediaServerMovies`/
  `fetchMediaServerSeries` for both platforms, including a behavior easy to miss reading either
  function in isolation — Plex silently skips a section whose items request fails, but Jellyfin/Emby
  *throws* on the equivalent failure, a real, deliberate asymmetry now pinned down by a test; Plex's
  shows-then-episodes two-pass fetch linking episodes to their show via `grandparentRatingKey`; and
  Jellyfin/Emby's own only-the-first-user behavior; `refreshMediaServerLibrary`'s per-path targeted
  Plex refresh vs. `triggerFullMediaServerScan`'s whole-section refresh (no `path` param), both
  platforms' best-effort never-throws contract even when the request itself throws;
  `resolvePlexFilePath`'s metadata-lookup unwrapping; and `pushWatchState`'s Plex scrobble/unscrobble
  and Jellyfin/Emby PlayedItems POST/DELETE, both matched by `pathTail` across genuinely different
  mount-point prefixes with identical trailing segments — the precise pitfall flagged in this
  project's own testing conventions memory, deliberately exercised rather than accidentally dodged.

Test count: 822 → 856 (83 → 84 files).

Verified: `tsc --noEmit` clean, all 856 server tests passing. No Docker rebuild — test-only change.

## Round 270 — more test coverage (import lists: Trakt/IMDb/Last.fm/TMDB)
No behavior changes. Continues the test-coverage push.

- `tests/importLists.test.ts` — `passesListFilters`'s rating/votes/genre gates (never rejecting on
  unknown data, case-insensitive genre exclusion, tolerating malformed `exclude_genres` JSON) and
  `insertTracksForAlbum`'s never-throws contract; then, for all four list sources dispatched by
  `syncImportList` — Trakt (list vs. watchlist URL routing, adding a new movie/series with the
  series' episodes fetched, per-hour dedup by tmdb id, exclusion, filter rejection, `require_review`
  queuing, and one malformed entry not aborting the rest of the batch), IMDb (its public per-list CSV
  export — including a quoted, comma-containing field, the exact scenario `splitCsvLine`'s own doc
  comment calls out — duplicate-in-library skip, no-metadata-match queuing for review, and a
  successful match's fields coming from the search result rather than the raw CSV row), Last.fm
  (bare-username vs. full-profile-URL parsing, and the single-artist-object-instead-of-an-array
  response shape its top-artists endpoint can return), and TMDB (bare-id vs. full-URL parsing,
  `media_type` vs. the `first_air_date` fallback heuristic for movie/TV detection, and its own
  genre-id-to-name mapping feeding into the shared filter) — each source's config-missing and non-OK-
  response error paths, plus `syncImportList`'s own persistence of `last_synced_at`/`last_added_count`/
  `last_error` on both success and failure, and `runAllImportLists`' enabled-only filtering and a
  genuine mid-loop `AbortSignal` stop (proven by aborting from inside the first list's own mocked
  fetch call, not just before the run starts). `metadata.js`'s four network-calling exports are
  mocked (closure-indirection, same pattern as Round 246); `importExclusions.js`/`duplicateCheck.js`/
  `importReview.js` all run for real against the test DB, being cheap DB-only siblings.

  Three bugs caught and fixed before or via the first Docker run: a missing `beforeEach` import
  (caught immediately by the run); a CSV test-fixture helper that didn't quote fields containing
  commas, which would have silently misaligned every column after "Num Votes"/"Genres"; and the
  `runAllImportLists` tests originally reading the *entire*, unscoped `import_lists` table built up
  by 15+ earlier tests in the same file — fixed with a local `beforeEach` that wipes the table first,
  the same shared-state lesson as `subtitleRescan.test.ts` and others before it.

Test count: 787 → 822 (82 → 83 files).

Verified: `tsc --noEmit` clean, all 822 server tests passing. No Docker rebuild — test-only change.

## Round 269 — more test coverage (indexer search client)
No behavior changes. Continues the test-coverage push. First of the genuinely large remaining
service files (443 lines) — the rest (`downloadClient`, `importLists`, `importer`, `libraryScan`,
`mediaServer`, `metadata`, `scheduler`) are all substantially bigger still.

- `tests/indexerClient.test.ts` — `checkIndexerHealth`'s caps-endpoint vs. direct-URL check per
  protocol, its three outcome shapes (ok, HTTP-status failure, thrown-error failure), and backing off
  entirely once a real 429 has been recorded; `searchIndexer`'s three protocol adapters — Torznab/
  Newznab XML parsing (category fallback to the media type's default, the full torznab:attr
  extraction including deriving `leechers` from `peers - seeders` only when no explicit leechers attr
  is present, the enclosure-vs-`<link>` download-URL fallback, protocol-to-torrent/usenet mapping),
  plain RSS (client-side case-insensitive title filtering, skipping an item with no resolvable URL),
  and the generic DDL JSON adapter (config validation, `{query}` substitution, dot-path field mapping,
  skipping an item missing its title/URL) — plus the 429 backoff and proactive per-hour query-limit
  gates (the latter counting failed attempts against the cap, same as a successful one); and
  `searchAllIndexers`'s orchestration: filtering to enabled indexers whose `mediaTypes` match, sorting
  combined results by seeders descending, one indexer's failure never blocking another's results, and
  the scene-name-variant fallback (tried only when the literal query returns nothing, stopping at the
  first variant that works) — this last one caught a case-sensitivity bug in the test's own fixture
  before it ever ran: `generateSceneVariants`'s `&`→"and" swap is a literal lowercase substitution
  ("Mr and Mrs Smith"), not the capitalized guess the first draft assumed.

Test count: 753 → 787 (81 → 82 files).

Verified: `tsc --noEmit` clean, all 787 server tests passing. No Docker rebuild — test-only change.

## Round 268 — more test coverage (subtitle search/download clients)
No behavior changes. Continues the test-coverage push.

- `tests/subtitleClient.test.ts` — `searchSubtitles`'s OpenSubtitles request shape (query/languages
  params, the optional hearing-impaired/foreign-parts-only filters, the Api-Key header) and response
  mapping (the release-name fallback chain: `release` → `feature_details.title` → the queried file
  name; booleans coerced from OpenSubtitles' raw fields); `pickBestSubtitle`'s full ranking algorithm
  (a movie-hash match always wins regardless of popularity, otherwise highest `downloadCount` wins
  with a missing count treated as 0, a "custom" provider result is eligible despite a null `fileId`,
  and the input array is never mutated) and `pickBestSubtitleForLanguage`'s per-language scoping;
  `downloadSubtitleContent`'s two-step POST-for-a-signed-link-then-fetch-it handoff and all three of
  its failure modes; `searchCustomSubtitles`'s generic dot-path JSON adapter (`{query}`/`{languages}`
  template substitution, the Bearer header only when an API key is configured, resolving the results
  array via a configured dot path or the response body itself when unconfigured, field-mapping via
  dot path with "unknown"/the queried file name as defaults, and silently skipping an item whose
  download-URL field doesn't resolve); and `downloadSubtitleFromUrl`'s direct fetch-and-return-text.
  Genuinely zero-import aside from global `fetch`, so no `setupTestDb()` needed.

Test count: 725 → 753 (80 → 81 files).

Verified: `tsc --noEmit` clean, all 753 server tests passing. No Docker rebuild — test-only change.

## Round 267 — more test coverage (hand-rolled IRC client)
No behavior changes. Continues the test-coverage push.

- `tests/ircClient.test.ts` — `IrcConnection` against a real local `net.createServer` standing in
  for an ircd (same reasoning as `smtp.ts`'s own tests — a hand-rolled protocol client built
  directly on sockets is best proven against a real socket, not a guess at what a mocked one should
  emit): NICK/USER registration and JOIN once welcomed, replying to a server PING with a matching
  PONG, the full SASL PLAIN exchange (CAP REQ/ACK, the base64 `\0user\0pass` payload decoded and
  verified, 903 success), SASL being skipped without blocking registration on a CAP NAK, SASL
  failing via a 904 reply after a full AUTHENTICATE round-trip, PRIVMSG routing (matched case-
  insensitively against the configured channel, ignoring a PRIVMSG to a different channel or to the
  bot's own nick), and the automatic-reconnect behavior after the server drops the connection
  (versus never reconnecting once `stop()` has been called). The reconnect test mixes real socket
  I/O with fake timers (`toFake: ["setTimeout", "clearTimeout"]` only, so the actual TCP handshake
  keeps running for real) — the first version raced the real close-event propagation against
  advancing the fake clock and hung until Vitest's own timeout, fixed by spying on `log.warn` and
  polling via `setImmediate` (never faked) until the reconnect's own `setTimeout` call is confirmed
  registered before advancing it. TLS (`useSsl: true`) is left untested — the branch is a one-line
  `tls.connect` vs `net.connect` choice, not worth a full TLS handshake fixture for this round.

Test count: 717 → 725 (79 → 80 files).

Verified: `tsc --noEmit` clean, all 725 server tests passing. No Docker rebuild — test-only change.

## Round 266 — more test coverage (Soulseek/slskd search client)
No behavior changes. Continues the test-coverage push.

- `tests/soulseek.test.ts` — `encodeSlskdDownloadUrl`/`decodeSlskdDownloadUrl`'s round-trip fidelity
  (including usernames/filenames needing URI-escaping — spaces, parens, unicode) and both malformed-
  URL error cases (missing username, missing filename); `searchSlskd`'s full async poll loop under
  `vi.useFakeTimers()` + `advanceTimersByTimeAsync` — creates a search, polls until `isComplete`,
  then fetches and maps responses into `SearchResult[]` (title reduced to the basename across both
  `/` and `\` separators, `seeders` derived from `hasFreeUploadSlot`); the https-scheme/API-key-
  header request shape; a single non-OK poll response being skipped rather than aborting the search;
  giving up politely and still returning gathered responses once the real 15-second deadline is
  exceeded; and the three failure modes (search-creation request failing, no search id returned, the
  final responses fetch failing). One test initially left a real unhandled-rejection window — the
  fake-timer advance drove the promise to rejection before the next line could attach `.rejects` —
  fixed by attaching the rejection expectation immediately after the call, before advancing timers.

Test count: 706 → 717 (78 → 79 files).

Verified: `tsc --noEmit` clean, all 717 server tests passing. No Docker rebuild — test-only change.

## Round 265 — more test coverage (course landing-page scraping)
No behavior changes. Continues the test-coverage push.

- `tests/courseScraper.test.ts` — `scrapeCoursePage`'s URL validation (a non-URL string, a non-
  http(s) protocol), network-failure handling (`fetch` itself throwing, a non-OK HTTP status), and
  title extraction (og:title preferred over `<title>`, falling back to `<title>` when og:title is
  absent, throwing when neither is present, HTML entity decoding for both named and numeric
  entities, and the edX-only "| edX" suffix stripping — confirmed to NOT apply to a similarly-shaped
  suffix on a non-edX hostname). Also covers `extractMetaContent`'s two documented robustness cases:
  a `content` attribute appearing before `property`/`name` in the tag, and a double-quoted content
  value containing an internal apostrophe not getting cut short — the exact scenario called out in
  the source's own comment. Genuinely zero-import aside from global `fetch`, so no `setupTestDb()`
  needed.

Test count: 691 → 706 (77 → 78 files).

Verified: `tsc --noEmit` clean, all 706 server tests passing. No Docker rebuild — test-only change.

## Round 264 — more test coverage (realtime SSE, Prowlarr/Jackett indexer sync)
No behavior changes. Continues the test-coverage push.

- `tests/realtime.test.ts` — `realtime.ts`'s SSE broadcast channel: only currently-registered
  clients receive a broadcast, an immediate broadcast fires when nothing has been sent recently, a
  burst of rapid calls coalesces into exactly one broadcast after the 1500ms cooldown window (proven
  by asserting the write count is exactly 1, not 2, after advancing fake timers — 2 would mean a
  second timer was wrongly scheduled), and a write failure on one half-closed client doesn't throw or
  stop the broadcast from reaching the others. Genuinely zero-runtime-import (only an erased `import
  type` from `express`), so no `setupTestDb()` needed — but the module's own `lastSentAt`/
  `pendingTimer` state persists across tests in the file, so each test jumps fake system time far
  past the cooldown window first and flushes any pending timer in `afterEach` to guarantee the next
  test starts clean.
- `tests/prowlarrSync.test.ts` / `tests/jackettSync.test.ts` — both sync services' shared shape: a
  config-missing guard that never calls `fetch` at all, an HTTP-error-status response, a
  request-level network error, a real API-key-header request, inserting a new `indexers` row per
  returned entry (protocol mapping and proxy-URL construction verified), updating rather than
  duplicating an existing row on re-sync (matched by the indexer id stashed in `config`), and one
  malformed indexer (a real `indexers.name` NOT NULL violation) getting logged and skipped without
  aborting the rest of the batch. `prowlarrSync.test.ts` additionally targets the exact concern its
  own source comment calls out: a numeric id search is terminated with the JSON's closing brace so
  indexer id 5 can't accidentally match an already-synced row for id 50 or 500 — verified by syncing
  ids 9250 and 92500 first, then confirming a later sync of 925 creates a genuinely new third row
  instead of updating either. `jackettSync.test.ts` covers its own distinct shape instead (string
  slug ids quoted in the stored JSON, `encodeURIComponent`-escaping an id into the per-indexer proxy
  path, `enabled` always forced to 1 rather than read from the API response).

Test count: 672 → 691 (74 → 77 files).

Verified: `tsc --noEmit` clean, all 691 server tests passing. No Docker rebuild — test-only change.

## Round 263 — more test coverage (ffprobe media analysis)
No behavior changes. Continues the test-coverage push.

- `tests/ffprobe.test.ts` — `probeMediaInfo` against `node:child_process`'s `execFile` mocked to
  return controlled ffprobe-shaped JSON: basic field extraction (codecs, resolution, duration,
  bitrate, bit depth), fractional frame-rate parsing (`24000/1001` → `23.98`) including the `0/0`
  divide-by-zero guard, graceful nulls for an audio-only file with no video stream, multi-stream
  audio/subtitle extraction (language, bitrate, default/forced flags), and the full HDR/Dolby Vision
  detection matrix — plain HDR10 (PQ transfer), HLG, HDR10+ (side-data), single-layer Dolby Vision
  (both the DOVI-config-record and codec-tag-string signaling paths), dual-layer Dolby Vision with
  an HDR10 base layer, plain SDR, and the "unknown transfer function" fallback — as well as the
  one-retry-then-give-up behavior on a transient "moov atom not found"-style error (succeeding on
  the second attempt, and returning `null` if the retry also fails), no retry at all for a
  non-transient error (e.g. ffprobe itself missing), and a `null` return instead of a throw on
  unparseable JSON output. The "no retry" and "exactly one retry" claims are backed by an explicit
  `execFile` call counter rather than queue-length alone — a queue-length check can't actually prove
  it, since `Array.shift()` on an empty queue is a silent no-op.

Test count: 653 → 672 (73 → 74 files).

Verified: `tsc --noEmit` clean, all 672 server tests passing. No Docker rebuild — test-only change.

## Round 262 — more test coverage (audiobook chapter merging)
No behavior changes. Continues the test-coverage push.

- `tests/audiobookConvert.test.ts` — `convertSubItemToM4b`'s full validation chain (sub-item not
  found, no downloaded folder yet, fewer than 2 downloaded tracks, a track file missing on disk,
  ffprobe unable to read a duration, and the output-path-collides-with-a-source-track guard), then
  the success path against real track files on disk: the ffmpeg args include every input track and
  the chapter metadata file, the DB transaction correctly replaces N per-track rows with one merged
  row (summed `duration_seconds`, `track_number` reset to 1), the original track files get deleted,
  and the temp chapter-metadata file is cleaned up in both the success case and when ffmpeg itself
  fails — the latter also confirming a failed merge never touches the database or deletes anything.
  `ffprobe.js`'s `probeMediaInfo` and `node:child_process`'s `execFile` (ffmpeg) are both mocked;
  `metadataExport.js`'s `safeFileName` runs for real.

Test count: 643 → 653 (72 → 73 files).

Verified: `tsc --noEmit` clean, all 653 server tests passing. No Docker rebuild — test-only change.

## Round 261 — more test coverage (CBZ comic image re-encoding)
No behavior changes. Continues the test-coverage push.

- `tests/comicImageConvert.test.ts` — `convertComicImages` against real `adm-zip`-built CBZ
  fixtures (`node:child_process`'s `execFile` mocked to stand in for ffmpeg): the CBR/RAR rejection,
  a no-op for an archive with no image entries, re-encoding to both WebP and JPEG with the pages
  correctly renamed, non-image entries (a `ComicInfo.xml`) left untouched alongside converted pages,
  and — the fix this file's own history is built around — two pages that only differ by original
  extension (`page01.png`/`page01.jpg`) both mapping to `page01.webp` get disambiguated
  (`page01-2.webp`) instead of one silently overwriting the other. `convertComicImagesBestEffort`'s
  three ways of not throwing (success, an unsupported extension, an ffmpeg failure mid-conversion)
  round it out.

Caught the same convention slip flagged in Round 251's memory update before this file ever ran:
`comicImageConvert.ts` imports `logger.js`, which touches `config.js`/`db/index.js` transitively —
the first draft skipped `setupTestDb()` entirely. Fixed before the first run, the same way and for
the same reason as `subtitleSync.test.ts` — every file needs its imports checked for this, not just
the ones that look database-adjacent at a glance.

Test count: 633 → 643 (71 → 72 files).

Verified: `tsc --noEmit` clean, all 643 server tests passing. No Docker rebuild — test-only change.

## Round 260 — more test coverage (Radarr/Sonarr/Lidarr one-time library migration)
No behavior changes. Continues the test-coverage push.

- `tests/starrImport.test.ts` — `fetchRadarrMovies`' file-path derivation (a direct `movieFile.
  path`, a `path` + `movieFile.relativePath` combination, and the `radarr:<tmdbId>` fallback id for
  a monitored-but-undownloaded movie) and its skip of entries with no title. `importArtistsFromLidarr`
  exercising the Lidarr/Readarr-shared `importCollectionData` matching core: creating a new parent
  and child, matching an existing parent by external id (even under a renamed title) or by exact
  title, coalescing a matched parent's missing fields without overwriting existing ones, skipping a
  child whose derived path tail is already tracked, updating a previously-fileless child once a real
  path appears, and never resetting an already-downloaded child back to missing when Lidarr no
  longer reports a file for it.

Caught three real bugs in the test before any of them passed, none of them subtle mock issues —
genuine gaps in understanding the source's actual behavior: (1) `root_folder_id` is a real foreign
key, and a literal `1` only works if a root folder with that id exists — fixed by inserting one for
real. (2) The most consequential one: `importCollectionData` never iterates parents directly — it
only ever resolves one as a side effect of processing a child that references it, so a Starr artist
with zero albums in the mocked response is completely invisible to the matching logic, and two
tests asserting on parent-matching with no album fixture were passing vacuously (nothing was ever
touched) rather than proving anything. Fixed by giving every parent-matching test at least one
child. (3) `pathTail` keeps only the last 3 path segments, and Lidarr-derived children are always
stored as a *folder* path, not the track file itself — a naive same-tail fixture pairing a filename-
terminated existing path against a freshly-derived folder path silently fails to collide, since the
segment 3 levels up differs by construction. Fixed by aligning both fixtures on folder paths that
share their last 3 segments, isolating the "different mount point" difference to a segment outside
that window — which is the actual case the test means to cover.

Test count: 623 → 633 (70 → 71 files).

Verified: `tsc --noEmit` clean, all 633 server tests passing. No Docker rebuild — test-only change.

## Round 259 — more test coverage (book ISBN scanning)
No behavior changes. Continues the test-coverage push.

- `tests/bookIsbnScan.test.ts` — `findIsbnInText`'s checksum-validated ISBN-10/13 extraction
  (labeled, bare, hyphenated, and the ISBN-10→13 conversion), all grounded against a real published
  book's genuine ISBN-10/13 pair rather than an invented one, plus a rejected checksum-invalid
  number. `extractIsbnFromBookFile`'s epub path against real `adm-zip`-built EPUB fixtures (a valid
  container.xml/OPF pair, a missing container.xml, an OPF with no `dc:identifier`, and a corrupt
  non-zip file) — no mocking needed, since `xml2js`/`adm-zip` are simple enough to exercise for
  real. The unsupported-extension short-circuit and the "never throws" contract for a `.pdf` that
  `pdf-parse` can't actually parse round it out, along with `fetchBookByIsbn`'s Open Library
  mapping (including its medium-then-large cover fallback) and error handling.

Test count: 606 → 623 (69 → 70 files).

Verified: `tsc --noEmit` clean, all 623 server tests passing. No Docker rebuild — test-only change.

## Round 258 — more test coverage (AI provider HTTP client)
No behavior changes. Continues the test-coverage push.

- `tests/aiClient.test.ts` — `queryAi`'s two backends: Ollama's native chat API (`/api/chat`,
  trailing slash stripped, an `images` array added only when a frame is supplied, Authorization
  only sent when an api key is configured) and the OpenAI-compatible chat-completions shape
  (`/chat/completions`, an `image_url` data-URI content part added for vision requests), plus both
  backends' error handling — a non-ok response throwing with the status code, the cloud path also
  surfacing the provider's own error message when the error body parses as JSON and degrading
  gracefully when it doesn't, and an unexpected response shape (missing message content) throwing
  a clear error instead of returning `undefined`. No database at all in this file's own import
  chain, so — unlike nearly every other file this session — a plain static import and zero
  `setupTestDb()` call; the whole file runs in single-digit milliseconds.

Test count: 595 → 606 (68 → 69 files).

Verified: `tsc --noEmit` clean, all 606 server tests passing. No Docker rebuild — test-only change.

## Round 257 — more test coverage (AI-assisted media identification)
No behavior changes. Continues the test-coverage push.

- `tests/aiIdentify.test.ts` — `identifyMediaFile`'s provider resolution (no provider configured,
  a specifically-requested provider that's disabled, the default provider versus an explicitly
  requested non-default one), and its three-way prompt strategy: a vision prompt built from an
  extracted video frame, a text prompt built from an audio file's embedded tags, and the filename-
  only fallback used both when frame/tag extraction fails *and* for any other file type outright.
  `aiClient.js`'s `queryAi` is mocked, and — using the same technique as Round 251's
  `subtitleSync.test.ts` — `node:child_process`'s `execFile` is mocked to drive both the genuine,
  environment-driven failure path (no `ffmpeg`/`ffprobe` in this container, so the fallback-to-
  filename behavior is exercised for real) and, by simulating a successful `ffmpeg`/`ffprobe` run,
  the frame-extraction and tag-extraction success paths that the missing binaries alone could never
  reach.

Test count: 586 → 595 (67 → 68 files).

Verified: `tsc --noEmit` clean, all 595 server tests passing. No Docker rebuild — test-only change.

## Round 256 — more test coverage (IRC feed connection lifecycle)
No behavior changes. Continues the test-coverage push.

- `tests/ircFeedManager.test.ts` — `restartIrcFeeds`'s config mapping from an `irc_feeds` row to a
  connection config (host/port/useSsl/nickname/channel), skipping disabled feeds, decrypting an
  encrypted `sasl_pass` versus passing a `null` one through untouched, stopping every previous
  connection before establishing new ones on a repeated call, the announce callback wiring
  (invoking it calls `handleAnnounce` with the right feed/text), and — the one worth calling out —
  that a `handleAnnounce` rejection never escapes as an unhandled promise rejection. `ircClient.js`'s
  `IrcConnection` and `ircAnnounce.js`'s `handleAnnounce` are both mocked.

Caught two real bugs in the test itself before it ever ran: the mock for `IrcConnection` (a class
the source instantiates with `new`) was first written as an arrow-function indirection, which
throws "is not a constructor" — fixed with a plain `function` wrapper instead, relying on the fact
that returning an explicit object from a constructor call overrides the implicit `this` regardless
of whether `new` was used internally. Separately, the first draft of the "rejection doesn't escape"
test only asserted the callback doesn't throw *synchronously* — which would have passed even if the
source's `.catch()` were deleted, since the wrapper never awaits `handleAnnounce()` and the
rejection only happens on a later microtask. Fixed by listening for a real `process`
`'unhandledRejection'` event instead, which actually proves the `.catch()` is doing its job.

Test count: 577 → 586 (66 → 67 files).

Verified: `tsc --noEmit` clean, all 586 server tests passing. No Docker rebuild — test-only change.

## Round 255 — more test coverage (scheduled subtitle rescan)
No behavior changes. Continues the test-coverage push.

- `tests/subtitleRescan.test.ts` — `rescanMissingSubtitles`'s gating (no enabled p