commit 0ca1d2b02fbe883d23a3681e1da7d55f8a8cfcf3
parent c042d12244a3ce3e728da4721f0272e027dd5cb9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 29 Jul 2026 11:55:06 -0400
Persist the sweep's SCOPE, not just the fact that one is armed
`startDigestSweep({channelSlugs})` recorded `sweepEnabled: true` and nothing
else, and the boot hook re-launches from settings alone. So a sweep an operator
had deliberately bounded to a couple of channels would come back after a restart
as an unscoped corpus-wide run — silently widening GPU-weeks of work, in the one
situation (an unattended multi-week job that outlives several restarts) where
nobody is watching for it to happen.
The scope now lives with the flag, and is cleared on stop so a stale one cannot
silently narrow the next sweep instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
3 files changed, 30 insertions(+), 166 deletions(-)
diff --git a/common/controller/digestSweep.ts b/common/controller/digestSweep.ts
@@ -305,13 +305,24 @@ export async function startDigestSweep(
if (running) return running;
const settings = getSettings();
- if (!settings.digest.sweepEnabled) {
+ // The SCOPE is persisted with the flag, not just the flag. The boot hook
+ // re-launches from settings alone, so arming without recording the scope
+ // would resurrect a deliberately-bounded run as a corpus-wide one.
+ const scope = opts.channelSlugs ?? settings.digest.sweepChannels;
+ if (
+ !settings.digest.sweepEnabled ||
+ settings.digest.sweepChannels.join("\u0000") !== scope.join("\u0000")
+ ) {
// AWAITED, and it matters: the loop reads `sweepEnabled` at the top of its
// very first pass, so an un-awaited write races it and the sweep quits
// immediately with "disarmed in settings" — refusing to start at all.
await writeSettings({
...settings,
- digest: { ...settings.digest, sweepEnabled: true },
+ digest: {
+ ...settings.digest,
+ sweepEnabled: true,
+ sweepChannels: scope,
+ },
});
}
@@ -335,7 +346,7 @@ export async function startDigestSweep(
await runSweepLoop(
paths,
lane,
- opts.channelSlugs,
+ scope.length > 0 ? scope : undefined,
live,
onLog,
signal,
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -229,6 +229,13 @@ export type DigestSettings = {
// re-derives eligibility from disk on every pull, so a resumed sweep does zero
// rework and needs nothing else remembered.
sweepEnabled: boolean;
+ // Channels the armed sweep covers. Empty = the whole corpus.
+ //
+ // Persisted alongside `sweepEnabled` because the boot hook re-launches from
+ // settings alone: without it, a sweep deliberately scoped to two channels
+ // would come back after a restart as an unscoped corpus-wide run — silently
+ // widening GPU-weeks of work that an operator had bounded on purpose.
+ sweepChannels: string[];
// Hard ceiling on cumulative metered spend per job, USD. 0 = no cap. Only ever
// consulted for a metered app.
spendCapUsd: number;
@@ -522,6 +529,7 @@ export function defaultDigest(): DigestSettings {
// OFF. A corpus-wide sweep is GPU-weeks of work and is never armed by
// default — an operator starts it.
sweepEnabled: false,
+ sweepChannels: [],
spendCapUsd: 0,
sections: ["chapters"],
timestampMode: DEFAULT_DIGEST_TIMESTAMP_MODE,
@@ -594,6 +602,11 @@ export function sanitizeDigest(value: unknown): DigestSettings {
// behaviour instead of silently opting into contention.
yieldToTranscription: r.yieldToTranscription !== false,
sweepEnabled: r.sweepEnabled === true,
+ sweepChannels: Array.isArray(r.sweepChannels)
+ ? r.sweepChannels.filter(
+ (v): v is string => typeof v === "string" && v.trim().length > 0,
+ )
+ : [],
spendCapUsd:
typeof r.spendCapUsd === "number" && r.spendCapUsd > 0
? Math.round(r.spendCapUsd * 100) / 100
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,167 +1,7 @@
# Changelog
## [Unreleased]
-- **A corpus-wide digest backfill can now be started, left alone, and watched.** The digest layer could generate, but only one channel at a time from that channel's own page — a full-archive pass meant 63 manual launches, and a server restart silently ended it with nothing to say so. There is now a **Start Digest Sweep** control on the dashboard that walks every channel in turn, **heaviest first by remaining audio-hours** (cost is audio, not videos: one VOD channel outweighs every duplicate mirror in the archive combined), and it **survives a restart** — the sweep is re-armed at boot the way the auto-download and auto-transcribe runners already were. It stores no work-list, so a resumed sweep re-does nothing: what still needs generating is re-derived from disk every time, which also means a transcript that finishes mid-sweep, or a duplicate cluster you confirm, is simply picked up on the next pass. A separate **Pause Digests** control holds a running sweep at zero without ending it (the pause flag has existed since the digest layer shipped and nothing could set it). **The digest lane now yields the GPU to transcription**: the two were deliberately on separate queues so they wouldn't serialise, whose unintended consequence was that the model and whisper competed for the same card — measured at 90 seconds per audio-hour against the 27 an idle machine managed. While transcription is working the digest lane steps aside and resumes when the card is free; it can be turned off in Settings. Coverage is now visible — a digest instrument on the dashboard with the corpus percentage (to two decimals, because rounding 0.13% up to 1% flatters an 80-day job), a **No digest** column on the channels table, and a per-channel count on the needs-work rows. Progress bars also **work during a regeneration** for the first time: they re-counted digest files from disk, and a regenerated digest is rewritten in place, so a job that was working sat at 0% for its whole run. Time-remaining estimates for digest work are now computed in **seconds per audio-hour** rather than by averaging videos, which for this archive is wrong by more than an order of magnitude between a VOD channel and a shorts channel. New `common/bin/digest-plan.ts` prices the whole backfill in audio-hours before you commit hardware to it.
-- **Duplicate clusters awaiting review can finally be confirmed or rejected.** A cluster whose evidence is only a shared title and runtime shares no AI digests and reaches no built site until a human confirms it — and the code to record that confirmation existed, complete, with **no way to reach it from anywhere in the app**. Roughly 166 clusters were therefore stuck permanently. `/actionable` duplicate cards now carry **Confirm** / **Not a duplicate** / **Undo**, badges that show what has already been decided, and confirming immediately shares the canonical copy's digest to any aligned member rather than making you wait for its next sweep.
-- **Digest passes that produced nothing now leave evidence.** When every chapter a model proposed was rejected by a guard — or when it proposed nothing at all — the generator deliberately wrote no digest, so the video would be retried. The side effect was that the **worst** outputs recorded their warnings nowhere but a job log that rotates after 30 days, exactly the videos a review pass most needs to find. Those failures are now recorded on the artifact, and they distinguish *"the model proposed nothing"* from *"the model proposed chapters and every one was rejected"* — which look identical from outside and need opposite fixes. A new **digest warnings** section on `/actionable` and a matching **Digest warnings** filter on the channel video list surface them.
-- **You can now read what the digest layer produced, and correct it, from the video page.** The digest generator shipped with nowhere for a human to look at its output — the channel Digest stage is a queue-and-count card, and the per-video page had no digest reference at all. Each video page now carries an **AI digest** panel that leads with what might be *wrong*: the `warnings` recorded during generation (grouped by guard, with the first offending value verbatim), then the **provenance** of each section — engine, requested vs actual model, lane, prompt version, prompt variant, context hash, and how many chunks of how many came back usable — then the chapters and tags themselves. A **freshness badge** says whether the digest still matches what a regeneration would produce right now, and a stale section spells out what it *would* be replaced with; changing an engine, model, prompt version or prompt shape is exactly what makes it stale, so this is where you see that a config change has invalidated your corpus. A digest that was **shared from a duplicate cluster's canonical member** says so plainly, links to the video it came from, and shows the measured cue offset that made placing it here safe — a borrowed digest is never presented as native. From the panel you can regenerate this one video on either lane (live log, cancellable, the same job machinery as a channel sweep) and hand-correct chapters: retitle one, or untick it to suppress it without deleting it, so a regeneration that re-emits the same item cannot resurrect something you rejected. Corrections are written **only** to `ai-digest.overrides.json` and never to the generated file. Settings also gains the **Digest** section the rest of the app has been pointing at (local engine, sections to generate, timestamp mode, prompt-variant label, per-engine model / context window / temperature, and the metered lane with its spend cap and long-tail cutoff), and **Digest** now appears in the channel status-header badges. See `editor/app/channels/[slug]/videos/[id]/components/DigestPanel.tsx`, `editor/app/settings/components/{SettingsForm,DigestAppsField}.tsx`, and `editor/e2e/digest.spec.ts`.
-- **Duplicate detection now runs over the whole archive, not just shorts.** It previously ran out of memory on a full-corpus pass and was left off. Two things were actually wrong, and both are fixed: candidate pairs were generated by pairing every short with every longer video (half a billion pairs), and transcript fingerprints for the entire corpus were held in memory at once. Detection now works one block of similar videos at a time — fingerprint, compare, discard — so a corpus-wide run finishes in minutes at ordinary memory. A new **`--blocking`** flag on `duplicate-shorts` chooses how candidates are proposed: `title` (default for a full-archive run — fast, finds cross-platform re-uploads that kept their name), `duration` (slower, but the only one that finds a *re-titled* mirror), or `both`. The choice only affects which pairs get *considered*; what counts as a duplicate is still decided by comparing the actual transcripts.
-- **Videos that share a title and a runtime are now surfaced for review instead of being dropped.** When one side has no transcript there is nothing to compare, so detection can't rule either way. Rather than discarding the pair, it is reported as a cluster badged **needs review** — visible in the archive's own tooling, excluded from every built site, and blocked from sharing AI digests until you confirm it. Clusters whose transcripts *were* compared are unaffected and behave exactly as before. Note that this test is doing real work: on the full archive, **63% of same-title, same-length pairs turned out not to be the same video.**
-- **Detection now records how well each copy's timings line up with the original.** Every confirmed cluster stores, per member, whether its transcript timings align with the cluster's canonical video and by how much. This is what lets the viewer offer "jump to this moment in the other copy" only when that moment actually corresponds — and lets a shared AI digest be placed correctly rather than plausibly.
-- **Every transcript can now be given AI-generated chapters and topic tags, from a model running on your own hardware.** A new **Digest** stage on each channel page sweeps its transcripts through a local model (ollama by default) and writes an `ai-digest.json` next to each one — a list of titled moments with timestamps, plus a short set of topic tags. Nothing leaves the machine unless you opt in: a second, **metered** lane (Claude Code) exists for the long tail of very long videos and is **off by default**, gated behind its own spend cap. The two lanes run on separate job queues on purpose — the local lane is GPU-bound and the metered one is network-bound, so sharing a queue would have halved the throughput of a sweep measured in weeks. **A re-run is cheap by construction.** Each generated section records the exact identity that produced it — engine, requested model, prompt version, prompt shape, and a hash of the channel-context inputs — and regeneration skips any section whose identity already matches. That is what makes "digest this channel again" take minutes instead of restarting a multi-week job, and it is why an alias like `qwen2.5` resolving to `qwen2.5:7b` is deliberately *not* treated as a model change. **Hand corrections are kept in a separate file** (`ai-digest.overrides.json`) that generation never opens, so no merge bug in the generator can destroy work a human did; readers compose the two, an override replaces a generated item by id, and `enabled: false` suppresses one without deleting it, so a regeneration can't resurrect something you rejected. **The output is guarded, not trusted.** Schema-constrained decoding pins every timestamp to a full `HH:MM:SS` and demands English titles, and the parser re-checks each item against the chunk's real time range, monotonicity, seam duplication and empty titles — every rejection recorded as a `warning` on the artifact rather than silently dropped, because a sweep this long is only tunable if its failures are inspectable. Chunk size is **sized to the configured context window**: ollama's default 4096 silently truncates over-long input and the model then summarizes whatever fragment survived, which looks like a bad model and is actually a misconfiguration. Two timestamp numberings are shipped and both are selectable (`absolute`, and `chunk-local`, which re-bases each chunk to `00:00:00` and adds the offset back before any guard runs) because which one is better was a measured question, not a guess — a bake-off harness (`common/bin/digest-bakeoff.ts`, reports under `plans/bakeoff/`) exists to settle it, and the choice is folded into the recorded identity so switching modes correctly invalidates the corpus instead of silently skipping it. Digests are also **shared across duplicate clusters**: a confirmed mirror of an already-digested video borrows its digest rather than paying for it twice, but only when detection *measured* the two as aligned, and the borrowed copy records where it came from and by what offset. Also fixes two wiring bugs found only end-to-end: the job log parser threw on the first line of every digest job (both of its parsers are null for this task, and the code asserted one was not — a whole sweep would have reported "0 generated, N failed" and looked like an engine fault), and saving Settings rebuilt the digest block without carrying `timestampMode`/`promptVariant` through, which would have silently reset the prompt shape on an unrelated save and invalidated every digest generated under it. See `common/lib/{digest,digestPrompt,digestParse,digestApps}.ts`, `common/controller/{digestVideo,digestBatch,digestSharing}.ts`, `editor/app/channels/[slug]/{digestActions.ts,components/stages/DigestStage.tsx}`, and `editor/e2e/digest.spec.ts`.
-- **Replace YouTube's auto-captions with transcripts of our own.** Most of the corpus rides on YouTube ASR captions, which are noticeably worse than what the transcription workers produce — no punctuation, rolling duplicate cues, `[Music]` filler — and they were *sticky*: `isVideoTranscribed()` counts any English VTT, so a video with only auto-captions was permanently invisible to every transcribe bucket and every transcribe job. There is now an opt-in, strictly-lowest-priority lane that finds those videos, downloads their audio, and transcribes them properly; whisper's `transcript.json` then wins the index pick automatically. Provenance is decided by a 4 KB sniff of the VTT itself (YouTube ASR marks ~96–100% of cues with `align:start position:N%` plus inline word timings; manual tracks mark 0%), with the 490 KB `metadata.info.json` parse kept only as a tie-breaker — so the per-regen cost is one small read per English-VTT-having video. Three snapshot buckets carry it: `autoSubsOnly` (needs audio) → `downloadedAutoSubsOnly` (needs whisper) → `supersededAutoSubs` (done, old VTT kept as a backup). Nothing is automatic by default: the auto-queue gains a per-runner **Replace YouTube auto-captions** switch that appends the bucket to the *tail* of the default union (real work always drains first), and a leaf can target the bucket by name for per-channel opt-in — the default unions are byte-identical to before, so existing setups are untouched. Manually, the Transcribe stage gains a two-step "YouTube auto-captions only" section and a single-video *Replace auto-captions* action, and each subtitle track is now labelled *YouTube auto-captions* / *manual captions*. A caption track whose provenance can't be proven machine-generated is **never** a candidate, and the original VTT is never deleted automatically — the Cleanup stage's purge button is the only thing that removes it (English ASR tracks only, honouring `do-not-clean`), so an AI-vs-YouTube comparison stays possible. See `common/lib/subtitleProvenance.ts`, `common/controller/purgeSupersededAutoSubs.ts`, `common/jobs/autoQueuePolicy.ts`, `editor/e2e/auto-subs-replace.spec.ts`.
-- **Social posts are archived as a parallel corpus to video transcripts.** The archive can now ingest X/Twitter and Bluesky accounts from the same commentators and search them *together* with video transcripts — one corpus, one set of searches. A channel gains `sourceKind: "social"` (a separate axis from `handling`, so every existing `handling === "transcribe" ? … : …` branch stays binary and can never misroute), plus `postFetcher` and `socialHandle`. Posts are modelled on the live-chat layer, not the video layer: their own month-sharded JSONL on disk (`channels/<slug>/posts/YYYY-MM.jsonl` + a `posts-archive` of seen ids, so a re-run is a no-op), their own LMDB sub-DB keyed `[createdAt, channelSlug, id]` (ISO-8601 sorts chronologically, fixing the intra-day ordering the `YYYYMMDD` video key has), and their own `/posts/<slug>/{manifest,page-NNNN}.json` page tree. Ingest is a pluggable `SocialFetcher` registry (`common/social/fetchers.ts`) modelled on the transcription-app registry: **`bluesky-atproto`** (pure `fetch` against the public AT Protocol — no auth, no binary, verified end-to-end against a live account), **`x-gallery-dl`** (the primary X path: a light headless subprocess, cookies via the existing `cookiePolicy.ts`), and **`x-playwright`** (the fallback, immune to the GraphQL query-id rotations that periodically break gallery-dl). One `fetch-posts` job kind carries it, drainable and bookmarkable, routed to `platform:x.com` / `platform:bsky.app` by the existing queue keys. A social channel gets a minimal two-stage rail (Fetch → Index) instead of the six video stages, and the channel form hides every video-only control (audio format, download format, keep-source-video, extraction mode, saved-video dir). Auto-sync works unchanged — but the scheduler's *dispatch* now routes social channels to a post fetch rather than a yt-dlp video sync. See `common/lib/posts.ts`, `common/social/*`, `common/controller/fetchPosts.ts`, `editor/e2e/social-channel.spec.ts`.
-- **Connect an X account once, instead of re-supplying cookies every few days.** X session cookies expire within days, which is gallery-dl's worst flaw as an archiving path. Settings gains an X-session broker: a headed "Connect X account" flow opens a browser on the editor host so the operator logs in by hand (2FA and captcha included — the login is deliberately never automated), storing a persistent browser profile. The fetcher then re-exports a fresh `cookies.txt` from that profile on demand and prefers it over `--cookies-from-browser`, so the session stops being the thing that breaks. Only X's own cookies are exported. See `common/social/xSessionBroker.ts`, `editor/app/settings/components/XSessionSection.tsx`.
-- **The dashboard is now a live mission-control cockpit.** The home page used to be a static SSR card stack (four stat tiles, a "needs attention" link, a bare channels table) — all the *live* operational density lived only in the opt-in `/widget` monitor. The dashboard is rebuilt as an information-first operations surface that updates in place, following the widget's proven pattern: `page.tsx` stays a server component that SSRs initial payloads and hands them to a client shell (`DashboardCockpit`) that polls the same `/api` endpoints (`/api/jobs/active`, `/api/workers`, `/api/widget/actionable`, `/api/widget/sync`). The hero is a full-width **Pipeline band** — a single instrument readout of running/queued jobs, worker-pool busy/total + pause state, sync heartbeat + scheduler on/off, the live job rows with progress bars, and the global controls (Pause transcriptions, Pause downloads, Sync all, + Add). Below it a **Needs-work** panel (top channels with ↓/✎ counts and inline Download/Transcribe actions, linking to `/actionable`) sits beside a **Quick-add / recent-changes** column, over an **enriched channels table** (relative "last sync" that ticks live, plus per-row inline Sync / Download-missing / Top-of-queue actions). Everything stays on the existing semantic "base" tokens (no new palette) and respects `prefers-reduced-motion`. The two ~1s poll hooks are extracted from the widget into `editor/app/widget/lib/usePolledPayload.ts` and the relative-time helpers into `.../relativeTime.ts`, shared by both surfaces. See `editor/app/page.tsx`, `editor/app/components/dashboard/*`, and `editor/e2e/dashboard.spec.ts`.
-- **URL-first channel onboarding.** The New-channel form now derives what it can from a pasted URL with **no network call** — platform (`detectPlatform`), handling (YouTube → subs, else transcribe), and a slug candidate (`@Veritasium` → `veritasium`, `/c/Some Name` → `some-name`) — and surfaces the **derived download queue** as a read-only hint (e.g. `Queue: platform:vimeo.com (new)` for a host the app doesn't recognize). An opt-in **Fetch details** button runs a lightweight single-entry yt-dlp probe (`probeChannelMeta` → `probeChannelUrlAction`) that fills the name and, crucially, lets a site **unknown to the app but known to yt-dlp** be created with its own `platform:<domain>` serial queue in the same action (no change to the closed `Platform` union). On create it now **always stores the playlist** ("Fetch playlist now", default on) so pending downloads populate immediately, with an optional **Add to top of auto-queue** that prepends a channel leaf at the head of the download policy tree and starts the runner (`prioritizeChannelDownloadAction`, also a one-click "Top of queue" on the dashboard channels table). Also fixes the missing **Kick** option in the platform select. See `editor/app/channels/{components/ChannelForm.tsx,actions.ts}`, `editor/app/auto-queue/actions.ts`, `common/ytdlp/runYtdlp.ts`, and `editor/e2e/new-channel-onboarding.spec.ts`.
-- **Pause is now first-class and survives restarts — for transcriptions and downloads.** Transcription pause was runtime-only (lost on restart) and buried on the Workers page; downloads had no pause at all. Both are now persisted in `settings.json` (`transcriptionsPaused` / `downloadsPaused`) and toggled from the dashboard Pipeline band (and, for parity, the monitor widget's controls). Pausing transcriptions persists the flag and re-pauses the worker pool at boot (`editor/instrumentation.ts`); pausing downloads idles the auto-download runner on its next loop iteration (re-read each tick, like the `enabled` flag) **and** makes manual download-bearing pipeline actions (`sync`, `download-from-playlist`, `download-missing`, `download-missing-subs`, `retry-bucket`) return a friendly "Downloads are paused" notice — store-playlist/enumeration stay allowed since they write no media. See `common/lib/settings.ts`, `editor/app/workers/actions.ts`, `editor/app/jobs/actions.ts`, `common/controller/autoRunner.ts`, and `editor/app/channels/[slug]/pipelineActions.ts`.
-- **Channel groups gain a "Show channels as individual chips" checkbox on the Site form.** Toggling it on marks the group `inline` in site.json, so the viewer renders each member channel as its own loose chip in the filter row instead of a collapsible group box. New sites' seeded default group ("All channels") ships with it **on**; groups added manually on the Site form or created inline from a channel form default **off**. See `editor/app/sites/components/SiteForm.tsx`, `common/lib/channelGroups.ts`, and `editor/e2e/sites-crud.spec.ts`.
-- **Cookies-from-browser is now configurable: three cookie modes, per-channel overrides, and a "Needs cookies" bucket.** The single global cookies value used to be hard-wired to one behavior (passed only on the download auth-retry attempt). Settings now carry a **Cookie mode** next to the value — **When required** (default; the old behavior, now also cookie-retrying a failed *metadata prefetch*, so an age-gated video succeeds on its first real attempt and reports `ok-with-cookies` with a `metadata-prefetch-auth-retry` attempt in `download-outcome.json`), **Always** (every yt-dlp invocation carries `--cookies-from-browser`: enumeration, prefetch, availability probes, downloads — for channels whose listing itself needs auth; a clean success still reports plain `ok`), and **Defer** (normal runs never use cookies: known `needs_auth` videos are excluded from sync/download-missing batches — previously they were re-attempted and re-failed on *every* run — and wait for a manual cookie run). Both the value and the mode can be overridden per channel in the channel form's Advanced section (blank/Inherit = use global). Every channel's Download stage gains a **Needs cookies (N)** card listing undownloaded videos whose recorded availability is cookie-recoverable (`needs_auth`, and also `members_only`/`private`, which batches always excluded but a subscribed/owning account's cookies can fetch) with a **Download with cookies** button that re-runs them with cookies forced on every invocation (bookmarkable, like the other bucket jobs; it warns if no cookie value is configured anywhere). Caveats: in defer mode a *manual single-video* download intentionally gets no cookies and no auth retry — the bucket button is the explicit cookie path; availability probes only attach cookies in Always mode, so needs_auth keeps being observed (defer depends on that signal). Back-compat: an existing `settings.json` without `cookieMode` behaves exactly as before (`when-required`). See `common/lib/cookiePolicy.ts`, `common/ytdlp/{downloadOneManaged,runYtdlp}.ts`, `common/controller/{channelSnapshot,checkAvailability}.ts`, and `editor/e2e/cookies-mode.spec.ts`.
-- **Channel forms now edit site membership directly — pick sites, groups, and create groups inline.** A channel's site membership used to be editable only from the Site form (`/sites/<id>`), and creating a channel silently appended it to the active site (a hidden `activeSite` field) with no group choice and no visibility. Both the **New channel** and channel **Configure** forms now carry a **Sites** section: every configured site listed with a membership checkbox and a compact group dropdown — `(default)`, any existing group, or **"+ New group…"**, which reveals a name input and creates the group on that site (id slugified from the name, reused if it already exists) as part of the save. On create, the active site (`?site=`) is pre-checked, reproducing the old behavior but visibly and overridably; on edit, current memberships pre-check with their groups, and unchecking removes the membership. The checked sites serialize into one hidden `siteMembershipsJson` field (the SiteForm hidden-JSON precedent); the server plans all writes up front — validating group ids, preserving other channels' entries and this channel's `order`, erroring clearly on a since-deleted group, skipping since-deleted sites, and rewriting only sites that actually changed. See the new `editor/app/channels/{lib/siteMemberships.ts,components/SiteMembershipsSection.tsx}`, `editor/app/channels/{actions.ts,components/{ChannelForm,ChannelFormClient}.tsx,new/page.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-site-membership.spec.ts`.
-- **The monitor widget can now start a sync and shows sync freshness + scheduler health — each behind its own flag.** The widget was start-a-sync-less and said nothing about how fresh your channels were. Five new opt-in URL flags, all toggleable in the builder and the in-widget gear (all default off, so existing links are unchanged): **(1) a channel-aware Sync button** (`sync=1`) — pinned to one channel (`channel=X`) it runs that channel's streaming sync; otherwise it sweeps all channels (`syncAllChannelsAction`), briefly showing `Queued X · skipped Y`. **(2) A last-sync readout** (`lastsync=1`) — `Last full sync: …` from a new persisted `lastSyncAllAt` marker written at the end of each Sync-all sweep, plus a `Last channel sync: …` line when an individual channel synced more recently. **(3) A scheduler-status strip** (`sched=1`) — `Auto-sync on/off · next … · last run …`, derived from the same `buildScheduleView` the `/scheduler` page uses. **(4) An absolute-time toggle** (`abstime=1`) — readouts show locale timestamps instead of relative "5m ago". **(5) A Sync-all confirm** (`syncask=1`) — a `window.confirm` before a full sweep. The two readouts poll a new lightweight `/api/widget/sync` route (a few scalars, 15s floor) and, with the Sync button/controls, keep the widget from collapsing to "Idle" — sync health is exactly what you check when nothing's running. See `editor/app/widget/lib/config.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls,WidgetConfigForm}.tsx`, the new `editor/app/api/widget/sync/route.ts`, `common/jobs/syncSchedulerState.ts` (`lastSyncAllAt`), `editor/app/channels/actions.ts`, and `editor/e2e/widget.spec.ts`.
-- **The audio-integrity check now tightens its probe interval when a source starts serving corruption, then relaxes as it stabilises.** Previously the integrity probe ran on a fixed cadence (default 60s) for the whole download, so up to ~60s of bytes were downloaded — and discarded — between a corruption event and the checkpoint that caught it. The interval is now adaptive (AIMD, like TCP congestion control, inverted): each **malformed** checkpoint **halves** the live interval (60→30→15→10s, floored at the existing `AUDIO_CHECK_INTERVAL_MIN_SECONDS` of 10s), so a misbehaving source gets probed more aggressively and wastes fewer bytes per rollback; a run of clean checkpoints then **steps it back up** additively (+15s after every 2 clean probes) toward the configured interval. The reduced cadence persists across yt-dlp relaunches for the rest of the download run. Fully backward compatible — a clean download never leaves the configured interval. Tunable via constants in `common/lib/channelConfig.ts` (`AUDIO_CHECK_INTERVAL_BACKOFF_FACTOR_DEFAULT`, `AUDIO_CHECK_INTERVAL_RECOVER_STEP_SECONDS`, `AUDIO_CHECK_INTERVAL_RECOVER_AFTER_CLEAN`) plus test-only env overrides. See `common/ytdlp/audioCheckCadence.ts` (pure AIMD math + `audioCheckCadence.test.ts`) and `common/ytdlp/audioCheckedDownload.ts` (`resolveKnobs`, the watcher loop, and the advance/malformed checkpoint branches).
-- **Docker build mode is now real: build every site in parallel, then deploy them serially.** The `Docker` build mode (Settings → Build pipeline) was previously a stub that fell back to the basic build. It now runs a proper pipeline, driven by a new **Build all sites** control on the Deploy page (one job, one log, one Cancel). The shared, corpus-scale work — the search index, the per-site staging, and the downloadable archive zips — runs **once on the host**; then each site's `compose + next build` runs in its **own container in parallel** (capped by the **Max parallel builds** setting), each writing an isolated per-site `out/` under `export/.export-builds/<siteId>/`; then the built sites **deploy serially** on the host (R2 upload + `wrangler pages deploy`), tolerant of a single site failing. Containers are read-only over the shared corpus/index/archive cache and run as your host user so outputs aren't root-owned. The image (`Dockerfile.build`, tag from **Build image**) is built/reused via Docker layer caching; when no container engine is available the action falls back to a serial host build+deploy. New env knobs: `DOCKER_BIN` (e.g. `podman`), `DOCKER_BUILD_MEMORY`/`DOCKER_BUILD_CPUS` (per-container caps). See `editor/app/deploy/buildDeployCore.ts` (`runDockerBuildAllPhase`/`runDockerDeployAllPhase`), `editor/app/build/buildAction.ts` (`buildAndDeployAllSitesAction`/`buildAllSitesAction`), `editor/app/deploy/components/BuildAllSitesButton.tsx`, `Dockerfile.build`, `docker/build-site.sh`, `common/bin/build-archives.ts`, and **[DEPLOY_DOCKER.md](../DEPLOY_DOCKER.md)**.
+- test bullet for cut-release spec
-## [0.7.3] - 2026-07-07
-- **Deploys no longer re-upload unchanged oversize archives to R2.** The deploy step used to stream every over-cap archive to R2 on every deploy, even ones byte-identical to what was already there. Because the export build now reuses an unchanged channel's cached zip verbatim, the upload step first does a cheap `HeadObject` and skips any archive whose R2 object already has the same size — so a redeploy after changing one channel only re-uploads that channel's oversize bundle. See `editor/app/deploy/buildDeployCore.ts`.
-- **New "Search aliases" page for authoring known-term suggestions.** A concept is often spelled many ways — transcription in particular mangles them (AI expands `loli` to `lolly`/`loly`) — so searching one spelling silently misses the rest. This page authors a curated dictionary where one entry groups all the trigger spellings of a concept with a single robust replacement (e.g. `\blol(i|ly)`). A **Global** section applies to every site and ships a small seeded default set you can edit or remove; a **per-site** section (shown when a site is selected in the sidebar) adds to or overrides the global list by id — reuse a global id and mark it disabled to hide that alias for just that site. Nothing is forced: the entries only surface a *suggestion* in the viewer's search box, which the searcher can apply or ignore. Stored as `search-aliases.json` (global under the data dir, per-site under `sites/<id>/`) and merged into each site's bundle at build time. See `editor/app/aliases/{page.tsx,actions.ts,EditorAliasesClient.tsx}`, `common/lib/{searchAliases,aliasesStore}.ts`, `editor/app/lib/nav.ts`, and `editor/e2e/aliases.spec.ts`.
-- **Fixed: the editor (and every public site) always loaded in light mode until you toggled the theme.** Dark styling is driven purely by a `.dark` class on `<html>`; the pre-paint `<ThemeScript>` set it correctly before first paint, but `<html>` is server-rendered with a static class that omits `dark`, so that class was lost across React's hydration boundary and nothing put it back — the page fell to the light palette on every load until a manual toggle re-applied it directly (which is why toggling then "stuck"). `ThemeProvider` now re-asserts the persisted, resolved family + mode to `<html>` on mount via `useLayoutEffect` (before paint) — idempotent with the script, so there's no flash and no toggle needed. Explicit dark, `system` on a dark OS, and non-Base families all now survive a refresh. See `common/components/ThemeProvider.tsx` and `editor/e2e/theme.spec.ts`.
-- **Search results scroll smoothly again on large result sets.** The results list windows one card per matching video (only the on-screen cards are mounted), but each visible card and every one of its hit rows was re-rendering on *every* scroll frame — and each hit row re-ran its `<mark>` highlighting, so a single video with hundreds of hits meant hundreds of redundant highlight passes per frame while scrolling. The result cards and individual hit rows are now memoized so an unchanged card/row is skipped during scroll, and opening the modal on a hit only re-renders the two rows whose highlight state actually changes. No visible/behavioral change — same DOM, same results, just far less work per frame. See `common/components/TranscriptSearch.tsx` (`ResultCard`/`HitRow` memoization, `openWithMode` stabilized via `useCallback`).
-- **The monitor widget gains a needs-work channel list, more interaction buttons, and an in-place settings gear.** Three additions, all driveable from the widget builder. **(1) A "Needs work" list** (URL flag `act=1`) — a compact, per-channel worklist of videos to download (`↓ N`) or transcribe (`✎ N`), reusing the same `loadActionableSummary` that powers the `/actionable` page via a new `/api/widget/actionable` route; it polls on a 15s floor (the backlog changes on job completions, not seconds) and caps at 6 channels with a `+N more` line. **(2) More interactions** behind the existing `controls=1` switch: each needs-work row gains the same per-channel **Download missing** / **Transcribe pending** buttons as the actionable page (reusing `InlineActionButton`), and the controls row adds **Retry all failed** alongside Pause/Resume + Drain. **(3) An in-place settings gear** (on by default; URL flag `gear=0` to hide, or a **Show settings gear** builder checkbox) — clicking it opens the builder's own form *inside the widget window*, so a pinned widget can be reconfigured live without opening the builder page; edits apply immediately and mirror into the address bar via `history.replaceState`, so a reload preserves them and the link stays copyable. The builder form is extracted into a shared `WidgetConfigForm` used by both the builder and the overlay, and the widget's poller now fetches immediately on (re)subscribe instead of after one interval, so newly-enabled sections render at once. Existing links render unchanged (the two new flags default to their old behavior; the gear is the one new default-visible affordance and is read-only — it mutates no server state). See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetConfigForm.tsx` and `editor/app/api/widget/actionable/route.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls}.tsx`, `editor/app/widget/builder/components/WidgetBuilder.tsx`, and `editor/e2e/widget.spec.ts`.
-- **Every site build now bundles downloadable per-channel transcript & live-chat archive zips.** The archive builders (per-channel `<slug>.zip` / `<slug>.live_chat.zip`) previously only ran as standalone actions that wrote to a non-served directory; now `compose-site` generates them for the site's own channels straight into the served `public/archives/` and writes a `manifest.json` (sizes + counts) that the site's new **Downloads** page reads. **`zip` is now the default archive format** everywhere (was `tar.gz`), and the Build page's format help text tracks the selected format. Generation is **on by default with three opt-out levels**: a global **Generate archive zips on build** toggle in Settings, a per-site **Generate archive zips** toggle (plus an optional **Archive size cap (MB)**) on the site's page, and a per-build **Skip archive zips** checkbox on the Build and Build & Deploy controls (`BUILD_ARCHIVES=0`). See `common/bin/compose-site.ts` (`composeArchives`), `common/controller/archive{Transcripts,LiveChat}.ts` (new `outDir` option), `common/lib/archiveOptions.ts` (default + manifest types), `common/lib/{site,settings}.ts` (opt-out flags), and `editor/app/{deploy/buildDeployCore.ts,build/buildAction.ts,deploy/components/Build{Export,Deploy}Button.tsx,sites/components/SiteForm.tsx,settings/components/SettingsForm.tsx}`.
-- **Oversize archives now overflow to Cloudflare R2 instead of being dropped.** A single file over 25 MB breaks a Cloudflare Pages deploy, so a channel zip over the cap (default 25 MB; `0` = no cap) used to be removed from what's served and flagged `oversize`. Now, when **Archive overflow storage** is configured in Settings (an R2 **bucket** + its **public URL**), `compose-site` stages each oversize archive to `export/.r2-staging/<siteId>/` and records its future public URL in the manifest; the deploy step then uploads it to `<bucket>/<siteId>/archives/<file>` **before** the Pages deploy, so the Downloads page links straight to R2. Uploads go over R2's **S3 API** using the AWS SDK's multipart uploader (`@aws-sdk/lib-storage`) — `wrangler r2 object put` caps a single upload at 300 MiB and real live-chat archives are larger, whereas multipart streams any size. This needs R2 **S3 credentials** in the environment (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `CLOUDFLARE_ACCOUNT_ID`); if they're missing when there's something to upload, the deploy fails before the Pages step (so the site never links to a missing file). With no bucket configured the old drop-and-flag behavior is unchanged (uploads run only in the editor's Deploy / Build & deploy actions, not a raw `pnpm deploy`). The **combined "whole site" archives were removed** — they duplicated the per-channel content and were always the first to blow the cap. Each R2 upload also now sets `Cache-Control: public, max-age=3600` so a Cloudflare custom domain caches downloads at the edge — the main defense against download-abuse cost (R2 egress is free; only origin reads are billable, and cached hits skip the origin). New **[DEPLOY_CLOUDFLARE.md](../DEPLOY_CLOUDFLARE.md)** documents the full R2 setup plus the Cloudflare custom-domain / caching / rate-limiting / bot config for instance operators. See `common/lib/settings.ts` (`archiveStorage`), `common/bin/compose-site.ts` (staging + manifest `url`), `common/lib/archiveOptions.ts` (`ArchiveManifestEntry.url`), and `editor/app/deploy/buildDeployCore.ts` (`runArchiveUploadIntoLog`, `ARCHIVE_CACHE_CONTROL`) / `deploy/deployAction.ts` / `build/buildAction.ts`.
-- **Archive downloads can now be served securely without owning a domain (`r2-proxy/` Worker).** Serving oversize R2 archives with cost/abuse protection previously implied a Cloudflare **custom domain** (for CDN caching + rate-limiting rules). New self-contained Cloudflare Worker at `r2-proxy/` serves the bucket on a free `*.workers.dev` subdomain instead — the same domain-free model as Pages' `*.pages.dev`, addressing the privacy/expense of registering a domain. It's a pure passthrough (request path `<siteId>/archives/<file>.zip` → bucket key), so **one Worker serves every site** — deploy it once, not per site. It adds edge caching (Cache API, honoring the object's `Cache-Control`), native per-IP rate limiting (free binding), path allow-listing (`*/archives/*.zip` only), and `Range`/resumable-download support. Point the editor's **Archive overflow public URL** at the `workers.dev` URL and nothing else changes (uploads/manifest are identical). `DEPLOY_CLOUDFLARE.md` now documents both paths (Worker vs custom domain), with the Worker as the recommended no-domain option. See `r2-proxy/{src/index.ts,wrangler.toml,package.json,README.md}` and `editor/app/settings/components/SettingsForm.tsx` (public-URL hint).
-- **The Duplicates page is now a per-site toggle and hides itself when empty.** Each site's editor page gains a **Show the Duplicates page** checkbox (on by default). `compose-site` writes the site-filtered `duplicates.json` only when the toggle is on *and* there's at least one in-scope cluster, and the export Header keys its Duplicates nav link off a new `hasDuplicates()` — so the link and page disappear both when a site opts out and when it simply has no detected duplicates. See `common/lib/site.ts` (`duplicates` flag), `common/bin/compose-site.ts` (gated write), `export/app/lib/duplicates.ts` (new), `export/app/components/Header.tsx`, and `editor/app/sites/{components/SiteForm.tsx,actions.ts}`.
-- **You can now change a channel's slug (its id) — deliberately, from the Danger zone.** A channel's slug *is* its on-disk directory name (`transcripts/channels/<slug>/`), so it used to be fixed at creation ("Slug is fixed once a channel is created"). A new **Rename** form in the channel's Danger zone lifts that: enter a new slug and **type the current slug to confirm** (same friction as delete), and the rename is blocked while the channel has running/queued jobs (the in-memory registry keys by slug). Because the slug is a directory name, the rename does a **full migration** of every slug-keyed store so nothing silently breaks: it moves the channel dir (config, data, playlist, snapshot, shards, failed lists) **and** the saved-video store dir — rewriting each `saved-video.json` pointer's absolute `dir` so persisted source videos still resolve — then retargets every site.json membership, the sync scheduler's per-channel backoff state, and any job bookmarks. The two filesystem moves run first and roll back on failure; the metadata updates that follow are atomic and best-effort (surfaced as warnings). Renaming **changes the channel's public URL** (the old one 404s), which the form warns about. The slug grammar is also now validated on create. See `common/controller/renameChannel.ts`, `common/controller/channels.ts` (`isValidChannelSlug`), `common/lib/savedVideo-server.ts` (`rewriteSavedVideoDir`), `common/jobs/bookmarks.ts` (`renameChannelInBookmarks`), `editor/app/channels/{actions.ts,components/RenameChannelForm.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-rename.spec.ts`.
-- **New Queue diagnostics page (`/jobs/queue`): see & force-release stuck jobs.** The job system has two sources of truth that can drift — the registry owns each job's `status`, the scheduler owns the running SLOT per queue. A cancel that never finalizes (a child that ignored SIGTERM, a crashed finalizer) leaves a job "cancelled" in the registry while the scheduler still marks its slot running, silently blocking every job behind it on that queue — and the Active Jobs page hides it (it filters to running/queued). The new **Queue** page reconciles the two: it builds from the **scheduler** as the source of truth for slots, cross-checks each against its registry record, and flags a running head as **stuck** when the record is terminal-but-holding-slot, evicted, or (softer) a live job idle past 10 minutes. It **auto-heals** the hard cases on every view/poll (frees terminal/evicted slots), shows a health strip (active queues, running, queued, **stuck**, workers), per-queue cards with the held-for duration / PID (`kill -9` hint) / last log line, and a **Force-release** button per slot (SIGKILLs the child and frees the slot unconditionally) plus a **Reap all stuck** action. Force-release is also available on any running job in Active Jobs, and Active Jobs links to Queue with a stuck-count badge. See `common/jobs/registry.ts` (`forceRelease`), `editor/app/jobs/queue/*`, `editor/app/jobs/{actions.ts,components/ForceReleaseJobButton.tsx}`, and `editor/e2e/queue.spec.ts`.
-- **Jobs page: real log retention + pagination (replaces the dead "Clear archived logs" button).** The old button only deleted logs absent from the in-memory registry — which, since the registry keeps the 100 newest finished jobs and sidecars preserve their real status, was almost never anything, so it did nothing. It's replaced by a **Clear logs** dropdown that prunes finished-job logs by age (older than 7 / 30 / 90 days) or all at once; running/queued jobs are never deleted. The `.jobs` directory also **self-trims on job finish** (throttled; keep newest 500, drop >30 days) so it can't grow unbounded. Job ids are now **ULIDs** (lexicographically time-sortable, timestamp decodable from the id), letting the list **paginate** — `listAllJobs` returns one page (default 50, grown by a **Load more** link) and only `stat`s/reads the sidecar for the shown page instead of every file on every load. `jobIdTime()` decodes both ULID and the legacy `<t36>-<rand>` ids, so existing on-disk logs still sort/read correctly. See `common/jobs/{ulid,listJobs,registry,streamCommand}.ts`, `editor/app/jobs/{page.tsx,actions.ts,components/ClearLogsMenu.tsx,[id]/page.tsx}`, and `editor/e2e/jobs.spec.ts`.
-- **"Move to top" button on the auto-queue policy editor.** Each reorderable rule/group in the auto-queue policy tree gains a **⤒** button beside the existing ↑/↓ swap controls that jumps the node straight to the front of its sibling list in one click (disabled on the first row, like ↑). Reordering stays local until **Save policy**, matching the swap buttons. See `editor/app/auto-queue/components/PolicyTreeEditor.tsx` and `editor/e2e/auto-queue.spec.ts`.
-- **Kick VOD playback + VOD-expiry indicators.** Kick becomes a first-class platform (`Platform` union, `detectPlatform`, `platformFromMetadata` `/^kick/i`, `extractVideoId` kick branch, `defaultWebpageUrl`). Kick VODs have no iframe embed, so playback streams the HLS manifest yt-dlp resolves at download time: `summarize()` persists `manifest_url` → `hlsUrl` on the transcript summary/detail, and a new client-only `common/components/KickPlayer.tsx` plays it in a native `<video>` via the bundled **hls.js** (not react-player's file player, which loads hls.js from a CDN and would break the offline export). It exposes the same `seekTo`/`onReady`/`onProgress` handle as the YouTube player, so Kick gets full scrubbing + cue highlighting; on a fatal manifest error it falls back to an expiry notice + source link. Separately, a shared `common/lib/vodExpiry.ts` (retention: Kick 30d, Twitch 14d, tunable) drives a new `VodExpiredBadge` on search result cards for likely-deleted Kick/Twitch VODs, with a `title=` tooltip explaining each platform's retention. Cache versions bumped so stale data re-derives (`transcriptStore` `DB_VERSION` 4, `normalizeTranscript` `CUES_FILE_VERSION` 2). See `common/lib/{platform,transcripts,transcripts-server,vodExpiry,format}.ts`, `common/components/{KickPlayer,PlayerProvider,badges,TranscriptSearch}.tsx`, `common/ytdlp/runYtdlp.ts`, and `export/e2e/kick-vod.spec.ts`.
-- **Clicking a site on the Sites list now opens its edit page instead of bouncing back to the list.** The sidebar site selector seeds the active site into the URL (`?site=`) on mount so the scoped server pages (Dashboard, Channels, Charts, Deploy) can read it — but it was also firing on the `/sites` CRUD pages, where a mount-time `router.replace("/sites?site=<id>")` raced and clobbered the in-flight navigation to `/sites/<id>` from a list link, dumping you back on the list. The seed is now skipped on `/sites*` routes (which never consume `?site=`), so site links navigate straight to the editor; scoped-page seeding is unchanged. See `editor/app/components/SiteScopeSelect.tsx`.
-- **"Build static export" can now skip the data rebuild and compose from existing staging.** The `export/` build normally regenerates the pool-wide data first (its npm `prebuild` hook runs `build:data` = `build:index && build:stats && build:templates`), and the index step (heavy transcript → page-tree + LMDB processing) dominates build time. A new **Skip data rebuild (index, stats, charts)** checkbox on the Build static export control lets you rebuild a site *without* that work — it runs only `compose:site && next build` against the current `.export-index/` staging, which is exactly what you want when re-composing after a code/theme/template change or building a different site from already-staged data. It routes to a new `build:nodata` export script (same body as `build`, but a distinct name so npm's `prebuild` hook doesn't fire); the checkbox reuses the streamed-log/queue/cancel machinery of the existing managed build. Assumes a prior full build produced the staging (do a full build first if the data is stale). Only the build-only control is affected — the one-click **Build & deploy** button always does a full build. See `export/package.json` (`build:nodata`), `editor/app/deploy/buildDeployCore.ts` (`runBuildPhase` `skipData`), `editor/app/build/buildAction.ts` (`buildExportAction`), and `editor/app/deploy/components/BuildExportButton.tsx`.
-- **The neutral Base theme is now all-sans.** The default Base family previously inherited the shared Source **Serif** display face for the wordmark and page headings; it now uses the sans face instead, matching the pre-theme all-sans look. This is a shared token change (`html:not([data-theme])` in `common/styles/tokens.css`), so any surface on the Base family — including the editor's default — reads sans; the Archive/Selenized/Swiss families keep their own type voices.
-- **A dedicated Cleanup page with a live "reclaimable" sidebar badge and per-channel include/exclude.** Space-reclaim cleaning now has its own home (`/cleanup`, in the Pool nav) instead of being scattered across each channel's detail page. It opens with a serif **reclamation console** readout — the total reclaimable audio across channels, a token-colored breakdown bar (transcribed audio / extra formats / wrong-format), and a per-channel ledger that runs the same `cleanAudioAction` / `cleanExtraAudioFormatsAction` / `removeWrongFormatAudioAction` sweeps (plus failed-list housekeeping) the channel page does — the per-channel `CleanupStage` stays put. The sidebar **Cleanup** item carries an amber badge with the running reclaimable total (e.g. `12.4 GB`), recomputed on each auto-refresh tick. A per-channel **Counted / Excluded** toggle (new `ChannelConfig.excludeFromCleanup`, persisted in `config.json` like `excludeFromBuild`/`excludeFromSync`) holds a channel's space back from the total without disabling its sweeps — e.g. keep a finicky-to-redownload channel's audio around for now. The headline total uses the primary "clean audio" reclaim only (the three sweeps overlap, so they aren't summed). See `editor/app/cleanup/{page.tsx,lib/loadCleanup.ts,components/{ChannelCleanupCard,ChannelCleanupToggle}.tsx}`, `editor/app/channels/actions.ts` (`toggleChannelCleanupInclusionAction`), `editor/app/{lib/nav.ts,layout.tsx}`, and `common/lib/channelConfig.ts`.
-- **Badges and alerts are now colorful and theme-aware in every theme.** The design system only tokenized red (`--destructive`); every green/amber/blue status was hardcoded Tailwind palette that ignored the four theme families. New semantic tokens — **`--success` / `--warning` / `--info`** (each with `-foreground` + a soft fill partner) plus `--destructive-soft` — are defined across all eight family blocks (base/archive/terminal/swiss × light/dark) and registered in `@theme inline`. The shared kit `Badge` gains `success` / `warning` / `info` / `brand` soft-filled variants, and a **new `Alert` component** (`common/components/ui/alert.tsx`) replaces ad-hoc notice divs. High-traffic status UI is migrated onto the tokens: channel stage badges (`StageBadge`), worker state badges + dots (`WorkersView`, `MonitorWidget`), the scheduler status column (`SchedulerView`), and the widget disk strip — so each recolors correctly in Archive, Terminal, and Swiss, light and dark. See `common/styles/tokens.css`, `common/components/ui/{badge,alert}.tsx`.
-- **Per-video "Exclude from truncated check".** Videos that legitimately have no speech for their back half (e.g. long ambient/music tails) kept getting false-flagged as truncated/incomplete transcripts. A new per-video toggle on the video panel writes an `exclude-truncated-check.json` marker (mirroring the `do-not-clean.json` pattern) that suppresses the flag everywhere it surfaces — the snapshot's `incompleteTranscript` + `shortAudio` buckets (so bulk actions, the `/actionable` lists, and channel row dots all skip it) **and** the per-video panel's "looks truncated" banner, which computes independently of the snapshot. The detection thresholds in `transcriptCoverage.ts` are unchanged (other videos unaffected). See `common/lib/excludeTruncatedCheck{,-server}.ts`, `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/videos/[id]/{videoActions.ts,components/VideoPanel.tsx,page.tsx}`, and `editor/app/channels/[slug]/page.tsx`.
-- **Monitor widget: an optional cleanable-data indicator, and the builder remembers your last config.** The widget builder gains a **Show cleanable indicator** option (URL flag `clean=1`) that adds a strip showing total reclaimable audio (polled from a new `/api/widget/cleanable` route, honoring `excludeFromCleanup`), styled like the disk strip. The builder also now **persists its form to localStorage** (`ytdlp-tb:widget-config`, versioned, SSR-guarded — mirroring `jobsFilterStorage`), so it reopens with your last configuration; the shared/embedded `/widget` URL stays authoritative for what actually renders. See `editor/app/widget/{lib/config.ts,builder/{widgetConfigStorage.ts,components/WidgetBuilder.tsx},components/MonitorWidget.tsx}` and `editor/app/api/widget/cleanable/route.ts`.
-- **The whole editor now follows the selected theme (not just light/dark).** Dozens of editor screens — the Active Jobs cards, channel pipeline/stage views, the jobs table, deploy/scheduler/workers panels, settings/sites forms, the widget builder, and more — hardcoded Tailwind palette colors (`bg-white`/`dark:bg-zinc-900`, `text-zinc-500`, red/amber/green/blue status colors) that tracked light/dark but **ignored the theme family**, so they stayed zinc/white in Archive, Selenized, and Swiss. Every one of these (~100 components across `editor/app` + shared `common/components`) is migrated onto the semantic tokens (`bg-card`, `border-border`, `text-muted-foreground`, `text-foreground`, and `destructive`/`warning`/`success`/`info` for status), so they recolor correctly in every family. The one deliberate exception is the audio-probe "scanning" fill (violet) and chart data-series colors, which are intentional encodings.
-- **Two more selectable themes + a theme picker, and the families now feel distinct.** A palette menu beside the light/dark toggle (in every app — editor, export, homepage) switches the theme family between **Archive** (warm reading room), **Selenized** (the Solarized successor — teal-slate/warm-tan), **Swiss** (red/black/white editorial), and **Base** (neutral), each in light/dark/system, persisted client-side. Beyond color, families now also carry their own **corner radius and type voice**: Swiss is hard-cornered (0px) in a neo-grotesque (Archivo), Selenized uses calm rounding with a code voice (JetBrains Mono display + IBM Plex Sans body), Archive/Base keep soft corners and the Source serif family. The token vocabulary also grew — `--surface`, `--border-strong`, `--faint`, `--brand-ink` are now defined for **every** family and exposed as utilities (`bg-surface`, `border-border-strong`, `text-faint`, `text-brand-ink`), so more chrome can be themed. Each family is still a pure CSS token swap in `common/styles/tokens.css` — no markup changes — wired through `common/components/{themeConfig.ts,ThemeMenu.tsx}` and `common/styles/fonts.ts`. (A previously-selected "Terminal" family migrates automatically to "Selenized".)
-- **Per-site brand accent.** Each site's config (under Sites) gains an optional **Brand accent** field — a hex color that overrides the family brass on that site's public build. Validated on save; blank inherits the family accent. The long-running action logs (download, transcribe, build, deploy, …) are also restyled onto the shared kit (Button + design tokens), keeping their exact accessible labels. See `common/lib/accent.ts`, `common/lib/site.ts`, `common/components/StreamActionLog.tsx`, `editor/app/sites/{components/SiteForm.tsx,actions.ts}`, and `export/app/layout.tsx`.
-- **Command-first cockpit: a ⌘K command palette that runs actions, plus a live dashboard.** Press **⌘K / Ctrl-K** anywhere to open a fast command palette (rebuilt on cmdk) that jumps to any page **and runs pool/site actions inline** — Sync all channels, Refresh all reports, Detect duplicate shorts, Retry all failed jobs, Drain the queue, Pause/Resume all workers — each firing the server action and reporting the result as a **toast** (Sonner). On a channel page it also offers the channel's status filters. The sidebar and palette now read from one shared nav source (`editor/app/lib/nav.ts`) so they never drift (the old palette listed a stale 5-route subset), and every nav item gains an icon. The **Dashboard** gains a **Live jobs** panel that polls running jobs in real time (reusing the active-jobs monitor), and the whole editor shell is restyled compact onto the shared design tokens/kit with the Source type family. See `editor/app/{layout.tsx,page.tsx,lib/nav.ts,components/{CommandPalette.tsx,ChangelogNavLink.tsx}}` and `common/components/ui/sonner.tsx`.
-- **New "Family hub URL" setting.** Settings gains an optional hub URL (e.g. `https://archilyzer.com`); every export site renders a link back to it in the header/footer, tying the family of public sites together. Leave it blank for no hub link. Normalized to an absolute http(s) URL on save (`settings.homepageUrl`; see `common/lib/settings.ts`, `editor/app/settings/{components/SettingsForm.tsx,actions.ts}`). Part of the family-wide UI redesign that also restyles the public export sites.
-- **Source-truncated downloads are now caught at download time, and the yt-dlp download format is selectable (Odysee defaults to `original`).** A download that completed (yt-dlp exit 0) but whose audio is far shorter than the video — e.g. an Odysee/LBRY video whose every HLS rung is CDN-truncated to a few minutes while the full audio lives only in the multi-GB `original` format — used to pass the audio-check (the short file is structurally *clean*) and get transcribed, so only the **post-transcription** coverage detector caught it. Two changes fix this. **(1) A download-time duration guard:** after each managed download the produced `audio.<fmt>` is probed with **ffprobe** (new `common/ytdlp/ffprobeDuration.ts`, `paths.ffprobeBin` / `FFPROBE_BIN`) and compared to the metadata duration using the same thresholds as the transcript-coverage detector (`isShortAudio` in `common/lib/transcriptCoverage.ts`: non-livestream, ≥10min, <50% covered). A large shortfall is recorded as a new terminal **`failed-short-audio`** status with the measured `{audioDurationSec, expectedDurationSec, coverage}` — caught **before** transcription (the inline no-subs-fallback whisper is pre-checked too). The short stub is **kept on disk** (so it isn't silently re-downloaded into a loop) and is **not** classified for platform backoff (a truncated stream isn't a transient error). Surfaced as a new **`shortAudio`** snapshot bucket (mirroring `corrupt-full-source`: excluded from `downloadedNoTranscript` so it's never auto-transcribed, and from the wrong-format cleanup so the kept file isn't deleted) → a **`short_audio`** channel-list filter + **Select short-audio** quick-select + bulk **Re-download truncated** action, a **"Download was truncated at the source"** banner on the video page with a one-click **Re-download as Original**, a **"Channels with truncated downloads (short audio)"** `/actionable` section, and a **"Truncated download — audio far shorter than the video (file kept)"** download badge. The auto-download runner lands a `failed-short-audio` unit as a failed job. **(2) Selectable, platform-aware download format:** the previously-hardcoded `-f bestaudio/worst` is now a preset (`auto` | `original` | `bestaudio` | `bestvideo_audio`; `common/ytdlp/downloadFormat.ts`) resolved with the same override chain as `audioFormat` — per-download (the redownload **Format** picker) > per-channel (`ChannelConfig.downloadFormat`, a select in the channel form) > global (`SiteSettings.downloadFormat`, a select in **Settings**). **`auto` is platform-aware:** Odysee/LBRY resolves to `original/bestaudio/worst` (so the truncation is avoided at the source); everything else stays `bestaudio/worst`. The source platform is taken from the prefetched metadata extractor. The short-audio bucket's re-download reuses the per-video fixer (delete the stub → re-fetch with the per-source default → re-transcribe) via the existing `redownload-incomplete-bucket` job (now also driving `ReplayBucket: "shortAudio"`). See `common/ytdlp/{downloadFormat.ts,ffprobeDuration.ts,downloadOneManaged.ts,runYtdlp.ts}`, `common/lib/{transcriptCoverage.ts,downloadOutcome.ts,settings.ts,channelConfig.ts,paths.ts,transcripts-server.ts}`, `common/controller/{channelSnapshot.ts,autoRunner.ts}`, `common/jobs/jobSpec.ts`, `editor/app/channels/[slug]/{incompleteTranscriptActions.ts,bulkVideoActions.ts,lib/{fixIncompleteTranscript.ts,videoRows.ts,videoRowsServer.ts,stageStatus.ts},components/VideoListPane.tsx,videos/[id]/{videoActions.ts,components/VideoPanel.tsx}}`, `editor/app/{settings/{actions.ts,components/SettingsForm.tsx},channels/components/{ChannelForm.tsx,parseChannelForm.ts},actionable/{lib/loadActionable.ts,page.tsx,components/InlineActionButton.tsx},jobs/jobReplayRegistry.ts}`, and `editor/e2e/download-format-guard.spec.ts`.
-- **A complete-but-corrupt audio download is no longer re-downloaded forever — it's kept and flagged instead.** When an audio-checked download finished (yt-dlp exit 0, all bytes) but its final integrity probe came back `malformed`, the orchestrator rolled the whole file back and re-downloaded it — repeatedly. For a fast download that completes inside one checkpoint interval no `.good` baseline ever exists, so each "rollback" discarded the entire file and re-fetched it from scratch (a 2.46 GB Odysee video looped until the rollback cap, leaving no audio behind), and a cancel landing mid-loop was deferred behind the next full re-download. Now the final-probe path is bounded: a malformed final probe triggers **exactly one** re-download; if it's still malformed, the orchestrator **keeps the downloaded container on disk** (for inspection) and records a new terminal **`corrupt-full-source`** status — re-downloading a complete file can't change a deterministic verdict. This is distinct from `failed-corrupt-source` (a download that never finished, driven by the checkpoint rollback cap). Surfaced as a new **`corruptFullSource`** channel-snapshot bucket → a Download-stage summary line ("corrupt full source (file kept)"), an **amber download-dot** + `corrupt_full_source` row status in the per-channel video list (folded into the *No audio* filter), and a **"Corrupt full source — download completed but audio is malformed (file kept)"** badge on the video page. It's terminal and **not** retried (auto-runner treats it as skipped; the kept file is an artifact, so it isn't re-queued) and **not** counted as a failed transcription or a transcode-pending video. Cancellation is also fixed: a pending abort now wins over an in-flight rollback decision, and the loop re-checks the abort signal after the final probe so Cancel stops it promptly. See `common/ytdlp/audioCheckedDownload.ts`, `common/ytdlp/downloadOneManaged.ts`, `common/lib/downloadOutcome.ts`, `common/controller/{autoRunner.ts,channelSnapshot.ts}`, `common/ytdlp/runYtdlp.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/components/VideoPanel.tsx}`, and `editor/e2e/audio-check-scenarios.spec.ts`.
-- **The Deploy page is reworked around a clearer build/deploy lifecycle, with one-click build-then-deploy and batch multi-site builds.** The page now reads top-to-bottom as you'd actually ship: **Release notes** (the `## [Unreleased]` changelog preview + Cut release) → **Build & deploy** → optional **Individual steps** → **Build multiple sites**. A new **Build & deploy** button runs the build and, only if it succeeds (and wasn't cancelled), deploys it — as a single managed job with one combined streamed log and one Cancel (`buildAndDeployAction`, a composite `runManagedFunction`; cancelling mid-build skips the deploy). The new **Build multiple sites** panel kicks off a build (optionally build+deploy) for several sites at once, each rendered as its own live status-chipped log lane (`BuildSitesPanel` + `JobLane`); in Basic mode the jobs serialize on the shared build/deploy queue (the `export/` output tree is shared), with a note that true parallelism arrives with Docker mode. A **Build mode** toggle (Basic | Docker) on the page persists the choice as the default (`settings.buildPipeline`, also editable on Settings); Docker mode is a follow-up and currently falls back to a basic build with an inline notice. The build/deploy commands now share a child-streaming helper (`common/jobs/runChild.ts`) and mode-routing core (`editor/app/deploy/buildDeployCore.ts`). See `editor/app/deploy/{page.tsx,buildAction usage,components/*}`, `editor/app/build/buildAction.ts`, and `common/lib/settings.ts`.
-- **Truncated transcripts are now detected and flagged for re-download.** When an audio download silently stops early (yt-dlp exits `ok`, `download-outcome.json` records success), whisper transcribes only the few minutes that landed — so a 2h22m video ends up with a ~7-minute transcript and nothing warns you. A new coverage check (last cue end ÷ video duration) flags any non-livestream video ≥10min whose transcript covers <50% of its runtime. The single source of truth is `common/lib/transcriptCoverage.ts` (`transcriptCoverage` + `isIncompleteTranscript`, with named thresholds), read from each video's `transcript.cues.json` so the existing corpus is flagged with no migration. Surfaced everywhere: a new **`incompleteTranscript`** channel-snapshot bucket → an **"Incomplete transcript"** filter chip and an **amber transcribed-dot** in the per-channel video list; a warning banner on the video page ("Transcript covers 6:52 of 2:22:21 (4.8%)…") with a one-click **Re-download & re-transcribe** button; and an **"Channels with incomplete (truncated) transcripts"** section on `/actionable`. The fix action (`redownloadIncompleteTranscriptAction`) deletes the truncated audio first, then re-downloads and re-transcribes — re-running whisper alone would just reproduce the short transcript. See `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/{components/VideoPanel.tsx,videoActions.ts,page.tsx},page.tsx}`, and `editor/app/actionable/{lib/loadActionable.ts,page.tsx}`.
-- **Fix truncated transcripts in bulk — two buttons, in three places.** The per-video fix now has channel-wide and cross-channel counterparts, each offered as a **batch re-fix** (queues one job that removes the truncated audio → re-downloads → re-transcribes every flagged video in place; the transcript is never gapped) **and** a **clear & re-queue** (deletes the truncated audio + transcript so the videos drop back into the normal *undownloaded → needs-transcript* pipeline, then enables + starts the auto-download/auto-transcribe runners so they reprocess automatically). Both appear on the **`/actionable`** "incomplete transcripts" section — per-channel **Re-download & re-transcribe** / **Clear & re-queue** buttons (replacing the old "Review"-only link) plus a section-header **Re-fix all** / **Clear & re-queue all** that acts across every affected channel — and on the **channel page bulk bar** as two new Action options with a new **Select incomplete** quick-select. The clear path needs no archive pruning: `undownloadedIds` is derived purely from on-disk artifacts, and a single-video re-download isn't archive-gated. Destructive clears are confirm-gated everywhere; enabling the runners is disclosed in the confirm (note: the auto-queue policy must cover the channel for auto-reprocessing — cleared videos also surface in the existing "Download missing" / "Transcribe pending" sections as a fallback). New shared helper `editor/app/channels/[slug]/lib/fixIncompleteTranscript.ts` is the single source of truth for the per-video fix/clear, reused by the per-video action, the new `redownload-incomplete-bucket` batch job (bookmarkable; re-derives the live `incompleteTranscript` bucket), the bulk-bar wrappers, and the global actions. See `editor/app/channels/[slug]/{incompleteTranscriptActions.ts,bulkVideoActions.ts,components/VideoListPane.tsx}`, `editor/app/actionable/{actions.ts,page.tsx,components/{InlineActionButton.tsx,FixAllIncompleteButton.tsx}}`, `common/jobs/{jobKinds.ts,jobSpec.ts}`, `editor/app/jobs/jobReplayRegistry.ts`, and `editor/e2e/incomplete-transcript.spec.ts`.
-- **Auto-queue rules with no bucket now draw from *all* of a runner's buckets, and auto-download can resume partial downloads.** A policy-tree rule left at the **"all buckets (default)"** setting (previously just labeled *default*) now draws from the **union** of every bucket that runner kind tracks — deduped, in priority order — instead of only the single primary bucket. This fixes channels (e.g. an Odysee channel mid-download) that quietly stopped being auto-downloaded once their remaining work drifted entirely into **partially-downloaded** videos: those have a `.part` file but no completed audio, so they live in the `partialDownloads` bucket and were **absent from `undownloadedIds`** — the only bucket auto-download used to load. The download runner now loads `partialDownloads` alongside `undownloadedIds` (partials first, so in-progress downloads resume via `downloadOneManaged` before fresh ones start), and exposes `partialDownloads` as a selectable bucket in the policy editor so you can dedicate a high-priority rule to resuming partials. The per-kind bucket lists are consolidated behind a single `bucketsForKind` source of truth shared by the runner, the per-rule pending-count helper, and the editor's bucket picker (so they can't drift). Note: a *bucketless* auto-transcribe rule now also drains `failedListed` after `downloadedNoTranscript` (it already loaded both); platform rate-limit backoff is unchanged and remains an independent reason a throttled platform may pause. See `common/jobs/autoQueuePolicy.ts` (`buildPendingByLeaf` + `bucketsForKind` + unit tests), `common/controller/autoRunner.ts`, and `editor/app/auto-queue/{page.tsx,components/PolicyTreeEditor.tsx}`.
-- **Hub homepage redesigned into a cross-site landing; the homepage page-creator is removed.** The hub's home page is now a single mobile-first cross-site landing (headline KPIs and one stacked activity chart with Metric [Transcribed/Downloaded] · Breakdown [By site/By channel] · Bucket [Week/Month/Cumulative] · Range [90d/12mo/All] · Display [Share/Counts] controls, plus a metric-aware site-links grid with sparklines and a "#1 this month" badge), built from a small `homepage-summary.json` pre-computed by `compose-homepage`. The separate `/stats` dashboard route folds into it. Consequently the hub's **Markdown-pages subsystem is dropped**: **Manage → Homepage** now edits only branding (the Pages list, New-page, and the page editor are gone), and the homepage config no longer carries a `nav`. The page server actions (`saveHomepagePageAction`/`deleteHomepagePageAction`), `editor/app/homepage/pages/*`, `PageEditor.tsx`, `common/lib/{homepagePages,homepageConstants}.ts`, and `paths.homepagePagesDir` are removed. See `editor/app/homepage/{page.tsx,actions.ts}`, `common/bin/compose-homepage.ts`, `common/lib/{homepageSummary,homepageChart}.ts`, and the `homepage/` package. (Re-addable later if needed.)
-- **One-click Retry for failed jobs (plus "Retry all failed").** A failed job that carries a replay descriptor (any bookmarkable kind — sync, download-missing, transcribe-all, retry-bucket, …) now shows a **Retry** button on the Jobs history table, and the page header gains a **Retry all failed** button whenever at least one such job is listed. Retry re-runs the job from its stored spec exactly like a bookmark re-run (so bucket jobs re-derive from the channel's *current* state), and the re-run **jumps ahead of other queued work** (it's promoted to the front of its queue, reusing the new reorder machinery) so a fix-and-retry runs next rather than at the back of the line. The spec is resolved from the live registry or, for an evicted/archived job, from its on-disk `<id>.meta.json` sidecar — so even a failure the 100-job cap has dropped is still retryable. Kinds with no replay descriptor (e.g. `import-one`) intentionally offer no Retry. See `editor/app/jobs/actions.ts` (`retryJobAction` / `retryAllFailedAction`), the new `RetryJobButton` / `RetryAllFailedButton`, and `editor/e2e/jobs-retry.spec.ts`.
-- **Reorder and promote queued jobs from Active Jobs.** A queued job's row now carries **Promote / ↑ / ↓** controls (mirroring the auto-queue policy editor's move buttons) to change its order within its queue — Promote sends it to the front so it runs next, ↑/↓ nudge it one slot. Only actionable moves render (the first-queued job shows no up/promote, the last no down), and a running job is never displaced. See `editor/app/jobs/components/ReorderJobButtons.tsx`, the `reorderJobAction` / `promoteJobAction` server actions, and `editor/e2e/jobs-reorder.spec.ts`.
-- **Job-queue internals unified onto one scheduler + one concurrency primitive (foundational refactor; no behavior change beyond the two features above).** The "foreground preempts background" priority was previously implemented twice (once for job queue ordering, once for worker-slot waiters); both now share a single `compareTier` comparator in a new `common/jobs/scheduler.ts` (priority tiers urgent/foreground/background, per-queue concurrency, and the reorder/promote operations the UI uses), which the registry delegates its queue ordering to. The auto-transcribe/-download runner's hand-written fill-to-capacity loop **and** the whisper batch's `Promise.all` are both replaced by one audited `runPool()` primitive (`common/jobs/concurrentRunner.ts`) that encodes the no-event-loop-spin wait once — structurally eliminating the "Drain all hangs" bug class rather than patching each loop. Per-kind metadata (label, drainability, bookmarkability) is consolidated into one `common/jobs/jobKinds.ts` table, and the bookmark/replay dispatch is now a data-driven lookup, so adding a job kind touches ~1 file instead of ~5. Covered by new unit tests (`scheduler`, `concurrentRunner`, `jobKinds`) and the existing queue/drain e2e suites.
-- **"Drain all" no longer hangs the server when an auto-transcribe/-download unit is actively running.** A second, distinct drain hang remained after the earlier parked-unit fix: the runner's internal `waitNext()` helper short-circuited to an *immediately-resolved* promise whenever a signal was *already* aborted — so once a soft drain fired (and `drainSignal` stays aborted for the rest of the run), every wait while in-flight units were still finishing returned with no delay. That turned the runner's poll loops into a timer-less **microtask spin** that starved the Node event loop (the spin was reached first in the main fill loop's at-capacity wait once the drain target dropped to 0, before the terminal drain-wait was ever hit). A transcription that drain deliberately lets finish completes via a child-process `exit` event — a *macrotask* — which the spin never let run, so the in-flight count never reached zero, a CPU core pegged, and the whole app appeared frozen. (The earlier fix only covered *parked* units, which settle via microtasks and so cleared even under the spin; a genuinely *running* unit depends on a macrotask and didn't.) `waitNext()` now only fast-paths a real pending `wake()`; an already-aborted signal falls through to a real timer, so all three wait sites pace instead of spinning while still being woken promptly by a finishing unit or by an abort firing mid-wait. The `whisper-all` batch was never affected (it `await Promise.all(...)` with no manual poll loop). See `common/controller/autoRunner.ts` (`waitNext`) and the new running-unit drain regression test in `editor/e2e/auto-queue.spec.ts`.
-- **First-class video-persistence UI (phase 5, the final phase): a Saved Videos area, per-channel retention controls, and per-video persist/unpersist.** The video-persistence subsystem built up over phases 1–4 is now driveable end to end from the editor. A new top-level **Saved videos** page (`/saved-videos`, in the Pool nav) summarizes the whole saved-video store — total count and size, per-channel breakdown (count, size, how many carry a backup checksum), the default store location, and the last backup time — and hosts the **backup configuration** (destination, scheduled on/off, interval) plus **Back up now** / **Verify backup** buttons. Each channel's **Cleanup stage** gains a **Retention & persistence** section (shown whenever keep-latest is on or the channel has saved videos) with live counts and three buttons: **Check kept videos** (re-probe the window for deleted-from-source videos and pin them), **Persist kept now** (a new bulk catch-up pass that re-fetches the source container for any in-window video whose source isn't saved yet — `persistKeptAction` / `common/controller/persistKept.ts`), and **Back up saved videos**. The **channel settings form** adds a Retention & persistence section: **keep latest** (window size), **extraction mode** (yt-dlp vs app-side ffmpeg), and a per-channel **saved-video store dir** override. Each **video page** gains a **Source video** card showing persisted status (file, size, stored time, keep reason, sha256, location) with an **Unpersist** control that moves the container back into the data dir, or a **Persist source video** button (re-fetch + archive) when it isn't saved yet. New job-kind label for `persist-kept`; `persist-kept` is re-runnable from bookmarks. The saved-video actions moved from `editor/app/savedVideos/` to `editor/app/saved-videos/` to match the route. Covered by `editor/e2e/saved-videos.spec.ts` and `common/controller/persistKept.test.ts`. (Deferred: surfacing kept-check/persist as Actionable-page rows, and streaming the player directly from the store — unpersist brings the container back to the data dir to play it.)
-- **Backups for the saved-video store: rsync mirror + per-backup manifest + drift verification (phase 4 of the video-persistence subsystem; backend + scheduler, UI lands later).** The (large, often irreplaceable) saved source videos can now be **backed up to a configured destination**. A backup walks every saved-video pointer across all channels (so per-channel store overrides are covered automatically) and **`rsync`-mirrors each container** into `<dest>/<slug>/<videoId>/` — incremental and resumable (`-a --partial`), additive (no deletes), so re-running only transfers changed or new files. It writes a **`backup-manifest.json`** at the destination root recording each container's canonical location, byte size, and a **streamed sha256**, and caches that hash back onto the live pointer. A **verify** step reads the manifest back and reports drift in four buckets — `missing`, `sizeMismatch`, `checksumMismatch` (re-hashing each present file), and `extra` (containers at the destination the manifest doesn't know about). New global settings block **`savedVideoBackup`** (`{ enabled, dest, intervalMinutes }`; a blank `dest` forces `enabled` off) plus a **`RSYNC_BIN`** env override. When enabled with a destination, the **sync scheduler** runs the backup automatically on its own cadence (a global, not per-channel, job — suppressed during quiet hours, tracked via `lastSavedVideoBackupAt`). Backups can also be run/verified manually via `backupSavedVideosAction` / `verifySavedVideoBackupAction` (managed jobs on a dedicated `saved-videos` queue). The destination is treated as a local filesystem path (a mounted backup disk). See the new `common/lib/savedVideoBackup.ts` (manifest types/parse), `common/controller/{backupSavedVideos,savedVideoInventory}.ts` (+ tests), `common/lib/paths.ts` (`rsyncBin`), `common/lib/settings.ts` (`savedVideoBackup`), `common/jobs/syncSchedulerState.ts`, `editor/app/savedVideos/backupActions.ts`, and `editor/app/scheduler/runTick.ts`.
-- **Saved-video store: persisted source videos move to a separate dir/disk, with retention pruning (phase 3 of the video-persistence subsystem; backend, UI lands later).** When the per-download persistence rule (phase 2) keeps a source video, the downloaded container is now **moved out of the per-video data dir into a separate saved-video store** — leaving only a small `saved-video.json` pointer behind — so the main data volume holds just audio + transcripts while the (large) source videos can live on another disk. The store root defaults to `<transcripts>/saved-videos`, is overridable globally via the **`SAVED_VIDEOS_DIR`** env var, and can be further overridden **per channel** (`savedVideosDir` in `config.json`); a video's container lands under `<root>/<slug>/<videoId>/`. The move is **cross-device-safe** (rename within a disk, copy-to-temp + atomic rename + unlink across disks) and **best-effort** — a failed move leaves the container in the data dir as `source-media.<ext>` (still persisted, just not relocated) rather than failing the download. **Transcription resolves from the store**: when no extracted `audio.*` exists, the transcribe fallback follows the pointer to the stored container (returned as a path relative to the video dir so both the local engine and the remote uploader read it correctly), so a kept-but-cleaned or archive-only video still transcribes. **Retention pruning** (the phase-2 follow-up) now bounds the store: the Clean-audio sweep also evicts any *keep-latest* container that has rolled out of the window — but **never** a manually-archived (`override`) or pinned/irreplaceable (`pin`/do-not-clean) one, distinguished by a `keepReason` recorded on each pointer. Reversible helpers ship for the upcoming UI: `unpersistSavedVideo` (move the container back) and `dropSavedVideo` (delete it). A reusable `checkDiskSpaceFor(dir, …)` lands so disk gating can target the store filesystem (used by the UI/backup phases). Note: source-video persistence is still skipped for audio-check channels (deferred), and saved-store counts aren't yet surfaced in the channel snapshot (lands with the phase-5 UI). See the new `common/lib/savedVideo.ts` (+ `savedVideo-server.ts` + tests), `common/controller/pruneSavedVideos.ts` (+ tests), `common/lib/paths.ts` (`savedVideosDir`), `common/lib/channelConfig.ts` (`savedVideosDir`), `common/lib/diskSpace.ts`, `common/ytdlp/{persistencePlan,downloadOneManaged}.ts`, `common/controller/{transcribeOne,cleanAudioFromTranscribed}.ts`.
-- **Per-download persistence rule + app-side audio extraction (phase 2 of the video-persistence subsystem; backend, UI lands later).** Each individual download now consults the channel's keep-latest rule (plus any per-run overrides) to decide *what to keep*: a video inside the keep-latest window — or one carrying a `do-not-clean` pin — downloads its **full source video** (`bestvideo*+bestaudio/best`) and the app extracts `audio.<fmt>` from it with ffmpeg, keeping the container as `source-media.<ext>` (a deliberately distinct name from `audio.<ext>` so it's never mistaken for cleanable audio); everything else stays audio-only as before. Crucially the keep decision is made per video by its **upload date against the channel's Nth-newest cutoff** (computed once per run via the new `computeKeepWindow`/`isInKeepWindow`), so the newest videos — which aren't on disk yet at download time — are correctly persisted. A new **`extractionMode`** channel setting (`"ytdlp"` default | `"app"`) selects who extracts audio for audio-only downloads; persisting always forces app-side extraction. **Per-run overrides** thread through `download-missing` (and the shared managed-download path): `keepSourceVideoOverride` (force keep/discard), `extractImmediately` (extract now + discard the container even on a keep channel — the disk-saving backfill case), and `audioFormatOverride`. **Transcription falls back to the source container** when no extracted `audio.*` exists (parakeet ffmpeg-slices any container), so a kept-but-cleaned video or an archive-only download is still transcribable. New per-video **"Archive source video"** action (`redownloadToArchiveAction`) re-fetches an existing video purely to grab + keep its source container without disturbing the transcript. Legacy `"ytdlp"`-mode downloads produce byte-identical yt-dlp args to before (no behavior change for existing channels). Note: persisted source containers currently remain in the data dir and are not auto-pruned when they roll out of the window, and source-video persistence is skipped (with a log note) for audio-check channels — both addressed by the saved-video store in phase 3. See `common/ytdlp/persistencePlan.ts` (+ tests), `common/controller/keptVideos.ts` (keep-window), `common/ytdlp/downloadOneManaged.ts`, `common/ytdlp/runYtdlp.ts`, `common/controller/transcribeOne.ts`, `common/lib/videoStatus.ts` (`source-media`/`isVideoContainer`), and `editor/app/channels/[slug]/pipelineActions.ts` / `videos/[id]/videoActions.ts`.
-- **New per-channel "keep latest N" retention rule that protects recent videos from the Clean-audio sweep and pins any that get deleted from their source (backend; UI lands in a later change).** A channel can set `keepLatest` (in `config.json` for now) to shield its newest N videos — by upload date, a rolling window — from the **Clean audio** cleanup: those dirs are skipped just like a `do-not-clean` marker, and the snapshot's reclaim estimates/cleanup buckets exclude them (a new `keptCount` is recorded). Because a kept video can later be **deleted from its source** (YouTube etc.) and become irreplaceable, a new **kept-deletion check** re-probes just the kept window's availability (reusing `runAvailabilityCheck` with `onlyIds` + `recheck-non-deleted`) and **permanently pins** any video found `deleted`/`private`/`members_only` with a `do-not-clean` marker, so it survives even after it rolls out of the window. The check runs on demand via the new `check-kept-deleted` managed job (`checkKeptDeletedAction`, re-runnable/bookmarkable) and automatically from the sync scheduler on its own cadence (new `syncScheduler.keepLatestCheckIntervalMinutes`, default daily; per-channel `lastKeptCheckAt` state; suppressed during quiet hours, capped per tick, and skipped for a channel just synced this tick). This is phase 1 of a larger **video-persistence** subsystem (per-download persistence rules, a separate saved-video store, and backups follow). See `common/lib/channelConfig.ts` (`keepLatest`), the new `common/controller/keptVideos.ts` (`computeKeptVideoIds` + tests) and `common/controller/checkKeptDeleted.ts`, `common/controller/cleanAudioFromTranscribed.ts`, `common/controller/channelSnapshot.ts`, and the scheduler wiring in `common/lib/settings.ts`, `common/jobs/syncSchedulerState.ts`, and `editor/app/scheduler/runTick.ts`.
-- **The monitor widget can now carry optional control buttons (Pause/Resume Transcriptions, Drain all).** The `/widget` view stays read-only by default, but a new **Show control buttons** option in the widget builder (URL flag `controls=1`) adds an interactive row at the top with the same **Pause Transcriptions** toggle (between-segment GPU release) and **Drain all** as the full app — so a pinned/iframe monitor can pause the GPU or wind work down without opening the editor. The controls stay visible even when the widget is otherwise idle (so you can pause preemptively), and the widget refetches its worker payload on a pause/resume so the toggle flips immediately instead of waiting for the next poll. Defaults keep the widget control-free, so existing links render unchanged. See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetControls.tsx`, `editor/app/widget/components/MonitorWidget.tsx`, and `editor/app/widget/builder/components/WidgetBuilder.tsx`.
-- **"Pause Transcriptions" now frees the GPU between parakeet segments instead of running the in-flight video to completion.** The global pause control (renamed from "Pause all" → **Pause Transcriptions** / **Resume Transcriptions**) used to only stop handing out new worker slots — any in-flight transcription kept running until its whole file was done, so the GPU stayed busy. Pausing now *also* sends a graceful between-segment stop to any partial-capable in-flight job: a **parakeet** worker finishes the current window, caches it (`win-NNNN.json`), and exits `paused` (a skip, not a failure), so the GPU frees within one segment and the video resumes from its cached windows on the next run — the same mechanism as the per-worker **Stop & keep progress** button, now wired into the global pause. Non-parakeet engines (whisper-cpp, chough) keep prior behavior: they stop taking new work but run their in-flight file to completion. The button is also surfaced on the **Active Jobs** screen (`/jobs/active`) next to **Drain all**, not just the Workers page. `resumeAll()` restores each worker's pre-pause state as before. See `common/jobs/workerPool.ts` (`pauseAll`), the new `editor/app/jobs/components/PauseTranscriptionsButton.tsx` (shared by both screens), `editor/app/workers/components/WorkersView.tsx`, and `editor/app/jobs/active/page.tsx`.
-- **"Drain all" (and the per-runner Drain) no longer hangs the auto-transcribe runner.** Draining could leave the runner stuck "draining" forever — appearing to freeze the app — whenever one of its per-video transcription units was *parked* in the worker pool waiting for a free slot at the moment drain fired (much more likely now that auto units yield slots to manual transcriptions). The runner forwarded only its hard-cancel signal — never the soft `drainSignal` — to a parked unit's `pool.acquire()`, so a soft drain could never unblock it: the unit's promise never settled, the runner's in-flight count never reached zero, and its drain loop spun indefinitely (hard **Cancel**/**Stop** always worked, since that signal *was* forwarded). The runner now threads `ctx.drainSignal` into each transcription unit, so a parked (not-yet-started) unit unblocks and is skipped on drain while a unit already transcribing finishes normally — correct drain semantics, and the runner finalizes promptly. See `common/controller/autoRunner.ts` (the `launchUnit` drainSignal wiring) and the new parked-unit drain regression test in `editor/e2e/auto-queue.spec.ts`.
-- **Manual transcriptions now preempt auto-queued ones — click "Transcribe missing" (or any non-auto transcribe) while auto-transcribe is running and yours goes next.** The auto-transcribe runner shares the global transcription **worker pool** with every manual transcribe (channel batch, bucket retry, single-video), so they already serialized — but the pool granted freed worker slots in plain arrival order, so a manual transcribe could wait behind the runner's next auto pick. The pool's waiter queue is now **priority-ordered**: a manual (foreground) acquire is served before any parked auto (background) one, with FIFO preserved within each class — the same foreground-vs-background priority the per-platform sync queue already uses, now applied to the pool too. The auto-runner acquires its per-video transcription slots at **background priority**, so a manual transcribe jumps ahead: the in-flight auto transcription is **not interrupted** (it finishes — "drains"), then every freed worker goes to the manual work until it's exhausted, then auto resumes. Worker N-way parallelism is untouched (only *parked* waiters are reordered). A future "auto-sync queues its own transcriptions" path gets the same yield for free by acquiring as background. See `common/jobs/workerPool.ts` (waiter priority), `common/controller/transcribeOne.ts`, `common/controller/transcribeOneFromQueue.ts`, and `common/controller/autoRunner.ts`.
-- **Auto-download now shares the same per-platform queue as a manual Sync, so you can click Sync while auto-download is running without risking a 429.** Each auto-download video is now launched as a real job on the channel's platform queue (e.g. `platform:youtube`) — the same queue Sync uses — instead of running out-of-band. The job registry serializes them, so the two never spawn yt-dlp against one platform at the same time (in either direction), and each auto-download unit appears as its own row in Active Jobs with progress. A manually clicked Sync **preempts** the runner's *queued* auto-download units on that platform (it doesn't interrupt one already downloading), and a Sync is **refused with a clear message** (showing remaining seconds) while that platform is in a rate-limit cooldown. A 429 hit by either path now records the shared cooldown, so manual and automatic downloads back off together. See `common/jobs/registry.ts` (queue priority), `common/controller/autoRunner.ts`, `common/jobs/downloadBackoff.ts`, and `common/jobs/streamCommand.ts`.
-- **Auto-queue downloads now back off per-platform on rate limits instead of hammering the source.** When a managed auto-download hits an HTTP 429 / "too many requests" or a network error (most often on Odysee), the runner pauses *that platform* for an exponential cooldown (1 min, doubling up to 30 min, with jitter) while other platforms keep flowing, and the affected video is retried after the cooldown rather than being burned as a false success. Previously the runner discarded the download outcome, counted the rate-limited video as done, and immediately re-hit the same platform — so it never actually downloaded and re-stormed the source on every restart. Failures are now classified against the full yt-dlp stderr (not just the last few lines), so a 429 that yt-dlp logs as a WARNING before failing with a different final error still triggers the backoff. Cooldowns persist across restarts (`.auto-queue` state), and a successful download clears the platform's backoff. To stop a runner immediately, use Cancel in Active Jobs (Drain still waits for the in-flight video to finish, by design). See `common/jobs/platformBackoff.ts` and `common/controller/autoRunner.ts`.
-- **Per-video progress bar now advances for subtitle-only (YouTube-handling) downloads.** YouTube-handling channels fetch only subtitles (`--skip-download`), which yt-dlp reports with no byte total — so the Active Jobs progress bar sat empty and jumped straight to 100%. It now steps once per subtitle track (e.g. `subs 1/2`) using the track list yt-dlp announces, while real media downloads (Odysee/transcribe) keep their byte-based bar. See `createDownloadProgressParser` in `common/jobs/progressParsers.ts`.
-- **New Archilyzer homepage (hub): a standalone marketing/docs site, configurable here.** A fourth workspace package, `homepage`, builds a single instance-level static site (`output: "export"`) that sits above the per-content export sites — for info/docs pages and cross-site charts that don't belong on any one content site. It's managed from the new **Manage → Homepage** page: edit branding (title/header/description/tagline/public URL/Cloudflare project) and author **Markdown pages** (a slug + nav label + body, with a live markdown-to-jsx preview), which the hub renders server-side at build (the `index` page is the home body; every other slug gets a `/<slug>` route). Page content lives in the data dir (`sites/_homepage/`, a reserved id `listSiteIds()` ignores), so copy changes need no code deploy. The hub's **/stats** dashboard charts downloads/transcriptions completed across **all** content sites, leading with a per-site breakdown — the chart engine gains a **Site** grouping option (`groupBy: "site"`) that fans each video out to every site exposing its channel, resolved through a channel→sites map the hub supplies; combined whole-pool totals remain one series. Build with `pnpm build:homepage` (a `compose-homepage` step stages whole-pool stats + the channel→sites map ahead of `next build`). See `common/lib/{homepage,homepagePages}.ts`, `common/bin/compose-homepage.ts`, `common/components/charts/channelSites.tsx`, the `homepage/` package, and `editor/app/homepage/*`.
-- **"Cut release" can now create the release commit for you.** After turning `## [Unreleased]` into a dated semver heading, cutting a release used to leave the changelog edit sitting in your working tree to `git commit` by hand. A **Commit changelog** checkbox now sits next to the **Cut release** button (on `/changelog` for the editor and `/deploy` for the export), checked by default — leave it on and the cut is followed by a path-limited `git commit` of just that one CHANGELOG.md, with the message `Release <workspace> <version>` (e.g. `Release export 0.4.1`). To keep the release commit clean it commits *only* the changelog: if the working tree has any *other* uncommitted change, the cut is refused up front (nothing is written) with an error telling you to commit or stash those first — a dirty changelog itself is fine, so uncommitted `[Unreleased]` bullets get folded into the release commit. Uncheck the box to cut without committing, exactly as before. See `editor/app/deploy/cutReleaseAction.ts`, `editor/app/deploy/components/CutReleaseForm.tsx`, and the new `common/lib/git.ts`.
-- **"Stop & keep progress" no longer mislabels the paused video as a failed transcription.** Using **Stop & keep progress** on a busy parakeet worker (or any partial-capable engine) sends the engine a graceful SIGTERM so it stops after the current window and the video resumes next run. But if the engine took longer than execa's 5-second force-kill window to exit — which a parakeet window routinely does, since finishing/stitching one ~480s window outlasts 5s — execa force-SIGKILLed it and the resulting "Command was killed with SIGTERM … forcefully terminated after 5000 milliseconds" error escaped the pause handling: it was treated as a genuine transcription failure and the video was written to the channel's `failed-transcriptions` file *permanently* (so even though its completed windows were cached for resume, it was skipped as "failed" on every later run). The transcribe path now recognizes that a force-killed **requested pause** is still a pause, not a failure — it returns the `paused` outcome (a skip, not a failure), so nothing lands in `failed-transcriptions` and the next "Transcribe missing" resumes it from the cached windows. Hard **Cancel** and **Drain** were never affected (their abort signal already classifies the kill as a skip). A video wrongly blacklisted by the old behavior won't auto-prune (it has real audio) — clear it with the channel's **Clear failed transcriptions** action to retry. See `common/controller/transcribeOne.ts`.
-- **New Auto-queue: automatically transcribe (and download) across all channels by a configurable priority policy, instead of running one channel batch at a time.** Previously the only way to process pending work was to manually fire a per-channel batch (e.g. *Transcribe missing* on one channel), and since every transcription job serialized on a single queue, a batch ran to completion before any other channel got a turn — there was no way to say "do cornbreadman first, then fall back to hasanabi." The new **Auto-queue** page (under Pool → Auto-queue) adds two always-on runners, **auto-transcribe** and **auto-download**, each driven by a **policy tree**: order rules top-to-bottom for **strict** priority, or wrap rules in a group set to **round-robin** or **weighted-fair** (smooth weighted round-robin) to *alternate* between rulesets. A rule (leaf) matches a **channel**, a whole **platform**, or **all** channels, optionally narrowed to a snapshot **bucket** (e.g. prioritize `failedListed` retries over fresh `downloadedNoTranscript`), and any rule or group can carry a **max-workers** cap (a saturated subtree falls through to the next-priority sibling, like an HTB ceil). The highest-priority channel with available work claims the **next freed worker slot** — non-destructive, so a higher-priority video never kills an in-flight transcription, it just wins the next slot; when a channel's work runs out the runner falls back automatically. Transcription concurrency is bounded by the worker pool's eligible slots (so policy decides *which* video runs, the pool decides *how many*); downloads have no pool, so the runner gates to **one download per platform at a time**, matching the per-platform serial queue's politeness. Each runner is a real, drainable/cancellable job (visible on the Jobs pages), and the Auto-queue page shows live per-rule pending counts and a recent-pick log. Independent of the sync **Schedule** (which only decides *when* to re-fetch a channel) — manual batches keep working alongside it. Policies live in `settings.json` under `autoQueue` (defensively sanitized like `syncScheduler`); fairness cursors persist in `transcripts/.auto-queue/state.json`. See `common/jobs/autoQueuePolicy.ts` (pure selection engine + unit tests), `common/controller/autoRunner.ts`, `common/controller/transcribeOneFromQueue.ts` (shared per-video gating, also used by the existing whisper batch), and `editor/app/auto-queue/*`.
- - **Auto-queue refinements:** (1) the auto-transcribe runner now honors disabled workers — it no longer used CPU workers you'd turned off via Workers → *Set as default*. The runner started at boot and called the worker pool's `reconfigure()` before anything triggered the pool's lazy init, which set the pool's `initialized` flag *without* applying the saved `.worker-defaults.json` arrangement, so disabled workers came back enabled. The runner no longer pre-empts that init (it relies on the pool's own first-use init, which applies both settings and the saved default). (2) The runners no longer show up under a generic **"Other"** group on the **Active Jobs** screen — channel-less jobs are now grouped into their own labeled sections ("Auto-transcribe" / "Auto-download") via a shared `jobKindLabel` map (also used for friendlier kind labels in the running-jobs list), and each in-flight item is labeled `channel/videoId` so you can see which channel it's on. See `common/controller/autoRunner.ts`, `editor/app/jobs/jobKindLabels.ts`, and `editor/app/jobs/components/ActiveJobsLive.tsx`. (3) The Auto-queue page now has a **Drain** control (graceful stop: finish in-flight items, start no new ones, then stop) alongside **Stop** (hard stop, aborts in-flight); both the **Drain** and **Cancel** buttons on the **Active Jobs** screen also act on the runner jobs.
-- **Charts can now track content *added to the sites* over time, not just when creators uploaded it.** The chart engine previously only binned the time axis on a video's **upload date**. Two acquisition dates are now recorded per video — when *we* downloaded it and when *we* transcribed it — and the chart editor's X-axis gains a **Date field** selector (Uploaded / Downloaded / Transcribed) alongside the bin. A new **Content added** preset group ships three ready charts (cumulative *Library growth (added)*, cumulative *Transcribed over time*, and *Added per month* stacked by channel), and the default dashboard now includes the cumulative library-growth-by-acquisition chart so the progress view is present out of the box. Everything reuses the existing charts UI, so per-channel filtering, cumulative curves, CSV/PNG export, and shareable URLs all work unchanged. Acquisition dates come from the per-video `download-outcome.json` (`finishedAt`) and a new `transcribe-outcome.json` sidecar written when a transcript is finalized; the stats build falls back to file mtimes for content added before the sidecars existed. Requires a one-time data rebuild (`build:index` + `build:stats`) on the bumped `STATS_SCHEMA_VERSION`. See `common/lib/{stats,chartConfig,chartAggregate,chartShare,transcribeOutcome}.ts`, `common/controller/{buildStats,transcribeOne}.ts`, and `common/components/charts/ChartConfigEditor.tsx`.
-- **The Schedule page is now a one-stop editor for per-channel sync cadence.** The `/scheduler` page used to be read-only — you could see each channel's interval, last sync, and next-due time, but to *change* a cadence you had to open that channel's editor (Source → Auto-sync), one channel at a time, and the headline global toggles lived only in Settings. Now each row's **Interval** cell is an inline editor: pick a preset (Default / Off / Every 10–30m / Hourly / 6h / 12h / Daily / Weekly) **or** choose **Custom (minutes)…** and type an exact minute count, then **Save** — writing just `syncIntervalMinutes` to that channel's `config.json` and leaving every other field untouched (it does *not* go through the full channel-form merge). The page also gained a **Global controls** block to toggle the master **enable**, the **default interval**, and the **internal heartbeat** right there (the advanced knobs — concurrency, quiet hours, backoff — still link out to Settings). The underlying due logic is unchanged: a channel auto-syncs on the next heartbeat once `now − lastSyncedAt ≥ its interval`. The live status columns keep polling every 5s, but each row's editor holds its own state seeded once from the stored value, so a refresh can't clobber an in-progress edit. The preset list is shared with the channel editor (`editor/app/scheduler/intervalPresets.ts`), and `GET /api/scheduler/status` now carries each channel's raw `configuredIntervalMinutes` so the editor can tell *inherit-default* from *explicit-off* from *explicit-minutes*. See `editor/app/scheduler/actions.ts` and `editor/app/scheduler/components/{ChannelIntervalEditor,SchedulerSettingsForm}.tsx`.
-- **Scheduled sync can now run without an external cron job.** The sync scheduler previously only fired when an OS cron entry POSTed to `/api/scheduler/tick` (via `pnpm sync:tick`) — fine on a server, but a chore to set up just to call a function the editor already hosts in-process. The editor can now drive its own heartbeat through a **Next.js instrumentation hook** (`editor/instrumentation.ts`): on server startup it arms a single in-process timer that calls `runSchedulerTick()` directly — no HTTP, no cron, no token. Turn it on with **Settings → Sync scheduler → Internal heartbeat (seconds)**: `0` = off (keep using an external cron heartbeat), any positive value is clamped to `[15, 3600]`s and is the cadence the editor ticks itself at; the `SYNC_HEARTBEAT_SECONDS` env var overrides the setting at runtime. The timer is a self-rescheduling, `unref`'d `setTimeout` loop (so it never holds the process open and never overlaps a tick), re-reading the cadence each fire so a change takes effect on the next tick — though turning it on *from 0* needs a restart, since the timer is armed once at boot. It's modeled on the existing snapshot-scheduler timer and reuses the already-overlap-guarded `runSchedulerTick()`, so internal and external heartbeats are interchangeable and may even coexist. The **Schedule** page header now reports how ticks are driven ("internal heartbeat every N" vs. "external heartbeat (cron)"), and `GET /api/scheduler/status` carries the effective `heartbeatSeconds`. Defaults to off, so dev/test and existing cron installs are unchanged. One caveat for multi-instance deployments: the timer runs once *per server instance*, so a cluster against one data dir should set `SYNC_HEARTBEAT_SECONDS=0` on all but one — see `SCHEDULED_SYNC.md`.
-- **The channel video-list filters are now combinable (intersection), with a new Partial filter.** The filter chips above a channel's video list used to be single-select — clicking one replaced the last — and there was no way to filter for partial downloads at all. Each chip is now an independent toggle, and selecting several narrows to videos matching **all** of them (an intersection). A new **Partial** chip surfaces videos with a leftover `.part` download. The headline use: **Transcribed + Partial** finds videos that are already transcribed but still carry an orphaned `audio.<ext>.part` (e.g. cornbreadman's, where the transcript is done but the partial lingers as cruft) — previously unreachable because a video's status is a single mutually-exclusive value where `partial_download` outranks `transcribed`. To make combinations meaningful the **Transcribed** and **Partial** chips now key off the row's independent `transcribed` / `partial` flags rather than that status enum, so "Transcribed" alone now also includes transcribed videos that happen to carry a `.part` or a failed-transcoding marker (they *are* transcribed). The active set is mirrored to the URL as a comma-joined `?filter=a,b` via `history.replaceState` (so reload/share preserves it without an RSC refetch per toggle, and without racing the global auto-refresh), and the **All** chip clears the selection. See `editor/app/channels/[slug]/lib/videoRows.ts` and `editor/app/channels/[slug]/components/VideoListPane.tsx`.
-- **Bookmarks can be reordered on the management page, and the order carries to the compact menu.** Both the `/jobs/bookmarks` management list and the compact one-click menu atop `/jobs` and `/jobs/active` render bookmarks in stored order, but there was no way to change it — new bookmarks just landed on top. Each row on the management page now has **↑ / ↓** buttons that move the bookmark one slot (disabled at the ends), persisting the new order immediately to `transcripts/.bookmarks/bookmarks.json`. Because both views read the same array in order, reordering on the management page is reflected in the compact quick-run menu too, so you can put your most-used job first. See `moveBookmark` in `common/jobs/bookmarks.ts`, `moveBookmarkAction` in `editor/app/jobs/bookmarkActions.ts`, and `editor/app/jobs/components/BookmarksList.tsx`.
-- **"Retry partial downloads" no longer skips partials that carry an audio-check snapshot.** A transcribe channel's retry-bucket prefilter decided a video was "already complete" by looking for any `audio.*` file not ending in `.part` — which wrongly matched the audio-integrity snapshots (`audio.<ext>.part.good` / `.part.testing`) and sidecars (`audio.info.json`, `audio.live_chat.json`, `audio.*.tmp-*`) left in a partial video's dir. So a genuine `audio.<ext>.part` that happened to sit next to a `.part.good` snapshot got prefiltered out (`Prefilter: 0 missing destination files, N already complete` → `Nothing to fetch`), even though that same snapshot is excluded when the video is placed in the **Partial downloads** bucket. The prefilter (`destinationExists`) now reuses the same `isRealAudioFile` predicate the bucket uses, so the two agree and genuine partials resume. See `common/ytdlp/runYtdlp.ts` and `common/lib/videoStatus.ts`.
-- **One-click "Drain all" to spin work down before a server restart.** The `/jobs/active` header gained a **Drain all** button that, in a single confirmed action, **drains every running job** (lets in-flight sub-operations finish, starts no new ones) and **cancels every queued job** — so you can wind the queue down gracefully before restarting the server instead of draining/cancelling each job row by hand. It reuses the existing per-job drain path (`requestDrain` drains running jobs and cancels queued ones), so semantics match the per-row **Drain**/**Cancel** buttons exactly; non-drainable running kinds are marked draining and finish on their own. A confirm prompt guards the bulk action. See `editor/app/jobs/components/DrainAllButton.tsx` and `drainAllAction` in `editor/app/jobs/actions.ts`.
-- **Queued jobs now have a Cancel button right on the row.** On `/jobs/active`, a queued (not-yet-started) job previously offered no inline way to cancel it — you had to expand its log to find a control. Each active row now shows a **Cancel** button directly (queued *and* running), so you can drop a queued job without opening anything. The running-only **Drain** button is unchanged. See `editor/app/jobs/components/RunningJobsList.tsx`.
-- **The Bookmarks panel is now a compact one-click run menu, with management moved to its own page.** The bookmarks block atop `/jobs` and `/jobs/active` used to render a heavy multi-row card per bookmark — inline rename, tags, created/last-run metadata, a confirm-gated delete, and a full streaming **Run again** button that popped a tall inline log box on every run — which buried the live job list below it. It's now a tight **wrap of quick-access buttons**, one per bookmark, labeled with the bookmark's name (the full kind/bucket/channel shows on hover). Clicking a button **launches the job fire-and-forget** — no inline shell, no expanding log — and the relaunched job simply appears in the live list below (it runs to completion server-side whether or not the page watches its stream); the button briefly reads *Launching… → Launched ✓*, an empty re-derived bucket still reads as a neutral *"Nothing to retry right now"* line, and a bookmark whose channel was deleted is disabled with a *channel missing* hint. The heavier controls — rename, delete, created/last-run metadata, and the full streaming **Run again** with its inline log — move to a new **`/jobs/bookmarks`** management page, reachable via the **Manage** link in the menu header. See `editor/app/jobs/components/BookmarksMenu.tsx`, `editor/app/jobs/components/BookmarkRunButton.tsx`, and `editor/app/jobs/bookmarks/page.tsx`.
-- **ETAs are smarter in two edge cases, and audio-integrity probes now show their own progress bar.** Batch ETAs (downloads/transcripts on `/jobs/active` and the widget) now round the remaining work up to whole **parallel waves** instead of dividing straight through, so the tail no longer reads too low — e.g. *2 videos left across 4 workers* now estimates ~one full task, not "half a task." For **audio-checked downloads**, the per-task ETA used to come straight from yt-dlp, which only counts active download time and is blind to the periodic ffmpeg integrity **probe** pauses (each one transcodes the whole, growing `.part`, so later probes cost more). The download parser now measures each probe and **projects the remaining probe overhead** — extrapolating the per-probe duration as an arithmetic progression — onto yt-dlp's ETA, so a long audio-checked download no longer counts down faster than wall-clock. And while a probe runs (yt-dlp is paused, so the download bar would otherwise just **freeze**), the task now shows a distinct violet **"probing audio"** bar that fills against the estimated probe duration. See `editor/app/jobs/active/buildActiveJobs.ts`, `common/jobs/progressParsers.ts`, and `common/ytdlp/audioCheckedDownload.ts`.
-- **Bookmark a job to re-run it with one click.** Any bookmarkable job now shows a **Bookmark** button — on the `/jobs` table rows, the job detail page, and the `/jobs/active` rows — and saved jobs appear in a **Bookmarks** panel atop both `/jobs` and `/jobs/active`, each with a **Run again** button (which streams the relaunched job's log) and **Delete**. So bookmarking a *whisper-all on HasanAbiVODs3* or a *retry partial downloads on cornbreadman* gives you a button that re-launches the same job on the same channel later. Re-running **re-derives the work from the channel's current state** rather than replaying a frozen list: bucket jobs (retry partial downloads, transcribe downloaded-no-transcript, missing-transcript-and-no-download) store only the snapshot bucket category, so "Run again" always acts on whatever's in that bucket *now* (and reports "Nothing to retry right now" when it's empty) — matching how *Transcribe missing* already re-scans. Every channel-scoped pipeline, whisper, transcode, and cleanup job kind is bookmarkable; ad-hoc checkbox selections and one-off URL imports are not (there's no stable set to re-derive). A bookmark captures the job's kind, channel, and its flags (queue, audio format, abort-on-error, etc.) as a small replay descriptor persisted on the job (and its `<id>.meta.json` sidecar, so even an archived job can be bookmarked) and saved to `transcripts/.bookmarks/bookmarks.json`. Each bookmark can be **renamed** (auto-named `kind · channel` by default — handy when two bookmarks differ only in their options), shows its **created / last-run** times, and **delete is confirm-gated**. An empty re-derived bucket reads as a **neutral notice** ("Nothing to retry right now") rather than a red error, and a bookmark whose channel was since deleted is **flagged and its Run-again disabled**. See `common/jobs/jobSpec.ts`, `common/jobs/bookmarks.ts`, and `editor/app/jobs/runJobSpec.ts`.
-- **In-progress bars with no percentage now read clearly as "working, no number."** A task that hasn't reported a parseable percent yet (a download before yt-dlp's first percent, an engine before its header line) used to show a static 1/3-width bar in the same fill color as real progress — easy to misread as "stalled at ~33%." It's now a **full-width amber pulse**, visually distinct from the emerald/blue progress fill, so the indeterminate state is obvious. Applied consistently on the monitor widget, `/jobs/active`, and `/workers`.
-- **The monitor widget's elements are now individually toggleable.** Each piece of the `/widget` view has its own GET param (and builder checkbox), so you can compose exactly the at-a-glance view you want and hand off the link. New flags: hide the per-job **batch/channel progress bar** (`jobbar=0`) to show only the individual per-task bars; optionally carry the batch progress (e.g. `5/10 · ~2m left`) as text in each job's **one-line heading** (`headtext=1`) so you keep the count/ETA after hiding the bar; hide all **time estimates** (`eta=0`, counts only); hide the **disk indicator** strip (`disk=0`); and collapse the Workers strip to **colored dots only** by hiding worker names (`wnames=0`). The combination `jobbar=0&headtext=1` yields the "only individual task bars under a text heading" layout. All are additive and back-compatible (existing `compact`/`titles`/`idle` links render identically) and omitted from shared URLs at their defaults. See `editor/app/widget/lib/config.ts`.
-- **Audio-integrity check no longer copies the `.part` just to probe it.** In the default paused mode the download is held SIGSTOPped across each integrity probe, so the in-progress `.part` can't change underfoot — the probe now reads it in place instead of first reflink-copying it to a `.part.testing` snapshot. A copy is only taken when a checkpoint passes, to freeze the known-good `.part.good` baseline (and on malformed verdicts, no copy is taken at all). Legacy `resumeDuringProbe` mode is unchanged — it still snapshots up front since the child keeps writing during the probe. See `common/ytdlp/audioCheckedDownload.ts`.
-- **Corrupt-source / undownloaded videos are no longer counted as failed transcriptions.** A video the downloader couldn't produce a real audio file for — most often a malformed source the audio-integrity check gave up on (`download-outcome.json` status `failed-corrupt-source`), or a download whose extract-to-`mp3` left no usable audio — used to get picked up by **Transcribe missing**, fail with "no audio file found", and be written to the channel's `failed-transcriptions` file *permanently* (so even a later clean re-download was skipped forever). These were download/source problems mislabeled as transcription failures, and they piled up. Now the transcribe pass **skips any video with no real audio file** instead of attempting it (the missing-audio case is classed `no-audio` and treated as a skip, not a failure), so it never lands in `failed-transcriptions`; it transcribes automatically once a good audio file exists. The pass also **auto-prunes** existing bogus entries — on each run, any id on the failed list whose dir currently has no audio is removed (it'll retry once re-downloaded). These videos are now surfaced *distinctly* rather than as failures: a new **corrupt-source** channel-report bucket feeds the **Download** stage summary ("N corrupt source (needs re-download)"), and in the video list they show a **corrupt_source** status (amber download dot, no red failure dot) and appear under the **No audio** filter. See `common/controller/whisperBatch.ts`, `common/controller/transcribeOne.ts`, and the new `corruptSource` snapshot bucket.
-- **Quick availability check: spot videos that vanished from a channel with one cheap yt-dlp call.** A channel's **Diagnostics → Availability checks** stage gained a **Quick check (flat playlist)** button that fetches the channel's *current* flat playlist (one `--flat-playlist --print url` pass, no per-video metadata) and diffs it against the videos already on disk. Any known video absent from the fresh listing is flagged **maybe-missing** — it was deleted, made private, or **unlisted** (unlisted videos legitimately drop out of channel listings, so a flag is a candidate, not a verdict). The set is persisted to `channels/<slug>/maybe-missing.json` and re-surfaced through the channel snapshot (intersected with on-disk dirs), so it survives report regens. This is the fast first pass that avoids running the full per-video `--dump-json` check across a whole channel just to notice the handful that disappeared.
-- **"Full-check unexpected": resolve maybe-missing videos without re-probing the whole channel.** Alongside the maybe-missing list, a **Full-check unexpected** button (with its own concurrency control) runs the full availability check on *only* the maybe-missing ids, skipping any already known permanently gone (`deleted` / `private` / `members_only`) and re-probing the rest (`unlisted` / `needs_auth` / `public` / `error` / never-checked, since their status may have changed). This efficiently resolves e.g. deleted-vs-unlisted for just the suspect videos.
-- **Per-video availability history.** Each video's `availability.json` now keeps a `history[]` timeline that appends an entry only when the observed availability *changes* — so a flip like `public → deleted` is preserved rather than overwritten. Entries are tagged by source (`check`, `backfill`, or `download` — the last captures a public video the uploader deleted, observed at download time, without disturbing the explicit-check fields). The video detail page renders this as an **Availability history** card (reverse-chronological, "No availability changes recorded" when empty).
-- **The Jobs screen has persistent filters, and archived jobs keep their details.** A filter bar atop `/jobs` lets you hide jobs by **kind** and **status** and **search** by id / channel / video; the choices persist in `localStorage` and, by default, hide the noisy `refresh-report` report-regen job so the list shows the work you actually triggered (a **Reset filters** button restores the defaults, and a `Showing N of M · K hidden` summary makes the active filtering obvious). Separately, each job now writes a small `<id>.meta.json` sidecar next to its log capturing its kind, channel, video, status, and timings — so once the in-memory registry evicts it (it keeps only the 100 most-recent finished jobs) or the server restarts, an **archived** job still shows that metadata instead of a bare id. The sidecar writes are best-effort and never delay or break a job, and **Clear archived** removes the sidecars along with the logs. See `common/jobs/jobMeta.ts`.
-- **The Workers page can save the current arrangement as a launch default, and "Stop & keep progress" now takes the worker out of rotation.** A **Set as default** button (top of `/workers`) snapshots which workers are enabled right now; on the next server launch the pool starts exactly those workers enabled and **every other worker disabled** — including workers added later that aren't in the saved set. This persists the otherwise-transient runtime on/off state across restarts without touching `settings.json` (it's a small `transcripts/.workers/defaults.json` the pool reads on first use). Once a default is saved the button reads **Update default** and each included worker shows a small **default** badge; the default governs the launch-time seed only, so editing a worker's Enabled flag in Settings still takes effect as before. Separately, **Stop & keep progress** now drains the worker after finishing the current window (it ends *disabled*) instead of leaving it enabled to immediately grab the next video — matching **Drain**, which already ended disabled. See `common/jobs/workerDefaults.ts`.
-- **Downloads stop and stay blocked when free disk space runs low.** A new **Settings → Minimum free disk space (GB)** floor (default **5 GB**; set **0** to disable) guards every download against filling the disk. When free space on the transcripts data directory is at or below the floor, a download job is **prevented from starting** — the channel/import action returns a clear "Low disk space: X free, Y required" error instead of queuing — and a **running batch stops between videos**: the in-flight download finishes, no new ones start, and the batch ends cleanly (status *done*, partial progress preserved) rather than crashing into an out-of-space error mid-file. Free space is measured natively (`statfs`, no new dependency) and the check **fails open** — if it can't read the filesystem, downloads proceed rather than being wrongly blocked. The monitor widget (and `/api/jobs/active`) gained a compact disk indicator showing free space, which turns red and reads "downloads paused" when below the floor (and stays visible even when idle, so it explains why nothing is downloading). `store-playlist` (which writes no media) is not gated. See `common/lib/diskSpace.ts`.
-- **New read-only monitor widget (`/widget`) plus a builder to compose and embed it.** A compact, chrome-less page shows worker status (a colored idle/busy/draining/disabled/degraded dot per worker) and active-job progress bars at a glance — no sidebar, no command palette, and no action controls — so it fits in a small pinned window or an `<iframe>` for at-a-glance monitoring. It reuses the existing `/api/jobs/active` and `/api/workers` endpoints (polled live), and its initial paint is server-rendered for no flicker. What it shows is driven entirely by GET params: `jobs`/`workers` (toggle each section), `channel` (filter active jobs to one slug), `poll` (refresh seconds), `compact` (drop per-task detail), `titles` (section headers), and `idle=hide` (collapse to a tiny "Idle" line when nothing is active). A new **Monitor** page under the sidebar's **Pool** group (`/widget/builder`) exposes all of those as form controls, builds the shareable link with a **Copy** button, an **Open popup** button that launches the widget in a chrome-less `window.open` popup (a tab-less window) at the selected preview size, and live-previews the real widget in a sized iframe. To strip the app shell on exactly the widget route, the root layout now renders its sidebar/command-palette/auto-refresh through a small `AppFrame` client wrapper that hides them when the path is `/widget` (the builder keeps the normal shell). The per-worker payload builder shared by the Workers page and `/api/workers` was extracted to `buildWorkersPayload()` so the widget reuses it too.
-- **The channel video selector can bulk-remove audio files and wrong-format audio, and a new channel-wide sweep clears wrong-format audio in one click.** The selector pane's bulk action picker (channel page → video list) gained two operations, both pure filesystem ops that queue no job (so they never trigger a transcode). **Remove audio files** deletes each checked video's finalized `audio.<ext>` files while keeping transcripts, metadata, and any in-progress `.part` download (which can still resume). **Remove wrong-format audio** deletes only audio files that aren't the channel's target format — e.g. the `audio.m4a` / `audio.mp4` leftovers from downloads that failed yt-dlp's extract-to-`mp3` step *before* audio-integrity checking existed — even when that's a video's only audio, so it re-downloads cleanly. A matching **Select wrong-format** quick-select (shown when any such videos exist) checks exactly those videos, so the cleanup is one flow: **Select wrong-format** → action **Remove wrong-format audio** → **Apply** (each is confirmed first). For whole-channel cleanup, the channel page's **Cleanup** stage gained a **Remove wrong-format audio** section: a `type "remove" to confirm` sweep that walks every video dir and deletes all non-target audio — including the failed-extract orphans the existing **Clean extra audio formats** deliberately skips (it only de-dupes extras when the target file already exists). The sweep respects per-video **do not clean** markers and shows a reclaim estimate; the bulk action, being an explicit selection, removes regardless of the marker.
-- **The channel video selector can bulk-delete directories and clear failure markers, and its action bar is now an action picker.** The selector pane's bulk bar (channel page → video list) previously stacked separate Transcribe / Retry / Mark-untranscribable buttons; it's now a single **Action** dropdown + **Apply** button that also exposes two new operations. **Delete directories** removes each checked video's directory outright (`fs.rm` recursive) — a pure filesystem op that queues no job, so it never triggers a transcode the way leaving failed downloads in place can; this is the quick way to clear a batch of failed downloads. It's gated by an inline *type `delete` to confirm* box (the Apply button stays disabled until matched), mirroring the Clean-extra-formats pattern. **Clear failed markers** prunes the selected ids from the channel's `failed-transcriptions` and `failed-transcodings` files so they're retried on the next pass. Two quick-select helpers, **Select failed** (every video listed in either failure file) and **Select filtered** (every row matching the current filter + search), make the cleanup one flow: filter **Failed** → **Select failed** → action **Delete directories** → type `delete` → **Apply**. Both new actions report a per-id success/failure summary and refresh the channel report like the existing bulk actions.
-- **Audio-integrity checks now pause the download while they run, cutting re-downloaded bytes and HTTP 429 risk.** With audio-integrity checking enabled, the downloader periodically snapshots the in-progress `.part` and validates it with ffmpeg. Previously yt-dlp was only paused for the brief *copy* of that snapshot and then resumed immediately, so it kept downloading throughout the (longer) ffmpeg probe — and if the probe came back malformed, every byte pulled during the probe, plus everything back to the last good checkpoint, was discarded and had to be re-fetched. That wasted, repeated fetching is a prime driver of rate-limit (429) responses. Now yt-dlp stays suspended (SIGSTOP) across the whole probe and only resumes on a clean verdict; on a corrupt verdict it's killed while still stopped and rolled back, having downloaded zero throwaway bytes. The trade-off is a briefly idle source connection during each probe (probes are seconds; if a held connection is ever dropped, yt-dlp's own `-c` resume recovers on the next launch). This is the new default; a per-channel **Resume during probe (legacy)** checkbox (channel editor → Audio-integrity checking, `audioCheck.resumeDuringProbe` in `config.json`) restores the old resume-immediately behavior for comparison, and the `AUDIO_CHECK_RESUME_DURING_PROBE` env var overrides it for one-off runs.
-- **Channels can sync automatically on a schedule.** Each channel gained an **Auto-sync** setting (channel editor → Source): *Default* (inherit the global cadence), *Off*, or a concrete interval (every 10m / 30m / hourly / 6h / 12h / daily / weekly), stored as `syncIntervalMinutes` in `config.json`. Inspired by the Laravel scheduler, a single lightweight cron heartbeat (`pnpm sync:tick`, an ~30-line client) POSTs to the editor's new `/api/scheduler/tick`, and the **server** decides which channels are due — a channel is due when `now − lastSyncedAt ≥ its interval`, so a missed tick (server down, machine asleep) simply runs at the next one with no catch-up storm. All work runs **inside the editor** through the existing job queue and per-channel lock, so a scheduled sync can't collide with a manual **Sync** click, shows up live on `/jobs`, and feeds the same transcription worker pool — no second process, no new file locks. Global controls live in **Settings → Sync scheduler**: a master **enable** (off by default), a **default interval**, a **max concurrent syncs** cap (a tick queues at most `cap − running` channels, most-overdue first, rolling the rest to the next tick — which both bounds load and staggers a large due-batch so it doesn't hit the source all at once), an optional **quiet-hours** window, and **failure backoff** (after N consecutive failures a channel waits `base·2^(N-1)` minutes, capped, before retrying). Channels already marked **Exclude from sync** never auto-sync. A new **Schedule** page (`/scheduler`) shows each channel's interval, last sync, next-due time, last outcome, and any active backoff, plus a recent-ticks log and a **Run scheduler now** button; the same data is at `GET /api/scheduler/status`. The cron client targets the editor's port (3001) by default and is hardenable with a `SYNC_TICK_TOKEN` bearer token for installs that expose the editor — see `SCHEDULED_SYNC.md`.
-- **Sites can link to each other.** A site's form gained a **Public URL** field (the absolute URL it's served at, e.g. `https://jeralyzer.com`) and a **Related sites** section. The export footer automatically links to every *other* site that has a Public URL, so filling these in is all that's needed for cross-site links; a site left without a URL is simply omitted from the lists. The **Related sites** editor lets a site pull closely-related siblings to the front under named groups (e.g. Jeralyzer featuring Rekietalyzer under "MTG drama") — add a group, give it an optional heading, and check which sibling sites belong; everything you don't feature falls into a trailing "Other sites" group on its own. Groups reorder with ↑/↓. The picker only lists sites that actually exist, and featured ids for sites that were since deleted are dropped on save (with a heads-up note). It's a subtle, secondary feature — see the matching note in the export changelog for how it renders.
-- **The editor refreshes itself on a timer so its data stays live without a manual reload.** Every page now passively re-fetches its own server-rendered data on a configurable interval — so the sidebar badges (active/running job counts, changelog dot), channel reports, and any other on-screen figures keep up to date on their own. It uses Next's `router.refresh()` (the same mechanism the jobs list already used) mounted once globally in the root layout, so it covers every page and the shared sidebar with no per-page wiring. To avoid wasting work when you're not looking, it **pauses entirely while the browser tab is hidden** and does **one immediate refresh the moment you return** to the tab (rather than waiting out the interval); it also skips a tick while a previous refresh is still settling, so refreshes can't pile up. The cadence is set in **Settings → Auto-refresh interval (seconds)**: default **5s** (clamped 1–600), or **0 to disable** passive refresh completely. This replaces the jobs page's old bespoke 2.5s auto-refresh (the `/jobs/active` page keeps its faster 1s progress-bar polling, which animates per-task bars without a full re-render).
-- **Transcription is now driven by configurable workers instead of one global engine.** The old single **App** dropdown in **Settings → Transcription** is replaced by a **Transcription workers** list. Each worker is **one processing slot** — one transcription at a time — with its own engine (whisper.cpp / chough / parakeet) and config, and a priority given by its position in the list (top = preferred). To run several in parallel, add more workers; a **Copy** button duplicates one (e.g. point two copies at the same chough `--server` for two togglable server slots). A batch ("Transcribe missing", bucket, bulk, single-video) hands each video — per task — to the highest-priority free worker, so a fast GPU worker and a slower CPU worker (e.g. parakeet on the GPU + chough on the CPU) run side by side instead of one engine doing everything. Total parallelism is the number of enabled workers; the old per-run **Concurrency** control and the global **Parallel transcriptions** setting are gone (add/remove workers, or disable/drain one, to change load). A pre-worker `settings.json` migrates automatically to one worker per slot of the previously-selected app (the old parallel-transcriptions count becomes that many enabled copies), plus a disabled worker for any other engine you had configured, so existing installs keep their parallelism. Scheduling is a single process-wide pool, so two batches can't oversubscribe the same GPU. One-slot-per-worker also means you can disable a single slot to free *some* of a CPU/GPU while the rest keep transcribing.
-- **Remote workers: offload transcription to another instance of this app on your LAN.** Add a **remote** worker in the Settings list with the base URL of another instance (e.g. `http://gpu-box.lan:3001`) and a shared token. When a video is dispatched to it, this instance uploads the audio over HTTP, the remote transcribes it through *its own* worker pool (picking among its local engines), streams progress and log back, and this instance pulls the finished `transcript.json` and normalizes it locally — so the remote needs no knowledge of your channels, just CPU/GPU. The protocol lives under `/api/worker/*` and is **disabled unless `WORKER_TOKEN` is set** in the environment, so an instance is never an open transcription server by accident; every request carries `Authorization: Bearer <token>`, validated with a constant-time compare against the accepting instance's own `WORKER_TOKEN` (never against settings). Uploaded audio and the produced transcript live in a scratch dir that's cleaned up once the result is pulled (or the job is cancelled). If a remote returns a transport error mid-job, the video is automatically retried on another worker; a genuine transcription failure on the remote is not retried. On a transport failure the remote's `GET /api/worker/health` is probed, and a remote confirmed **down** is auto-disabled (shown "degraded" on the Workers page, with **Enable** to retry once it's back) so neither the current video nor later ones keep burning attempts on it — they fail over to a healthy worker. A worker that racks up repeated failures while still reachable is auto-disabled after a few strikes.
-- **New Workers page (`/workers`) with live status and runtime controls.** Lists every worker with its state (idle / busy / draining / disabled / degraded) and the video it's currently transcribing with per-task progress. Each worker can be **disabled** (stop taking new work immediately; in-flight transcriptions keep running), **drained** (stop taking new work but let the current video finish — the graceful "free up the GPU when it's done" path), or **enabled** again — without editing settings, so you can hand a CPU/GPU back to other programs and reclaim it later. A **Pause all** button disables every worker at once and remembers each one's state; **Resume all** restores them exactly. These runtime controls are transient (a restart returns workers to their configured enabled state, unless you capture the current arrangement with **Set as default**); the Settings list is where the persisted per-worker config lives.
-- **parakeet.cpp transcriptions are resumable, and can be paused mid-run.** The overlapping-segment wrapper now writes each window's raw parakeet-cli JSON to a per-audio work dir (`.<audio>.parakeet/`) as it finishes, and stitches the final transcript only once *all* windows are done (then removes the work dir). Re-running the same transcription picks up the cached windows and only does what's missing — yt-dlp-style resume, so a crash, cancel, or pause never loses completed windows. A busy parakeet worker on the Workers page shows a **Stop & keep progress** button: it finishes the in-flight window, stops and frees the worker (no transcript written yet), and the next "Transcribe missing" resumes from the cached windows and completes. Handy to reclaim a GPU mid-run. (whisper.cpp/chough run as a single pass and don't offer this.)
-- **Selectable compute device for parakeet.cpp.** A parakeet worker gained a **Device** field that forces the compute device — `cpu` to run on CPU, or a specific GPU like `CUDA0` / `Vulkan1`. parakeet.cpp's `parakeet-cli` has no `--device` flag and otherwise auto-grabs the first GPU the ggml registry reports, so the wrapper now exports the choice as the **`PARAKEET_DEVICE`** environment variable to the CLI (previously it was passed as a non-existent `--device` flag, which the CLI ignored — so a worker set to `cpu` still ran on the GPU). Combined with one-worker-per-slot and Copy, you can pin different parakeet workers to different devices.
-- **The Workers page and Active Jobs page cross-reference each other.** Each busy worker on `/workers` now shows what it's transcribing right now — the video (linked), the channel it's in, a live elapsed timer, percent, and the engine's progress detail — not just a bare bar. Conversely, every in-flight transcription on `/jobs/active` now says which worker it's running **on** (e.g. "Transcribing <id> on GPU"), so you can see how a batch is spread across your workers at a glance.
-- **Batches pause instead of failing when no worker is available.** If every worker is disabled (or you hit **Pause all**) while a transcription batch is running, the batch parks — it keeps its in-flight video to completion, starts no new ones, and stays **running** on `/jobs/active` rather than failing the remaining videos. Re-enabling any worker (or **Resume all**) immediately resumes it where it left off. A video whose worker fails for a transport reason (e.g. a remote worker that went away) is automatically retried on another worker before being recorded as failed.
-- **Git worktrees can run in parallel on non-colliding ports (dev tooling).** Two checkouts of the repo (via `git worktree`) can now run their dev servers and e2e suites at the same time without port clashes. A new helper, `scripts/worktree.mjs` (exposed as `pnpm wt`), assigns each worktree a port block offset by `index * 100` based on its position in `git worktree list` — the main worktree keeps the original defaults (editor 3001, test 3011, export 3010/3000/3020), worktree #1 gets 31xx, and so on. `pnpm dev:editor`, `pnpm dev:export`, `pnpm start:export`, and `pnpm e2e` route through `wt run`, which injects the assigned ports, so they "just work" per worktree; the editor/export `package.json` port flags and the Playwright configs now honor these env vars (previously `pnpm dev:test` hardcoded 3011, so a custom `PORT` only moved the URL Playwright waited on, not the server). E2E specs that hit the editor's test API now derive the base URL from `PLAYWRIGHT_BASE_URL` (centralized in `editor/e2e/baseUrl.ts`) instead of hardcoding `localhost:3011`. `pnpm wt add <branch>` creates a sibling worktree pre-seeded with `settings.json` and prints its ports; `--share-data` links it to the main worktree's downloaded `transcripts/` for read-mostly reuse (with an LMDB concurrent-write caveat). See `WORKTREES.md`.
-- **Channel reports refresh themselves automatically, on a global debounce.** Any action that changes what a channel report (the per-channel snapshot powering the Channels list, `/actionable`, and the channel page) would say now regenerates that report on its own when it finishes — no more manually clicking **Refresh report** after transcribing, transcoding, cleaning audio, checking availability, editing channel config, or the per-video file operations (delete file, set primary transcript, delete dir, mark untranscribable, archive/do-not-clean). Every report-changing action marks its channel "dirty" and re-arms one shared debounce timer; when activity settles, the scheduler regenerates each dirty channel's snapshot in parallel (reusing the existing `refresh-report` job, deduped against any refresh already running) and revalidates the affected pages. This happens **after each completed sub-operation within a batch**, not only when the whole batch finishes — so a long download or transcription run updates its report incrementally as each video lands, rather than staying stale until the end. The debounce coalesces bursts — videos that finish within the same window collapse into a single regen pass instead of one rewrite per operation. The window is configurable in **Settings → Report refresh debounce**: **Fast** (~1s after activity settles, no cap — the default), **Balanced** (~3s, 30s max), or **Lazy** (~10s, 60s max). The download/sync pipeline's previous behavior of regenerating its report inline is removed in favor of this one uniform mechanism (so its report now lags by the debounce window — ~1s by default — rather than being written synchronously). The manual **Refresh report** / **Update all reports** buttons are unchanged.
-- **Parakeet transcriptions show a per-video ETA.** While a video is being transcribed with the parakeet app, its per-task progress bar on `/jobs/active` now shows an estimate of how long that single video has left (e.g. `segment 3/12 · ETA 6:10`), alongside the existing segment count and percent. The wrapper (`scripts/parakeet-stitch.mjs`) measures each segment's real transcription wall-time and projects the remaining time as **average time per completed segment × remaining segments**, emitting it on its progress lines; the parser surfaces that ETA in the task detail. It appears from the second segment onward (the first segment has no average to project from yet). This is distinct from the batch-level "~4:30 left" estimate across all videos.
-- **Each active sub-operation shows how long it has been running.** Every per-task bar on `/jobs/active` (each in-flight download or transcription) now leads with a live `m:ss` timer of its own elapsed wall-time, ticking once a second — e.g. `1:42 · 25% · segment 3/12 · ETA 6:10`. The start time comes from the job registry's per-task `startedAt`, so the timer survives page reloads and reflects the real runtime, not time-since-open.
-- **The single-video download pipeline reconstructs the source URL when a video has no `metadata.info.json`.** Running the per-video **Download** / **Audio + Whisper** action resolves the video's URL by reading its `metadata.info.json` (`webpage_url`), then by matching the canonical id against the channel's stored `playlist`. When a video directory exists but has neither — e.g. a manually-placed video, or a channel whose playlist was never stored — the pipeline previously gave up with "Could not determine the video URL". It now re-creates the URL from the video's canonical id (its directory name) and the channel's platform (from `config.platform`, falling back to detecting it from `config.url`), so the download proceeds. Reconstruction is a last resort — the exact URL from metadata or the playlist always wins when present — and only fires when the platform is known (no blind guess); note a reconstructed Odysee URL drops the channel-name prefix, so it may not resolve.
-- **New transcription app: parakeet.cpp with overlapping-segment stitching.** A third **App** option (alongside whisper.cpp and chough) in **Settings → Transcription** transcribes via `parakeet-cli`, working around its two long-audio limitations: it only accepts a single WAV, and a >4GB PCM stream silently yields an empty transcript (dr_wav's data-chunk size is 32-bit). A standalone wrapper (`scripts/parakeet-stitch.mjs`) — usable both as the app's binary and directly on the CLI — slices the source into **overlapping** 16kHz-mono windows (the format parakeet-cli resamples to internally anyway), runs `parakeet-cli --json --timestamps` on each, then stitches the per-window word timestamps back into one transcript. The overlap guarantees a word clipped at one window's boundary is captured whole by the neighbour; a cut point in the middle of each overlap region decides which window owns each word, so nothing is dropped or duplicated across seams. Output is chough-native JSON (seconds-based `chunk_data`), so it flows through the existing format-aware normalize/index path with no downstream changes and is tagged `chough-json` in `transcript.cues.json`. The settings fields are **Model** (the `.gguf` path) and **Chunk size** (per-window length, default 480s); the underlying `parakeet-cli` binary and default model resolve from `PARAKEET_CLI` / `PARAKEET_MODEL` (overlap is tunable via `PARAKEET_OVERLAP_SEC`). Run on the CLI with `scripts/parakeet-stitch.mjs --model <gguf> <audio> [out.json]` (prints JSON to stdout when no output path is given). Per-window progress drives the job progress bars.
-- **Batch jobs estimate the time remaining.** A running batch's overall progress bar on `/jobs/active` now shows an estimate of how long is left (e.g. `~4:30 left`), alongside the existing `Transcripts: 12 / 50` count. The estimate is **remaining tasks × the measured average time per task**: each download/transcription's real wall-clock duration is folded into per-job running totals as it finishes, and that average is converted to a wall-clock figure using the parallelism observed so far (so a 4-way parallel transcribe batch isn't estimated as if it ran one at a time). It reads as **estimating…** until the first sub-operation completes (no average yet), and disappears once no work remains. Jobs without per-task tracking (e.g. storing a playlist) show no estimate.
-- **Audio-checked downloads recover correctly when an interrupted attempt left a malformed `.part` and the video id isn't derivable from its URL.** For sources where the canonical id only appears after metadata (e.g. Odysee), the integrity-checked downloader's pre-check would correctly roll back a corrupt leftover `.part` to its `.good` snapshot (or discard it), but the post-launch file discovery then ignored that same directory as a "pre-existing" one — so the freshly re-downloaded audio was never found and the download was recorded as failed. The pre-check now reports the directory it acted on, and discovery scopes to it, so the resumed download finalizes as `ok-audio-checked`. Unrelated stale `.part`s from other videos' interrupted attempts are still ignored (clean pre-existing parts aren't reported), so the cross-video protection is unchanged.
-- **First-class support for multiple transcription apps (chough + whisper.cpp).** Transcription is no longer hardcoded to whisper.cpp. **Settings → Transcription** now has an **App** dropdown (whisper.cpp / chough) with per-app fields, replacing the old flat Binary / Model / Args command. Each app owns how it builds its command line, what file it writes, how its output JSON is parsed, and how its progress output is read — so adding another tool is a small code module (`common/lib/transcriptionApps.ts`). **chough** is supported in both **local** and **remote** modes (set a **Remote URL** to transcribe via a `chough --server`, passing `CHOUGH_URL`; leave it blank for local), with optional **Chunk size** (`-c`) and **Model** (`CHOUGH_MODEL`) fields; its binary defaults to the `CHOUGH_BIN` env var. whisper.cpp keeps its Binary / Model / custom-args template. Because chough writes its output to the exact `-o` path (no `.json` appended, unlike whisper-cli's `-of`), the runner now renames the app-declared output file — fixing transcripts that previously failed to materialize under chough. Transcript parsing is **format-aware and back-compatible**: existing whisper.cpp `transcript.json` files and new chough files coexist, each parsed correctly by content sniff (chough's seconds-based `chunk_data` vs whisper's millisecond `transcription` offsets), with the detected format recorded per video in `transcript.cues.json` (`transcriptFormat`) and a fallback to whisper.cpp for anything unrecognized — so re-indexing a mixed corpus (including across shard machines running different tools) just works. An existing `settings.json` migrates automatically: legacy `transcribeBin`/`transcribeArgs`/`transcribeModel` map onto the matching app (a binary named `chough` adopts the chough app; everything else becomes whisper.cpp, preserving a customized args template).
-- **Global default for parallel transcriptions.** A new **Settings → Parallel transcriptions** field sets how many videos a "Transcribe missing" / bucket run transcribes at once when its per-run Concurrency input is left blank (default **2**, clamped 1–16). This replaces the old `PARALLEL_TRANSCRIBE_LIMIT` env-var default of 4 as the source of the default: the value is now persisted in `settings.json`, shown as the placeholder in each channel's Concurrency input, and used as the server-side fallback. The per-run Concurrency input still overrides it for a single run.
-- **Managed downloads skip live and upcoming videos by default.** Before downloading each video, the managed downloader now runs a quick metadata-only pass, then evaluates an app-level filter: videos that are currently live or scheduled/upcoming are skipped (their finished VODs still download normally — a skip isn't archived, so the next **Sync** / **Download missing** retries the video once the stream ends). A skip is recorded in the video's `download-outcome.json` (`status: "skipped-filtered"` with the filter name and reason) and logged to `download.log`; it never counts as a failure or aborts the batch, and the run's summary line reports how many were skipped. Skipped-live videos are surfaced in a new **Skipped: live or upcoming** bucket in the channel's Diagnostics (with a **Retry** to force an attempt now). On by default for every channel via a new **Settings → Skip live and upcoming videos** toggle, with a per-channel override (`config.json` `skipLiveDownloads`). To support the filter (and as a reusable building block for future per-video decisions), each managed download is now split into a metadata fetch followed by the real download, which reuses that metadata via `--load-info-json` so the second pass doesn't re-extract; the audio-integrity-checked download path re-extracts as before. Turn the toggle off for a channel that streams nothing to allow live captures.
-- **Global default social links, overridable per site.** Footer social links can now be defined once in **Settings → Social links** and apply to every site by default. Each site's form gained a **Use global default social links** checkbox (on by default): leave it checked to inherit the global list, or uncheck it to give that site its own links (an empty list shows none). Existing sites keep their current footer — sites that already had links stay as explicit overrides until you flip the toggle. Validation and SVG sanitization are unchanged and shared by both the global and per-site editors.
-- **Import a single off-playlist video by URL.** A channel's **Playlist** stage gained an **Import single video** panel: paste a video URL, click **Import video**, and that one video is downloaded into the channel using its normal handling (subtitles + metadata for YouTube channels, audio for transcribe channels) and appended to the archive — no need to add it to the stored playlist first. It reuses the same single-video downloader as the per-video "Download" button (including the auth-retry and no-subs→audio fallbacks), streams yt-dlp's output live, and runs on the channel's download queue. Transcription stays a separate step (use the per-video **Transcribe** button afterward), same as a normal download.
-- **Duplicate shorts detection on `/actionable`.** A new **Duplicate shorts** section finds the same short re-uploaded under a new id, re-titled, posted on another channel, or cross-posted to another platform. It runs a global, cross-platform pass: a cheap duration pre-cluster narrows candidates, then transcript content is compared (exact text hash → near-duplicate similarity → containment for "this short is a clip of a longer video"). Matches are **content-confirmed only** — a shared duration alone never clusters videos (that produced huge false clusters of unrelated same-length videos), and plain (non-karaoke) VTT captions that the index stores as zero cues are recovered by reading the raw transcript. Pick a scope — **Shorts only** (default ≤ 180s) or **All durations** (a heavier one-off that also surfaces clip-of-longer matches) — and click **Detect duplicates**; results list each cluster with its members (links), match strength, score, and cross-platform/cross-channel/clip-of-longer badges. It's **flag-only** — nothing is merged or deleted. Reads the cues + per-video stats written by **Build index** + **Build stats dataset**, so run those first; the report is written to `transcripts/duplicates.json` (also producible from the CLI via `pnpm --filter export run detect:duplicates [-- --all-durations]`).
-- **Cleanup sections now estimate how much disk space they'll reclaim.** Both the `/actionable` cleanup rows and a channel's **Cleanup** stage show an estimated size next to each cleanup operation — the **Actionable** page gained an **Est. reclaim** column for "cleanable transcribed audio" and "extra audio formats", and the channel Cleanup stage shows an "Estimated space to reclaim: ~N" line under each of its two actions. The estimate is computed when a channel's report is generated (it sums the `audio.*` files each cleanup would delete — all audio for transcribed-audio cleanup, every non-target format for extra-format cleanup — and respects "do not clean"), so refresh a channel's report to populate it. Older reports without the figure show `~0 B` until refreshed.
-- **Videos whose English captions only exist under a regional/auto code are now indexed.** YouTube occasionally serves a video's English subtitles only as `en-US`, `en-en-US`, or `en-orig` with no plain `en` track, so yt-dlp wrote e.g. `transcript.en-US.vtt` but never `transcript.en.vtt`. The index only recognized the literal `transcript.en.vtt`, so such a video looked untranscribed and never appeared in search. Build index now falls back to the best available English VTT (preferring `en`, then `en-orig`, then regional `en-US`/`en-GB`, then auto-translated `en-en-*`) while ignoring true translation tracks like `es-en-US`; whisper also treats these as already-transcribed. Re-run **Build index** to pick up affected videos already on disk. The video detail page now reflects the same fallback (it previously hardcoded `transcript.en.vtt`, so a regional-only video showed as untranscribed there).
-- **Pick which subtitle track is a video's transcript.** The video page has a new **Transcript source** section listing every `transcript.<lang>.vtt` track, marking the current primary, with a **Set as transcript** button that promotes any track to the canonical `transcript.en.vtt` (the chosen track is copied, so the original stays and the choice is reversible — delete `transcript.en.vtt` to fall back to the automatic English pick, or pick another track to switch). Useful when the auto-picked track isn't the one you want, or when a video's only captions are a non-English track.
-- **Diagnostics: "Non-standard transcript VTT name" bucket.** A channel's Diagnostics now lists videos whose transcript rides on a non-canonical VTT name (e.g. only `transcript.en-US.vtt`, or a foreign-language track) rather than the standard `transcript.en.vtt` — each links to the video so you can normalize/switch the primary transcript. Refresh the channel snapshot to populate it.
-- **Managed downloads stream yt-dlp's full output again, and progress bars now read a structured progress template.** The per-video archive marker is captured with yt-dlp's `--print`, which silently implies `--quiet` — so managed downloads ran nearly silent: `download.log` held little more than the archive line, and the per-video progress bars on `/jobs/active` never advanced (the `[download]` lines they parsed were suppressed). Managed downloads now re-enable full logging (`--no-quiet`) and emit a machine-readable `--progress-template` line (throttled to ~1/sec) that the progress parser reads directly instead of scraping the human progress text — so the bars advance reliably during real downloads (with speed/ETA detail) and `download.log` captures the whole run. The legacy `[download]` line parser is kept as a fallback for non-managed single-video/subs-only downloads.
-- **One global site selector scopes the editor to a single site.** The sidebar gained a single **site** dropdown (with an **All sites** option) that scopes the **Dashboard**, **Channels**, **Charts**, and **Deploy** views to the chosen site: Dashboard stats and the channel table, and the Channels list, now show only that site's member channels; Charts edits that site's dashboard; and Build static export / Deploy target it. Views that act on the shared pool — **Jobs**, **Active**, **Build**, and **Actionable** — always show every site regardless of the selector. The nav is regrouped to match: a **Site** group first, then **Pool**, then **Manage**. The selection persists in `localStorage` and is mirrored into the `?site=` URL param. Under "All sites", Charts and Deploy ask you to pick a specific site; creating a channel while a specific site is active also adds it to that site's membership. This replaces the per-page site tabs on Charts and the per-button site dropdowns on Deploy.
-- **Date-range filter on the public site's search.** The exported site's search filters gained a **Date** row (From / To pickers) that restricts results to videos uploaded within an inclusive range. The range saves in profiles and shareable search links and carries over to "Chart this search", reusing the same date mechanism the charts dashboard already uses. No editor-chrome change; it ships in every built site.
-- **Bulk checkbox transcribe/retry now queue like every other batch.** Selecting videos in the channel's list and clicking **Transcribe** (or **Retry download**) used to fire one queued single-video job per selection onto the channel's *platform* queue — diverging from "Transcribe missing"/"Download", which submit one batch job on the shared `transcription`/platform queue. The checkbox actions now submit a **single** batch job through the same path (so bulk and the stage buttons serialize together instead of contending), and the resulting job shows up in the page's running-jobs list. The selection bar gained the matching controls: a **queue** selector for each action (defaulting to `transcription` for transcribe and the platform queue for retry), a **Parallel** concurrency input for transcribe, and an **Abort on error** toggle for retry. Default-queue resolution for all batch features now lives in one shared helper (`common/lib/queueKeys.ts`) so they can't drift apart again. "Mark untranscribable" is unchanged (instant metadata write).
-- **Shard controls overhauled: "Save" button, 1-based `i / N` inputs, remaining-only transcribe slices, and always-on logging.** The shard inputs now read **`i / N`** (this shard's number first, 1-based — a 2-way split is `1/2` and `2/2`) and each box has its own hover title so they're no longer easy to mix up. A new **Save** button persists a slice to `shard-<op>.json` *without* starting the job — handy for dividing the remaining videos across machines (that share an identical synced copy) up front, and for confirming the slice actually saved; the saved-shard pill updates immediately. **Transcribe** sharding now splits only the videos that still need a transcript (e.g. 6 untranscribed videos split 2 ways → 3 each) instead of slicing the whole channel's video list. And every Download/Transcribe/Availability run now logs whether it's running a computed/saved shard slice or the full set, so a run that silently ignored shard inputs is no longer indistinguishable from one that honored it.
-- **Shard configs now show up immediately, and a shard job's progress bar totals only its slice.** Previously, setting a shard (N/i) and running **Download videos** / **Transcribe missing** did persist the slice to `shard-*.json`, but the saved-shard indicator never updated until you manually reloaded the whole page — so it looked like nothing saved. The indicator on the Download/Transcribe/Diagnostics controls (and channel counts, buckets, and failed-lists generally) now refresh as soon as a run finishes — including after a cancel — without a reload. The download slice is still taken over the *missing* set and snapshotted to the file, so a resume run with the same N/i reuses that exact slice instead of re-slicing the now-smaller set. A sharded job's progress bar on `/jobs/active` now totals only its own slice rather than the whole channel.
-- **Multi-site support (major change).** One editor instance can now power several public sites (e.g. "Jeralyzer" and "Rekietalyzer") over a single shared channel pool — a channel's downloads are stored once and reused by every site that includes it. A new **Sites** area (sidebar) manages each site's `sites/<id>/site.json`: its branding (title, header, description, tagline), social links, channel-group layout, and **which channels it exposes** (with a per-site group for each member, so the same channel can sit in different groups on different sites) plus its Cloudflare Pages project. Site-level branding/groups/social have moved **out** of global Settings, which now holds only operational config plus a new **Admin title** for the editor's own shell; the per-channel "Group" field is gone (grouping is configured per site). The **Charts** tab is per-site (pick the site at the top; each site has its own dashboard at `sites/<id>/chart-templates.json`). **Build index** / **Build stats dataset** now build the shared per-channel data once and a filtered bundle per site; the Deploy page's **Build static export** and **Deploy** gained a site selector and deploy each site to its own Cloudflare project. Upgrading an existing single-site install: the Sites page shows a one-click **Migrate** button (also a `migrate-to-sites` CLI) that lifts your current branding, channel groups, per-channel group assignments, and chart dashboard into a first site and reduces `settings.json` to operational config.
-- **"Audio + Whisper (skip pipeline)" no longer mistakes a live-chat sidecar for audio.** yt-dlp writes the live-chat track as `audio.live_chat.json` (it ignores the subtitle output path), so an interrupted download could leave an `audio.live_chat.json.part` behind. One-click whisper saw the `audio.` prefix, decided audio was already on disk, skipped the download, and then failed with "no audio file found". Live-chat sidecars (`.part` or completed) are now excluded everywhere audio is detected, so whisper downloads the real audio and transcribes as expected.
-- **Cleanup tasks on `/actionable`.** The global Actionable view now surfaces two housekeeping sections alongside the download/transcribe backlog: **channels with cleanable transcribed audio** (videos that have a whisper transcript but still keep their audio on disk) and **channels with extra audio formats** (videos with leftover audio files beside the channel's configured format), each with a per-channel count and an inline button. Because these delete files, the buttons pop a confirm dialog before queuing. The dashboard "Needs attention" card is unchanged — it still tracks only download/transcribe work.
-- **Per-video "do not clean" archive toggle.** Each video detail page has a new **Archive media** section to mark a video "do not clean". Marked videos are skipped by both cleanup jobs (their audio is preserved for archiving) and excluded from the cleanup counts on `/actionable`. The marker is reversible from the same toggle, and an "archived" badge shows on the video page while it's set.
-- **Twitch.tv support.** Twitch is now a first-class platform: Twitch channel/VOD URLs are auto-detected, "Twitch" is selectable in the channel form's platform dropdown and the charts platform filter, videos play via an in-browser Twitch embed, and downloads are queued on a `platform:twitch` queue like the other platforms.
-- **Downloads always land in the canonical `data/<id>/` dir.** Previously yt-dlp chose the directory from its own extractor id, which diverges from the app's URL-derived id on Twitch (`v<id>` prefix), Rumble, and Odysee — so a video's audio/metadata and its `download-outcome.json` could end up split across two sibling dirs and the editor couldn't resolve the video. The app now pins yt-dlp's output path per video, and a reconcile pass (run automatically when a channel's report regenerates) merges any pre-existing split dirs back together. A one-time migration command, `reconcile-video-dirs`, repairs all existing channels (`--dry-run` to preview). Sync and "download missing subtitles" now fetch one video per yt-dlp run (sync walks the channel a page at a time, downloading the diff against the archive and stopping once it reaches already-synced videos).
-- **Per-video download logs.** Each managed download now writes a `download.log` alongside the video's data, capturing that run's full yt-dlp output for after-the-fact debugging.
-- **Smarter queues for unrecognized sources.** When a channel's platform can't be detected, its jobs are now queued per-domain (e.g. `platform:vimeo.com`, with subdomains stripped) instead of all sharing the single `platform:unknown` queue — so unrelated unknown sources no longer block one another.
-- **Charts authoring + stats dataset.** A new **Charts** tab lets you author the default chart dashboard that ships to the viewer — add/edit/remove charts and configure axes, metrics, series, filters, and search-derived series with a live preview; edits save automatically and bake into the next export build. Search-derived charts now use the full layered query builder (AND/OR/NOT, nesting, per-layer scope/regex), matching the search page. A new **Build stats dataset** action on the Build page extracts per-video stats (views, likes, comments, follower count, duration, categories, language, cue counts) into `export/public/stats/` for the charts to read; it's incremental (mtime short-circuit) and also runs automatically as part of the static export build.
-- **Per-operation progress bars on `/jobs/active`.** Each running download/transcription now shows its own live progress bar parsed from the tool's shell output — yt-dlp's download percent (and fragment count) and whisper's transcribed position against the audio length. Batch jobs (Transcribe missing, Download from playlist, Sync, …) list a bar per in-flight video underneath the batch's overall bar; standalone single-video jobs get one too. The screen now polls about once a second so the bars advance live.
-- **"Drain" (soft-cancel) for batch jobs.** Alongside the existing Cancel (which immediately kills everything), running batches now offer **Drain**: it lets the in-flight operations finish, starts no new ones, then completes the job normally and releases the queue so the next batch can run. Hard Cancel still works at any time, including mid-drain.
-- **"Build static export" moved from the Build page to the top of the Deploy page**, co-locating it with the export changelog preview, "Cut release", and "Deploy static export" so the whole build → review → cut → deploy sequence lives on one page.
-- **The embedded single-video view on the channel page is now collapsed by default**, keeping the channel view compact. Clicking a video in the list expands the panel and scrolls to it; navigating away from a video (no video selected) collapses it again. A manual collapse/expand toggle is available on the panel, mirroring the video list's existing collapse.
-- **Unlisted videos now get their own clickable list in the channel availability diagnostics**, alongside Deleted/Private/Members-only/Needs-auth/Error. Previously unlisted videos were only shown as a count, so there was no way to jump to the specific videos.
-- **Cutting a release now clears `## [Unreleased]` entirely** instead of leaving an empty heading sitting above the new dated section. The `/changelog` and `/deploy` pages stop showing a blank Unreleased block after a release; the heading reappears the next time somebody hand-adds a pending bullet to the file.
-
-## [0.2.0] - 2026-05-22
-- **`/actionable` page** aggregating pending work across all channels — three sections (channels with undownloaded videos, channels with downloaded videos awaiting transcription, and channels with stale or missing reports), each row linking to the channel with inline "Download missing" / "Transcribe pending" / "Refresh report" buttons. An "Update all reports" button queues a refresh per channel. A "Needs attention" card on the dashboard links here whenever either of the first two lists has anything in it.
-- **Pipeline jobs regenerate the channel's report when they finish** (sync, download-from-playlist, download-missing, download-missing-subs, retry-bucket, store-playlist), so reports stay current without a manual refresh.
-- **`/changelog` page** rendering this changelog, linked from the sidebar nav. A subtle blue dot appears on the **Changelog** nav item when there's an entry you haven't viewed yet, clearing once the page is opened. Per-heading copy-link buttons give permalinks to any section, and a "Cut release" form at the bottom turns `## [Unreleased]` into a dated heading. The dashboard's "Recent changes" card shows the most recent entry, clamped to a fixed height with a gradient fade, and the whole card links to the full changelog.
-- **`/deploy` page** that previews `export/CHANGELOG.md` pending changes, offers a "Cut release" form for the export side, and a "Deploy" button that runs the export deploy with live log streaming.
-- **Composable layered search** in the export viewer — see `export/CHANGELOG.md` for the user-visible details. Old `?q=…&m=…&re=…` share-links keep working.
-- **Per-channel "Include in Sync all" toggle** on the `/channels` list. Excluded channels are skipped by **Sync all** with reason "excluded from sync all" in the skipped tooltip; their rows are dimmed in the table.
-- **Sortable column headers** on the `/channels` table — click any column to sort, click again to flip direction. Sort state doesn't persist across navigation.
-- **`/jobs/active` overhaul.** Each running batch job gets its own progress bar scaled to that job's own work, replacing the old channel-wide bar. Running jobs now appear above queued ones (both within each channel section and across channels), so live work stays at the top of the page. The sidebar **Jobs** badge still counts running + queued; the new **Active** badge counts only running.
-- **Deleted, private, and members-only videos no longer stick around as "awaiting transcription".** A failed download attempt that finds a video unavailable is now respected even if an older availability sweep had it as public. `/actionable` counts, the dashboard "Needs attention" summary, the channel page's Transcribe stage, and the Download stage's "dirs missing transcript & audio" list all exclude these videos. The video's row in the channel list still shows its true on-disk state.
-- **Channel page navigation fixes.** Clicking a pipeline stage in the side rail (or a mobile stage badge) no longer scrolls up into the selected video's viewer panel, sticky offsets now clear the header on every width, and clicking a stage when the "Channel pipeline & settings" wrapper is collapsed auto-expands it before scrolling into view.
-- **Audio download integrity fixes.** `.part` files with mid-stream codec corruption (hundreds of decoder errors that ffmpeg still treated as a clean exit) are now flagged as malformed instead of finalising as a finished download. A single download on a channel with many leftover `.part` files no longer stalls for minutes pre-checking unrelated videos, and no longer occasionally finalises the wrong file by latching onto a stale neighbour's `.part`.
-- **Editor's `/changelog` page no longer renders unstyled** — Tailwind now scans the shared changelog component.
+## [9.9.9] - 2024-01-01
+- old released bullet