after ffmpeg finishes we used to block the queue on a jellyfin refresh
+ re-analyze round-trip. now we just kick jellyfin and return. a new
mqtt subscriber listens for library events from jellyfin's webhook
plugin and re-runs upsertJellyfinItem — flipping plans back to pending
when the on-disk streams still don't match, otherwise confirming done.
- execute.ts: hand-off is fire-and-forget; no more sync re-analyze
- rescan.ts: upsertJellyfinItem takes source: 'scan' | 'webhook'.
webhook-sourced rescans can reopen terminal 'done' plans when
is_noop flips back to 0; scan-sourced rescans still treat done as
terminal (keeps the dup-job fix from a06ab34 intact).
- mqtt.ts: long-lived client, auto-reconnect, status feed for UI badge
- webhook.ts: pure processWebhookEvent(db, deps) handler + 5s dedupe
map to kill jellyfin's burst re-fires during library scans
- settings: /api/settings/mqtt{,/status,/test} + /api/settings/
jellyfin/webhook-plugin (checks if the plugin is installed)
- ui: new Settings section with broker form, test button, copy-paste
setup panel for the Jellyfin plugin template. MQTT status badge on
the scan page.
one-click for the common case: anything whose language came from
radarr/sonarr (confidence='high') is trusted enough to skip manual
review. low-confidence items stay pending.
- POST /api/review/auto-approve filters on rp.confidence='high' and
enqueues audio jobs through the same dedup-guarded helper
- ColumnShell now takes actions[] instead of a single action, so the
Review header can show Auto Review + Skip all side by side
root cause: all five job-insert sites in review.ts blindly inserted a
'pending' row, so a double-click on approve (or an overlap between
/approve-all and individual /approve) wrote N jobs for the same item.
job 1 stripped subtitles + reordered audio; jobs 2..N then ran the
same stale stream-index command against the already-processed file
and ffmpeg bailed with 'Stream map matches no streams'.
fix: funnel every insert through enqueueAudioJob(), which only writes
when no pending job already exists for that item. covers approve,
retry, approve-all, season approve-all, series approve-all.
root cause of duplicate pipeline entries: rescan.ts flipped done plans
back to pending whenever a post-job jellyfin refresh returned stale
metadata, putting the item back in review and letting a second jobs row
pile up in done. done is now sticky across rescans (error still
re-opens for retries).
second line of defense: before spawning ffmpeg, ffprobe the file and
compare audio count/language/codec order + embedded subtitle count
against the plan. if it already matches, mark the job done with the
reason in jobs.output and skip the spawn. prevents corrupting a
post-processed file with a stale stream-index command.
the previous TimeInput was a bespoke two-field widget. correct in behaviour
but off-policy: we don't roll our own ui primitives when a maintained
library solves it. swap for react-aria-components + @internationalized/date
pinned to hourCycle={24}, granularity=minute, shouldForceLeadingZeros so
the output is always strict HH:MM regardless of browser/OS locale.
wrapper lives at src/shared/components/ui/time-input.tsx and keeps the
existing string-based API (value: "HH:MM", onChange(next)) so callers don't
change.
also updates the stack docs: web-stack.md now pins react-aria-components
as THE required library for every date/time ui; iOS and Android entries
mark their canonical component as TBD and explicitly forbid rolling our
own without user sign-off.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
native <input type="time"> inherits the browser/OS locale; on chrome/macos in
en-US that means AM/PM in the settings schedule editor. no attribute turns
it off — lang="en-GB" is not honored. swap for a tiny headless TimeInput
(two number fields, HH clamp 0..23, MM clamp 0..59) that always emits HH:MM.
also drop toLocaleString() in ScanPage, which silently flips thousands
separators in de-DE / fr-FR. add formatThousands() that always produces the
1,234,567 form regardless of locale.
shared/components/ui/time-input.tsx + shared/lib/utils.ts#formatThousands are
now the canonical helpers — every future time/number render must use them.
captured as a forward-looking feedback memory so this doesn't regress.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
analyzer removes every subtitle unconditionally (see case 'Subtitle' in
decideAction) and the pipeline extracts all of them to sidecars — the config
was purely informational and only subtitles.ts echoed it back as
'keepLanguages' for a subtitle-manager ui that doesn't exist yet. we'll
revive language preferences inside that manager when it ships.
removes: the settings card + ui state, POST /api/settings/subtitle-languages,
the config default, the SUBTITLE_LANGUAGES env mapping, AnalyzerConfig's
subtitleLanguages field, RescanConfig's subtitleLanguages field, every
caller site (scan.ts / execute.ts / review.ts), and the keepLanguages
surface in subtitles.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the reset button nukes every table and reseeds defaults, so on a schema
change we no longer need per-column ALTERs to keep old databases working.
fresh installs get SCHEMA; upgrading installs click Reset once and they're
on the new layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the existing clear-scan button only drops media_items + related; settings
survived. useful when schema changes or corrupt state make you want a full
do-over on a running container without ssh-ing in to rm data/netfelix.db.
POST /api/settings/reset truncates everything (config included) then re-seeds
DEFAULT_CONFIG via the exported reseedDefaults helper. env-var overrides keep
working through getConfig's env fallback. ui lives next to clear-scan in the
danger zone with a double confirm and reload to /, so the setup wizard shows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
upgraded containers (like the unraid one with a persistent ./data volume)
kept their old media_items/media_streams tables, so upsertJellyfinItem hit
"table media_items has no column named original_title" on first scan after
the canonical-language rewrite. sqlite has no native add-column-if-not-exists
so we try/catch each alter, same pattern we had before i deleted the shims.
also backports the older alters (stream_decisions.custom_title, review_plans
subs_extracted/confidence/apple_compat/job_type) so pre-rewrite installs
still converge without a wipe.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the gitea workflow tags the image with the package.json version; docker oci
refs forbid '+', so 2026.04.13+2 broke buildx with "invalid reference format".
switch to 2026.04.13.3. this matches what project memory already documented;
the global AGENTS.md example using +n doesn't apply here because we ship via
a registry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the old one-window scheduler gated only the job queue. now the scan loop and
the processing queue have independent windows — useful when the container
runs as an always-on service and we only want to hammer jellyfin + ffmpeg
at night.
config keys renamed from schedule_* to scan_schedule_* / process_schedule_*,
plus the existing job_sleep_seconds. scheduler.ts exposes parallel helpers
(isInScanWindow / isInProcessWindow, waitForScanWindow / waitForProcessWindow)
so each caller picks its window without cross-contamination.
scan.ts checks the scan window between items and emits paused/resumed sse.
execute.ts keeps its per-job pause + sleep-between-jobs but now on the
process window. /api/execute/scheduler moved to /api/settings/schedule.
frontend: ScheduleControls popup deleted from the pipeline header, replaced
with a plain Start queue button. settings page grows a Schedule section with
both windows and the job sleep input.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ffmpeg now writes -metadata:s:a:i language=<iso3> on every kept audio track so
files end up with canonical 3-letter tags (en → eng, ger → deu, null → und).
analyzer passes stream.profile (not title) to transcodeTarget so lossless
dts-hd ma in mkv correctly targets flac. is_noop also checks og-is-default and
canonical-language so pipeline-would-change-it cases stop showing as done.
normalizeLanguage gains 2→3 mapping, and mapStream no longer normalizes at
ingest so the raw jellyfin tag survives for the canonical check.
per-item scan work runs in a single db.transaction for large sqlite speedups,
extracted into server/services/rescan.ts so execute.ts can reuse it.
on successful job, execute calls jellyfin /Items/{id}/Refresh, waits for
DateLastRefreshed to change, refetches the item, and upserts it through the
same pipeline; plan flips to done iff the fresh streams satisfy is_noop.
schema wiped + rewritten to carry jellyfin_raw, external_raw, profile,
bit_depth, date_last_refreshed, runtime_ticks, original_title, last_executed_at
— so future scans aren't required to stay correct. user must drop data/*.db.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When I switched the settings UI to read result.saved to decide whether the
'✓ Saved & connected' / '⚠ Saved, but connection test failed' / '✗ error'
states should appear, I only updated the Radarr and Sonarr endpoints to
return that shape. Jellyfin still returned bare { ok: true } so the UI
saw saved=undefined and showed '✗ Save failed' even on a perfectly
successful save — making it look like Jellyfin had stopped working.
Bring Jellyfin in line:
- Save the URL+API key (and setup_complete) BEFORE running testConnection
so the input survives a failed probe (same fix as Radarr/Sonarr).
- Only do the admin-user discovery on test success.
- Return { ok, saved, testError }.
Two cleanups:
1. Rename the page from 'Setup' to 'Settings' all the way down. The H1
already said Settings; the file/component/api path were lying.
- src/features/setup/ → src/features/settings/
- SetupPage.tsx → SettingsPage.tsx, SetupPage → SettingsPage,
SetupData → SettingsData, setupCache → settingsCache
- server/api/setup.ts → server/api/settings.ts
- /api/setup → /api/settings (only consumer is our frontend)
- server/index.tsx import + route mount renamed
- ScanPage's local setupChecked → configChecked
2. Sonarr (and Radarr) save flow: persist the values BEFORE running the
connection test. The previous code returned early if the test failed,
silently dropping what the user typed — explained the user's report
that Sonarr 'forgets' the input. Now setConfig fires unconditionally
on a valid (non-empty) URL+key; the test result is returned as
{ ok, saved, testError } so the UI can show 'Saved & connected' on
success or '⚠ Saved, but connection test failed: …' on failure
instead of erasing the input.
Note: setup_complete config key kept as-is — it represents 'has the user
configured Jellyfin' which is conceptually setup and not user-visible.
Single landing page: stats grid up top, then scan controls + progress,
then recent items log. Drops the 'click → bounce to Scan' indirection.
- ScanPage pulls /api/dashboard for the stats card grid; refetches when
a scan completes so totals reflect the new state
- Scan page also owns the setup-complete redirect to /settings (was on
Dashboard) and the empty-library 'click Start Scan' nudge
- / route now renders ScanPage; /scan route deleted
- DashboardPage and its feature dir gone
- Nav: drop 'Dashboard', repoint 'Scan' to /
The per-input LockedInput already shows a 🔒 inside any field that's
controlled by an env var, with a tooltip pointing at the .env file. The
extra '🔒 JELLYFIN_URL' badge in the section title was duplicate signal —
remove it. Drop EnvBadge entirely; section titles go back to plain text
('Jellyfin', 'Radarr (optional)', etc.).
Two regressions from the radarr/sonarr fix:
1. ERR_INVALID_URL spam — when radarr_enabled='1' but radarr_url is empty
or not http(s), every per-item fetch threw TypeError. We caught it but
still ate the cost (and the log noise) on every movie. New isUsable()
check on each service: enabled-in-config but URL doesn't parse →
warn ONCE and skip arr lookups for the whole scan.
2. Per-item HTTP storm — for movies not in Radarr's library we used to
hit /api/v3/movie (the WHOLE library) again per item, then two
metadata-lookup calls. With 2000 items that's thousands of extra
round-trips and the scan crawled. Now: pre-load the Radarr/Sonarr
library once into Map<tmdbId,..>+Map<imdbId,..>+Map<tvdbId,..>,
per-item lookups are O(1) memory hits, and only the genuinely-missing
items make a single lookup-endpoint HTTP call.
The startup line now reports the library size:
External language sources: radarr=enabled (https://..., 1287 movies in library), sonarr=...
so you can immediately see whether the cache loaded.
All ack'd as real bugs:
frontend
- AudioDetailPage / SubtitleDetailPage / PathsPage / ScanPage /
SubtitleListPage / ExecutePage: load() was a fresh function reference
every render, so 'useEffect(() => load(), [load])' refetched on every
render. Wrap each in useCallback with the right deps ([id], [filter],
or []).
- SetupPage: langsLoaded was useState; setting it inside load() retriggered
the same effect → infinite loop. Switch to useRef. Also wrap saveJellyfin/
Radarr/Sonarr in async fns so they return Promise<void> (matches the
consumer signatures, fixes the latent TS error).
- DashboardPage: redirect target /setup doesn't exist; the route is
/settings.
- ExecutePage: <>...</> fragment with two <tr> children had keys on the
rows but not on the fragment → React reconciliation warning. Use
<Fragment key>. jobTypeLabel + badge variant still branched on the
removed 'subtitle' job_type — relabel to 'Audio Transcode' / 'Audio
Remux' and use 'manual'/'noop' variants.
server
- review.ts + scan.ts: parseLanguageList helper catches JSON errors and
enforces array-of-strings shape with a fallback. A corrupted config
row would otherwise throw mid-scan.
The real reason 8 Mile landed as Turkish: Radarr WAS being called, but the
call path had three silent failure modes that all looked identical from
outside.
1. try { … } catch { return null } swallowed every error. No log when
Radarr was unreachable, when the API key was wrong, when HTTP returned
404/500, or when JSON parsing failed. A miss and a crash looked the
same: null, fall back to Jellyfin's dub guess.
2. /api/v3/movie?tmdbId=X only queries Radarr's LIBRARY. If the movie is
on disk + in Jellyfin but not actively managed in Radarr, returns [].
We then gave up and used the Jellyfin guess.
3. iso6391To6392 fell back to normalizeLanguage(name.slice(0, 3)) for any
unknown language name — pretending 'Mandarin' → 'man' and 'Flemish' →
'fle' are valid ISO 639-2 codes.
Fixes:
- Both services: fetchJson helper logs HTTP errors with context and the
url (api key redacted), plus catches+logs thrown errors.
- Added a metadata-lookup fallback: /api/v3/movie/lookup/tmdb and
/lookup/imdb for Radarr, /api/v3/series/lookup?term=tvdb:X for Sonarr.
These hit TMDB/TVDB via the arr service for titles not in its library.
- Expanded NAME_TO_639_2: Mandarin/Cantonese → zho, Flemish → nld,
Farsi → fas, plus common European langs that were missing.
- Unknown name → return null (log a warning) instead of a made-up 3-char
code. scan.ts then marks needs_review.
- scan.ts: per-item warn when Radarr/Sonarr miss; per-scan summary line
showing hits/misses/no-provider-id tallies.
Run a scan — the logs will now tell you whether Radarr was called, what
it answered, and why it fell back if it did.
Two bugs compounded:
1. extractOriginalLanguage() in jellyfin.ts picked the FIRST audio stream's
language and called it 'original'. Files sourced from non-English regions
often have a local dub as track 0, so 8 Mile with a Turkish dub first
got labelled Turkish.
2. scan.ts promoted any single-source answer to confidence='high' — even
the pure Jellyfin guess, as long as no second source (Radarr/Sonarr)
contradicted it. Jellyfin's dub-magnet guess should never be green.
Fixes:
- extractOriginalLanguage now prefers the IsDefault audio track and skips
tracks whose title shouts 'dub' / 'commentary' / 'director'. Still a
heuristic, but much less wrong. Fallback to the first track when every
candidate looks like a dub so we have *something* to flag.
- scan.ts: high confidence requires an authoritative source (Radarr/Sonarr)
with no conflict. A Jellyfin-only answer is always low confidence AND
gets needs_review=1 so it surfaces in the pipeline for manual override.
- Data migration (idempotent): downgrade existing plans backed only by the
Jellyfin heuristic to low confidence and mark needs_review=1, so users
don't have to rescan to benefit.
- New server/services/__tests__/jellyfin.test.ts covers the default-track
preference and dub-skip behavior.
The pipeline tab fully replaces the audio list: same items, better
workflow. What the old list contributed (per-item details + skip/approve)
now lives inline on each pipeline card.
- delete src/routes/review/audio/index.tsx + src/features/review/AudioListPage.tsx
- /review/ now redirects to /pipeline (was /review/audio, which no longer exists)
- AudioDetailPage back link goes to /pipeline
- nav: drop the Audio link
- PipelineCard: three buttons on every card — Details (TanStack Link to
/review/audio/$id — the detail route stays, it's how you drill in),
Skip (POST /api/review/:id/skip), Approve (POST /api/review/:id/approve).
Remove the old 'Approve up to here' button (it was computing against
frontend ordering we don't want to maintain, and it was broken).
- SeriesCard: drop onApproveUpTo, pass new approve/skip handlers through
to each expanded episode card
- server: remove now-unused POST /api/review/approve-batch (no callers)
Bug: every approve path (buildCommand used by review approve/approve-all/
series approve-all/season approve-all/retry/detail preview) was building
an ffmpeg command that -map'd only the 'keep' streams and dropped all
subtitles. For a file like Wuthering Heights with 37 embedded subs, the
run would delete every sub into the void — user expected extraction to
sidecar files per the pipeline contract.
buildPipelineCommand already did the right thing (extract every subtitle
with -map 0:s:N -c:s copy 'basename.lang.srt', then remux kept streams)
but it was only reached by tests. buildCommand now delegates to it — one
call site, subtitle extraction always runs, predictExtractedFiles records
the sidecar paths after job success (same logic, same basePath).
Added a regression test: buildCommand on a 2-subtitle file contains
-map 0:s:0, -map 0:s:1 and the expected 'basename.en.srt'/'.de.srt' paths.
Two issues with the old bar:
1. progress state was never cleared between jobs — when a job finished,
its 100% bar lingered on the next job's card until that job emitted
its first progress event. Clear progress on any job_update where
status != 'running', and on the column side ignore progress unless
progress.id matches the current job.id.
2. labels were misleading: the left/right times were ffmpeg's *input*
timestamp position (how far into the source it had read), not wall-
clock elapsed/remaining. For -c copy jobs ripping a 90-min file in
5 wall-seconds, the user saw '0:45 / 90:00' jump straight to
'90:00 / 90:00' which looks broken.
New display: 'elapsed M:SS N% ~M:SS left'. Elapsed is wall-clock
since the job started (re-renders every second), percent comes from
ffmpeg input progress as before, ETA is derived from elapsed × (100-p)/p
once we have at least 1% to avoid wild guesses.
The server's old /approve-up-to/:id re-ran its own SQL ORDER BY against
ALL pending plans (no LIMIT) to decide which rows fell 'before' the target.
The pipeline UI uses a different ordering — interleaving movies with
series cards, sorting by confidence tier without a name tiebreaker, and
collapsing every episode of a series into one card. Visible position
therefore did not map to the server's iteration position, and clicking
'Approve up to here' could approve far more (or different) items than
the user expected.
- replace POST /approve-up-to/:id with POST /approve-batch { planIds: [...] }
— server only approves the plans the client lists, idempotent: skips
ids that are no longer pending, were already approved, or are noop
- ReviewColumn now builds visiblePlanIds in actual render order
(each movie's id, then every episode id of each series in series order)
and 'approve up to here' on any card sends slice(0, idx+1) of that list
- works the same for both PipelineCard (movie) and SeriesCard (whole series
through its last episode)
Extract a ColumnShell component so all four columns share the same flex-1
basis-0 width (no more 24/16/18/16 rem mix) and the same header layout
(title + count + optional action button on the right).
Per-column actions:
- Review: 'Skip all' → POST /api/review/skip-all (new endpoint, sets all
pending non-noop plans to skipped in one update)
- Queued: 'Clear' → POST /api/execute/clear (existing; cancels pending jobs)
- Processing: 'Stop' → POST /api/execute/stop (new; SIGTERMs the running
ffmpeg via a tracked Bun.spawn handle, runJob's catch path
marks the job error and cleans up)
- Done: 'Clear' → POST /api/execute/clear-completed (existing)
All destructive actions confirm before firing.
The pipeline endpoint returned every pending plan (no LIMIT) while the audio
list capped at 500 — that alone was the main lag. SSE compounded it: every
job_update (which fires per-line of running ffmpeg output) re-ran the entire
endpoint and re-rendered every card.
- review query: LIMIT 500 + a separate COUNT for reviewTotal; column header
shows 'X of Y' and a footer 'Showing first X of Y. Approve some to see
the rest' when truncated
- doneCount: split the OR-form into two indexable counts (is_noop + done&!noop),
added together — uses idx_review_plans_is_noop and idx_review_plans_status
instead of full scan
- pipeline page: 1s debounce on SSE-triggered reload so a burst of
job_update events collapses into one refetch
Subtitle extraction lives only in the pipeline now; a file is 'done' when it
matches the desired end state — no embedded subs AND audio matches the
language config. The separate Extract page was redundant.
- delete src/routes/review/subtitles/extract.tsx + SubtitleExtractPage
- delete /api/subtitles/extract-all + /:id/extract endpoints
- delete buildExtractOnlyCommand + unused buildExtractionOutputs from ffmpeg.ts
- detail page: drop Extract button + extractCommand textarea, replace with
'will be extracted via pipeline' note when embedded subs present
- pipeline endpoint: doneCount = is_noop OR status='done' (a file in the
desired state, however it got there); UI label 'N files in desired state'
- nav: drop the now-defunct 'Extract subs' link, default activeOptions.exact
to false so detail subpages (e.g. /review/audio/123) highlight their
parent ('Audio') in the menu — was the cause of the broken-feeling menu
Nav only exposed a subset; Dashboard, Audio review, Subtitle extract, Jobs,
and Paths were reachable only via URL. Add links for every top-level route:
- left: Dashboard, Scan, Pipeline, Audio, Extract subs, Subtitle mgr, Jobs
- right: Paths, Settings
Split the two subtitle pages explicitly (Extract subs = per-item extraction
queue, Subtitle mgr = language summary + title harmonization) so their
distinct purpose is visible from the nav instead of hidden under one label.
Prod minified bundle crashed with 'can't access lexical declaration 'o'
before initialization' because flush was memoized with stopFlushing in its
deps, and stopFlushing was memoized with flush in its deps — circular.
In dev this still worked (refs paper over TDZ), but Vite's minifier emitted
the declarations in an order that tripped the temporal dead zone.
Extract the interval-clearing into a plain inline helper (clearFlushTimer)
that both flush and stopFlushing call. flush no longer depends on
stopFlushing; the cycle is gone.
Gitea's act runner re-clones every referenced GitHub action on every build
because it has no action cache. docker/setup-buildx-action alone was taking
~2 minutes to clone before the build even started.
buildx is already bundled in gitea/runner-images:ubuntu-latest, so call
'docker buildx build --push' directly with --cache-from/--cache-to pointing
at a registry buildcache tag. Keeps the layer caching benefit, skips the
action-clone tax entirely.
Root cause of 6+ min builds: Dockerfile stage 1 ran 'npm install' with no
package-lock.json, so every build re-resolved + re-fetched the full npm tree
from scratch on a fresh runner.
- Dockerfile: replace node:22-slim+npm stage with oven/bun:1-slim; both
stages now 'bun install --frozen-lockfile' against the tracked bun.lock;
--mount=type=cache for the bun install cache
- workflow: switch to docker/build-push-action with registry buildcache
(cache-from + cache-to) so layers persist across runs
- dockerignore: add .worktrees, docs, tests, tsbuildinfo so the build context
ships less
- split tsconfig.json into project references (client + server) so bun-types and DOM types don't leak into the other side; server now resolves Bun.* without diagnostics
- client tsconfig adds vite/client types so import.meta.env typechecks
- index.tsx spa fallback: use async/await + c.html(await …) instead of returning a Promise of a Response, which Hono's Handler type rejects
- subtitles normalize-titles: narrow canonical to string|null (Map.get widened to include undefined)
- execute: actually call isInScheduleWindow/waitForWindow/sleepBetweenJobs in runSequential (they were dead code); emit queue_status SSE events (running/paused/sleeping/idle) so the pipeline's existing QueueStatus listener lights up
- review: POST /:id/retry resets an errored plan to approved, wipes old done/error jobs, rebuilds command from current decisions, queues fresh job
- scan: dev-mode DELETE now also wipes jobs + subtitle_files (previously orphaned after every dev reset)
- biome: migrate config to 2.4 schema, autoformat 68 files (strings + indentation), relax opinionated a11y/hooks-deps/index-key rules that don't fit this codebase
- routeTree.gen.ts regenerated after /nodes removal
- analyzer: rewrite checkAudioOrderChanged to compare actual output order, unify assignTargetOrder with a shared sortKeptStreams util in ffmpeg builder
- review: recompute is_noop via full audio removed/reordered/transcode/subs check on toggle, preserve custom_title across rescan by matching (type,lang,stream_index,title), batch pipeline transcode-reasons query to avoid N+1
- validate: add lib/validate.ts with parseId + isOneOf helpers; replace bare Number(c.req.param('id')) with 400 on invalid ids across review/subtitles
- scan: atomic CAS on scan_running config to prevent concurrent scans
- subtitles: path-traversal guard — only unlink sidecars within the media item's directory; log-and-orphan DB entries pointing outside
- schedule: include end minute in window (<= vs <)
- db: add indexes on review_plans(status,is_noop), stream_decisions(plan_id), media_items(series_jellyfin_id,series_name,type), media_streams(item_id,type), subtitle_files(item_id), jobs(status,item_id)
- remove nodes table, ssh service, nodes api, NodesPage route
- execute.ts: local-only spawn, atomic CAS job claim via UPDATE status
- wrap job done + subtitle_files insert + review_plans status in db transaction
- stream ffmpeg output per line with 500ms throttled flush
- bump version to 2026.04.13
After a scan completes, show a "Review in Pipeline →" link next to the
status label. Nav already included the Pipeline entry from a prior task.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>