adds POST /api/review/:id/reopen that flips done or errored plans back
to pending, clears the lingering job row, resets verified=0, and keeps
the prior ffmpeg error summary in the plan's notes so the user has
context for redeciding.
done column cards grow a hover-only '← back to review' button next to
the status badge — works identically for both the ✓/✓✓ and the ✗ rows,
since the server accepts either. also hid the existing queue card's
back-to-review behind the same hover affordance so the two columns
behave consistently and the cards stay visually calm when not hovered.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
each top-level card now shows a secondary button on hover ('↑ approve
above') that approves every card listed above this one in one
round-trip. uses a new POST /api/review/approve-batch { itemIds } that
ignores non-pending items so stale client state can't 409. series cards
get the same affordance scoped via a named tailwind group so it
doesn't collide with the inner episode cards' own hover state.
fix the horizontal-scroll glitch: long unbreakable audio titles (e.g.
the raw release filename) now line-wrap inside the card via
[overflow-wrap:anywhere] + min-w-0 on the span. previously
break-words was a no-op since there were no whitespace break points
in the release string.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
worked through AUDIT.md. triage:
- finding 2 (subtitle rescan wipes decisions): confirmed. /:id/rescan now
snapshots custom_titles and calls reanalyze() after the stream delete/
insert, mirroring the review rescan flow. exported reanalyze + titleKey
from review.ts so both routes share the logic.
- finding 3 (scan limit accepts NaN/negatives): confirmed. extracted
parseScanLimit into a pure helper, added unit tests covering NaN,
negatives, floats, infinity, numeric strings. invalid input 400s and
releases the scan_running lock.
- finding 4 (parseId lenient): confirmed. tightened the regex to /^\d+$/
so "42abc", "abc42", "+42", "42.0" all return null. rewrote the test
that codified the old lossy behaviour.
- finding 5 (setup_complete set before jellyfin test passes): confirmed.
the /jellyfin endpoint still persists url+key unconditionally, but now
only flips setup_complete=1 on a successful connection test.
- finding 6 (swallowed errors): partial. the mqtt restart and version-
fetch swallows are intentional best-effort with downstream surfaces
(getMqttStatus, UI fallback). only the scan.ts db-update swallow was
a real visibility gap — logs via logError now.
- finding 1 (auth): left as-is. redacting secrets on GET without auth
on POST is security theater; real fix is an auth layer, which is a
design decision not a bugfix. audit removed from the tree.
- lint fail on ffmpeg.test.ts: formatted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
monitoring the mqtt broker revealed two bugs and one design dead-end:
1. the jellyfin-plugin-webhook publishes pascalcase fields
(NotificationType, ItemId, ItemType) and we were reading camelcase
(event, itemId, itemType). every real payload was rejected by the
first guard — the mqtt path never ingested anything.
2. the plugin has no ItemUpdated / Library.* notifications. file
rewrites on existing items produce zero broker traffic (observed:
transcode + manual refresh metadata + 'recently added' appearance
→ no mqtt messages). ✓✓ via webhook is structurally impossible.
fix the webhook path so brand-new library items actually get ingested,
and narrow ACCEPTED_EVENTS to just 'ItemAdded' (the only library-side
event the plugin emits).
move the ✓✓ signal from webhook-corroboration to post-execute ffprobe
via the existing verifyDesiredState helper: after ffmpeg returns 0 we
probe the output file ourselves and flip verified=1 on match. the
preflight-skipped path sets verified=1 too. renamed the db column
webhook_verified → verified (via idempotent RENAME COLUMN migration)
since the signal is no longer webhook-sourced, and updated the Done
column tooltip to reflect that ffprobe is doing the verification.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adds review_plans.webhook_verified, set to 1 whenever a fresh analysis
(scan or post-execute webhook) sees is_noop=1, cleared if a webhook
later flips the plan off-noop. resurrected the try/catch alter table
migration pattern in server/db/index.ts for the new column.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Queued jobs now render the full pipeline card with locked-in audio
stream checkboxes and transcode badges, so the rationale for queuing
stays visible. The primary action becomes "Back to review" which
unapproves the plan and moves the item back to the Review column.
The dropdown showed every language known to LANG_NAMES — not useful
because you can only keep streams that actually exist on the file. The
right tool is checkboxes, one per track, pre-checked per analyzer
decisions.
- /api/review/pipeline now returns audio_streams[] per review item
with id, language, codec, channels, title, is_default, and the
current keep/remove action
- PipelineCard renders one line per audio track: checkbox (bound to
PATCH /:id/stream/:streamId), language, codec·channels, default
badge, title, and '(Original Language)' when the stream's normalized
language matches the item's OG (which itself comes from
radarr/sonarr/jellyfin via the scan flow)
- ReviewColumn + SeriesCard swap onLanguageChange → onToggleStream
- new shared normalizeLanguageClient mirrors the server's normalize so
en/eng compare equal on the client
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.
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>
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 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)
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
- 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)
rewrite from monolithic hono jsx to react 19 spa with tanstack router
+ hono json api backend. add scan, review, execute, nodes, and setup
pages. multi-stage dockerfile (node for vite build, bun for runtime).
previously, server/ and src/shared/lib/ were silently excluded by
global gitignore patterns (/server/ from emacs, lib/ from python).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>