Commit Graph

107 Commits

Author SHA1 Message Date
d2983d5f38 skip non-extractable subs (dvdsub/dvbsub/unknown), summarise ffmpeg errors
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
Abraham Lincoln crashed with exit 234 because the file had 14 dvd_subtitle
streams: our extraction dict only keyed on the long form (dvd_subtitle)
while jellyfin stores the short form (dvdsub), so the lookup fell back
to .srt, ffmpeg picked the srt muxer, and srt can't encode image-based
subs. textbook silent dict miss.

replaced the extension dict with an EXTRACTABLE map that pairs codec →
{ext, codecArg} and explicitly enumerates every codec we can route to a
single-file sidecar. everything else (dvd_subtitle/dvdsub, dvb_subtitle/
dvbsub, unknown codecs) is now skipped at command-build time. the plan
picks up a note like '14 subtitle(s) dropped: dvdsub (eng, est, ind,
kor, jpn, lav, lit, may, chi, chi, tha, vie, rus, ukr) — not extractable
to sidecar' so the user sees exactly what didn't make it.

also added extractErrorSummary in execute.ts: when a job errors, scan
the last 60 stderr lines for fatal keywords (Error:, Conversion failed!,
Unsupported, Invalid argument, Permission denied, No space left, …),
dedupe, prepend the summary to the job's stored output. the review_plan
notes get the same summary — surfaces the real cause next to the plan
instead of burying it under ffmpeg's 200-line banner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:42:05 +02:00
afd95f06df ✓✓ is now jellyfin-corroborated, not a self-confirming ffprobe
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m11s
user reported ad astra got the double checkmark instantly after
transcode — correct, and correct to flag: the post-execute
verifyDesiredState ran ffprobe on the file we had just written, so it
tautologically matched the plan every time. not a second opinion.

replaced the flow with the semantics we actually wanted:

1. refreshItem now returns { refreshed: boolean } — true when jellyfin's
   DateLastRefreshed actually advanced within the timeout, false when it
   didn't. callers can tell 'jellyfin really re-probed' apart from
   'we timed out waiting'.

2. handOffToJellyfin post-job: refresh → (only if refreshed=true) fetch
   fresh streams → upsertJellyfinItem(source='webhook'). the rescan SQL
   sets verified=1 exactly when the fresh analysis sees is_noop=1, so
   ✓✓ now means 'jellyfin independently re-probed the file we wrote
   and agrees it matches the plan'. if jellyfin sees a drifted layout
   the plan flips back to pending so the user notices instead of the
   job silently rubber-stamping a bad output.

3. dropped the post-execute ffprobe block. the preflight-skipped branch
   no longer self-awards verified=1 either; it now does the same hand-
   off so jellyfin's re-probe drives the ✓✓ in that branch too.

refreshItem's other two callers (review /rescan, subtitles /rescan)
ignore the return value — their semantics haven't changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:29:00 +02:00
90fd87be61 pipeline cards: click the title to open the audio details view
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m49s
across review, processing, and done columns the movie/episode name is
now a link to /review/audio/\$id — matches the usual web pattern and
removes an extra click through the now-redundant Details button on
pipeline cards. jellyfin's deep link moves to a small ↗ affordance
next to the title so the 'open in jellyfin' path is still one click
away without hijacking the primary click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:24:00 +02:00
47781e04f9 review column: 'approve above' on hover, wrap long audio titles
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
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>
2026-04-14 18:11:57 +02:00
1de5b8a89e address audit findings: subtitle rescan decisions, scan limit, parseId, setup gate
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
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>
2026-04-14 17:41:36 +02:00
d05e037bbc webhook: PascalCase payload + ItemAdded only, switch ✓✓ signal to ffprobe
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m56s
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>
2026-04-14 17:27:22 +02:00
9cdc054c4b audio titles: rewrite to canonical 'ENG - CODEC · CHANNELS', two-line pipeline card
All checks were successful
Build and Push Docker Image / build (push) Successful in 59s
audio tracks now get a harmonized title on output (overriding any file
title like 'Audio Description' — review has already filtered out tracks
we don't want to keep). mono/stereo render numerically (1.0/2.0), matching
the .1-suffixed surround layouts. pipeline card rows become two-line so
long titles wrap instead of being clipped by the column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:02:32 +02:00
027ea498c3 done column: double-checkmark when jellyfin webhook corroborates the plan
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m37s
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>
2026-04-14 15:50:57 +02:00
4baf209134 pipeline card: always show track title so same-language audios are distinguishable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:50:51 +02:00
ff74cc3a04 queue column: reuse review card read-only, back-to-review instead of approve
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m52s
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.
2026-04-14 12:48:27 +02:00
4057b692ba audio: single EAC3 transcode target, prefer direct-play over lossless default
All checks were successful
Build and Push Docker Image / build (push) Successful in 47s
two simplifications to how we pick and transcode the one-per-language
audio track, motivated by seeing inconsistent DTS → FLAC vs DTS →
EAC3 outputs in the wild:

transcode target:
- drop the FLAC path entirely. every incompatible source now targets
  EAC3 regardless of container or lossless/lossy status
- FLAC for movie audio is bad value: ~2-3× the file size vs EAC3, no
  Atmos spatial metadata (TrueHD Atmos → FLAC silently loses Atmos),
  no AVR passthrough on Apple TV
- one target = no more container-conditional surprises

winner within a language group (betterAudio):
- new priority: highest channels → Apple-compatible → default → index
- old order put 'default' on top which forced a DTS-HD MA transcode
  even when an AC3 track at equal channels was right next to it.
  flipping means AC3 beats DTS-HD MA at the same channel count — pure
  copy instead of a lossless-then-re-encode round trip
- channel count still dominates, so 7.1 TrueHD still beats 5.1 AC3
  (and gets transcoded, which is the right call for real surround)

tests: new case for DTS-HD MA default + AC3 non-default at 5.1 → AC3
wins, job_type=copy. new case for 7.1 TrueHD beats 5.1 AC3 default.
every other existing test still holds.
2026-04-14 10:23:49 +02:00
aca627930f pipeline card: checkboxes over actual audio streams, not a language dropdown
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m3s
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
2026-04-14 10:13:37 +02:00
6698af020d analyzer: keep only one audio track per language, drop commentary/AD
All checks were successful
Build and Push Docker Image / build (push) Successful in 42s
a release with 2× english (main + director's commentary, or a surround
track plus an audio-description track) was keeping both. the user only
wants one per language. rules, in priority order:

- always drop commentary / audio-description / visually-impaired /
  karaoke / sign-language tracks (matched by title regex + the
  is_hearing_impaired flag)
- within each kept-language group, pick one winner by:
  1. default disposition (main track the muxer chose)
  2. highest channel count
  3. apple-compatible codec (skip a transcode pass)
  4. lowest stream_index for stability

tests cover: commentary dropped even when it matches OG, AD flag
dropped, default beats non-default, higher channels beat default-less
candidates of equal type, Apple-compat tiebreak, per-language dedupe
runs independently, and single-stream files stay noop.
2026-04-14 10:07:02 +02:00
e3686f2b76 mqtt section: spell out that webhooks also auto-ingest new jellyfin items
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m10s
the existing handler already inserts fresh media_items when an Item
Added arrives for a jellyfin id we haven't seen (upsertJellyfinItem
runs insert-or-update). nothing code-wise to change — just make the
ui copy describe both directions of the channel: ingest new adds,
and confirm post-ffmpeg state.
2026-04-14 10:02:49 +02:00
a42429d33c mqtt: split end-to-end test into its own labelled block with instructions
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
the test button doesn't belong next to Save — running it is an
optional verification, not part of saving config. pulled it into a
dedicated 'End-to-end test' section below the plugin setup panel
with a numbered 4-step recipe: add Playback Start temporarily, hit
Start test, start playback in jellyfin, read the result, then remove
Playback Start. Notification Type in the setup panel now lists only
'Item Added' since that's all production needs.
2026-04-14 10:01:47 +02:00
425ee751ce mqtt test: use playback start as reliable trigger, drop auto-prefix
All checks were successful
Build and Push Docker Image / build (push) Successful in 48s
two fixes based on actual behavior of the jellyfin webhook plugin:

- 'Webhook Url' setup value no longer re-serialized with mqtt://. show
  the user's broker url verbatim so whatever protocol they use (ws://,
  http://, etc.) survives the round trip
- dropped the server-side 'trigger a jellyfin rescan during the test'
  machinery. a refresh that doesn't mutate metadata won't fire Item
  Added, so relying on it produced false negatives. now we just wait
  for any message on the topic; ui instructs the user to hit play on a
  movie in jellyfin while the test runs — playback start is a
  deterministic trigger, unlike library events
- setup panel now lists Notification Types as 'Item Added, Playback
  Start'. playback start is for the test only; the production handler
  still filters events down to item added / updated
2026-04-14 09:55:32 +02:00
7b138f4346 mqtt webhook: nest under jellyfin card, strict enable gating, end-to-end test
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m29s
- MqttSection now renders as a nested block inside the Jellyfin
  ConnSection instead of its own card; ConnSection grew a children slot
- when the enable checkbox is off, broker/topic/credentials inputs and
  the whole plugin setup panel are hidden; only the toggle + a small
  save button remain
- 'Test Connection' became 'Test end-to-end': connects to the broker,
  subscribes, picks a random scanned movie/episode, asks jellyfin to
  refresh it, and waits for a matching webhook message. the UI walks
  through all three steps (broker reachable → jellyfin rescan triggered
  → webhook received) with per-step success/failure so a broken
  plugin config is obvious
2026-04-14 09:35:21 +02:00
9bb46ae968 mqtt setup panel: gate on enable toggle, reorder, move next to jellyfin
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
- new mqtt_enabled config + toggle at top of the section; subscriber
  only starts when the box is checked
- moved the whole MqttSection directly below the Jellyfin section so
  all jellyfin-adjacent config lives together
- rewrote the plugin setup list to match the actual form order and
  group it: 'Top of plugin page' (Server Url = jellyfin base URL),
  'Generic destination', 'MQTT settings', 'Template'
- fields the user picks from a dropdown or toggles (Status,
  Notification Type, Item Type, Use TLS, Use Credentials, QoS) now
  render a 'select' hint instead of a broken Copy button
2026-04-14 09:26:43 +02:00
76d97901cd mqtt setup panel: clipboard fallback for http lan, fill webhook url
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m53s
two issues surfaced on unraid over plain http:

copy buttons: navigator.clipboard is undefined in non-secure contexts.
fall back to a hidden textarea + document.execCommand('copy'), and
surface a 'select & ⌘C' hint if even that fails.

webhook url: the field is only used by http destinations but the plugin
form requires a value regardless. put the broker url there (mqtt://host:
port) so validation passes and it's still obvious what it points at.
2026-04-14 09:13:43 +02:00
62ec7e0255 mqtt setup panel: match jellyfin webhook plugin's actual fields
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m4s
the plugin's MQTT destination form uses different field names and
a different shape than what we'd documented:
- single 'Broker URL' → split 'MQTT Server' + 'MQTT Port' (+ 'Use TLS')
- 'Events' → 'Notification Type', and 'Item Updated' doesn't exist;
  jellyfin reports every file change as 'Item Added'
- plugin requires Webhook Name, Status, Use Credentials, Quality of
  Service fields that weren't in the panel

derive server/port/TLS from the saved mqtt_url so the copy buttons
give back the exact values the plugin expects. handler still accepts
ItemUpdated as a safety net in case the plugin adds it later.
2026-04-14 09:07:32 +02:00
a27e4f4025 close the jellyfin ping-pong via mqtt webhook subscriber
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m5s
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.
2026-04-14 08:26:42 +02:00
2e8d790326 spec: jellyfin webhook ping-pong via mqtt 2026-04-14 08:14:18 +02:00
9b03a33e24 add auto-review button that approves every high-confidence pending item
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m0s
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
2026-04-14 07:40:38 +02:00
4f1433437b dedupe pending jobs to stop rapid-fire approvals from spawning ghost ffmpeg runs
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m35s
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.
2026-04-14 07:36:15 +02:00
a06ab34b98 make done plans terminal, add ffprobe preflight to skip already-processed files
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m34s
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.
2026-04-13 21:43:10 +02:00
c5ea37aab9 address audit findings: schedule validation, settings json guard, pipeline types, a11y labels
All checks were successful
Build and Push Docker Image / build (push) Successful in 58s
2026-04-13 15:48:55 +02:00
c0bcbaec1b time input: replace hand-rolled fields with react-aria-components TimeField
All checks were successful
Build and Push Docker Image / build (push) Successful in 49s
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>
2026-04-13 15:37:03 +02:00
a1122d7666 kill AM/PM from the schedule picker, enforce iso 8601 24h everywhere
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
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>
2026-04-13 15:29:53 +02:00
6d8a8fa6d6 drop the subtitle-languages setting, it never influenced extraction
All checks were successful
Build and Push Docker Image / build (push) Successful in 53s
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>
2026-04-13 15:26:48 +02:00
a3fde7c441 drop schema migrations now that the factory-reset button handles the upgrade
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
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>
2026-04-13 15:16:50 +02:00
f4859317fa settings: add factory reset button that wipes every table incl. config
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
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>
2026-04-13 15:16:07 +02:00
c06172f412 migrate existing sqlite dbs to the new columns, don't force a nuke on deploy
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
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>
2026-04-13 15:09:45 +02:00
b1b15924ec fix ci: calver suffix must be .N not +N so docker tags stay valid
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
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>
2026-04-13 14:56:18 +02:00
23dca8bf0b split scheduling into scan + process windows, move controls to settings page
Some checks failed
Build and Push Docker Image / build (push) Failing after 8s
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>
2026-04-13 14:50:25 +02:00
6fcaeca82c write canonical iso3 language metadata, tighten is_noop, store full jellyfin data
Some checks failed
Build and Push Docker Image / build (push) Failing after 16s
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>
2026-04-13 13:56:19 +02:00
cc418e5874 fix: jellyfin save now matches the new { ok, saved, testError } response shape
All checks were successful
Build and Push Docker Image / build (push) Successful in 31s
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 }.
2026-04-13 12:33:26 +02:00
94a460be9d rename setup → settings throughout; persist arr creds even on test failure
All checks were successful
Build and Push Docker Image / build (push) Successful in 36s
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.
2026-04-13 12:26:30 +02:00
e8f33c6224 consolidate dashboard into scan page; / now renders Scan
All checks were successful
Build and Push Docker Image / build (push) Successful in 45s
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 /
2026-04-13 12:21:03 +02:00
962b5efc6f settings: drop the section-header env-var lock badges
All checks were successful
Build and Push Docker Image / build (push) Successful in 34s
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.).
2026-04-13 12:14:00 +02:00
b8525be015 scan: validate arr URLs upfront, cache library once per scan
All checks were successful
Build and Push Docker Image / build (push) Successful in 30s
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.
2026-04-13 12:06:17 +02:00
1aafcb4972 apply codex code review: fix useEffect refetch loops, dead routes, subtitle job_type leftovers
All checks were successful
Build and Push Docker Image / build (push) Successful in 36s
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.
2026-04-13 12:01:57 +02:00
cafb3852a1 radarr/sonarr: stop silent failures, add metadata lookup fallback, diagnostic logs
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
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.
2026-04-13 11:46:26 +02:00
50d3e50280 fix '8 Mile is Turkish': jellyfin guesses never earn high confidence
All checks were successful
Build and Push Docker Image / build (push) Successful in 28s
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.
2026-04-13 11:39:59 +02:00
e3b241bef3 drop audio list tab, move per-item actions onto pipeline cards
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
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)
2026-04-13 11:20:57 +02:00
d12dd80209 fix: buildCommand now extracts subtitles to sidecars before stripping them
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m12s
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.
2026-04-13 11:12:10 +02:00
5fa39aee7c processing card: meaningful progress display
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m42s
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.
2026-04-13 10:29:49 +02:00
37e30e9ade processing column: per-card stop button alongside the column-header one
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s
2026-04-13 10:26:20 +02:00
2ada728e50 fix approve-up-to: client sends explicit visible plan id list
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m12s
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)
2026-04-13 10:16:58 +02:00
4a378eb833 pipeline: equal-width columns + per-column clear/stop button
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
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.
2026-04-13 10:08:42 +02:00
ec28e43484 make pipeline responsive at scale: cap review query, debounce sse reload, indexable done count
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s
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
2026-04-13 10:00:08 +02:00