# Changelog
All notable changes to Sublarr are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6.4] - 2026-07-05
### Fixed
- **Scheduled wanted search could silently stall on a large backlog** —
the eligibility check (backoff window, search-attempt cap) ran on an
already-truncated batch of the oldest-searched items, so items that were
actually due for a re-check could be excluded purely because slightly
more recently-searched items filled up the per-run batch first. The
eligibility check now runs across the full candidate pool before the
per-run limit is applied, so due items are never starved by unrelated
backlog ordering.
## [1.6.3] - 2026-07-05
### Fixed
- **`mt_on_original_found` and `mt_min_original_score` are now actually
configurable** — these profile fields (added with the provisional-MT
re-seek job) were fully implemented on the backend but had no reachable
API or UI control, so every profile stayed on the "notify" default
regardless of intent. Both are now exposed on the language profile create
and update API and have a Settings UI control (visible once "Keep seeking
the original" is enabled on a profile).
## [1.6.2] - 2026-07-05
### Added
- **Provisional machine-translation now actively seeks the human original** —
when a profile keeps a machine-translated subtitle provisional
(`mt_keep_seeking_original`), a new scheduled job (`mt_reseek`) periodically
re-searches it in original-only mode (no re-translation). When a genuine
provider/embedded original is found, the profile's `mt_on_original_found`
setting decides what happens: `auto_replace` installs the original and
trashes the superseded machine translation automatically, while `notify`
raises a pending-review entry that can be approved (install the original)
or rejected (keep the machine translation and stop re-checking it) from
a review panel on the Wanted page. A machine translation can also be pinned so it is never
auto-replaced or re-checked, regardless of the profile setting.
## [1.6.1] - 2026-07-05
### Added
- **Multi-language auto-translate source** — the missing target subtitle is now
translated FROM whatever source subtitle actually exists (any language),
instead of only the configured source language. Sublarr picks the best
available source across embedded tracks, external sidecars, and provider
search (which can try several source languages), sets the correct translation
direction from the real source language, and honours the per-profile source
language. Configurable via Settings → Automation → Post-Processing (two
toggles: "Translate From Any Source Language" and "Provider Multi-Language
Source Search") and the `/config` API (candidate source-language list
included).
## [1.6.0] - 2026-07-05
A large feature release: combined subtitles, side-by-side sync-compare, a full
statistics page, provisional machine-translation, reverse-proxy SSO, and
encryption at rest — plus a broad round of PostgreSQL-correctness and
standalone-mode fixes surfaced by staging against a copy of production.
### Added
- **Combined / bilingual subtitles** — compose two per-language sidecars into one
bilingual file (ASS overlay with configurable positioning, or stacked SRT),
on-demand from the episode menu or automatically per language profile. Uniquely,
a missing language can be generated via the translation stack and then combined.
Combined files stay editable in the editor.
- **Side-by-side sync-compare** — preview a subtitle sync against the video
(ffsubsync/alass) without overwriting the sidecar, see the per-cue timing diff,
and choose which result to keep.
- **Statistics page** — a full analytics page (library, subtitles, translation,
providers, system, and daily trends) backed by a rollup table + scheduler job,
with a time-range selector.
- **Manual subtitle upload** — hand-drop a `.srt/.ass/.vtt` for an episode or movie.
- **Provisional machine-translation** — a Sublarr-produced translation can be
flagged `machine_translation` and kept provisional while Sublarr keeps seeking a
human original, per language profile (with a UI toggle).
- **Reverse-proxy header auth** — trust `Remote-User` / `X-Forwarded-User` from a
trusted proxy for Authelia / authentik SSO.
- **Encrypted API keys at rest** — provider keys are Fernet-encrypted in the database.
- **What's-New wizard** — per-version release highlights, re-openable from About.
- **Editor fullscreen toggle** — `f` / `Esc` in the subtitle editor.
### Fixed
- Standalone series-detail now lists every episode from the series folder on disk
instead of the transient wanted-items table, so a fully-satisfied series no
longer shows "no episodes".
- A standalone-scan database race (a concurrent-delete `StaleDataError`) is
recovered instead of poisoning the shared session and emptying the scan.
- The Statistics aggregate and its export no longer return 500 on PostgreSQL
(SQLite-only SQL made dialect-neutral; `AVG()` Decimal coercion).
- Subtitles are written world-readable (`0644`) — uploads, combined files, and
provider downloads are no longer unreadable by the media server.
- Batch re-translation flags its output as a machine translation and resolves the
wanted item per profile; the per-profile keep-seeking flag is settable via the API/UI.
- Episode-level actions (upload, combine, tracks) work for TV episodes on
standalone installs without Sonarr.
- The orphaned-subtitle scan no longer flags valid sidecars whose filename uses an
unrecognised modifier, and the subtitle-`.bak` cleanup rule is now usable.
- A daily-stats upsert race under parallel search workers (which affected
PostgreSQL / production too).
- CI now runs the backend suite on the shipped Python (3.12), a services→routes
layering violation is resolved, and dependencies were refreshed.
## [1.5.0] - 2026-07-02
### Added
- **Translation queue can be cleared from the UI** — queued translation
jobs get a cancel button and the Activity → Translations tab a
"Clear queued" action, backed by `DELETE /api/v1/jobs/{id}` and
`POST /api/v1/jobs/clear-queued`. A guard re-checks the job status
before execution, so cancelled jobs can never start; this also cleans
up jobs orphaned by container restarts.
### Fixed
- **Dashboard no longer contradicts itself** — the header status pill
and the footer automation status now derive from one shared source
(scheduler jobs + live scanner state) with a new "partially paused"
state and tooltips naming the paused jobs. Provider health dots
reflect connectivity instead of the lifetime download-conversion rate
(now a separate, tooltipped figure), the hidden 7-provider cap is
gone, the success-rate tile is labeled as translation success (30d),
and the activity feed no longer labels lifetime counts as "today".
- **Interface language setting is applied again** — the stored
`interface_language` setting and the rendered language could silently
differ (the setting said German while the UI showed English). The
setting now applies on load, the Settings dropdown switches
immediately, the sidebar toggle persists to the setting, and the
selector offers only the actually translated languages (DE/EN).
- **Broken characters in the English UI** — 49 double-encoded strings
(mojibake such as "1×" and "â€"") in the English locale were
repaired, and two German leftovers in the EN locale translated.
- **Mobile layout** — the dashboard stacks into one column with a 2×2
stats grid, and the settings sub-navigation collapses on phones
instead of squeezing the content to an unusable sliver.
- **Wanted page** — the duplicate page header is gone, the header count
and the "Total Wanted" card now report the same number, and a failed
list fetch shows a retryable error instead of an empty list (the
Library page got the same error state).
- **Series detail and library cards** — series with specials no longer
open on a fileless Season 0 (season 0 is labeled "Specials", the
season-count tag is translated), and the library card badges got
tooltips explaining the missing count and the coverage score. Numbers
and provider names now render consistently across dashboard and
history.
## [1.4.0] - 2026-06-30
### Added
- **Translation backend is now selectable in the UI** — a global default backend
(with an optional single fallback) on Settings → Translation → Backends &
Glossary, plus a per-profile override on Languages & Profiles. Profiles inherit
the global default unless overridden. Previously the backend was a DB-only field
with no UI, so configuring e.g. DeepL never changed which backend was actually
used.
- **Dubtitle Detection settings UI** — the dubtitle keys (detection,
verify-on-download, minimum score/margin, auto cue floor) are now
configurable under Settings → Subtitles → Stream Management instead of being
config/env-only. The two dubtitle API endpoints are now documented in OpenAPI.
### Fixed
- **Translation ran but silently produced nothing** — Queued translation jobs
never executed. The job queue defaulted to Redis/RQ, which needs a separate
worker process the single-container deployment does not run, so jobs sat in
"queued" forever; and the in-process fallback ran jobs without a Flask app
context, so the first DB access raised "working outside of application
context". The in-process queue is now the default and runs each job inside an
app context, and an RQ backend with no worker is logged loudly at startup.
Separately, a language profile with an empty backend and empty fallback chain
selected no backend at all ("All backends failed. Last error: None"); empty
profile values now fall back to Ollama and the error names the real cause.
- **Cloud translation backend cards crashed the settings page** — Expanding the
Claude, Gemini, DeepSeek, Mistral, ChatGPT, Azure Translator or MyMemory card
threw "toLowerCase of undefined" and broke the whole Translation tab, because
those backends declared their config fields with `name` instead of the
contracted `key`. The field descriptors are fixed and the UI degrades
gracefully on a bad descriptor. Backend help tooltips are no longer clipped
at the card edge.
- **Translation backend credential overwrite** — saving a configured backend
without re-typing its key overwrote the stored secret with the mask token
`***`; masked password fields are no longer re-sent on save.
- **Translation could not be re-enabled after disabling** — the disabled state
is stored as the string `'false'`, which the UI mis-read as "enabled"
(`Boolean('false')` is true), hiding the enable control. It now compares
explicitly so disable → re-enable works.
- **Backend config field types** — Ollama's `use_chat_api` (checkbox) and
`system_prompt` (textarea) rendered as plain text inputs; they now render
correctly, and translation numeric inputs guard against NaN and clamp to range.
- **Translation settings i18n** — the beta banner, enable/disable controls and
several help texts were hardcoded German/English; they are now fully
translated (DE + EN).
- **Translation could not be enabled from the UI** (#151) — the Translation
settings group was hidden from the settings navigation until translation
was already enabled, and the only enable control lived on a settings
overview screen that is no longer reachable. Translation is now always
listed in the settings navigation, and its page shows an explicit
"Translation aktivieren" button (plus a disabled-state hint) when
translation is off, with the Disable danger zone shown only when it is on.
- **Reasoning-model output leaked into translations** — Ollama responses from
reasoning models (qwen3, deepseek-r1) carry a `…` block that
was written straight into the translated subtitle. It is now stripped before
the translation is parsed, for both the generate and chat APIs.
- **Dead "not implemented" branch** — the standalone status endpoint no
longer advertises a 501 response for a manager that has long existed.
### Added
- **Dub-audio verification on download (opt-in)** — with
`dubtitle_verify_on_download` enabled, a downloaded English subtitle is
scored against the English dub audio before being kept; a confident
mismatch is kept but flagged in the logs. Off by default, and it falls
back to "keep" whenever Whisper or dub audio is unavailable, so it never
blocks a download.
### Removed
- **Never-enforced notification content filters** — the notification
settings API no longer accepts or stores per-content `{field, operator,
value}` filters, which were persisted but never applied. Event-type
include/exclude filters are unchanged.
### Changed
- **Subtitle-sync docs** now list only the two real engines (ffsubsync,
alass); the never-built "nanosync" / "LLM-assisted" engines were removed
from the documentation.
- **Streaming setting label** clarified — the bare "experimental" tag was
replaced with a description of what the toggle does (HTTP-range streaming
of the source video to the Waveform Editor).
- **OpenAPI cleanup `rule_type` enums** aligned with the rule types the
backend actually accepts (added `signs_cleanup`, `foreign_tracks`, and
others that were undocumented).
## [1.3.0] - 2026-06-22
### Added
- **Signs/forced subtitle removal** — A configurable cleanup level
(off / signs / signs+forced / signs+forced+songs, default off) that
detects and removes signs, forced and songs subtitle tracks — both
external sidecars and embedded streams — using metadata plus
cue-density analysis. It runs retroactively as a new (default-disabled)
`signs_cleanup` cleanup rule with a dry-run preview, and going-forward
whenever subtitles are extracted, so signs don't re-accumulate.
Removals go to the trash and stay recoverable; a full dialogue track,
or the last remaining subtitle of any language, is never removed.
Configurable under Settings → Cleanup.
- **Subtitle Health** — A new detect-and-repair subsystem for subtitle
content defects across embedded tracks and sidecars. Nine checkers flag
problems like leaked ASS escape codes (literal `\N`), language mislabels,
broken encoding, timing errors, format mismatches and control characters;
matching fixers repair them with full, reversible backups and one-click
rollback. Findings surface per episode in the track panel, per series via
a bulk scan, and library-wide under Settings → System → Subtitle Health.
A scheduled sweep can auto-apply the safe fixes. Each finding offers
contextual actions — fix, strip the embedded track (remux), open the file in
the editor, or dismiss an accepted advisory (e.g. a high-CPS timing warning).
Embedded-track defects are automatically suppressed once a clean sidecar of
the same language exists, so a fixed issue no longer reappears on every rescan.
Accepted timing/CPS warnings can be dismissed for good, and a sidecar timing
issue can be re-aligned to the audio directly from the finding.
- **Update banner** — A dismissible announcement bar notifies you when a
newer Sublarr version is available; dismissals are remembered per version.
### Changed
- **Episode track panel — language-first subtitles + separate audio** — Embedded
subtitle tracks and on-disk sidecar files (`.de.srt`, `.en.srt`, …) now appear
together in one language-sorted Subtitles table (instead of a mixed list that
hid sidecars), each with source-appropriate actions (extract / editor / convert
/ remove / set-default for embedded; editor / download / delete for sidecars).
Audio tracks have their own table with a "Set default" control.
### Fixed
- **Cleanup re-walked its own trash** — The sidecar cleanup and orphan
scan descended into the `.sublarr` trash subtree, re-processing
already-removed files on every run — producing thousands of harmless
"permission denied" warnings and tens of thousands of phantom
"orphans" each night. The trash subtree is now excluded from both
walks.
- **Corrupted SRT timecodes on download** — Every downloaded SRT/VTT
subtitle had its `-->` timecode separator HTML-escaped to `-->` by the
content sanitizer, producing malformed timecodes that media players reject.
The sanitizer now preserves the timecode arrow (and a leading BOM) while
keeping full XSS protection on cue text. (Pre-existing since 0.15.2-beta.)
- **Subtitles unreadable by the media server after a fix/extract** — Files
Sublarr wrote into the library could land mode 0600 (owner-only), so Emby/Plex
(running as a different user) could not read them and playback failed. Written
files are now group/other-readable (atomic-write chmod + container umask).
## [1.2.0] - 2026-06-15
### Added
- **Automatic dubtitle detection** — Sublarr now detects the dubtitle
automatically instead of only on demand. A background sweep classifies the
English tracks and caches the result in a new `dubtitle_detections` table, so
the episode track panel shows the detected dubtitle immediately on open
without re-probing. Tier-1 heuristics were hardened (margin gate + minimum
cue-count floor) to reduce false matches among multiple English tracks.
### Fixed
- **App-wide styling broken on the Docker image** — A global CSS reset declared
outside Tailwind's cascade layers silently overrode every spacing utility
(padding, margin, gaps) across the whole app, leaving forms and pages
unstyled. The reset now defers to Tailwind's layered Preflight, restoring
correct spacing everywhere. (#148)
- **White patches in dark mode** — The page background could fail to apply,
letting the browser's white canvas show through panels in dark mode. Base
element styles are now emitted reliably so the dark background always paints.
- **First-run profile selection** — Picking a profile other than the
recommended one now visibly deselects the recommended card; the highlight
used to stick to both.
- **Noisy WebSocket errors in the browser console** — The frontend tried to
open a native WebSocket the backend never supported, failing the handshake on
every page and spamming the console before falling back to long-polling. The
client now uses long-polling directly. (#148)
- **Initial password rejected unexpectedly** — The setup form accepted
passwords the backend then refused (frontend required 4 characters, backend
12). The minimum is now a consistent 8 characters across the UI and API.
(#149)
- **Providers settings page errored on SQLite** — `GET /api/v1/providers/stats`
returned a 500 (`can't compare offset-naive and offset-aware datetimes`) on
SQLite installs because cached-entry timestamps were compared without
normalising timezones. Naive timestamps are now treated as UTC. (Postgres was
unaffected.)
- **First-run wizard didn't fully apply the chosen profile** — The provider
budget safety margin prescribed by each profile (light/balanced/aggressive)
was silently dropped because the field wasn't declared in settings, so the
budget manager always used the default. The field is now persisted.
- **Settings reset to defaults after completing the wizard** — Finishing the
first-run wizard reloaded settings without the database overrides, briefly
resetting all UI-configured values to defaults in memory until the next
config write or restart. The wizard now reloads with the full config.
## [1.1.0] - 2026-06-14
### Added
- **Dubtitle detection & selection** — Anime files often bundle several
English subtitle tracks (fansub, dubtitle/CC, signs & songs) all tagged
"eng", so viewers watching the English dub get fansub subs that don't match
what's spoken. Sublarr can now identify the dubtitle specifically. A cheap
heuristic pass classifies each English track (signs/forced, SDH/CC, cue
density, characters-per-second, overlapping cues); when several full-text
English tracks remain ambiguous, it samples the English dub audio with
Whisper and scores each subtitle's wording against what's actually spoken —
the closest match above the threshold is flagged as the dubtitle. Per-track
match scores are surfaced in the episode track panel and nothing is applied
automatically (suggest-then-confirm). A "fetch, then verify" helper can also
score a downloaded English subtitle against the dub audio. Off by default
(`dubtitle_detection`); also available on demand per episode.
## [1.0.1] - 2026-06-14
### Fixed
- **Trash backups now auto-pruned reliably** — The scheduled cleanup job ran
on a weekly interval that never fired dependably on a frequently restarted
container, so the 7-day backup-retention rule never executed and pre-remux
video backups piled up (113 GB observed in production). Cleanup now runs on
a fixed daily schedule (03:45) and a one-time reconciliation migrates the
stale job on upgrade. Retention itself is unchanged (7 days).
- **Large video files no longer time out during remux and subtitle sync** — A
hardcoded 600-second subprocess limit false-failed big files (e.g. a 25 GB
Remux movie) on every cleanup cycle. Timeouts now scale with file size (up
to 60 minutes), so foreign-track stripping and ffsubsync finish on large
media instead of being left untouched.
### Changed
- **`wanted_search` search window raised to 30 minutes** — Searching thousands
of wanted items across rate-limited providers could exceed the previous
15-minute ceiling on high-latency days. Per-request HTTP timeouts already
prevent a single hung provider from stalling the whole sweep.
## [1.0.0] - 2026-06-13
Sublarr 1.0 — first stable release. The core subtitle pipeline (search,
ASS-first scoring, download, Sonarr/Radarr integration, standalone mode,
waveform editor and sync tools) is considered stable for everyday use.
LLM translation remains experimental.
### Added
- **Complete UI internationalization** — the remaining ~360 hardcoded interface strings (toasts, dialogs, labels, tooltips) now flow through the i18n system; the entire UI is available in German and English with full de/en key parity.
### Fixed
- **Raw translation keys in several panels** — fixed namespace-binding bugs where dropdowns and table headers (series processing override, event/system hooks, integrations, translation backends, sync preview) could display raw i18n keys instead of translated text.
### Changed
- **Multi-arch image** — the 1.0.0 image ships for `linux/amd64` and `linux/arm64`, enabling ARM hosts (Raspberry Pi) and Unraid Community Applications on ARM.
## [0.95.2-beta] - 2026-06-12
### Security
- **Stream URLs no longer carry the API key** — `/media/stream` now uses a path-scoped, short-lived HMAC stream token (6 h) requested via `POST /media/stream-token`, instead of embedding the API key in the URL where it could leak through logs, history or referrers.
- **Bounded background execution** — the roughly twenty unbounded `threading.Thread` spawns across route handlers are replaced by a shared, size-capped executor (`SUBLARR_BACKGROUND_WORKERS`, default 8) that is drained cleanly on shutdown, preventing thread exhaustion under load.
- **Atomic subtitle writes** — subtitle saves now write to a temporary file and atomically rename it into place, so a crash mid-write can no longer leave a partially written or corrupt sidecar.
- **Hardened inputs and responses** — SSRF validation on translate callback URLs (blocks decimal/hex-encoded IPs), a streaming size cap plus safe member reads on full-backup ZIPs, a strict Content-Security-Policy with no inline scripts outside `/api/docs`, `is_safe_path` enforcement on waveform extraction, subprocess-argument quoting across the remux/sync/OCR engines, and generic 500 responses that no longer leak internal error text or filesystem paths.
- **Configurable cookie and rate-limit security** — `SESSION_COOKIE_SECURE` and the rate-limit storage backend are now driven by environment/Redis configuration rather than hardcoded defaults.
## [0.95.1-beta] - 2026-06-10
### Fixed
- **Standalone watched-folder re-add** — Re-adding an existing watched-folder path hit the UNIQUE(path) constraint and returned HTTP 500; the upsert now updates the existing row in place instead of blindly inserting.
- **Six components crashed on render due to missing translation hooks** — The comparison-view close button, the episode score badge, the hook form modal, the Whisper model table, the API-key edit field and the statistics quality table referenced `t()`/`tc()` without declaring `useTranslation`, raising a ReferenceError when rendered.
- **Trash actions raised a TypeError on every toast** — The trash hooks called the non-existent `toast.success()`/`toast.error()`; all four trash mutations now use the actual `toast(message, type)` API.
- **Attention banner never appeared** — The dashboard banner read the wanted-items response from the wrong field and filtered on fields the API does not return; it now uses the real contract (`data`, `current_score`, `season_episode`).
- **Dashboard batch indicator stuck** — The quick-actions panel read `is_running` while the API returns `running`, so the batch-search spinner state never reflected a running batch.
- **Movie detail breadcrumb link dead** — The library breadcrumb used a `to` prop the component does not know; it now uses `href` and navigates again.
- **Notification history showed "Invalid Date" and an empty column** — The table read `timestamp`/`channel` while the API returns `sent_at`/`title`; the channel column was replaced by a title column (DE+EN).
### Security
- **Dependency CVE bumps** — `requests` 2.32.4→2.33.0 (CVE-2026-25645) and `python-dotenv` 1.0.1→1.2.2 (CVE-2026-28684). `npm audit fix` resolved the `socket.io-parser` (HIGH) and `ws` advisories, and `@lhci/cli` moved to devDependencies — the production npm audit is now clean (0 vulnerabilities).
### Changed
- **TypeScript 6 migration with real type-checking** — The frontend now compiles under TypeScript 6.0 and the CI type-check step actually checks all sub-projects (`tsc -b`); CI also runs on direct master pushes again. This is what surfaced the latent runtime bugs fixed above.
- **Docker frontend build stage upgraded to Node 26** (Dependabot).
## [0.95.0-beta] - 2026-06-10
### Added
- **Media-server refresh after subtitle changes** — Jellyfin, Plex and Kodi libraries are now refreshed automatically after a subtitle download or a foreign-track strip, so external players pick up newly written sidecars without waiting for a manual library rescan.
### Fixed
- **ffsubsync mis-lock rejection** — The subtitle sync sanity threshold default was lowered from 60s to 45s after a production bulk run surfaced a cluster of engine mis-locks at ±56–60ms, just under the old ceiling. Genuinely large offsets are still accepted, while the spurious near-ceiling cluster is now rejected and falls through to the next engine.
- **Foreign-track cleanup and track extraction no longer skip files with a phantom trailing segment** — The remux duration sanity check compared the container's reported duration, which a trailing subtitle track can inflate tens of seconds past the actual video. A subtitle-only remux then legitimately shrank the container to the real video end and was wrongly rejected, leaving the file uncleaned (e.g. Solo Leveling S01E12: container 1478s vs real video 1420s). The check now compares the video stream's own duration; a genuinely truncated video still fails.
---
## [0.94.0-beta] - 2026-06-09
### Added
- **Foreign-track cleanup rule ("Fremdsprachen-Tracks entfernen")** — A new cleanup rule that strips embedded subtitle tracks whose language is not on the keep-list (default German + English) from video files across the library. Available in Settings → Cleanup with a dry-run preview and manual or scheduled execution; originals are kept as restorable backups in the media trash directory. Closes the gap where provider-downloaded episodes never had their embedded foreign subtitle tracks removed (previously only the extract path did this).
- **Automatic foreign-track stripping after download** — Provider subtitle downloads now strip non-target embedded subtitle tracks automatically when foreign-track cleanup is enabled, instead of only on the extract path. A configurable always-keep language list (`cleanup_foreign_tracks_keep_languages`, default German + English) guarantees English survives even for a German-only download target.
### Fixed
- **Foreign-track cleanup no longer risks removing the target language** — The keep-set was compared as bare 2-letter codes against raw ISO-639-2 track tags (e.g. `ger`/`eng`), which could classify the target language itself as foreign and strip it. Language codes are now expanded to all known tag variants before comparison.
---
## [0.93.3-beta] - 2026-05-14
### Changed
- **Subtitle sync — throttled to one parallel job per process** — `ffsubsync` and `alass` previously ran two parallel from `/auto-sync/bulk` plus an inline auto-sync from every newly downloaded wanted-search subtitle. On a 24-core homeserver the combined CPU+IO spiked host load to 9+ and crowded out Plex / Home Assistant VM / qBittorrent. A module-level `BoundedSemaphore(1)` now serialises every sync subprocess across the Flask process so at most one engine runs at a time regardless of which route or worker called it (catches the UI bulk-sync, the legacy `wanted_search` auto-sync, and the new orchestrator path in one chokepoint). The command is prefixed with `nice -n 19` on Linux so the subprocess yields CPU to whatever else needs it. Bulk-sync wall-clock is longer but per-job duration is unchanged.
---
## [0.93.2-beta] - 2026-05-14
### Fixed
- **`sync_sanity_threshold_ms` is now enforced in the legacy sync path too** — The threshold introduced in 0.93.0-beta was only wired into `services.sync_engines.orchestrator`. The legacy `services.video_sync.sync_with_ffsubsync` path, which powers `/video-sync`, `/auto-sync`, `/auto-sync/bulk`, `wanted_search` and the CLI sync command, parsed the ffsubsync shift only after overwriting the destination sidecar and never compared it against the threshold. Mis-locks in the ±56-60s band therefore wrote `sync_job_runs` rows with `status='ok'` and corrupted the destination subtitle. Now the shift is parsed before any file is touched; if `|shift| > sync_sanity_threshold_ms` the function audits `status='rejected'` with reason `exceeds_sanity_threshold:Nms`, raises a new `SyncSanityThresholdError`, and leaves the sidecar intact. Mirrors the gate enforced at `services/sync_engines/orchestrator.py:70`. Two regression tests added.
## [0.93.1-beta] - 2026-05-14
### Fixed
- **Bulk auto-sync `scope=all` and `scope=standalone` returned HTTP 500** — `_bulk_videos_from_standalone` constructed `StandaloneRepository(db.session)` but `BaseRepository.__init__()` takes no positional arguments, so every call with these scopes raised `TypeError`. Workaround was `scope=library` (Sonarr only) or `scope=radarr` until this fix shipped.
- **Sync silently overwrote sidecars without writing an audit row** — `shutil.move` from `/tmp` to a bind-mounted `/media` fell back to `copy2`, whose `copystat` raised `PermissionError` on hosts with mismatched user/group ownership. The destination was already overwritten by `copyfile` at that point, but the exception prevented `_audit("ok")` from ever running — every successful sync looked failed in logs, no `sync_job_runs` row was written, and `/tmp` leaked the temp output. Replaced with explicit `copyfile + _safe_remove` so metadata is never touched. Applies to both ffsubsync and alass code paths.
## [0.93.0-beta] - 2026-05-12
### Added
- **Configurable sync sanity threshold** — Settings → Sync now exposes `sync_sanity_threshold_ms` (default 60000). Lower to 45000 to reject ffsubsync mis-locks where the engine catches the wrong reference section and reports a shift just under the previous hard-coded 60s ceiling. The orchestrator falls through to the next engine in the chain when the threshold is exceeded.
- **Bulk auto-sync: Radarr + standalone library coverage** — `POST /api/v1/tools/auto-sync/bulk` now accepts `scope=radarr`, `scope=standalone`, and `scope=all` in addition to `series` and `library`. Per-video sidecar walk queues a job for every matching `.{lang}.srt|ass` next to the video (was previously only the first match), with deduplication so re-runs and overlapping scopes do not double-queue. Response surfaces `videos_considered` and a `skipped[]` list with reasons.
- **Prometheus metrics for subtitle sync** — `sync_jobs_total`, `sync_jobs_duration_seconds`, and `sync_jobs_offset_ms` counters/histograms now bump alongside every `sync_job_runs` audit row. Scrape `/api/v1/metrics` for Grafana visibility into the bulk auto-sync flow.
### Fixed
- **Onboarding wizard: 12 bugs closed** — Form fields no longer lose focus on every keystroke (the inline `Field` component was re-mounting on each parent render). Language step now syncs `target_language_name` to the chosen language code. Progress bar exposes ARIA attributes for screen readers. Test buttons added under Sonarr / Radarr / TMDb / TVDB / Ollama. Switching between ARR-only and standalone setup modes no longer leaks orphan config keys into the saved payload. Mediaserver-instance UIDs are stable across re-renders so show-password toggles target the right row.
- **Path mapping in audio + OCR routes** — `/audio/extract` and `/ocr/extract` previously checked `settings.media_path_mapping`, a Pydantic field that does not exist (the real field is `path_mapping`). The check silently always failed and remote→local path translation never applied to these two endpoints. Both routes now call `config.map_path()` directly so the mapping takes effect.
## [0.92.7-beta] - 2026-05-12
### Fixed
- **Manual subtitle sync regained its audit trail and accurate `shift_ms` reporting** — Two regressions in the `/api/v1/tools/auto-sync` and `/video-sync` paths surfaced while re-syncing a series in prod. First, `_parse_ffsubsync_shift`'s regex no longer matched ffsubsync ≥0.4's `INFO offset seconds: 22.960` output format (it still expected a trailing `s` literal that current ffsubsync no longer emits), so every API response reported `shift_ms=0` regardless of the real shift and `sync_job_runs.offset_ms` was stuck at 0 too. Second, the `ThreadPoolExecutor` worker that runs `sync_with_ffsubsync` / `sync_with_alass` had no Flask app context, so the audit-row write into `sync_job_runs` and the `post_processing.after_sync` trigger both logged "Working outside of application context" and swallowed silently — manual syncs left no audit trail and never fired post-processing hooks. The regex now handles legacy (`offset: 1.234s`, `estimated shift:`) and modern (`offset seconds: X.XXX`) formats; the worker is now wrapped in `app.app_context()` via a new `_submit_sync` helper that captures the live Flask app at submit time. Mirror fix in `services/sync_engines/ffsubsync_engine.py`.
### Tests
- 4 new regression cases in `test_video_sync.py`: modern ffsubsync output (positive/negative/zero), parser parity between the two duplicate implementations, worker `has_app_context()` assertion inside the patched executor scope, and route-level submit-receives-real-app check.
## [0.92.6-beta] - 2026-05-09
### Fixed
- **`format_upgrade` cleanup rule now resolves ISO language aliases (`ger` ↔ `de`, `deu` ↔ `de`, `german` ↔ `de`, etc.)** — Previously the rule indexed sidecars by `(dirpath, base_without_extension)`, so `Show.de.ass` and `Show.ger.srt` were treated as unrelated tracks even though both are German. The redundant `.ger.srt` stayed on disk forever. The rule now keys by `(video_base, canonical_lang, modifier)` and collapses every recognised tag-variant to its ISO-639-1 canonical via `normalize_language_code`. Modifier-aware: `forced`, `sdh`, `hi`, `cc`, `sign`/`signs` are part of the key so a `de.forced.srt` is never trashed in favour of a `de.ass` (different logical track, not a lower-quality version of the same one). Multiple inferior peers in the same logical-track bucket are all trashed at once. Same-format dedup within one canonical lang (e.g. two `.ass` files for `de` and `ger`) is intentionally out of scope — that's a future dedicated dedup rule. Cross-checked with Gemini cold-review.
### Tests
- 19 new regression cases in `test_format_upgrade_alias_2026_05_09.py` covering ISO alias resolution (`ger`/`deu`/`german`), modifier protection (`forced`/`sdh`/`hi`/`signs`), multi-peer trash, lone-inferior keep, untagged sidecar preservation, cross-language independence, `keep_format=srt` inverse pairing, dry-run safety, and the Akame ga Kill! S01E06 real-case (`de.ass` + `ger.srt` + `en.srt` → `ger.srt` trashed, others kept).
## [0.92.5-beta] - 2026-05-09
### Changed
- **Scheduler settings page redesigned to match the rest of Sublarr** — `/settings/system/scheduler` now groups the nine background jobs into three `SettingsSection` cards (Scanning, Metadata sync, Maintenance) using the same icon + accent-bg + border + padding the other settings pages already use. Each job collapses from a five-button card into a compact row with a colour-coded status dot, friendly translated name plus the technical `job.id` as a mono chip, the trigger label, last/next run, and a 1px stacked health bar that visualises the 7-day ok/error/timeout/missed split at a glance. "Run now" stays as a single primary button; Pause/Resume, Edit trigger, History, and Reset to default move into a kebab menu (more-vertical icon) with click-outside + Escape close. Reduces visual noise by ~70% per row and lets the user scan all nine jobs at a glance.
### Added
- New i18n keys (DE + EN) for category names, per-job friendly names + descriptions, the actions menu label, and the 7d health summary string.
## [0.92.4-beta] - 2026-05-09
### Fixed
- **Sonarr/Radarr quality-upgrade duplicates in `wanted_items`** — When Sonarr swapped a file via a quality-upgrade (e.g. `Show.S01E01.WEBRip-1080p.mkv` → `Show.S01E01.WEBDL-1080p.mkv`), Sublarr's `upsert_wanted_item` matched on `file_path + target_language + subtitle_type` and therefore inserted a fresh row for the new path while the old row was left orphaned. The search pipeline later flagged the orphan with `error="File not found on disk"` and `status="failed"`, so the UI showed the same episode twice — one `Gesucht` row for the new file plus one `Fehlgeschlagen` row per language for the old file. `upsert_wanted_item` now sweeps shadowed siblings transactionally: any row with the same logical entity (Sonarr episode / Radarr movie / standalone series+S/E / standalone movie) and the same `(target_language, subtitle_type)` but a different `file_path` is deleted when its file no longer exists on disk. Mount-blip defence: the prune only runs when the freshly-upserted file actually exists, so a brief NFS reconnect or Unraid share-reload can't wrongly drop legacy rows.
### Tests
- 7 new regression tests in `test_wanted_upsert_prune_2026_05_09.py` covering the quality-upgrade case, target-language separation, sibling preservation when both files exist, mount-blip safety, Radarr movies, and the no-logical-identity short-circuit.
## [0.92.3-beta] - 2026-05-09
### Security
- **Subprocess argv injection defence in remux + ffprobe (audit G8 + Gemini R1+R3)** — `_try_reflink` now passes `--` to `cp` before src/dst, and `run_ffprobe` wraps `file_path` with `_safe_arg_path`. A leading `-` in a media-file basename can no longer be parsed as a flag by either tool. Same defence the extract path already enforces for ffmpeg/mkvmerge.
- **`_resolve_trash_dir` rejects relative-path traversal (audit G3 + Gemini R2)** — An absolute `remux_trash_dir` on `/etc`, `/proc`, `/sys`, `/dev`, `/run`, `/boot`, `/root`, `/var/log`, `/var/run`, `/var/lib`, `/usr`, `/lib`, `/lib64`, `/sbin`, `/bin` falls back to `/.sublarr`. Relative settings like `../../../etc` used to be `os.path.join`-ed and returned unchecked; the resolver now `is_safe_path`-validates the joined path.
- **Path-boundary check in `POST /cleanup/orphaned/delete` switched to `is_safe_path` (Gemini R6)** — Replaced the platform-specific `startswith` boundary with the canonical `is_safe_path(file_path, base_dir)` helper used everywhere else in the codebase.
- **Defence-in-depth boundary on `POST /wanted//extract` (audit E1-1)** — `_validate_extract_target` now runs `is_safe_path` against `wanted_items.file_path` before forking ffmpeg/mkvmerge, preventing an attacker-controlled DB row from steering subprocess at `/etc/anything`.
- **`extra_media_paths` validates each entry (audit C2-7)** — `old_subtitle_baks` used to walk every comma-separated entry verbatim; a typo like `extra_media_paths=/etc, /proc` would steer the cleanup walker into privileged paths. Each entry is now refused if it lands on a forbidden root and required to be a directory.
### Fixed
- **Cleanup data-loss class — recoverable trash by default (audit C0-1 + E1-2)** — Every cleanup executor (`language_filter`, `format_upgrade`, `orphan_files`, `orphan_db`) and `POST /cleanup/orphaned/delete` now move files to `/.sublarr/trash//` instead of hard-deleting. Hard-delete is still available via an explicit `permanent_delete=true` opt-in. The previous behaviour silently destroyed any file that matched the rule; an admin who set up a rule with the wrong `keep_languages` list would have lost every other language sidecar with no recovery path.
- **Empty `keep_languages` aborts language_filter (audit C0-2)** — A misconfigured `keep_languages=[]` would previously cause the executor to walk the media tree and treat every sidecar as foreign, deleting them all. The executor now refuses to run when `keep_languages` is empty after normalisation.
- **Mount-blip mass-delete defence in cleanup executors (audit C0-3)** — Every executor now `os.stat`s the configured `media_path` first and aborts on `OSError`. A brief NFS reconnect, Unraid share-reload, or USB disconnect can no longer make a fully-mounted library look empty and trigger a wholesale wipe. Mirrors the standalone-S0-1 fix.
- **Atomic ffmpeg extraction (audit G5 + G12)** — `extract_subtitle_stream` used to pass `output_path` straight into `ffmpeg -y`, which writes incrementally to the destination. A crash, timeout, or ENOSPC mid-write left a partial sidecar at the final location that downstream code treated as a complete file. Extraction now writes to a same-directory tempfile, refuses zero-byte output, and `os.replace`s into place only on `returncode == 0`.
- **Atomic subtitle-repair write (Gemini R4)** — The post-extraction subtitle repair pass used to write the repaired bytes directly to the live sidecar with `Path.write_bytes`. A crash or ENOSPC mid-write would have truncated the freshly-extracted file with no trash backup to recover from. The repair pass now writes to a same-directory tempfile and `os.replace`s into place.
- **`_make_backup` no longer falls back to a sibling `.bak` (audit G7)** — When the configured trash dir was unreachable (read-only mount, missing parent), the helper used to write `.bak` next to the original. That hid configuration problems behind a "recovery copy next to your video" UX surprise. Failure now raises `RemuxError` and the caller aborts the destructive remux swap.
- **Untagged sidecars no longer mis-classified as foreign (audit C1-1)** — `_detect_sidecar_language("Movie.S01E01.ass", "Movie")` used to return `"s01e01"` because the parser regex matched the dotted token before the extension. The parser now validates its result against `_REVERSE_LANGUAGE_TAGS` (the canonical whitelist of language codes) and returns `None` when no recognised tag is present, so untagged sidecars are kept as-is by every cleanup executor.
- **Concurrent rule execution serialised (audit C1-4)** — Scheduler tick + manual `POST /cleanup/rules//run` clicks used to race; both walked the file system simultaneously, both queued the same files for deletion, and `cleanup_history` collected `IntegrityError` noise. Rule execution is now guarded by a non-blocking module-level lock; a second concurrent attempt fails fast with HTTP 409 + `CleanupBusyError` instead of stacking up.
- **TOCTOU race on `POST /wanted/batch-extract` busy flag (Gemini R5)** — The route used to check `_batch_extract_state["running"]` inside its lock but only set the flag inside the background thread, leaving a window where two concurrent POSTs could both pass the busy check and start two parallel extract threads. The flag is now flipped synchronously inside the same lock block.
- **`batch_extract` enforces a 5000-item hard cap and skips items with existing sidecars (audit C2-4 + C2-5)** — A 100k-element `item_ids` POST would tie up the worker thread + DB session for hours. The cap aligns with the ffprobe `batch_probe` cap, and the route additionally filters items whose `existing_sub` is already a sidecar (re-extracting wastes ffprobe + ffmpeg time and races with provider downloads).
- **Symlink-cycle defence in cleanup walks (audit C2-1)** — `_safe_walk` follows symlinks with `(st_dev, st_ino)` cycle tracking, preventing infinite loops on crafted directory trees while still letting users follow legitimate library-path symlinks.
- **`orphan_files` shares the canonical orphan-detection (audit C1-3)** — The executor used to scan with its own ad-hoc walk that disagreed with `dedup_engine.scan_orphaned_subtitles` on edge cases (extension whitelist, video-pair detection). Both code paths now go through `scan_orphaned_subtitles`.
- **Batch DB orphan delete (audit C2-2)** — `execute_orphan_db` used to call `delete_by_path(p)` once per missing row (N+1 queries on a typical 50k-entry library). It now collects every missing path and issues a single chunked `delete_by_paths` call.
- **Auto-translate worker pushes app context (audit C2-3)** — `_translate_async` reads the language profile and writes `translation_events` via the SQLAlchemy session, so it must run inside a Flask app context. The previous version raised `RuntimeError: Working outside of application context`, which the catch-all silently dropped, killing the auto-translate flow.
- **Foreign-track cleanup logs failure classes (audit G13)** — The post-extraction foreign-track cleanup used to mask every failure under one `except Exception` with a generic warning, hiding persistent problems (mkvmerge missing, read-only mount, ENOSPC) behind one-line entries that looked like transient blips. The handler now distinguishes `FileNotFoundError`, `PermissionError`, `OSError`, and `RemuxError` — each at the appropriate log level.
### Tests
- 25 new regression tests in `test_extract_cleanup_audit_2026_05_09.py` covering G3, G5, G7, G8, C0-1, C0-2, C0-3, C1-1, C1-3, C1-4, E1-1, C2-2, C2-4, C2-7 and the Gemini follow-ups R1, R2, R3, R5, R6.
## [0.92.2-beta] - 2026-05-09
### Security
- **Forbidden-root blocklist on `POST/PUT /standalone/folders` (audit S0-2)** — Watched-folder paths are now validated against a blocklist (`/`, `/etc`, `/proc`, `/sys`, `/dev`, `/run`, `/boot`, `/root`, `/var/log`, `/var/run`, `/var/lib`, `/usr`, `/lib`, `/lib64`, `/sbin`, `/bin`) before being persisted. Even on a single-tenant homelab API a typo like `path: "/"` would have walked the entire container with `followlinks=True`; the validator now refuses obvious system roots and additionally soft-flags any folder outside the configured `media_path` for visibility. Cross-platform path normalisation handles Windows + POSIX uniformly.
### Fixed
- **`POST /standalone/scan/` was a dead endpoint (audit G1)** — `launch_folder_scan` called `scanner.scan_folder(folder_path)` but `StandaloneScanner` only exposed the private `_scan_folder(folder: dict)`. Every per-folder rescan request returned `202 Accepted` while raising `AttributeError` inside the worker thread, which the catch-all swallowed and logged as ERROR. The UI's "Rescan this folder" button has been silently broken since the manager refactor. New public `scan_folder()` accepts both a path string and a folder dict.
- **Mass-delete on transient mount-loss (audit S0-1)** — `_cleanup_stale_series` and `_cleanup_stale_wanted` walked the standalone DB and removed any row whose `folder_path` failed `os.path.isdir`, with no protection against an NFS reconnect, Unraid share-reload, or USB disconnect briefly making every path look unreachable. A new `_watched_roots_reachable()` probe `os.stat`s every enabled watched folder before either cleanup runs and aborts the entire pass on the first OSError. An empty watched-folder list also short-circuits the cleanup so an empty config can never wipe rows.
- **Orphan `wanted_items` after stale-series cleanup (audit S1-5)** — `_cleanup_stale_series` called `delete_standalone_series(sid)` directly, which removes the series row but leaves any `wanted_items.standalone_series_id` pointing at a non-existent series. The cleanup now goes through `services.standalone_manager.delete_series_cascade`, which deletes the dependent wanted_items in the same transaction.
- **Watcher overwrote NFO-sourced metadata (audit S1-2)** — When a new episode was dropped into a series folder, the watcher's `process_single_file` jumped straight to `MetadataResolver.resolve_series` without ever calling `parse_series_nfo`. With a TVDB ID present in `tvshow.nfo` the upsert overwrote it with whatever the resolver returned (often `None` during transient TMDB outages), so the same series flapped between `metadata_source="nfo"` after a full scan and `metadata_source="filename"` after a single watcher event. The watcher now mirrors the full-scan NFO-first flow exactly.
- **Resolver cache was completely dead code (audit S1-1)** — `metadata.MetadataResolver._get_cached` / `_set_cached` accessed `db.standalone` as if it were an attribute on the SQLAlchemy session (`hasattr(db, "standalone")` always evaluated `False`), and additionally called `cache_metadata(cache_key, data)` with the wrong arity (the actual signature requires `(cache_key, provider, response_json, ttl_days)`). Every standalone scan re-hit TMDB / TVDB / AniList because the `metadata_cache` table was never written or read. Both helpers now import the standalone cache functions directly and serialise to JSON.
- **Filename-stub results poisoned the cache (audit S1-6)** — Once the cache layer was repaired, transient TMDB outages would have written a filename-only stub (`metadata_source="filename"`, every ID `None`) into `metadata_cache` for the full 30-day TTL, so even after the upstream came back online the resolver would return the stub for a month. The cache write now skips any result whose `metadata_source == "filename"`.
- **`_scan_lock` was process-wide ineffective (audit S1-3)** — `launch_full_scan`, `launch_folder_scan`, `scan_series_or_fallback`, and the scheduled `standalone_scan_tick` each instantiated a fresh `StandaloneScanner()`, so the per-instance `_scan_lock` only protected against re-entry on the same object — parallel scans simply created independent scanners with independent locks. A new `get_scanner()` module-level singleton with double-checked locking funnels every entry point through the same instance and therefore the same lock.
- **`upsert_standalone_series` / `upsert_standalone_movie` raced on UNIQUE constraint (audit S1-4)** — The repository performed SELECT-then-INSERT against `folder_path` / `file_path`, both of which carry `UNIQUE`. A full-scan and a watcher event landing on the same folder simultaneously could pass the SELECT in parallel and race at INSERT, raising IntegrityError up to the route layer. Both upserts now catch IntegrityError, roll back, re-fetch the row, and apply the UPDATE — race-safe across SQLite and Postgres without dialect-specific ON CONFLICT.
- **TMDBClient surrendered immediately on 429 / transient 5xx (audit G2)** — Any rate-limit or 502/503/504 from TMDB collapsed straight through the resolver to the filename-stub fallback, persisting bad metadata for that scan. `_get` now retries once, honouring TMDB's `Retry-After` header up to a 10-second cap (with a 1-second default for missing/invalid headers) and falling back to a 1-second wait for transient 5xx. Persistent failures still return `None` so the resolver chain can proceed to TVDB / AniList.
- **`/refresh-metadata` blocked Flask workers (audit G4)** — The route called `refresh_series_metadata_sync` synchronously, so a slow TMDB lookup tied up a Flask worker thread for the duration. The route now schedules `refresh_series_metadata_async` and returns `202 Accepted` immediately; the UI picks up the refreshed data on the next series-detail fetch.
### Added
- **Symlink-cycle detection in `os.walk` (audit S0-3)** — Scanner walks now track every directory by its `(st_dev, st_ino)` tuple and prune subdirectories whose inode has already been visited. A circular symlink (`/media/x → /media/`) used to walk forever with `followlinks=True`; it now short-circuits at first repeat and logs a warning.
- **Bounded watcher stability-check pool (audit G3)** — `MediaFileWatcher` previously ran the 2-second file-stability sleep inline in each `KeyedDebouncedCallback` Timer thread, so a bulk import of 100 files spawned 100 concurrently sleeping threads that then stormed the DB and external APIs. Stability checks now flow through a shared `ThreadPoolExecutor(max_workers=4)` so the watcher stays responsive without flooding downstream services.
### Tests
- **+24 new regression tests** (`tests/test_standalone_audit_2026_05_09.py`) covering each of the twelve findings above, plus rewrites of `tests/test_metadata.py::TestCache`, `tests/test_routes_standalone.py::TestRefreshMetadata`, `tests/test_standalone_manager.py`, `tests/test_standalone_manager_ext.py`, and `tests/test_standalone_scanner.py::TestCleanupStale*` to match the new contracts. Full standalone + metadata + nfo sweep: 508 passed, 1 skipped (POSIX-only symlink-cycle test).
## [0.92.1-beta] - 2026-05-08
### Fixed
- **`/providers/health` exposes new metrics** — Follow-up to 0.92.0-beta: the dashboard-oriented `/providers/health` endpoint now mirrors the new `successful_searches`, `download_rate`, and `result_rate` fields from `/providers`, so any client consuming the health overview gets the same data shape as the full provider list. Without this, `download_rate` / `result_rate` rendered as `null` on this endpoint while showing correct values on `/providers`.
## [0.92.0-beta] - 2026-05-08
### Added
- **Provider result-rate metric (audit C1)** — Provider tiles + the editor drawer now show two stacked bars: the new **Treffer-Quote** (`result_rate` = providers that returned ≥1 hit / total searches, accent-coloured) above the existing **Download-Quote** (`download_rate` = sub actually downloaded from this provider / total searches, traffic-light coloured). The two metrics together make it obvious whether a provider with thousands of searches and 0 % downloads is dead (no hits at all) or merely outranked by a stronger competitor. New SQLAlchemy column `provider_stats.successful_searches` is incremented by `record_search(success=True)`; the API exposes the new fields as `successful_searches`, `result_rate`, and `download_rate`. The legacy `success_rate` field is kept as an explicit alias of `download_rate` so older clients continue to work.
### Fixed
- **Stale `auto_disabled` in `/providers/health` (audit B1)** — `manager_status_mixin.get_provider_status` previously sourced from `get_provider_stats()`, which returned the raw DB row and so kept a provider flagged as `auto_disabled=True` indefinitely once its `disabled_until` had passed (the actual search path called the cooldown-aware `is_provider_auto_disabled()` and re-enabled the provider transparently — but the UI never noticed). The status mixin now sources from the cooldown-aware `get_all_provider_stats_enriched()` mirror, so a provider whose cooldown has expired shows up immediately as healthy without waiting for the next search to write through. Concretely fixes the live `jimaku` row that had been showing `disabled_until=2026-04-15` 24 days into the past.
- **`/tools/convert` UTF-8 decode failure on legacy SRTs (audit B2)** — `pysubs2.load(path)` defaults to UTF-8 and raised `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa3 in position 4` for any CP1252 / Latin-1 / MacRoman SRT. The endpoint now mirrors the `chardet` detection pattern already in use by `/tools/content`, `/tools/analysis`, and `/tools/common-fixes`, then passes the detected encoding to `pysubs2.load(path, encoding=…)` and falls back to a lenient `latin-1` decode if pysubs2 still raises. Conversion now succeeds on every legacy encoding without the user having to pre-transcode the file.
### Tests
- **+9 audit regression tests** (`tests/test_audit_2026_05_08.py`) — three for the cooldown-mirror (expired clears, active stays, status mixin agrees with the enriched view), three for the new `successful_searches` / `result_rate` / `download_rate` shape, three for the convert encoding path (default UTF-8 fails, explicit CP1252 succeeds, latin-1 fallback never raises). Refresh of `tests/test_providers_init_refactor_safety.py::_STATS_KEYS` to pin the new keys (`successful_searches`, `download_rate`, `result_rate`) into the public-API contract.
## [0.91.0-beta] - 2026-05-08
### Security
- **Prompt-injection guard hardening (P3)** — Added `translation/prompt_safety.py` as the central choke point for every untrusted field that flows into an LLM prompt: subtitle lines, glossary terms, `series_context`, `lookback`/`lookahead`. The default `LLMBackend._assemble_messages` now funnels every untrusted field through `escape_for_prompt`, so Claude / OpenAI / ChatGPT / Gemini / Mistral / DeepSeek backends share the same Trojan-Source / zero-width / line-separator / Unicode-Tag-block / soft-hyphen / invisible-operator defences that previously fired only on the Ollama trained-prompt path. New batch-geometry cap (`MAX_BATCH_LINES=100`, `MAX_BATCH_CHARS_TOTAL=50000`) rejects 100k-line / 10 MB-line attacks before any API cost is incurred. Ollama's `series_context` is now escaped *before* the `{series_context}` format substitution so the placeholder cannot be used to re-introduce raw newlines.
- **Provider filename sanitisation at API boundary (P2)** — Provider-supplied `result.filename` values now run through `werkzeug.secure_filename` once at the JSON-response boundary in `wanted_search/search.py::_safe_provider_filename`, instead of patching each of the 19 provider adapters. Closes the coverage gap where 15 of 19 adapters never sanitised the upstream value before exposing it via the API.
- **SSRF bypass class fixed in `validate_service_url` (R4-01)** — Internal pentest against 0.90.0-beta found a wide bypass surface for `PUT /api/v1/config` URL fields (`sonarr_url`, `radarr_url`, `ollama_url`, etc.): loopback literals (`127.0.0.1`, `[::1]`, `127.0.0.0/8`), decimal IPv4 (`2130706433` = 127.0.0.1, `2852039166` = AWS / Azure / GCP metadata), octal / hex IPv4 (`0177.0.0.1`, `0x7f.0.0.1`), IPv4-mapped IPv6 (`[::ffff:127.0.0.1]`), the unspecified address (`0.0.0.0` / `0`), the Docker bridge (`172.17.0.1`), and DNS hostnames such as `localhost` that the original validator never resolved. New `_normalise_to_ip` (handles non-canonical encodings via `socket.inet_aton` + IPv4-mapped IPv6 unwrap) plus `_check_ip_address` (rejects `is_unspecified`, `is_loopback`, `is_link_local`, and any address inside the metadata networks) cover every form; DNS hostnames are resolved via `socket.getaddrinfo` and every answer re-checked. Private LAN (10/8, 172.16/12, 192.168/16) deliberately stays allowed for homelab Sonarr / Radarr / Ollama. DNS rebinding (validate-time vs fetch-time mismatch) is documented as a known deferred limitation.
- **XXE / Billion-Laughs defence in XML parsers (R4-02)** — `anidb_mapper`, `anidb_sync`, `providers/podnapisi`, and `standalone/nfo_parser` switched from stdlib `xml.etree.ElementTree` to `defusedxml`, so a compromised AniDB / anime-list / Podnapisi feed (or a malicious `.nfo` planted alongside subtitle downloads) cannot pivot into XXE / Billion-Laughs / DTD-based attacks. Podnapisi keeps its `lxml` fast-path via `defusedxml.lxml.fromstring`, with the stdlib fallback also defused.
### Tests
- **+73 security tests** across `tests/test_security_prompt.py` (+51 cases for the prompt-safety module + extended Unicode codepoint coverage + glossary hardening), `tests/test_security_download.py` (+6 cases for the API-boundary filename sanitiser), and the new `tests/test_security.py` (+22 cases for the SSRF bypass forms and XXE / Billion-Laughs payloads). Full security suite green (103 / 103 in the prompt + download + general security trio; 549 / 549 across the broader translation + wanted-search + security regression sweep).
## [0.90.0-beta] - 2026-05-07
### Added
- **Disk safety-valve for wanted_search** — When the `/config` device crosses the configured threshold (default 98 %), `wanted_search` now early-returns with `paused_reason="disk_critical"` instead of fetching the eligibility list, querying providers, or writing downloads. Configurable via the new UI setting `wanted_search_disk_pause_pct` (range 50.0–100.0; set to 100.0 to disable). Closes a V1 readiness gap surfaced by the 2026-05-07 audit: with Cardinal at 97 % usage, a single bulk run could push `/config` over the cliff and corrupt the application DB.
- **Auto-mark-as-unsourceable** — A wanted item that fails through the configured number of slow-mode cycles (default `wanted_search_max_slow_cycles=3` — i.e. once `search_count` reaches `max_attempts + max_slow_cycles`) now moves to `status='unsourceable'` and drops out of the scheduler queue, instead of cycling indefinitely every 30 days. Existing slow-mode items escalate organically as they hit the threshold. New UI setting `wanted_search_max_slow_cycles` (range 0–999; set to 0 to skip slow-mode entirely, set high for legacy never-escalate behaviour). Eliminates the "2084 wanted, never shrinks" UX wart for items where the target language genuinely is not available at any provider.
## [0.89.0-beta] - 2026-05-06
### Added
- **Localized provider editor + add modal** — Provider editor and the new "Add Provider" modal are fully translated in DE/EN.
### Fixed
- **Legacy_frozen regression in wanted-search** — Items hitting `wanted_max_search_attempts` while `wanted_auto_translate` was disabled were left with `failure_kind=NULL` and `retry_after=NULL`, which caused `_filter_eligible` to drop them forever. The "no result" exit now funnels through `record_search_outcome` so the slow-mode contract is honoured (1 retry per ~30 days). Migration `a7f2e1c9d3b4` reapplies the 2026-04-27 resurrection UPDATE to the ~2005 rows that already slipped through.
### Changed
- **Translation backend recommendations** — Dropped the broken `anime-translator-v6` preset; the UI now suggests `qwen2.5:14b` / `llama3.1:8b` / cloud backends as general-purpose Ollama options that reliably produce DE output.
## [0.88.0-beta] - 2026-05-04
### Changed
- **UI-first configuration convention (semi-breaking for `.env` users)** — `Settings` is now a composite of `BootSettings` (a `pydantic_settings.BaseSettings` with exactly 11 fields: `port`, `api_key`, `media_path`, `config_dir`, `db_path`, `database_url`, `redis_url`, `log_level`, `log_file`, `log_format`, `cors_origins`) and `UISettings` (a plain `pydantic.BaseModel` with all 200+ other fields). `BaseModel` cannot load environment variables by language design, so every legacy `SUBLARR_` env var is now silently dropped at startup. `reload_settings(overrides=...)` writes only to UI fields; boot fields stay env-only. The composite class keeps full backwards-compat across ~370 existing call sites via a `__getattr__` forwarder, so `settings.target_language`, `settings.opensubtitles_api_key`, `settings.get_safe_config()` etc. all still work unchanged. See the [Upgrade Guide → 0.88.0-beta](https://sublarr.de/docs/getting-started/upgrade-guide/) for the migration map (legacy env-var group → new Settings UI page).
### Added
- **Loud per-key startup warnings for ignored env vars** — `create_app()` calls `warn_on_ignored_env_vars()` after settings + logging are wired. Every `SUBLARR_` found in `os.environ` produces a discoverable `WARNING config_settings: Ignoring SUBLARR_X — UI field, not env-loadable since v0.88.0-beta. Move to Settings UI.` line. Boot-field env vars (the 11 above) stay silent; unrelated env vars without the `SUBLARR_` prefix are ignored. Two regression tests assert the per-field log line and the ignored-list return value.
- **CI linter enforcing the UI-first contract (`tools/lint_no_new_env_fields.py`)** — AST walker over `backend/config_settings.py` that asserts: (1) `_ALLOWED_BOOT_FIELDS` frozenset is the source of truth; (2) `BootSettings` declares exactly those fields — no more, no less; (3) `UISettings` does NOT subclass `BaseSettings` (otherwise env loading would silently come back); (4) no other class in the file subclasses `BaseSettings`. Self-test in `tools/test_lint_no_new_env_fields.py` exercises 6 cases (1 happy + 5 violation paths). Wired into CI between the existing `is_safe_path` linter and mypy. Refuses any future PR that tries to add a new env-loadable field outside the curated 11-entry allowlist.
## [0.87.1-beta] - 2026-05-04
### Security
- **Subtitle-track extraction path traversal (Tier-0)** — `/library/series//extract-tracks` and `/series//batch-extract-tracks` baked the user-supplied `language` body field straight into the sidecar output path. A value like `"../../etc/cron.d/evil"` could write extracted subtitle bytes to an arbitrary filesystem location. Added ISO-639 language-tag validation plus a post-resolution check that the output directory matches the source video directory. Same hole closed in the batch path where the language tag arrives via container metadata (also attacker-controlled).
- **Config update wiped real secrets (Tier-0)** — `PUT /config` masked password roundtrips at the top-level scalar level, but JSON instance blobs (`sonarr_instances_json`, `radarr_instances_json`, mediaservers, …) embed `***configured***` as *subkey* values. A naive PUT-after-GET overwrote the real `api_key` with the literal mask string. Now deep-merges masked subkeys with the stored value before persistence; `test_path_mapping` is constrained to `media_path` via `is_safe_path`.
- **Trash-restore manifest poisoning (Tier-0)** — `POST /subtitles/trash/restore` read `trashed`/`original` paths from `manifest.json` (which lives inside `media_path`, often a user-writable share) and `shutil.move()`'d them with no boundary check. A poisoned manifest could move arbitrary system files. Both ends of every move now pass `is_safe_path` against `trash_root` and `media_path` respectively.
- **Hook executions silently dropped (Tier-0)** — `_execute_and_log` ran in a `ThreadPoolExecutor` worker without a Flask app context. Every DB call and the `hook_executed` signal handler raised `RuntimeError`, which the catch-all swallowed — every hook execution looked successful but logged nothing. Now wraps the whole body in `app.app_context()`.
- **Webhook-secret roundtrip mask leak** — `PUT /hooks/webhooks/` accepted a client-echoed `***configured***` as the new HMAC `secret`, overwriting the real signing key with the literal mask string on every GET-edit-PUT cycle. Now drops `secret` from the payload when the client sends back the sentinel.
### Added
- **`is_safe_path` arg-order linter** — `tools/lint_is_safe_path.py` walks every Python file in `backend/` and flags any `is_safe_path(base, candidate)` call with the arguments reversed. The reversed-args bug class has surfaced twice in the past (`routes/cleanup.py` audit + `routes/remux.py` commit `b03e305b`). Self-test in `tools/test_lint_is_safe_path.py` exercises 1 good file + 3 reversed patterns; CI step added between ruff format and mypy. Current scan: 0 findings.
### Changed
- **Rate limits + clamps on amplification routes (Tier-1)** — added `@limiter.limit(...)` to all heavy ffmpeg/Tesseract endpoints and bounded parameter ranges so a single request can't trigger multi-GB allocations: `audio.py` (5 endpoints, width [100, 8000], sample_rate [10, 1000]), `video.py` (4 endpoints, screenshot width [160, 3840], path-separator check on bare segment names), `ocr.py` (3 endpoints, Tesseract language regex `^[a-z]{3}(\+[a-z]{3})*$`, 256-entry LRU cap on `_ocr_jobs`), `nfo.py` (single + series), `spell.py` (locale-tag regex, 10000-entry custom-words cap).
- **Webhook + scoring + log limits** — `retry_count` clamped to `[0..10]`, `timeout_seconds` to `[1..300]`, `hooks/logs.py` `?limit` capped at 1000, `hooks/scoring.py` provider name ≤ 64 chars and modifier clamped `[-100, 100]`, `subtitle_processor.py` `older_than_days` non-negative-int guard.
- **Config import SSRF guard** — `POST /config/import` now validates URL fields through the same `_url_fields` set as `update_config`, plus a list-payload guard.
- **Onboarding refire guard** — `/config/onboarding/complete` refuses to re-fire when already marked complete.
### Tests
- Refreshed two stale tests after upstream refactors: `test_process_undo_409_on_os_error` now patches `os.replace` (the new atomic-swap call after commit `3ce75d8c`) instead of the long-gone `shutil.move`; `test_accepts_audio_track_index` and `test_missing_index_defaults_to_none` now monkeypatch `_is_whisper_enabled` to true (gate added in `d4aa25ca`, tests never updated).
## [0.87.0-beta] - 2026-05-03
### Added
- **Inline cue-text editing in the Waveform tab** — promised in 0.85.0 as a "subsequent step". Double-click the text cell of a row in the synchronized cue list (only when the editor is unlocked) to open an inline textarea; Enter commits, Shift+Enter inserts a newline, Esc cancels, blur commits. Each commit pushes the pre-edit content onto the same waveform undo stack used by timing edits, so the toolbar Undo/Redo/Save buttons work uniformly across both edit kinds. ASS cues that contain override tags (`{\b1}`, positioning, fades, etc.) are refused with an info toast pointing the user to the source-tab — silently overwriting them would destroy styling the user can't see in the cue-list view.
### Fixed
- **Waveform: undo back to disk-state now clears the dirty flag** — `hasUnsavedChanges` was a flag that only flipped one way (set to `true` on every edit, `false` only on save). Undoing every edit back to the originally-loaded content left "Unsaved" pinned and the Save button enabled even though the in-memory content matched what was on disk. Replaced the flag with a derived `content !== baselineContent` comparison; `baselineContent` updates on initial load and after every successful save (both waveform-Save and CodeMirror-Save paths), so the UI now reflects the true dirty state.
- **Waveform: requestAnimationFrame leak on modal close** — the visible-range recompute effect scheduled a `requestAnimationFrame` callback on every native scroll / resize / zoom event, but the cleanup path didn't `cancelAnimationFrame`. If the user closed the modal while a recompute was in flight, the queued callback would call `ws.getDuration()` on an already-destroyed WaveSurfer instance. Added a `cancelled` flag plus a `wsRef.current === ws` identity check inside the callback, and `cancelAnimationFrame` in the cleanup so the queue is drained before WaveSurfer gets destroyed.
- **Waveform: Save button no longer triggers whole-tree re-renders during save** — `handleWaveformSave` had the entire React Query mutation object in its `useCallback` dep array. Every `isPending` tick from React Query re-created the callback identity, which propagated through `WaveformEditor` and forced its sub-tree to re-render on every state change of the in-flight save. Destructured to `{ mutate, isPending }` so the callback identity stays stable across the save flight.
### Changed
- **Waveform: removed the dead WaveSurfer `scroll` event listener** — the comment already said WaveSurfer v7's `scroll` event is unreliable (doesn't fire on programmatic `ws.zoom()`), and the visible-range recompute is owned entirely by the native scroller listener + `ResizeObserver`. The `ws.on('scroll', ...)` subscription was just dead weight; removed.
- **Toast call-site cleanup** — three `toast.warning(...)` / `toast.error(...)` calls in the modal would have thrown a runtime `TypeError: toast.warning is not a function` because the toast helper is a single function with a `(message, type)` signature, not an object with named methods. Replaced with the supported `toast(msg, 'info'|'error')` form.
## [0.86.6-beta] - 2026-05-03
### Fixed
- **Waveform sync-scroll: borderLeftColor via inline-style** — Tailwind v4 kept overriding the left-border colour through the `border-border` shorthand on this row, regardless of whether we used `border-l-{token}`, `border-l-[#hex]`, or `[border-left-color:#hex]` arbitrary-property syntax. The class string carried the right utility, but the computed style stayed at the gray `--border` token. Worked around with an inline `style={{ borderLeftColor }}` — CSS specificity rules guarantee inline-style beats any class selector, so the cyan/amber stripes now actually render. Permitted by the project's "Tailwind-can't-reach" inline-style escape hatch.
## [0.86.5-beta] - 2026-05-03
### Fixed
- **Waveform sync-scroll: arbitrary-property border-left-color** — 0.86.4's `border-l-[#hex]` arbitrary VALUE syntax also failed to override `border-border`'s shorthand color in Tailwind v4 (computed `borderLeftColor` stayed at the gray `--border` token even though the class string carried the hex). Switched to arbitrary PROPERTY syntax `[border-left-color:#hex]` which emits the CSS rule directly and is no longer subject to shorthand overrides. The colored left stripes (cyan for in-viewport / active, amber for now-playing) should now render reliably.
## [0.86.4-beta] - 2026-05-03
### Fixed
- **Waveform sync-scroll: visible left-border stripe** — 0.86.3's `border-l-[var(--accent-dim)]` arbitrary value didn't resolve through Tailwind v4 (computed border stayed at the default gray `--border` token), so the left-stripe distinction between in-viewport and out-of-viewport rows was missing. Switched to hex literals (`#0a7089` for in-viewport stripe, `#1DB8D4` for active selection, `#f59e0b` for now-playing) so the borders are guaranteed to render. The cyan-tinted background already landed correctly in 0.86.3.
## [0.86.3-beta] - 2026-05-03
### Changed
- **Waveform sync-scroll: cyan-tinted band + active-row ring** — 0.86.2's `bg-elevated` switch made the in-viewport rows technically distinguishable, but the ~14-RGB-unit difference vs the dark cue-list container was still too subtle to read at a glance against the spectrogram-lit modal. Replaced with explicit cyan-tinted backgrounds via Tailwind v4 arbitrary values (`bg-[rgba(29,184,212,0.06)]` for in-viewport, hover `0.14`) plus a `border-l-[var(--accent-dim)]` stripe — guaranteed-visible regardless of any token-resolution edge cases. Active selection also gains a `ring-2 ring-inset ring-[var(--accent)]` so the bright cyan now-selected cue is unambiguous. Now-playing keeps its amber band.
## [0.86.2-beta] - 2026-05-03
### Changed
- **Waveform sync-scroll: visible tint + list-follows-wave + playhead highlight** — 0.86.0–.1's "subtle" `bg-surface/60` tint was effectively invisible against the dark `bg-primary` cue list, so editors couldn't see the in-viewport flag the wave was actually computing for them. Three concrete upgrades:
- **Visible in-viewport tint.** Switched from `bg-surface/60` to `bg-elevated` plus a 4 px `border-l-accent-dim` stripe — the rows currently visible on the wave now form a clear band in the list. Active selection keeps its bright `border-l-accent` so it stays the unambiguous focus.
- **Auto-follow when the wave scrolls.** When the wave's visible window changes (zoom, manual scroll, programmatic seek), the list auto-scrolls so the first in-viewport cue lands at the top of the list lane. Suppressed for ~3 seconds after any user wheel/pointerdown on the list itself, so reading ahead isn't yanked away whenever the wave moves.
- **Now-playing highlight** while audio plays. New `playheadSec` state, exposed by `useWaveformRegions` via WaveSurfer's `timeupdate` event (throttled to 10 Hz so playback doesn't trigger 60 list re-renders per second). The cue containing the playhead picks up a warning-tinted `bg-warning-bg + border-l-warning` band — visually distinct from both the active selection and the in-viewport set, so editors can audit what they're hearing right now even when no cue is selected.
## [0.86.1-beta] - 2026-05-03
### Fixed
- **Sync-scroll highlight flagged ALL cues as "in viewport"** — 0.86.0's WaveSurfer `scroll`-event subscription only fires on user-initiated scrolls, not on programmatic `ws.zoom()` calls. The seed-on-ready fallback measured the wrapper width BEFORE the post-ready zoom had applied (when the wave still fits the full audio in the container width), so `visibleRange` degenerated to `[0, total_duration]` — every cue passed the intersection test, the entire list lit up as "visible", and the feature looked like a no-op. Replaced the seed with a real scroller-bound effect: native `scroll` event + `ResizeObserver` on both wrapper and scroller, recomputed after every zoom change inside a `requestAnimationFrame` so layout has flushed. Verified in prod with E01 of Witch Hat Atelier (285 cues, 23 min audio): `inViewportCount` now reflects the actual ~10–25 cues currently on screen instead of all 285.
## [0.86.0-beta] - 2026-05-03
### Added
- **Waveform editor — clean wave + lock-by-default + Undo/Redo/Save + full-height list + sync-scroll** — five interlocking refinements that turn the Stacked-Lanes layout from "draggable rectangles plus a list" into a real production-ready timing surface:
- **Region labels removed from the wave.** With 285+ cues per episode, dimmed labels overlaid on tightly-packed regions still produced an unreadable wall of letters. Cue text now lives only in the list lane below the wave; regions are pure timing rectangles. The wave is for audio reading, the list is for text reading — separate surfaces, separate jobs.
- **Locked by default.** The Waveform tab opens read-only — drag, resize, L/R click-set and S/D hotkeys are inert until the editor explicitly clicks the **Gesperrt → Entsperrt** toolbar toggle. Protects against the "I just wanted to scroll, I didn't want to retime cue 47" footgun. The unlock state visualises with a warning-tinted button so the editor can see at a glance whether they're in look-mode or touch-mode.
- **Undo / Redo / Save buttons in the toolbar.** Each waveform-driven cue-timing edit now pushes the previous content onto a per-modal undo stack; Undo pops back, Redo reapplies. Clear-on-fresh-edit semantics, so the user can't fork timelines. Save POSTs the dirty content to `/tools/content` directly from the Waveform tab — no more tab-hop to CodeMirror to persist a region drag. Save button stays disabled while there are no unsaved changes and shows an inline spinner during the round-trip.
- **Cue list fills the full remaining height.** The 5-row visible cap is gone; the list now grows with the modal via `flex-1 min-h-0`. Toolbar / active-cue card / wave keep their fixed heights at the top, the list lane absorbs everything below — typically ~25 rows visible on a 1080p screen vs 5 before.
- **Sync-scroll highlight.** WaveSurfer's `scroll` event drives a `visibleRange: [startSec, endSec]` state that flows into the cue list. Rows whose `[start, end]` intersect the current wave viewport pick up a soft `bg-surface/60` tint and a faint left-border, so the editor can see at a glance which cues are visible on the wave right now without losing the active selection's accent highlight. Click on a row still seeks the wave to that cue's start (origin guard prevents the fight-the-user auto-scroll).
### Tests
- 1 `useWaveformRegions` test rewritten to assert no `content` is painted on regions; 1 modal-suite mock extended to cover `useSaveSubtitle`. Total frontend suite: 1023 tests, all green.
## [0.85.0-beta] - 2026-05-03
### Added
- **Waveform Stacked-Lanes layout** — restructures the Waveform tab from a "dashboard" layout (active-cue card above the wave + 12-control toolbar wedged below the wave) to a "control → focus → context" linear workflow. The toolbar now sits at the top (DAW convention; the editing zone — wave + list — stays together at the bottom for short mouse-travel), the active-cue card stays directly above the wave for "what am I timing right now" focus, and a new synchronized 5-row `WaveformCueList` lane appears underneath. Region labels overlaid on the wave dim from ~0.95 to ~0.55 alpha — the wave is no longer the primary text-reading surface, so labels remain only as faint navigation anchors during a drag. The new "Liste" toolbar toggle collapses the list when the editor wants the full ~250 px wave height back; preference persists in `localStorage` alongside the other waveform prefs. Cue list rows auto-scroll the active row into view on selection-change with an origin flag that suppresses the scroll when the user just clicked a row themselves — avoids the classic scroll-fight from bidirectional selection feedback loops. Read-only first pass; inline text editing, add/delete/split/merge UI affordances are layered in subsequent steps.
### Tests
- 11 new tests for `WaveformCueList` covering render / select / scroll / quality-dot semantics; 1 existing `useWaveformRegions` test updated to match the new dimmed-span content contract. Total waveform suite: 122 tests, all green.
## [0.84.1-beta] - 2026-05-03
### Fixed
- **Waveform audio blocked by CSP in production** — The Content-Security-Policy header had no `media-src` directive, so it fell back to `default-src 'self'` and blocked every `blob:` URL. WaveSurfer fetches the extracted audio from `/api/v1/tools/waveform-audio/`, wraps it in `URL.createObjectURL()`, and feeds the resulting blob into an internal `