commit a8fd648a462d2a175ded12b8531ad223aebc0b2e
parent c747a348ee1825ab51e2209ae16fde9c8abe57a8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 5 Aug 2026 00:51:34 -0400
Set every cadence in hours and days, not raw minutes
Seven intervals in the editor were bare "minutes" number boxes, so a
weekly cadence meant knowing that 10080 is a week. Each is now a
DurationField: a native <select> plus amount/unit inputs over one hidden
input that still posts a plain integer in the stored base unit, which is
why no server-side parser changed.
Not shadcn's Select: that file is unused, and Radix renders a
<button role="combobox">, which would break .selectOption() and force
every cadence spec onto a different idiom from the rest of the suite.
splitDuration only picks a unit that divides the stored value exactly, so
a hand-edited 137 shows as "137 minutes" instead of rounding to 2 hours.
The echo line under each field restates both the raw number and the
derived rate ("about 1 full listing fetch per channel per day") from that
same value, so it can't disagree with what will be saved.
The full-sweep cadence gets its first inputs — global on /settings and
/scheduler, per channel on the channel form — plus a forced Full sweep
button on the channel page and Full sweep all on the channels page, for
when waiting out a day of cadence isn't the point. /scheduler now carries
both cadences per channel: read-only chips that expand to editors, and a
bulk bar that retunes many at once with "Leave unchanged" defaults, so
sweeps can be re-tuned across forty channels without touching anyone's
auto-sync.
Also fixes keepLatestCheckIntervalMinutes, which had no input anywhere
and was read with a coercion that reset it to the default on every
unrelated save. It now has an input, and both save paths preserve fields
their form doesn't render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
22 files changed, 1514 insertions(+), 266 deletions(-)
diff --git a/common/jobs/syncScheduler.ts b/common/jobs/syncScheduler.ts
@@ -1,5 +1,6 @@
import type { ChannelConfig } from "../lib/channelConfig";
import type { SyncSchedulerSettings } from "../lib/settings";
+import { resolveFullSweepIntervalMinutes } from "./deepSync";
import type { SchedulerSkip, SchedulerState } from "./syncSchedulerState";
// Pure, side-effect-free scheduling logic for the cron-driven sync system. It
@@ -140,6 +141,15 @@ export type ChannelScheduleView = {
// (inherit vs explicit-off vs explicit-minutes), which `intervalMinutes`
// alone can't express once it's resolved against the default.
configuredIntervalMinutes: number | undefined;
+ // The full-sweep cadence, in the same three shapes as the auto-sync one
+ // above: the raw stored value (undefined = inherit, 0 = off), the resolved
+ // interval, and when the next sweep becomes due. nextFullSweepAt is null when
+ // sweeps are off or the channel has never swept (never-swept = due now, the
+ // same convention nextDueAt uses for never-synced).
+ configuredFullSweepMinutes: number | undefined;
+ fullSweepIntervalMinutes: number; // resolved; 0 = off
+ lastFullSweepAt: string | null;
+ nextFullSweepAt: number | null;
lastSyncedAt: string | null;
nextDueAt: number | null; // epoch ms; null when disabled or never synced
overdue: boolean;
@@ -158,8 +168,10 @@ export function buildScheduleView(input: {
const { channels, scheduler, state, now } = input;
return channels.map(({ slug, config }) => {
const interval = resolveIntervalMinutes(config, scheduler);
+ const sweepInterval = resolveFullSweepIntervalMinutes(config, scheduler);
const cs = state.channels[slug];
const last = config.lastSyncedAt ?? null;
+ const lastSweep = config.lastFullSweepAt ?? null;
let nextDueAt: number | null = null;
let overdue = false;
if (interval > 0) {
@@ -186,6 +198,10 @@ export function buildScheduleView(input: {
intervalMinutes: interval,
inheritsInterval: config.syncIntervalMinutes === undefined,
configuredIntervalMinutes: config.syncIntervalMinutes,
+ configuredFullSweepMinutes: config.fullSweepIntervalMinutes,
+ fullSweepIntervalMinutes: sweepInterval,
+ lastFullSweepAt: lastSweep,
+ nextFullSweepAt: nextFullSweepAt(lastSweep, sweepInterval),
lastSyncedAt: last,
nextDueAt,
overdue,
@@ -197,6 +213,20 @@ export function buildScheduleView(input: {
});
}
+// When the next full sweep becomes due, as epoch ms. Null when sweeps are off
+// or the channel has never swept — a never-swept channel is due immediately, and
+// there is no meaningful future timestamp to show for it (the same shape
+// nextDueAt uses for a never-synced channel).
+function nextFullSweepAt(
+ lastFullSweepAt: string | null,
+ intervalMinutes: number,
+): number | null {
+ if (intervalMinutes <= 0 || !lastFullSweepAt) return null;
+ const parsed = Date.parse(lastFullSweepAt);
+ if (Number.isNaN(parsed)) return null;
+ return parsed + intervalMinutes * 60_000;
+}
+
// How far past its interval a channel is, in ms. A never-synced channel is
// maximally overdue (Infinity) so it sorts first. Returns null when not yet due.
function overdueAmount(
diff --git a/common/lib/duration.test.ts b/common/lib/duration.test.ts
@@ -0,0 +1,143 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ formatBaseValue,
+ formatRate,
+ isDurationUnit,
+ perDayRate,
+ splitDuration,
+ toBaseUnit,
+ unitLabel,
+ type DurationUnit,
+} from "./duration";
+
+// Run with: pnpm --filter yt-dlp-transcript-common test
+// (or `./node_modules/.bin/tsx --test lib/duration.test.ts` from common/)
+
+const MHD: DurationUnit[] = ["minutes", "hours", "days"];
+
+test("splitDuration picks the largest unit that divides evenly", () => {
+ assert.deepEqual(splitDuration(1440, MHD), { amount: 1, unit: "days" });
+ assert.deepEqual(splitDuration(60, MHD), { amount: 1, unit: "hours" });
+ assert.deepEqual(splitDuration(120, MHD), { amount: 2, unit: "hours" });
+ assert.deepEqual(splitDuration(10080, MHD), { amount: 7, unit: "days" });
+ assert.deepEqual(splitDuration(10080, ["minutes", "hours", "days", "weeks"]), {
+ amount: 1,
+ unit: "weeks",
+ });
+});
+
+test("splitDuration never rounds a hand-set value", () => {
+ // 137 divides by nothing above minutes, so it stays 137 minutes.
+ assert.deepEqual(splitDuration(137, MHD), { amount: 137, unit: "minutes" });
+ // 90 is 1.5h — hours would have to round, so minutes wins.
+ assert.deepEqual(splitDuration(90, MHD), { amount: 90, unit: "minutes" });
+ assert.deepEqual(splitDuration(1441, MHD), { amount: 1441, unit: "minutes" });
+});
+
+test("splitDuration falls back to baseUnit even when it isn't offered", () => {
+ // The full-sweep field offers only hours and days, but a hand-edited 137 must
+ // still round-trip unchanged rather than snapping to 2 hours.
+ assert.deepEqual(splitDuration(137, ["hours", "days"], "minutes"), {
+ amount: 137,
+ unit: "minutes",
+ });
+ assert.deepEqual(splitDuration(1440, ["hours", "days"], "minutes"), {
+ amount: 1,
+ unit: "days",
+ });
+});
+
+test("splitDuration handles a seconds base unit", () => {
+ const SM: DurationUnit[] = ["seconds", "minutes"];
+ assert.deepEqual(splitDuration(300, SM, "seconds"), {
+ amount: 5,
+ unit: "minutes",
+ });
+ assert.deepEqual(splitDuration(45, SM, "seconds"), {
+ amount: 45,
+ unit: "seconds",
+ });
+ assert.deepEqual(splitDuration(14400, ["seconds", "minutes", "hours"], "seconds"), {
+ amount: 4,
+ unit: "hours",
+ });
+});
+
+test("splitDuration passes sentinels through in the base unit", () => {
+ assert.deepEqual(splitDuration(0, MHD), { amount: 0, unit: "minutes" });
+ assert.deepEqual(splitDuration(0, ["hours", "days"], "minutes"), {
+ amount: 0,
+ unit: "minutes",
+ });
+ assert.deepEqual(splitDuration(Number.NaN, MHD), {
+ amount: 0,
+ unit: "minutes",
+ });
+ // Units smaller than the base unit are never chosen.
+ assert.deepEqual(splitDuration(5, ["seconds", "minutes"], "minutes"), {
+ amount: 5,
+ unit: "minutes",
+ });
+});
+
+test("toBaseUnit converts both directions across the base", () => {
+ assert.equal(toBaseUnit(1, "days", "minutes"), 1440);
+ assert.equal(toBaseUnit(36, "hours", "minutes"), 2160);
+ assert.equal(toBaseUnit(137, "minutes", "minutes"), 137);
+ assert.equal(toBaseUnit(5, "minutes", "seconds"), 300);
+ assert.equal(toBaseUnit(1, "weeks", "minutes"), 10080);
+ assert.equal(toBaseUnit(Number.NaN, "hours", "minutes"), 0);
+});
+
+test("round-trip: toBaseUnit(splitDuration(n)) === n", () => {
+ for (const base of ["minutes", "seconds"] as DurationUnit[]) {
+ const units: DurationUnit[] =
+ base === "seconds"
+ ? ["seconds", "minutes", "hours"]
+ : ["minutes", "hours", "days", "weeks"];
+ for (const n of [
+ 1, 2, 7, 15, 30, 45, 60, 90, 120, 137, 300, 360, 720, 1439, 1440, 2160,
+ 4320, 10080, 14400, 43200, 44640,
+ ]) {
+ const { amount, unit } = splitDuration(n, units, base);
+ assert.equal(
+ toBaseUnit(amount, unit, base),
+ n,
+ `round-trip failed for ${n} ${base} (${amount} ${unit})`,
+ );
+ }
+ }
+});
+
+test("unitLabel singularises at exactly 1", () => {
+ assert.equal(unitLabel("days", 1), "day");
+ assert.equal(unitLabel("days", 2), "days");
+ assert.equal(unitLabel("days", 0), "days");
+ assert.equal(unitLabel("minutes"), "minutes");
+ assert.equal(unitLabel("hours", 1), "hour");
+});
+
+test("formatBaseValue keeps the raw stored number visible", () => {
+ assert.equal(formatBaseValue(1440, "minutes"), "1,440 minutes");
+ assert.equal(formatBaseValue(1, "minutes"), "1 minute");
+ assert.equal(formatBaseValue(0, "minutes"), "0 minutes");
+ assert.equal(formatBaseValue(90, "seconds"), "90 seconds");
+});
+
+test("perDayRate / formatRate describe the cadence as events per day", () => {
+ assert.equal(perDayRate(1440, "minutes"), 1);
+ assert.equal(perDayRate(60, "minutes"), 24);
+ assert.equal(formatRate(perDayRate(2160, "minutes")), "0.7");
+ assert.equal(formatRate(perDayRate(10080, "minutes")), "0.1");
+ assert.equal(formatRate(perDayRate(44640, "minutes")), "<0.1");
+ assert.equal(formatRate(perDayRate(0, "minutes")), "0");
+ assert.equal(formatRate(perDayRate(10, "minutes")), "144");
+});
+
+test("isDurationUnit guards the unit <select> value", () => {
+ assert.equal(isDurationUnit("days"), true);
+ assert.equal(isDurationUnit("fortnights"), false);
+ assert.equal(isDurationUnit(undefined), false);
+ assert.equal(isDurationUnit(60), false);
+});
diff --git a/common/lib/duration.ts b/common/lib/duration.ts
@@ -0,0 +1,116 @@
+// Pure conversion helpers behind the editor's DurationField control.
+//
+// Every cadence in the app is stored as a bare integer in one fixed unit —
+// minutes for the sync/sweep/backoff intervals, seconds for the heartbeat. That
+// keeps settings.json and every existing validator simple, but it means an
+// operator reads `10080` and has to do the arithmetic to learn it means weekly.
+// DurationField shows the same number as an amount + unit pair; these helpers do
+// the conversion both ways, and are kept here (rather than beside the component)
+// because `lib/*.test.ts` is what the common package's unit-test glob picks up.
+//
+// The invariant that matters: splitDuration only ever picks a unit that divides
+// the stored value EXACTLY, so a hand-edited `137` round-trips as "137 minutes"
+// instead of being silently rounded to "2 hours".
+
+export type DurationUnit = "seconds" | "minutes" | "hours" | "days" | "weeks";
+
+// Posted by DurationField's "Leave unchanged" mode, and understood by the
+// actions that read it. A distinct token rather than "", because "" already
+// means "clear this key and inherit the global default" — a bulk edit that
+// skips a field must be distinguishable from one that resets it. Lives here
+// rather than beside the component so a server action can import it without
+// crossing the "use client" boundary.
+export const DURATION_KEEP = "keep";
+
+// Seconds per unit. Ordered smallest-first; the order is load-bearing for
+// splitDuration, which walks it backwards to find the largest even divisor.
+const UNIT_SECONDS: Record<DurationUnit, number> = {
+ seconds: 1,
+ minutes: 60,
+ hours: 3600,
+ days: 86400,
+ weeks: 604800,
+};
+
+const UNIT_ORDER: DurationUnit[] = [
+ "seconds",
+ "minutes",
+ "hours",
+ "days",
+ "weeks",
+];
+
+export function isDurationUnit(value: unknown): value is DurationUnit {
+ return typeof value === "string" && value in UNIT_SECONDS;
+}
+
+// Label for a unit, pluralised for `amount`. Used by both the unit <select> and
+// the echo line, so they can never disagree about wording.
+export function unitLabel(unit: DurationUnit, amount = 2): string {
+ const singular = unit.slice(0, -1);
+ return Math.abs(amount) === 1 ? singular : unit;
+}
+
+// Convert `amount` of `unit` into the stored integer, expressed in `baseUnit`.
+// Rounded because the caller may hand us a fractional intermediate (e.g. a
+// half-typed input); every real conversion here is exact.
+export function toBaseUnit(
+ amount: number,
+ unit: DurationUnit,
+ baseUnit: DurationUnit,
+): number {
+ if (!Number.isFinite(amount)) return 0;
+ return Math.round((amount * UNIT_SECONDS[unit]) / UNIT_SECONDS[baseUnit]);
+}
+
+// Split a stored value (in `baseUnit`) into the largest unit from `units` that
+// divides it evenly: 1440 minutes -> {1, "days"}, 137 minutes -> {137,
+// "minutes"}. Falls back to `baseUnit` when nothing divides evenly, even if
+// `baseUnit` isn't itself in `units` — that fallback is what preserves an
+// arbitrary hand-set value on a field whose presets are all hours and days.
+//
+// 0 and negatives are sentinels ("off"), not durations: they come back
+// unchanged in the base unit.
+export function splitDuration(
+ value: number,
+ units: DurationUnit[],
+ baseUnit: DurationUnit = units[0] ?? "minutes",
+): { amount: number; unit: DurationUnit } {
+ if (!Number.isFinite(value) || value <= 0) {
+ return { amount: Number.isFinite(value) ? value : 0, unit: baseUnit };
+ }
+ const seconds = value * UNIT_SECONDS[baseUnit];
+ const candidates = UNIT_ORDER.filter(
+ (u) => units.includes(u) && UNIT_SECONDS[u] >= UNIT_SECONDS[baseUnit],
+ );
+ for (let i = candidates.length - 1; i >= 0; i -= 1) {
+ const unit = candidates[i];
+ if (seconds % UNIT_SECONDS[unit] === 0) {
+ return { amount: seconds / UNIT_SECONDS[unit], unit };
+ }
+ }
+ return { amount: value, unit: baseUnit };
+}
+
+// "1,440 minutes" — the raw stored value, always shown so nothing is hidden
+// from someone who also reads settings.json.
+export function formatBaseValue(value: number, baseUnit: DurationUnit): string {
+ return `${value.toLocaleString("en-US")} ${unitLabel(baseUnit, value)}`;
+}
+
+// How many times something on this cadence happens per day. This is the number
+// an operator actually reasons about when judging whether they're about to get
+// rate-limited by the source.
+export function perDayRate(value: number, baseUnit: DurationUnit): number {
+ if (!Number.isFinite(value) || value <= 0) return 0;
+ return UNIT_SECONDS.days / (value * UNIT_SECONDS[baseUnit]);
+}
+
+// Round a rate to something readable: 0.7, 1, 24. Sub-0.1 rates collapse to a
+// "less than" form rather than printing 0.
+export function formatRate(rate: number): string {
+ if (rate <= 0) return "0";
+ if (rate < 0.1) return `<0.1`;
+ if (rate < 10) return String(Math.round(rate * 10) / 10);
+ return String(Math.round(rate));
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
+- **Every schedule is set in minutes, hours, or days now — not a raw minute count.** Seven different cadences in the editor were plain number boxes measured in minutes, which meant knowing that `10080` is a week and `44640` is the documented maximum. Each one is now an amount plus a unit, with a line underneath restating both the stored number and what it works out to — "Every 1,440 minutes · about 1 full listing fetch per channel per day" — so the rate you are about to ask of a video host is visible while you set it, not after. A cadence typed by hand into a config file keeps its exact value: 137 minutes stays 137 minutes rather than being rounded to the nearest tidy unit. The **deep pass** cadence is settable at last — globally in Settings and on the Scheduler page, and per channel on the channel form, where a big archive can be told to re-read its listing weekly while everything else does it daily. **Sync all** gained a **Full sweep all** button (and each channel a **Full sweep** one) for when you don't want to wait out a day of cadence — right after upgrading, say, when nothing has been swept yet. The Scheduler page now shows both cadences for every channel at a glance and can retune many at once: tick the channels, set one or both cadences, apply. Leaving a field alone leaves that cadence alone, so forty channels' deep passes can be re-tuned without touching anyone's ordinary sync.
+- **Fixed: the keep-latest check interval was reset to its default every time you saved settings.** It was the one scheduler cadence with no input anywhere in the app, and saving an unrelated setting quietly overwrote whatever you had put in the config file by hand. It now has an input, and the save path preserves any field the form doesn't render.
- **Sync now notices when a video disappears.** A channel's video list was being fetched three separate times for three purposes that never shared their work: "store playlist" refreshed the stored list, Sync walked the newest 50 entries at a time, and "Quick check" re-read the whole listing to find videos that had gone missing. Because Sync only ever saw the newest slice, it could never spot a deletion — and it never refreshed the stored list either, so "Download missing" and the report's not-yet-downloaded count kept working off whatever the last "store playlist" click wrote, possibly months earlier. Sync now periodically pays for **one** full read of the channel and gets all three out of it: the stored list is refreshed, videos that have left the listing are flagged, and new uploads are downloaded as before. That means **Sync all** surfaces upstream deletions across every channel on its own, where it used to take a per-channel "Quick check" click. The deep pass runs at most once a day per channel by default (it is much more expensive than a normal sync on a large channel); every sync in between stays exactly as cheap as it was. When a handful of videos are flagged — 25 or fewer by default — the same job goes on to work out which are deleted, private, or merely unlisted; past that it flags them and leaves the call to you. **What it will not do is download anything a normal sync wouldn't**: on a channel you deliberately keep only the newest few hundred of, a deep pass will not start dragging down the back catalogue. It changes what the editor *knows*, never what it *fetches*. If the channel listing comes back empty — a network blip, an expired cookie — the deep pass leaves the stored list and the missing-video flags untouched rather than concluding your whole archive vanished.
- **The editor is fast now.** Every page in the editor had a floor of about 4.4 seconds on it, and the reason was one line in the sidebar. The reclaimable-disk badge — the little "12.4 GB" pill next to Cleanup — asked for the channel list, and the function it asked was the one that counts the corpus from scratch: a `readdir` for each of the **78,350** video directories plus a digest sidecar read for each of the ~70,000 transcribed ones, **~474,559 files touched, measured at 3,985 ms**, to describe **98 videos**. Every count that walk produced was then thrown away. It sat in the root layout, so *every* document load paid it; the 5-second auto-refresh re-ran it on a timer, on every route, forever; and three widget endpoints called it on each poll. It now reads the 65 per-channel snapshots it could always have read — the same numbers, **68 ms**, a 59× improvement — and the sidebar badge itself is down to ~40 ms. Loading `/channels` went from 4.5 s to roughly a tenth of a second; the dashboard from ~10 s. The corpus-walking function still exists under a name that says what it costs (`listChannelStatsFromDisk`) for the batch jobs that genuinely need ground truth, and a test now fails the build if it ever reappears anywhere the editor renders. **The honest trade:** the video, transcript and download counts on `/channels` and the dashboard now come from each channel's last generated report rather than from disk directly, so a job that just finished can take a moment — the snapshot scheduler's ~1 second debounce — to show up. Verified against the live corpus: those three counts match a full walk **exactly** on all 65 channels. The one field that doesn't is digest coverage, which reads 0 for the 11 channels whose reports predate per-engine digest counts until their next report refresh. `/channels` now prints how old the oldest report on the page is, rather than leaving you to assume the numbers are live.
- **Auto-refresh no longer refreshes when nothing has changed.** The passive refresher called `router.refresh()` on a timer — a full server-side re-render of the entire page tree, every 5 seconds, on every route, whether or not anything had actually happened. Against the real corpus that was ~4.9 seconds of work per tick, and it held the editor's server process at roughly **22% of a CPU core, permanently, with a single idle tab open**. It now asks a new `/api/pulse` endpoint whether anything moved — a change token built from in-memory job, queue and worker state plus two file timestamps, no corpus reads at all — and re-renders only when the answer is yes. An idle page now performs **zero** re-renders where it used to perform one every five seconds; there's a test that fails if that ever regresses. Same setting, same 5-second default, same "0 disables" behaviour, and editing settings still repaints the sidebar immediately, because the settings file's timestamp is part of the token. The sidebar's job and reclaimable-disk pills now update on their own rather than requiring the whole page to re-render — which in turn let fifteen server actions stop invalidating the client's entire navigation cache to announce that a job count had changed. Also fixed while in here: opening a channel with no report used to **generate one inside the page load**, a full analysis of every video directory in that channel — minutes, on the big ones, with no progress and no way to stop it. It now shows a banner offering to run it as a normal background job, and renders the rest of the page as usual — the video list comes from disk, not the report, and the pipeline controls are exactly what you want on a channel you haven't analysed yet.
diff --git a/editor/app/channels/[slug]/components/stages/PlaylistStage.tsx b/editor/app/channels/[slug]/components/stages/PlaylistStage.tsx
@@ -81,6 +81,18 @@ export function PlaylistStage({
</div>
<div className="flex flex-col gap-2">
<Heading
+ title="Full sweep"
+ desc="The same sync, but forced to re-read the whole channel listing first: refreshes the stored playlist and flags videos that have gone missing upstream. Happens on its own cadence too — this is for when you don't want to wait."
+ />
+ <StreamActionLog
+ trigger={() => syncAction(slug, syncQueue, true)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Full sweep"
+ runningLabel="Sweeping…"
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Heading
title="Import single video"
desc="Fetch one off-playlist video by URL into this channel, using the channel's normal handling (subtitles for YouTube, audio for transcribe). It's added to the archive like any other download."
/>
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -23,8 +23,14 @@ import {
AUDIO_CHECK_MAX_ROLLBACKS_DEFAULT,
AUDIO_CHECK_MAX_ROLLBACKS_MAX,
AUDIO_CHECK_MAX_ROLLBACKS_MIN,
+ SYNC_INTERVAL_MAX_MINUTES,
+ SYNC_INTERVAL_MIN_MINUTES,
} from "yt-dlp-transcript-common/lib/channelConfig";
-import { SYNC_INTERVAL_PRESETS } from "../../scheduler/intervalPresets";
+import {
+ FULL_SWEEP_PRESETS,
+ SYNC_INTERVAL_PRESETS,
+} from "../../scheduler/intervalPresets";
+import { DurationField } from "../../components/DurationField";
import { handleFromAccountUrl } from "yt-dlp-transcript-common/social/fetchers";
import { probeChannelUrlAction } from "../actions";
import {
@@ -524,7 +530,10 @@ export function ChannelForm({
</label>
</>
)}
- <SyncIntervalField value={c?.syncIntervalMinutes} />
+ <SyncIntervalField
+ value={c?.syncIntervalMinutes}
+ fullSweepValue={c?.fullSweepIntervalMinutes}
+ />
</Section>
{/* Video-only sections. A social account has no audio, no download
format and no source video to retain, so they are hidden entirely
@@ -770,36 +779,63 @@ function CollapsibleSection({
);
}
-function SyncIntervalField({ value }: { value?: number }) {
- const current = value != null ? String(value) : "";
- const known = SYNC_INTERVAL_PRESETS.some((p) => p.value === current);
+// The two per-channel cadences. Both are DurationFields, so an arbitrary value
+// can now be *typed* here rather than only preserved — the old control was a
+// bare <select> whose only concession to a hand-edited config.json was a
+// synthetic extra <option>.
+function SyncIntervalField({
+ value,
+ fullSweepValue,
+}: {
+ value?: number;
+ fullSweepValue?: number;
+}) {
return (
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Auto-sync</span>
- <select
+ <>
+ <DurationField
name="syncIntervalMinutes"
- defaultValue={current}
- className="rounded border border-border bg-card px-2 py-1 text-sm"
- >
- {SYNC_INTERVAL_PRESETS.map((p) => (
- <option key={p.value} value={p.value}>
- {p.label}
- </option>
- ))}
- {/* Preserve a hand-set non-preset interval so saving doesn't change it. */}
- {!known && current !== "" && (
- <option value={current}>{`Every ${current} minutes`}</option>
- )}
- </select>
- <span className="text-xs text-muted-foreground">
- How often the scheduled sync system auto-syncs this channel, when the
- global scheduler is enabled in{" "}
- <a href="/settings" className="underline">
- Settings
- </a>
- . Requires a URL; excluded-from-sync channels never auto-sync.
- </span>
- </label>
+ label="Auto-sync (newest)"
+ baseUnit="minutes"
+ units={["minutes", "hours", "days"]}
+ value={value}
+ presets={SYNC_INTERVAL_PRESETS}
+ allowInherit
+ allowOff
+ offLabel="Off (never auto-sync)"
+ min={SYNC_INTERVAL_MIN_MINUTES}
+ max={SYNC_INTERVAL_MAX_MINUTES}
+ rate={{ noun: "check", per: "channel" }}
+ hint={
+ <>
+ Checks the newest videos and downloads what's new, when the
+ global scheduler is enabled in{" "}
+ <a href="/settings" className="underline">
+ Settings
+ </a>
+ . Requires a URL; excluded-from-sync channels never auto-sync.
+ </>
+ }
+ />
+ <DurationField
+ name="fullSweepIntervalMinutes"
+ label="Full sweep"
+ baseUnit="minutes"
+ units={["hours", "days"]}
+ value={fullSweepValue}
+ presets={FULL_SWEEP_PRESETS}
+ allowInherit
+ allowOff
+ offLabel="Off (never sweep)"
+ min={SYNC_INTERVAL_MIN_MINUTES}
+ max={SYNC_INTERVAL_MAX_MINUTES}
+ rate={{
+ noun: "full listing fetch",
+ pluralNoun: "full listing fetches",
+ per: "channel",
+ }}
+ hint="How often a sync upgrades itself to re-read the whole channel listing: refreshes the stored video list and flags videos that have gone missing upstream. Costs one full enumeration, so it runs far less often than auto-sync."
+ />
+ </>
);
}
diff --git a/editor/app/channels/components/SyncAllChannelsButton.tsx b/editor/app/channels/components/SyncAllChannelsButton.tsx
@@ -9,17 +9,17 @@ import {
type Status =
| { kind: "idle" }
- | { kind: "running" }
+ | { kind: "running"; fullSweep: boolean }
| { kind: "done"; result: SyncAllResult }
| { kind: "error"; message: string };
export function SyncAllChannelsButton() {
const [status, setStatus] = useState<Status>({ kind: "idle" });
- async function handleClick() {
- setStatus({ kind: "running" });
+ async function handleClick(fullSweep: boolean) {
+ setStatus({ kind: "running", fullSweep });
try {
- const result = await syncAllChannelsAction();
+ const result = await syncAllChannelsAction({ fullSweep });
setStatus({ kind: "done", result });
} catch (e) {
setStatus({ kind: "error", message: (e as Error).message });
@@ -27,16 +27,30 @@ export function SyncAllChannelsButton() {
}
const running = status.kind === "running";
+ const sweeping = status.kind === "running" && status.fullSweep;
return (
<div className="flex items-center gap-2">
<button
type="button"
- onClick={handleClick}
+ onClick={() => handleClick(false)}
disabled={running}
aria-label="sync all channels"
className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
- {running ? "Syncing…" : "Sync all"}
+ {running && !sweeping ? "Syncing…" : "Sync all"}
+ </button>
+ {/* Forces the deep pass on every channel instead of waiting out each
+ one's cadence — the button to reach for right after upgrading, when
+ nothing has swept yet and no playlist carries deletion flags. */}
+ <button
+ type="button"
+ onClick={() => handleClick(true)}
+ disabled={running}
+ title="Re-read every channel's whole listing: refresh its video list and flag videos that have gone missing upstream."
+ aria-label="full sweep all channels"
+ className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50"
+ >
+ {sweeping ? "Sweeping…" : "Full sweep all"}
</button>
{status.kind === "done" && (
<span
diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts
@@ -46,6 +46,7 @@ export const CHANNEL_FORM_FIELDS = [
"savedVideosDir",
"ytdlpExtraArgs",
"syncIntervalMinutes",
+ "fullSweepIntervalMinutes",
"sleepBetweenDownloadsSeconds",
"cookiesFromBrowser",
"cookieMode",
@@ -177,6 +178,28 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
syncIntervalMinutes = n;
}
+ // Full-sweep cadence — same three-state shape as the auto-sync interval
+ // above: "" inherits the global default, "0" disables the sweep for this
+ // channel, anything else is a concrete minute count.
+ const fullSweepRaw = String(
+ formData.get("fullSweepIntervalMinutes") ?? "",
+ ).trim();
+ let fullSweepIntervalMinutes: number | undefined;
+ if (fullSweepRaw) {
+ const n = Number.parseInt(fullSweepRaw, 10);
+ if (
+ !Number.isFinite(n) ||
+ n < 0 ||
+ (n !== 0 &&
+ (n < SYNC_INTERVAL_MIN_MINUTES || n > SYNC_INTERVAL_MAX_MINUTES))
+ ) {
+ throw new Error(
+ `Full sweep interval must be 0 (off) or ${SYNC_INTERVAL_MIN_MINUTES}–${SYNC_INTERVAL_MAX_MINUTES} minutes`,
+ );
+ }
+ fullSweepIntervalMinutes = n;
+ }
+
// Cookie overrides. Blank value / "Inherit global" mode = omit, so a cleared
// input actually clears the stored override (see CHANNEL_FORM_FIELDS).
const cookiesFromBrowser = stringOrUndef(formData, "cookiesFromBrowser");
@@ -261,6 +284,9 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
if (syncIntervalMinutes != null) {
config.syncIntervalMinutes = syncIntervalMinutes;
}
+ if (fullSweepIntervalMinutes != null) {
+ config.fullSweepIntervalMinutes = fullSweepIntervalMinutes;
+ }
if (sleepBetweenDownloadsSeconds != null) {
config.sleepBetweenDownloadsSeconds = sleepBetweenDownloadsSeconds;
}
diff --git a/editor/app/components/DurationField.tsx b/editor/app/components/DurationField.tsx
@@ -0,0 +1,267 @@
+"use client";
+
+import { useId, useState } from "react";
+import {
+ DURATION_KEEP,
+ formatBaseValue,
+ formatRate,
+ perDayRate,
+ splitDuration,
+ toBaseUnit,
+ unitLabel,
+ type DurationUnit,
+} from "yt-dlp-transcript-common/lib/duration";
+
+// A cadence control: pick a preset, or type an amount and choose a unit.
+//
+// Every duration in this app is stored as a bare integer in one fixed unit, and
+// the forms used to expose that integer directly — so setting a weekly sweep
+// meant knowing that 10080 is a week. This renders the same integer as an
+// amount + unit pair with the raw value echoed underneath, so nothing is hidden
+// from someone who also reads settings.json.
+//
+// Deliberately built on a native <select> and <input type="number"> rather than
+// the (unused) shadcn/Radix Select: Radix renders a <button role="combobox">,
+// which breaks Playwright's .selectOption() and would make every cadence spec
+// use a different idiom from the rest of the suite. It also copies the shape of
+// the hand-rolled fields around it — label > control > hint — so it doesn't
+// read as foreign.
+//
+// THE SUBMITTED VALUE IS A SINGLE HIDDEN INPUT in `baseUnit`. The visible
+// controls carry no `name`, so no server-side parser changes: every consumer
+// keeps reading a plain integer. The hidden input never emits "" outside the
+// explicit inherit mode, because the settings action reads blank as
+// "absent — keep what's saved", which would silently discard an edit.
+
+const inputClass = "rounded border border-border bg-card px-2 py-1 text-sm";
+
+export const DURATION_CUSTOM = "custom";
+const MODE_INHERIT = "inherit";
+const MODE_OFF = "off";
+const MODE_UNCHANGED = "unchanged";
+
+export type DurationPreset = { value: string; label: string };
+
+export type DurationFieldProps = {
+ /** Form field name — the hidden input that actually posts. */
+ name: string;
+ /** Unit of the stored integer. */
+ baseUnit: DurationUnit;
+ /** Units offered in the unit picker. */
+ units: DurationUnit[];
+ /** Stored value in `baseUnit`; undefined means "inherit". */
+ value: number | undefined;
+ label: string;
+ hint?: React.ReactNode;
+ /** Visible label is dropped (table cells); `label` still names the controls. */
+ hideLabel?: boolean;
+ /** Offer "Default (use global)", which submits "" so the key is cleared. */
+ allowInherit?: boolean;
+ /** Offer the 0 sentinel. */
+ allowOff?: boolean;
+ /** Offer "Leave unchanged" (bulk edits), which submits DURATION_KEEP. */
+ allowUnchanged?: boolean;
+ offLabel?: string;
+ inheritLabel?: string;
+ /** Shown in the echo line when inheriting, so the effective cadence is visible. */
+ inheritValue?: number;
+ /** Optional quick picks, values expressed in `baseUnit`. */
+ presets?: DurationPreset[];
+ min?: number;
+ max?: number;
+ /** Echo the derived rate: "about 1 full listing fetch per channel per day". */
+ rate?: { noun: string; pluralNoun?: string; per?: string };
+ className?: string;
+};
+
+type Mode = typeof MODE_INHERIT | typeof MODE_OFF | typeof DURATION_CUSTOM | string;
+
+// Map a stored value to the control's initial mode. Read ONCE, as a useState
+// initializer — the scheduler page re-renders these rows every 5s from a status
+// poll, and re-deriving state from props there would clobber an in-progress
+// edit. There is deliberately no prop -> state effect.
+function initialMode(
+ value: number | undefined,
+ presets: DurationPreset[],
+ allowInherit: boolean,
+ allowOff: boolean,
+ allowUnchanged: boolean,
+): Mode {
+ if (allowUnchanged) return MODE_UNCHANGED;
+ if (value === undefined) return allowInherit ? MODE_INHERIT : DURATION_CUSTOM;
+ if (value === 0 && allowOff) return MODE_OFF;
+ const hit = presets.find((p) => Number(p.value) === value);
+ return hit ? hit.value : DURATION_CUSTOM;
+}
+
+export function DurationField({
+ name,
+ baseUnit,
+ units,
+ value,
+ label,
+ hint,
+ hideLabel,
+ allowInherit = false,
+ allowOff = false,
+ allowUnchanged = false,
+ offLabel = "Off",
+ inheritLabel = "Default (use global)",
+ inheritValue,
+ presets = [],
+ min = 1,
+ max = 44640,
+ rate,
+ className,
+}: DurationFieldProps) {
+ const reactId = useId();
+ const errorId = `${reactId}-error`;
+ const [mode, setMode] = useState<Mode>(() =>
+ initialMode(value, presets, allowInherit, allowOff, allowUnchanged),
+ );
+ // Seeded from the stored value so switching to "Custom…" starts from what is
+ // already configured rather than an empty box. splitDuration is what keeps a
+ // hand-set 137 showing as "137 minutes" instead of rounding to "2 hours".
+ const seed = splitDuration(value && value > 0 ? value : min, units, baseUnit);
+ const [unit, setUnit] = useState<DurationUnit>(() => seed.unit);
+ const [amount, setAmount] = useState(() => String(seed.amount));
+
+ const hasModeSelect =
+ allowInherit || allowOff || allowUnchanged || presets.length > 0;
+ const isCustom = mode === DURATION_CUSTOM || !hasModeSelect;
+ const isInherit = mode === MODE_INHERIT;
+ const isOff = mode === MODE_OFF;
+ const isUnchanged = mode === MODE_UNCHANGED;
+
+ const parsedAmount = Number.parseInt(amount, 10);
+ const amountValid = Number.isFinite(parsedAmount) && parsedAmount > 0;
+ const customBase = amountValid
+ ? toBaseUnit(parsedAmount, unit, baseUnit)
+ : (value ?? min);
+ const outOfRange = amountValid && (customBase < min || customBase > max);
+
+ // What actually posts. "" only in inherit mode; otherwise always a concrete
+ // integer, so a blank box can't be read as "keep the old value".
+ let submitted: string;
+ if (isUnchanged) submitted = DURATION_KEEP;
+ else if (isInherit) submitted = "";
+ else if (isOff) submitted = "0";
+ else if (isCustom) submitted = String(customBase);
+ else submitted = mode;
+
+ const effective = isInherit
+ ? inheritValue
+ : Number.parseInt(submitted, 10) || 0;
+
+ // The unit currently displayed may be the base unit even when it isn't
+ // offered (a hand-set 137 on an hours/days field), so it has to be listed or
+ // the <select> would silently snap to another value.
+ const unitOptions = units.includes(unit) ? units : [unit, ...units];
+
+ return (
+ <div className={`flex flex-col gap-1 text-sm ${className ?? ""}`}>
+ {!hideLabel && <span className="font-medium">{label}</span>}
+ <div className="flex flex-wrap items-center gap-2">
+ {hasModeSelect && (
+ <select
+ aria-label={label}
+ value={mode}
+ onChange={(e) => setMode(e.target.value)}
+ className={inputClass}
+ >
+ {allowUnchanged && (
+ <option value={MODE_UNCHANGED}>Leave unchanged</option>
+ )}
+ {allowInherit && (
+ <option value={MODE_INHERIT}>{inheritLabel}</option>
+ )}
+ {allowOff && <option value={MODE_OFF}>{offLabel}</option>}
+ {presets.map((p) => (
+ <option key={p.value} value={p.value}>
+ {p.label}
+ </option>
+ ))}
+ <option value={DURATION_CUSTOM}>Custom…</option>
+ </select>
+ )}
+ {isCustom && (
+ <>
+ <input
+ type="number"
+ inputMode="numeric"
+ aria-label={`${label} amount`}
+ aria-invalid={outOfRange || !amountValid ? true : undefined}
+ aria-describedby={outOfRange ? errorId : undefined}
+ value={amount}
+ onChange={(e) => setAmount(e.target.value)}
+ min={1}
+ className={`${inputClass} w-24`}
+ />
+ <select
+ aria-label={`${label} unit`}
+ value={unit}
+ onChange={(e) => setUnit(e.target.value as DurationUnit)}
+ className={inputClass}
+ >
+ {unitOptions.map((u) => (
+ <option key={u} value={u}>
+ {unitLabel(u, parsedAmount || 2)}
+ </option>
+ ))}
+ </select>
+ </>
+ )}
+ </div>
+ <input type="hidden" name={name} value={submitted} />
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
+ <span
+ aria-live="polite"
+ className="text-xs text-muted-foreground tabular-nums"
+ >
+ {echoLine({ isUnchanged, isInherit, isOff, effective, baseUnit, rate })}
+ </span>
+ {outOfRange && (
+ <span id={errorId} role="alert" className="text-xs text-destructive">
+ Must be between {formatBaseValue(min, baseUnit)} and{" "}
+ {formatBaseValue(max, baseUnit)}.
+ </span>
+ )}
+ </div>
+ );
+}
+
+// The live restatement under the control: the raw stored number (so it stays
+// legible against settings.json) plus the derived rate, which is the number an
+// operator actually reasons about when judging rate-limit risk. Rendered from
+// the same value the hidden input posts, so it can never disagree with it.
+function echoLine({
+ isUnchanged,
+ isInherit,
+ isOff,
+ effective,
+ baseUnit,
+ rate,
+}: {
+ isUnchanged: boolean;
+ isInherit: boolean;
+ isOff: boolean;
+ effective: number | undefined;
+ baseUnit: DurationUnit;
+ rate?: { noun: string; pluralNoun?: string; per?: string };
+}): string {
+ if (isUnchanged) return "Left as each channel has it.";
+ if (isOff || effective === 0) return "Off — never runs.";
+ if (isInherit) {
+ return effective === undefined
+ ? "Uses the global default."
+ : `Uses the global default — every ${formatBaseValue(effective, baseUnit)}.`;
+ }
+ if (effective === undefined) return "";
+ const head = `Every ${formatBaseValue(effective, baseUnit)}`;
+ if (!rate) return `${head}.`;
+ const perDay = perDayRate(effective, baseUnit);
+ const noun =
+ perDay === 1 ? rate.noun : (rate.pluralNoun ?? `${rate.noun}s`);
+ const per = rate.per ? ` per ${rate.per}` : "";
+ return `${head} · about ${formatRate(perDay)} ${noun}${per} per day`;
+}
diff --git a/editor/app/saved-videos/components/SavedVideosControls.tsx b/editor/app/saved-videos/components/SavedVideosControls.tsx
@@ -2,7 +2,9 @@
import { useActionState, useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { SYNC_INTERVAL_MAX_MINUTES } from "yt-dlp-transcript-common/lib/channelConfig";
import { cancelJobAction } from "../../jobs/actions";
+import { DurationField } from "../../components/DurationField";
import {
backupSavedVideosAction,
saveSavedVideoBackupAction,
@@ -86,17 +88,17 @@ export function SavedVideosControls({ enabled, dest, intervalMinutes }: Props) {
</span>
</span>
</label>
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Backup interval (minutes)</span>
- <input
- type="number"
- name="backupIntervalMinutes"
- defaultValue={String(intervalMinutes)}
- min={1}
- aria-label="backup interval minutes"
- className="rounded border border-border bg-card px-2 py-1 text-sm w-40"
- />
- </label>
+ <DurationField
+ label="Backup interval"
+ name="backupIntervalMinutes"
+ baseUnit="minutes"
+ units={["hours", "days"]}
+ value={intervalMinutes}
+ min={1}
+ max={SYNC_INTERVAL_MAX_MINUTES}
+ rate={{ noun: "backup" }}
+ className="max-w-md"
+ />
<div>
<button
type="submit"
diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts
@@ -16,66 +16,91 @@ import {
writeSettings,
type SiteSettings,
} from "yt-dlp-transcript-common/lib/settings";
-import { SYNC_INTERVAL_CUSTOM } from "./intervalPresets";
+import { DURATION_KEEP } from "yt-dlp-transcript-common/lib/duration";
export type SaveResult = { ok: true } | { ok: false; error: string };
-// Resolve the submitted interval to a stored value. The editor posts a `preset`
-// (one of SYNC_INTERVAL_PRESETS values, or SYNC_INTERVAL_CUSTOM) plus, in custom
-// mode, a free `customMinutes` number. Returns:
-// undefined -> clear the field (inherit the global default)
-// 0 -> off (never auto-sync)
+// One cadence field, as posted by DurationField. Four outcomes:
+// "keep" -> leave the stored value alone (bulk edits skipping this field)
+// "" -> clear the key (inherit the global default)
+// 0 -> off (never)
// >0 -> explicit minute count
-// Throws on invalid input so the action can surface a friendly error.
-function resolveSubmittedInterval(formData: FormData): number | undefined {
- const preset = String(formData.get("preset") ?? "").trim();
- const raw =
- preset === SYNC_INTERVAL_CUSTOM
- ? String(formData.get("customMinutes") ?? "").trim()
- : preset;
- if (!raw) return undefined; // inherit global default
- const n = Number.parseInt(raw, 10);
+// Throws on out-of-range input — rejecting is better than clamping, because a
+// clamp would silently save a cadence nobody asked for.
+type CadenceEdit = "keep" | { minutes: number | undefined };
+
+function resolveCadenceField(
+ formData: FormData,
+ key: string,
+ label: string,
+): CadenceEdit {
+ const raw = formData.get(key);
+ if (raw === null) return "keep";
+ const trimmed = String(raw).trim();
+ if (trimmed === DURATION_KEEP) return "keep";
+ if (!trimmed) return { minutes: undefined }; // inherit global default
+ const n = Number.parseInt(trimmed, 10);
if (
!Number.isFinite(n) ||
n < 0 ||
(n !== 0 && (n < SYNC_INTERVAL_MIN_MINUTES || n > SYNC_INTERVAL_MAX_MINUTES))
) {
throw new Error(
- `Auto-sync interval must be 0 (off) or ${SYNC_INTERVAL_MIN_MINUTES}–${SYNC_INTERVAL_MAX_MINUTES} minutes`,
+ `${label} must be 0 (off) or ${SYNC_INTERVAL_MIN_MINUTES}–${SYNC_INTERVAL_MAX_MINUTES} minutes`,
);
}
- return n;
+ return { minutes: n };
}
-// Update just one channel's per-channel auto-sync cadence. Unlike the full
-// channel form's updateChannelAction, this touches only syncIntervalMinutes and
-// preserves every other field, so it's safe to call from the scheduler page.
-export async function setChannelSyncIntervalAction(
- slug: string,
+// Set the auto-sync and/or full-sweep cadence on one or many channels. Unlike
+// the full channel form's updateChannelAction this read-modify-writes only the
+// two cadence keys and preserves every other field, so it's safe to fire from
+// the scheduler page — including across a multi-channel selection, where the
+// point is to retune sweeps without disturbing anyone's auto-sync.
+export async function setChannelCadencesAction(
+ slugs: string[],
_prev: SaveResult | undefined,
formData: FormData,
): Promise<SaveResult> {
- let minutes: number | undefined;
+ let sync: CadenceEdit;
+ let sweep: CadenceEdit;
try {
- minutes = resolveSubmittedInterval(formData);
+ sync = resolveCadenceField(
+ formData,
+ "syncIntervalMinutes",
+ "Auto-sync interval",
+ );
+ sweep = resolveCadenceField(
+ formData,
+ "fullSweepIntervalMinutes",
+ "Full sweep interval",
+ );
} catch (e) {
return { ok: false, error: (e as Error).message };
}
+ if (slugs.length === 0) return { ok: false, error: "No channels selected" };
+ if (sync === "keep" && sweep === "keep") return { ok: true }; // nothing to do
+
const paths = getPaths();
- const existing = await readChannelConfig(paths, slug);
- if (!existing) return { ok: false, error: `Channel "${slug}" not found` };
- const next = { ...existing };
- if (minutes === undefined) {
- delete next.syncIntervalMinutes;
- } else {
- next.syncIntervalMinutes = minutes;
+ for (const slug of slugs) {
+ const existing = await readChannelConfig(paths, slug);
+ if (!existing) return { ok: false, error: `Channel "${slug}" not found` };
+ const next = { ...existing };
+ if (sync !== "keep") {
+ if (sync.minutes === undefined) delete next.syncIntervalMinutes;
+ else next.syncIntervalMinutes = sync.minutes;
+ }
+ if (sweep !== "keep") {
+ if (sweep.minutes === undefined) delete next.fullSweepIntervalMinutes;
+ else next.fullSweepIntervalMinutes = sweep.minutes;
+ }
+ await writeChannelConfig(paths, slug, next);
+ // Keep the channel report/badges in sync with the config edit.
+ requestChannelSnapshot(paths, slug);
+ revalidatePath(`/channels/${slug}`);
}
- await writeChannelConfig(paths, slug, next);
- // Keep the channel report/badges in sync with the config edit.
- requestChannelSnapshot(paths, slug);
revalidatePath("/scheduler");
revalidatePath("/channels");
- revalidatePath(`/channels/${slug}`);
return { ok: true };
}
@@ -87,16 +112,36 @@ export async function saveSchedulerSettingsAction(
_prev: SaveResult | undefined,
formData: FormData,
): Promise<SaveResult> {
- const intOrNaN = (key: string): number =>
- Number.parseInt(String(formData.get(key) ?? "").trim(), 10);
const current = getSettings();
+ // An absent or blank field keeps the saved value rather than coercing to NaN
+ // and resetting it to the default — this form renders a subset of the sync
+ // scheduler block, so a "Save controls" click must never disturb the fields
+ // it doesn't show.
+ const intOrKeep = (key: string, fallback: number): number => {
+ const raw = formData.get(key);
+ if (raw === null) return fallback;
+ const trimmed = String(raw).trim();
+ if (!trimmed) return fallback;
+ const n = Number.parseInt(trimmed, 10);
+ return Number.isFinite(n) ? n : fallback;
+ };
const next: SiteSettings = {
...current,
syncScheduler: {
...current.syncScheduler,
enabled: formData.get("syncSchedulerEnabled") === "on",
- defaultIntervalMinutes: intOrNaN("syncSchedulerDefaultIntervalMinutes"),
- heartbeatSeconds: intOrNaN("syncSchedulerHeartbeatSeconds"),
+ defaultIntervalMinutes: intOrKeep(
+ "syncSchedulerDefaultIntervalMinutes",
+ current.syncScheduler.defaultIntervalMinutes,
+ ),
+ heartbeatSeconds: intOrKeep(
+ "syncSchedulerHeartbeatSeconds",
+ current.syncScheduler.heartbeatSeconds,
+ ),
+ fullSweepIntervalMinutes: intOrKeep(
+ "syncSchedulerFullSweepIntervalMinutes",
+ current.syncScheduler.fullSweepIntervalMinutes,
+ ),
},
};
try {
diff --git a/editor/app/scheduler/components/BulkCadenceBar.tsx b/editor/app/scheduler/components/BulkCadenceBar.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useActionState } from "react";
+import { setChannelCadencesAction, type SaveResult } from "../actions";
+import { DurationField } from "../../components/DurationField";
+import {
+ FULL_SWEEP_PRESETS,
+ SYNC_INTERVAL_PRESETS,
+} from "../intervalPresets";
+
+// Sticky footer bar that appears once rows are selected. Both fields default to
+// "Leave unchanged", so sweeps can be retuned across forty channels without
+// disturbing anyone's auto-sync — the whole reason this page is the right place
+// for a per-channel override on a corpus where one channel wants a weekly sweep
+// and everything else wants a daily one.
+export function BulkCadenceBar({
+ slugs,
+ onClear,
+ onApplied,
+}: {
+ slugs: string[];
+ onClear: () => void;
+ onApplied: () => void;
+}) {
+ const [state, formAction, pending] = useActionState<
+ SaveResult | undefined,
+ FormData
+ >(async (prev, formData) => {
+ const result = await setChannelCadencesAction(slugs, prev, formData);
+ if (result.ok) onApplied();
+ return result;
+ }, undefined);
+
+ return (
+ <form
+ action={formAction}
+ aria-label="bulk cadence"
+ className="sticky bottom-0 z-10 flex flex-col gap-3 rounded border border-border bg-card p-3 shadow-lg sm:flex-row sm:flex-wrap sm:items-end motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2"
+ >
+ <span className="text-sm font-medium sm:self-center">
+ {slugs.length} selected
+ </span>
+ <DurationField
+ name="syncIntervalMinutes"
+ label="Bulk auto-sync interval"
+ baseUnit="minutes"
+ units={["minutes", "hours", "days"]}
+ value={undefined}
+ presets={SYNC_INTERVAL_PRESETS}
+ allowUnchanged
+ allowInherit
+ allowOff
+ offLabel="Off (never auto-sync)"
+ max={44640}
+ className="sm:w-64"
+ />
+ <DurationField
+ name="fullSweepIntervalMinutes"
+ label="Bulk full sweep interval"
+ baseUnit="minutes"
+ units={["hours", "days"]}
+ value={undefined}
+ presets={FULL_SWEEP_PRESETS}
+ allowUnchanged
+ allowInherit
+ allowOff
+ offLabel="Off (never sweep)"
+ max={44640}
+ className="sm:w-64"
+ />
+ <div className="flex items-center gap-2 sm:pb-1">
+ <button
+ type="submit"
+ disabled={pending}
+ className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Applying…" : "Apply"}
+ </button>
+ <button
+ type="button"
+ onClick={onClear}
+ className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted"
+ >
+ Clear
+ </button>
+ {state?.ok === true && (
+ <span role="status" className="text-sm text-success">
+ Applied.
+ </span>
+ )}
+ {state?.ok === false && (
+ <span role="alert" className="text-sm text-destructive">
+ {state.error}
+ </span>
+ )}
+ </div>
+ </form>
+ );
+}
diff --git a/editor/app/scheduler/components/ChannelCadenceEditor.tsx b/editor/app/scheduler/components/ChannelCadenceEditor.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import { setChannelCadencesAction, type SaveResult } from "../actions";
+import { DurationField } from "../../components/DurationField";
+import {
+ FULL_SWEEP_PRESETS,
+ SYNC_INTERVAL_PRESETS,
+} from "../intervalPresets";
+import { formatCadence } from "./cadence";
+
+// Per-row cadence cell for the scheduler table. Collapsed to two read-only
+// chips by default — this page's job is to show every channel's cadence at
+// once, and two full editors per row would bury that — expanding to the pair of
+// editors on click.
+//
+// Holds its own state (seeded once from props) so the page's 5s status poll
+// can't clobber an in-progress edit: the poll replaces the whole channels array
+// and re-renders every row, but rows are keyed by slug so this is re-rendered,
+// never remounted, and React discards the recomputed initial value. There must
+// be no prop -> state effect here. scheduler.spec.ts guards exactly this.
+export function ChannelCadenceEditor({
+ slug,
+ configuredInterval,
+ configuredFullSweep,
+ defaultIntervalMinutes,
+ defaultFullSweepMinutes,
+}: {
+ slug: string;
+ configuredInterval: number | undefined;
+ configuredFullSweep: number | undefined;
+ defaultIntervalMinutes: number;
+ defaultFullSweepMinutes: number;
+}) {
+ const [open, setOpen] = useState(false);
+ const [state, formAction, pending] = useActionState<
+ SaveResult | undefined,
+ FormData
+ >(setChannelCadencesAction.bind(null, [slug]), undefined);
+
+ if (!open) {
+ return (
+ <button
+ type="button"
+ onClick={() => setOpen(true)}
+ aria-label={`Edit cadence for ${slug}`}
+ className="flex flex-wrap items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-muted"
+ >
+ <Chip label="Auto" value={formatCadence(configuredInterval)} />
+ <Chip label="Sweep" value={formatCadence(configuredFullSweep)} />
+ </button>
+ );
+ }
+
+ return (
+ <form action={formAction} className="flex flex-col gap-2 min-w-72">
+ <DurationField
+ name="syncIntervalMinutes"
+ label={`Auto-sync interval for ${slug}`}
+ hideLabel
+ baseUnit="minutes"
+ units={["minutes", "hours", "days"]}
+ value={configuredInterval}
+ presets={SYNC_INTERVAL_PRESETS}
+ allowInherit
+ allowOff
+ offLabel="Off (never auto-sync)"
+ inheritValue={defaultIntervalMinutes}
+ max={44640}
+ />
+ {/* Distinct label prefix on purpose: a second control matching
+ /Auto-sync interval/ would make the existing row-scoped getByLabel
+ queries strict-mode-ambiguous. */}
+ <DurationField
+ name="fullSweepIntervalMinutes"
+ label={`Full sweep interval for ${slug}`}
+ hideLabel
+ baseUnit="minutes"
+ units={["hours", "days"]}
+ value={configuredFullSweep}
+ presets={FULL_SWEEP_PRESETS}
+ allowInherit
+ allowOff
+ offLabel="Off (never sweep)"
+ inheritValue={defaultFullSweepMinutes}
+ max={44640}
+ />
+ {/* One Save for both cadences — a second per-row submit would make
+ getByRole("button", { name: "Save" }) ambiguous inside the row. */}
+ <div className="flex items-center gap-2">
+ <button
+ type="submit"
+ disabled={pending}
+ className="px-2 py-1 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : "Save"}
+ </button>
+ <button
+ type="button"
+ onClick={() => setOpen(false)}
+ className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted"
+ >
+ Close
+ </button>
+ {state?.ok === true && (
+ <span role="status" className="text-xs text-success">
+ Saved
+ </span>
+ )}
+ {state?.ok === false && (
+ <span role="alert" className="text-xs text-destructive">
+ {state.error}
+ </span>
+ )}
+ </div>
+ </form>
+ );
+}
+
+function Chip({ label, value }: { label: string; value: string }) {
+ return (
+ <span className="rounded-full border border-border bg-card px-2 py-0.5 text-xs whitespace-nowrap">
+ <span className="text-muted-foreground">{label} </span>
+ <span className="tabular-nums">{value}</span>
+ </span>
+ );
+}
diff --git a/editor/app/scheduler/components/ChannelIntervalEditor.tsx b/editor/app/scheduler/components/ChannelIntervalEditor.tsx
@@ -1,98 +0,0 @@
-"use client";
-
-import { useActionState, useState } from "react";
-import {
- setChannelSyncIntervalAction,
- type SaveResult,
-} from "../actions";
-import {
- isPresetIntervalMinutes,
- SYNC_INTERVAL_CUSTOM,
- SYNC_INTERVAL_PRESETS,
-} from "../intervalPresets";
-
-const inputClass =
- "rounded border border-border bg-card px-2 py-1 text-sm";
-
-// Map a stored per-channel interval to the editor's initial control state.
-// undefined -> "" (inherit global default)
-// matches a preset -> that preset value
-// any other positive value -> custom mode, prefilled
-function initialState(configured: number | undefined): {
- preset: string;
- custom: string;
-} {
- if (configured === undefined) return { preset: "", custom: "" };
- if (configured === 0 || isPresetIntervalMinutes(configured)) {
- return { preset: String(configured), custom: "" };
- }
- return { preset: SYNC_INTERVAL_CUSTOM, custom: String(configured) };
-}
-
-// Inline per-channel interval editor for the scheduler page. Holds its own state
-// (seeded once from `configured`) so the page's 5s status poll can't clobber an
-// in-progress edit — the surrounding live status columns keep refreshing.
-export function ChannelIntervalEditor({
- slug,
- configured,
-}: {
- slug: string;
- configured: number | undefined;
-}) {
- const init = initialState(configured);
- const [preset, setPreset] = useState(init.preset);
- const [custom, setCustom] = useState(init.custom);
- const [state, formAction, pending] = useActionState<
- SaveResult | undefined,
- FormData
- >(setChannelSyncIntervalAction.bind(null, slug), undefined);
-
- return (
- <form action={formAction} className="flex flex-wrap items-center gap-2">
- <select
- name="preset"
- aria-label={`Auto-sync interval for ${slug}`}
- value={preset}
- onChange={(e) => setPreset(e.target.value)}
- className={inputClass}
- >
- {SYNC_INTERVAL_PRESETS.map((p) => (
- <option key={p.value} value={p.value}>
- {p.label}
- </option>
- ))}
- <option value={SYNC_INTERVAL_CUSTOM}>Custom (minutes)…</option>
- </select>
- {preset === SYNC_INTERVAL_CUSTOM && (
- <input
- type="number"
- name="customMinutes"
- aria-label={`Custom interval minutes for ${slug}`}
- min={1}
- max={44640}
- value={custom}
- onChange={(e) => setCustom(e.target.value)}
- placeholder="minutes"
- className={`${inputClass} w-24`}
- />
- )}
- <button
- type="submit"
- disabled={pending}
- className="px-2 py-1 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 disabled:opacity-50"
- >
- {pending ? "Saving…" : "Save"}
- </button>
- {state?.ok === true && (
- <span role="status" className="text-xs text-success">
- Saved
- </span>
- )}
- {state?.ok === false && (
- <span role="alert" className="text-xs text-destructive">
- {state.error}
- </span>
- )}
- </form>
- );
-}
diff --git a/editor/app/scheduler/components/SchedulerSettingsForm.tsx b/editor/app/scheduler/components/SchedulerSettingsForm.tsx
@@ -6,9 +6,8 @@ import {
saveSchedulerSettingsAction,
type SaveResult,
} from "../actions";
-
-const inputClass =
- "rounded border border-border bg-card px-2 py-1 text-sm";
+import { DurationField } from "../../components/DurationField";
+import { FULL_SWEEP_PRESETS } from "../intervalPresets";
// The headline global controls, editable inline on the scheduler page. Advanced
// knobs (concurrency, quiet hours, backoff) stay on /settings to avoid a sprawl;
@@ -50,32 +49,51 @@ export function SchedulerSettingsForm({
</span>
</span>
</label>
- <div className="flex flex-wrap gap-3">
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Default interval (minutes)</span>
- <input
- type="number"
- name="syncSchedulerDefaultIntervalMinutes"
- defaultValue={String(scheduler.defaultIntervalMinutes)}
- className={`${inputClass} w-40`}
- />
- <span className="text-xs text-muted-foreground">
- Cadence for channels left on “Default”. 60 = hourly, 1440 = daily.
- </span>
- </label>
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Internal heartbeat (seconds)</span>
- <input
- type="number"
- name="syncSchedulerHeartbeatSeconds"
- defaultValue={String(heartbeatSeconds)}
- className={`${inputClass} w-40`}
- />
- <span className="text-xs text-muted-foreground">
- 0 = off (use an external cron heartbeat). Clamped to 15–3600s.
- Turning it on from 0 needs a server restart.
- </span>
- </label>
+ <div className="flex flex-wrap gap-4">
+ <DurationField
+ label="Default interval"
+ name="syncSchedulerDefaultIntervalMinutes"
+ baseUnit="minutes"
+ units={["minutes", "hours", "days"]}
+ value={scheduler.defaultIntervalMinutes}
+ min={1}
+ max={44640}
+ rate={{ noun: "check", per: "channel" }}
+ hint="Auto-sync cadence for channels left on “Default”."
+ className="max-w-sm"
+ />
+ <DurationField
+ label="Default full sweep interval"
+ name="syncSchedulerFullSweepIntervalMinutes"
+ baseUnit="minutes"
+ units={["hours", "days"]}
+ value={scheduler.fullSweepIntervalMinutes}
+ presets={FULL_SWEEP_PRESETS}
+ allowOff
+ offLabel="Off (never sweep)"
+ min={1}
+ max={44640}
+ rate={{
+ noun: "full listing fetch",
+ pluralNoun: "full listing fetches",
+ per: "channel",
+ }}
+ hint="How often a sync re-reads a channel's whole listing to refresh it and flag missing videos. Channels can override this."
+ className="max-w-sm"
+ />
+ <DurationField
+ label="Internal heartbeat"
+ name="syncSchedulerHeartbeatSeconds"
+ baseUnit="seconds"
+ units={["seconds", "minutes"]}
+ value={heartbeatSeconds}
+ allowOff
+ offLabel="Off (external cron)"
+ min={15}
+ max={3600}
+ hint="When off, drive ticks with an external cron heartbeat. Clamped to 15–3600s; turning it on from off needs a server restart."
+ className="max-w-sm"
+ />
</div>
<div className="flex items-center gap-3">
<button
diff --git a/editor/app/scheduler/components/SchedulerView.tsx b/editor/app/scheduler/components/SchedulerView.tsx
@@ -2,7 +2,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SchedulerStatusPayload } from "../status";
-import { ChannelIntervalEditor } from "./ChannelIntervalEditor";
+import { BulkCadenceBar } from "./BulkCadenceBar";
+import { ChannelCadenceEditor } from "./ChannelCadenceEditor";
import { SchedulerSettingsForm } from "./SchedulerSettingsForm";
export function SchedulerView({
@@ -13,6 +14,9 @@ export function SchedulerView({
const [data, setData] = useState<SchedulerStatusPayload>(initial);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
+ // Slugs ticked for a bulk cadence edit. Kept as a Set of slugs (not indices)
+ // so the 5s poll reordering or dropping a row can't retarget a selection.
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const mounted = useRef(true);
const refresh = useCallback(async () => {
@@ -66,6 +70,25 @@ export function SchedulerView({
const { scheduler, channels, runs, now, heartbeatSeconds } = data;
const eligible = channels.filter((c) => c.autoSyncEligible);
+ // A slug can vanish between polls (channel deleted elsewhere), so the bar
+ // always acts on the intersection with what's on screen.
+ const selectedSlugs = channels
+ .map((c) => c.slug)
+ .filter((s) => selected.has(s));
+ const allSelected =
+ channels.length > 0 && selectedSlugs.length === channels.length;
+
+ function toggleOne(slug: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (!next.delete(slug)) next.add(slug);
+ return next;
+ });
+ }
+
+ function toggleAll(checked: boolean) {
+ setSelected(checked ? new Set(channels.map((c) => c.slug)) : new Set());
+ }
return (
<div className="flex flex-col gap-5">
@@ -81,7 +104,8 @@ export function SchedulerView({
</span>
<span className="text-sm text-muted-foreground">
{eligible.length} channel{eligible.length === 1 ? "" : "s"} auto-syncing
- · default {formatInterval(scheduler.defaultIntervalMinutes)} · max{" "}
+ · default {formatInterval(scheduler.defaultIntervalMinutes)} · full
+ sweep {formatInterval(scheduler.fullSweepIntervalMinutes)} · max{" "}
{scheduler.maxConcurrentSyncs} concurrent ·{" "}
{heartbeatSeconds > 0
? `internal heartbeat every ${formatSeconds(heartbeatSeconds)}`
@@ -111,8 +135,16 @@ export function SchedulerView({
<table className="w-full text-sm">
<thead className="bg-card text-left text-muted-foreground">
<tr>
+ <th className="px-2 py-2 font-medium">
+ <input
+ type="checkbox"
+ aria-label="select all channels"
+ checked={allSelected}
+ onChange={(e) => toggleAll(e.target.checked)}
+ />
+ </th>
<th className="px-3 py-2 font-medium">Channel</th>
- <th className="px-3 py-2 font-medium">Interval</th>
+ <th className="px-3 py-2 font-medium">Cadence</th>
<th className="px-3 py-2 font-medium">Last synced</th>
<th className="px-3 py-2 font-medium">Next due</th>
<th className="px-3 py-2 font-medium">Status</th>
@@ -121,7 +153,7 @@ export function SchedulerView({
<tbody>
{channels.length === 0 && (
<tr>
- <td colSpan={5} className="px-3 py-4 text-muted-foreground">
+ <td colSpan={6} className="px-3 py-4 text-muted-foreground">
No channels.
</td>
</tr>
@@ -131,22 +163,36 @@ export function SchedulerView({
key={c.slug}
className="border-t border-border"
>
+ <td className="px-2 py-2">
+ <input
+ type="checkbox"
+ aria-label={`select ${c.slug}`}
+ checked={selected.has(c.slug)}
+ onChange={() => toggleOne(c.slug)}
+ />
+ </td>
<td className="px-3 py-2">
<a href={`/channels/${c.slug}`} className="hover:underline">
{c.name ?? c.slug}
</a>
</td>
<td className="px-3 py-2">
- <ChannelIntervalEditor
+ <ChannelCadenceEditor
slug={c.slug}
- configured={c.configuredIntervalMinutes}
+ configuredInterval={c.configuredIntervalMinutes}
+ configuredFullSweep={c.configuredFullSweepMinutes}
+ defaultIntervalMinutes={scheduler.defaultIntervalMinutes}
+ defaultFullSweepMinutes={scheduler.fullSweepIntervalMinutes}
/>
</td>
<td className="px-3 py-2 text-muted-foreground">
{c.lastSyncedAt ? formatAgo(c.lastSyncedAt, now) : "never"}
</td>
<td className="px-3 py-2 text-muted-foreground">
- {nextDueLabel(c, now)}
+ <span>{nextDueLabel(c, now)}</span>
+ <span className="block text-xs">
+ {nextSweepLabel(c, now)}
+ </span>
</td>
<td className="px-3 py-2">{statusBadge(c)}</td>
</tr>
@@ -155,6 +201,14 @@ export function SchedulerView({
</table>
</div>
+ {selectedSlugs.length > 0 && (
+ <BulkCadenceBar
+ slugs={selectedSlugs}
+ onClear={() => setSelected(new Set())}
+ onApplied={refresh}
+ />
+ )}
+
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold">Recent ticks</h2>
{runs.length === 0 ? (
@@ -219,6 +273,17 @@ function nextDueLabel(c: ChannelRow, now: number): string {
return `in ${formatDuration(c.nextDueAt - now)}`;
}
+// The full-sweep half of "Next due". A never-swept channel with sweeps on is due
+// immediately — that first sweep is what seeds the stored playlist and the
+// missing-video flags — which is why nextFullSweepAt is null there rather than a
+// timestamp.
+function nextSweepLabel(c: ChannelRow, now: number): string {
+ if (c.fullSweepIntervalMinutes <= 0) return "sweep off";
+ if (c.nextFullSweepAt == null) return "sweep due";
+ if (now >= c.nextFullSweepAt) return "sweep due";
+ return `sweep in ${formatDuration(c.nextFullSweepAt - now)}`;
+}
+
function formatInterval(minutes: number): string {
if (minutes <= 0) return "off";
if (minutes % 1440 === 0) {
diff --git a/editor/app/scheduler/components/cadence.ts b/editor/app/scheduler/components/cadence.ts
@@ -0,0 +1,16 @@
+// Compact cadence labels for the scheduler table's chips: "10m", "1w", "off",
+// "default". Deliberately terse — the point of the column is that 40 channels'
+// cadences stay scannable at a glance; the expanded editor spells everything
+// out in full.
+export function formatCadence(configuredMinutes: number | undefined): string {
+ if (configuredMinutes === undefined) return "default";
+ return formatMinutes(configuredMinutes);
+}
+
+export function formatMinutes(minutes: number): string {
+ if (minutes <= 0) return "off";
+ if (minutes % 10080 === 0) return `${minutes / 10080}w`;
+ if (minutes % 1440 === 0) return `${minutes / 1440}d`;
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
+ return `${minutes}m`;
+}
diff --git a/editor/app/scheduler/intervalPresets.ts b/editor/app/scheduler/intervalPresets.ts
@@ -1,12 +1,9 @@
-// Shared per-channel auto-sync interval presets, used by both the channel edit
-// form (ChannelForm) and the scheduler page's inline interval editor.
-// Values are the raw `syncIntervalMinutes` string the form submits:
-// "" -> inherit the global default (field absent from config)
-// "0" -> off (never auto-sync this channel)
-// "N" -> a concrete minute count
+// Shared cadence quick-picks for the two DurationFields that set a channel's
+// schedule: the channel edit form (ChannelForm) and the scheduler page's inline
+// row editor and bulk bar. Values are the raw minute-count string the field's
+// hidden input submits; "" (inherit) and "0" (off) are rendered by
+// DurationField itself from allowInherit/allowOff, so they aren't listed here.
export const SYNC_INTERVAL_PRESETS: { value: string; label: string }[] = [
- { value: "", label: "Default (use global)" },
- { value: "0", label: "Off (never auto-sync)" },
{ value: "10", label: "Every 10 minutes" },
{ value: "30", label: "Every 30 minutes" },
{ value: "60", label: "Hourly" },
@@ -16,14 +13,15 @@ export const SYNC_INTERVAL_PRESETS: { value: string; label: string }[] = [
{ value: "10080", label: "Weekly" },
];
-// The sentinel the scheduler-page editor's <select> uses for "type an exact
-// minute count" — distinct from any real preset value so it never collides.
-export const SYNC_INTERVAL_CUSTOM = "custom";
-
-// True when `minutes` exactly matches one of the concrete preset values, so the
-// editor can decide whether to start in preset or custom mode.
-export function isPresetIntervalMinutes(minutes: number): boolean {
- return SYNC_INTERVAL_PRESETS.some(
- (p) => p.value !== "" && Number(p.value) === minutes,
- );
-}
+// Full-sweep quick picks, weighted toward the long end: a sweep re-enumerates
+// the entire listing in one yt-dlp spawn, so it's a daily-to-weekly concern
+// where auto-sync is a minutes-to-hours one. Anything between these is still
+// reachable through the field's Custom… mode.
+export const FULL_SWEEP_PRESETS: { value: string; label: string }[] = [
+ { value: "360", label: "Every 6 hours" },
+ { value: "720", label: "Every 12 hours" },
+ { value: "1440", label: "Daily" },
+ { value: "4320", label: "Every 3 days" },
+ { value: "10080", label: "Weekly" },
+ { value: "43200", label: "Every 30 days" },
+];
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -184,8 +184,12 @@ export async function saveSettingsAction(
backoffBaseMinutes: intOrNaN("syncSchedulerBackoffBaseMinutes"),
backoffMaxMinutes: intOrNaN("syncSchedulerBackoffMaxMinutes"),
heartbeatSeconds: intOrNaN("syncSchedulerHeartbeatSeconds"),
- keepLatestCheckIntervalMinutes: intOrNaN(
+ // Had no input at all until the cadence UI landed, so every unrelated save
+ // used to coerce it to NaN and silently reset it to the default. intOrKeep
+ // makes that impossible even if a future render drops the field again.
+ keepLatestCheckIntervalMinutes: intOrKeep(
"syncSchedulerKeepLatestCheckIntervalMinutes",
+ current.syncScheduler.keepLatestCheckIntervalMinutes,
),
// Full-sweep cadence + auto-confirm cap. Read from the form when present,
// otherwise the currently-saved value is preserved: these are 0-sentinel
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -21,6 +21,12 @@ import {
} from "yt-dlp-transcript-common/ytdlp/downloadFormat";
import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps";
import type { DigestAppDescriptor } from "yt-dlp-transcript-common/lib/digestApps";
+import {
+ SYNC_INTERVAL_MAX_MINUTES,
+ SYNC_INTERVAL_MIN_MINUTES,
+} from "yt-dlp-transcript-common/lib/channelConfig";
+import { DurationField } from "../../components/DurationField";
+import { FULL_SWEEP_PRESETS } from "../../scheduler/intervalPresets";
import { DigestAppsField } from "./DigestAppsField";
import {
SocialLinksField,
@@ -327,12 +333,43 @@ export function SettingsForm({ initial, apps, digestApps }: Props) {
</span>
</span>
</label>
- <Field
- label="Default interval (minutes)"
+ <DurationField
+ label="Default interval"
name="syncSchedulerDefaultIntervalMinutes"
- defaultValue={String(initial.syncScheduler.defaultIntervalMinutes)}
+ baseUnit="minutes"
+ units={["minutes", "hours", "days"]}
+ value={initial.syncScheduler.defaultIntervalMinutes}
+ min={SYNC_INTERVAL_MIN_MINUTES}
+ max={SYNC_INTERVAL_MAX_MINUTES}
+ rate={{ noun: "check", per: "channel" }}
+ hint="Auto-sync cadence for channels left on 'Default'. Checks the newest videos and downloads what's new."
+ />
+ <DurationField
+ label="Default full sweep interval"
+ name="syncSchedulerFullSweepIntervalMinutes"
+ baseUnit="minutes"
+ units={["hours", "days"]}
+ value={initial.syncScheduler.fullSweepIntervalMinutes}
+ presets={FULL_SWEEP_PRESETS}
+ allowOff
+ offLabel="Off (never sweep)"
+ min={SYNC_INTERVAL_MIN_MINUTES}
+ max={SYNC_INTERVAL_MAX_MINUTES}
+ rate={{
+ noun: "full listing fetch",
+ pluralNoun: "full listing fetches",
+ per: "channel",
+ }}
+ hint="How often a sync upgrades itself to re-read a channel's whole listing: refreshes the stored video list and flags videos that have gone missing upstream. Costs one full enumeration per channel, so it runs far less often than auto-sync. Individual channels can override this."
+ />
+ <Field
+ label="Full-sweep auto-confirm cap"
+ name="syncSchedulerFullSweepConfirmMaxSuspects"
+ defaultValue={String(
+ initial.syncScheduler.fullSweepConfirmMaxSuspects,
+ )}
type="number"
- hint="Cadence for channels left on 'Default'. e.g. 60 = hourly, 1440 = daily."
+ hint="When a sweep finds at most this many videos missing from the listing, it probes each one upstream to confirm; above the cap it only flags them and leaves the probing to 'Check maybe-missing'. 0 = never auto-confirm. This is a count of videos, not a duration."
/>
<Field
label="Max concurrent syncs"
@@ -341,12 +378,27 @@ export function SettingsForm({ initial, apps, digestApps }: Props) {
type="number"
hint="A tick queues at most (this − already-running) channels, most-overdue first; the rest roll to the next tick. Bounds load and staggers big batches."
/>
- <Field
- label="Internal heartbeat (seconds)"
+ <DurationField
+ label="Keep-latest check interval"
+ name="syncSchedulerKeepLatestCheckIntervalMinutes"
+ baseUnit="minutes"
+ units={["hours", "days"]}
+ value={initial.syncScheduler.keepLatestCheckIntervalMinutes}
+ min={SYNC_INTERVAL_MIN_MINUTES}
+ max={SYNC_INTERVAL_MAX_MINUTES}
+ hint="How often channels with a keep-latest limit are re-checked so old videos get pruned even when nothing new arrived."
+ />
+ <DurationField
+ label="Internal heartbeat"
name="syncSchedulerHeartbeatSeconds"
- defaultValue={String(initial.syncScheduler.heartbeatSeconds)}
- type="number"
- hint="0 = off (use an external cron heartbeat). When > 0, the editor ticks the scheduler itself this often (clamped to 15–3600s). Changes take effect on the next tick; turning it on from 0 needs a server restart. The SYNC_HEARTBEAT_SECONDS env var overrides this."
+ baseUnit="seconds"
+ units={["seconds", "minutes"]}
+ value={initial.syncScheduler.heartbeatSeconds}
+ allowOff
+ offLabel="Off (external cron)"
+ min={15}
+ max={3600}
+ hint="When on, the editor ticks the scheduler itself this often (clamped to 15–3600s); when off, drive it with an external cron heartbeat. Changes take effect on the next tick; turning it on from off needs a server restart. The SYNC_HEARTBEAT_SECONDS env var overrides this."
/>
<div className="flex gap-3">
<Field
@@ -374,18 +426,24 @@ export function SettingsForm({ initial, apps, digestApps }: Props) {
Optional local-clock window when auto-sync is suppressed (may wrap
past midnight, e.g. 22 → 6). Leave both blank to always allow.
</p>
- <div className="flex gap-3">
- <Field
- label="Backoff base (minutes)"
+ <div className="flex flex-wrap gap-3">
+ <DurationField
+ label="Backoff base"
name="syncSchedulerBackoffBaseMinutes"
- defaultValue={String(initial.syncScheduler.backoffBaseMinutes)}
- type="number"
+ baseUnit="minutes"
+ units={["minutes", "hours"]}
+ value={initial.syncScheduler.backoffBaseMinutes}
+ min={SYNC_INTERVAL_MIN_MINUTES}
+ max={SYNC_INTERVAL_MAX_MINUTES}
/>
- <Field
- label="Backoff max (minutes)"
+ <DurationField
+ label="Backoff max"
name="syncSchedulerBackoffMaxMinutes"
- defaultValue={String(initial.syncScheduler.backoffMaxMinutes)}
- type="number"
+ baseUnit="minutes"
+ units={["minutes", "hours", "days"]}
+ value={initial.syncScheduler.backoffMaxMinutes}
+ min={SYNC_INTERVAL_MIN_MINUTES}
+ max={SYNC_INTERVAL_MAX_MINUTES}
/>
</div>
<p className="text-xs text-muted-foreground -mt-1">
diff --git a/editor/e2e/cadence-ui.spec.ts b/editor/e2e/cadence-ui.spec.ts
@@ -0,0 +1,246 @@
+import { test, expect } from "@playwright/test";
+import {
+ generateReport,
+ readJson,
+ resetData,
+ writeChannelConfig,
+ writeSettings,
+} from "./helpers";
+
+// The cadence UI: every interval in the app is now set as an amount plus a unit
+// (DurationField) instead of a raw minute count, the full-sweep cadence is
+// settable globally and per channel, and the scheduler page can retune many
+// channels at once.
+//
+// The controls are deliberately a native <select> + <input type="number">, so
+// .selectOption()/.fill() keep working the way the rest of this suite expects.
+// Each field posts ONE hidden input in its base unit, which is why no server
+// parser had to change — these specs assert on the stored integers.
+
+const CHANNEL = "cadence-test";
+const CONFIG = `test-transcripts/channels/${CHANNEL}/config.json`;
+
+type CadenceConfig = {
+ syncIntervalMinutes?: number;
+ fullSweepIntervalMinutes?: number;
+};
+
+async function seedChannel(config: Record<string, unknown> = {}) {
+ await resetData(null);
+ await writeChannelConfig(CHANNEL, {
+ handling: "youtube",
+ name: "Cadence Test",
+ url: "https://www.youtube.com/@cadence-test/videos",
+ ...config,
+ });
+}
+
+// The channel form auto-resets after a React 19 <form action> submit, so
+// assertions read the file rather than the inputs.
+async function saveChannelForm(page: import("@playwright/test").Page) {
+ await page.getByRole("button", { name: /save changes/i }).click();
+}
+
+test("channel form saves a full-sweep preset as a minute count", async ({
+ page,
+}) => {
+ await seedChannel();
+ await generateReport(page, CHANNEL);
+ await page.goto(`/channels/${CHANNEL}`);
+
+ await page.getByLabel("Full sweep", { exact: true }).selectOption("1440");
+ // The echo line restates the raw stored value, so nothing is hidden from
+ // someone who also reads config.json.
+ await expect(page.getByText("Every 1,440 minutes ·")).toBeVisible();
+ await page
+ .getByLabel("Auto-sync (newest)", { exact: true })
+ .selectOption("30");
+ await saveChannelForm(page);
+
+ await expect
+ .poll(async () => readJson<CadenceConfig>(CONFIG))
+ .toMatchObject({ fullSweepIntervalMinutes: 1440, syncIntervalMinutes: 30 });
+});
+
+test("a hand-set non-preset interval round-trips unchanged", async ({
+ page,
+}) => {
+ // 137 minutes divides by neither hours nor days, so the field must show it as
+ // "137 minutes" and save it back untouched rather than rounding to 2 hours.
+ await seedChannel({ fullSweepIntervalMinutes: 137 });
+ await generateReport(page, CHANNEL);
+ await page.goto(`/channels/${CHANNEL}`);
+
+ await expect(page.getByLabel("Full sweep amount")).toHaveValue("137");
+ await expect(page.getByLabel("Full sweep unit")).toHaveValue("minutes");
+ await saveChannelForm(page);
+
+ await expect
+ .poll(async () => readJson<CadenceConfig>(CONFIG))
+ .toMatchObject({ fullSweepIntervalMinutes: 137 });
+});
+
+test("clearing a cadence to Default removes the key from config", async ({
+ page,
+}) => {
+ // The CHANNEL_FORM_FIELDS clearing contract: "Default (use global)" must
+ // actually delete the stored value, not re-layer the previous one.
+ await seedChannel({ fullSweepIntervalMinutes: 720 });
+ await generateReport(page, CHANNEL);
+ await page.goto(`/channels/${CHANNEL}`);
+
+ await page.getByLabel("Full sweep", { exact: true }).selectOption("inherit");
+ await saveChannelForm(page);
+
+ await expect
+ .poll(async () => {
+ const cfg = await readJson<Record<string, unknown>>(CONFIG);
+ return "fullSweepIntervalMinutes" in cfg;
+ })
+ .toBe(false);
+});
+
+test("settings round-trips the full-sweep cadence and confirm cap", async ({
+ page,
+}) => {
+ await resetData(null);
+ await page.goto("/settings");
+
+ // Preset: every 3 days.
+ await page
+ .getByLabel("Default full sweep interval", { exact: true })
+ .selectOption("4320");
+ await page.getByLabel(/full-sweep auto-confirm cap/i).fill("5");
+ // The keep-latest check interval had no input at all before this — and the
+ // action read it with a coercion that reset it to the default on every save.
+ await page
+ .getByLabel("Keep-latest check interval unit")
+ .selectOption("hours");
+ await page.getByLabel("Keep-latest check interval amount").fill("8");
+ await page.getByRole("button", { name: /save settings/i }).click();
+
+ await expect
+ .poll(async () =>
+ readJson<{
+ syncScheduler: {
+ fullSweepIntervalMinutes: number;
+ fullSweepConfirmMaxSuspects: number;
+ keepLatestCheckIntervalMinutes: number;
+ };
+ }>("test-settings.json").then((s) => s.syncScheduler),
+ )
+ .toMatchObject({
+ fullSweepIntervalMinutes: 4320,
+ fullSweepConfirmMaxSuspects: 5,
+ keepLatestCheckIntervalMinutes: 480,
+ });
+});
+
+test("an unrelated settings save no longer resets the keep-latest interval", async ({
+ page,
+}) => {
+ await resetData(null);
+ await writeSettings({
+ adminTitle: "Test Admin",
+ maxTranscriptPageBytes: 8388608,
+ sleepBetweenDownloadsSeconds: 0,
+ minFreeDiskGB: 0,
+ verifyAvailabilityBeforeClean: false,
+ syncScheduler: {
+ fullSweepIntervalMinutes: 0,
+ keepLatestCheckIntervalMinutes: 4321,
+ },
+ });
+ await page.goto("/settings");
+ await page.getByLabel(/admin title/i).fill("Renamed Admin");
+ await page.getByRole("button", { name: /save settings/i }).click();
+
+ await expect
+ .poll(async () =>
+ readJson<{
+ adminTitle: string;
+ syncScheduler: { keepLatestCheckIntervalMinutes: number };
+ }>("test-settings.json"),
+ )
+ .toMatchObject({
+ adminTitle: "Renamed Admin",
+ syncScheduler: { keepLatestCheckIntervalMinutes: 4321 },
+ });
+});
+
+test("bulk apply retunes selected channels and leaves the rest alone", async ({
+ page,
+}) => {
+ await resetData(null);
+ for (const slug of ["bulk-a", "bulk-b", "bulk-c"]) {
+ await writeChannelConfig(slug, {
+ handling: "youtube",
+ name: `Bulk ${slug.slice(-1).toUpperCase()}`,
+ url: `https://www.youtube.com/@${slug}/videos`,
+ syncIntervalMinutes: 30,
+ });
+ }
+ await page.goto("/scheduler");
+
+ await page.getByLabel("select bulk-a").check();
+ await page.getByLabel("select bulk-b").check();
+ // Only the sweep is set; auto-sync stays on "Leave unchanged", which is the
+ // whole point of the bar — retune sweeps without disturbing anyone's sync.
+ await page
+ .getByLabel("Bulk full sweep interval", { exact: true })
+ .selectOption("10080");
+ await page.getByRole("button", { name: "Apply" }).click();
+ await expect(page.getByText("Applied.")).toBeVisible();
+
+ await expect
+ .poll(async () =>
+ readJson<CadenceConfig>("test-transcripts/channels/bulk-a/config.json"),
+ )
+ .toMatchObject({ fullSweepIntervalMinutes: 10080, syncIntervalMinutes: 30 });
+ await expect
+ .poll(async () =>
+ readJson<CadenceConfig>("test-transcripts/channels/bulk-b/config.json"),
+ )
+ .toMatchObject({ fullSweepIntervalMinutes: 10080, syncIntervalMinutes: 30 });
+ const c = await readJson<Record<string, unknown>>(
+ "test-transcripts/channels/bulk-c/config.json",
+ );
+ expect("fullSweepIntervalMinutes" in c).toBe(false);
+});
+
+test("the Full sweep button forces a sweep the cadence would have skipped", async ({
+ page,
+}) => {
+ // Sweeps are OFF in the e2e default settings, so this proves the button
+ // passes fullSweep through rather than relying on the cadence gate.
+ await resetData("availability-baseline");
+ const slug = "availability-test";
+ const configRel = `test-transcripts/channels/${slug}/config.json`;
+ await generateReport(page, slug);
+ await page.goto(`/channels/${slug}`);
+
+ const button = page.getByRole("button", { name: "Full sweep", exact: true });
+ // Retried: a click landing before React hydrates fires nothing at all.
+ for (let attempt = 0; attempt < 5; attempt++) {
+ await button.click({ timeout: 5_000 }).catch(() => {});
+ let swept = false;
+ for (let i = 0; i < 80; i++) {
+ const cfg = await readJson<{ lastFullSweepAt?: string }>(configRel).catch(
+ () => null,
+ );
+ if (cfg?.lastFullSweepAt) {
+ swept = true;
+ break;
+ }
+ await new Promise((r) => setTimeout(r, 250));
+ }
+ if (swept) break;
+ }
+
+ const cfg = await readJson<{ lastFullSweepAt?: string }>(configRel);
+ expect(cfg.lastFullSweepAt).toBeTruthy();
+ // The run announces itself, so the two flavours are distinguishable in a log.
+ await expect(
+ page.getByText(/Full sweep: re-reading the whole channel listing/),
+ ).toBeVisible();
+});
diff --git a/editor/e2e/scheduler.spec.ts b/editor/e2e/scheduler.spec.ts
@@ -58,6 +58,10 @@ test("scheduler queues due channels, skips not-due, and dedups running ones", as
quietHoursEnd: null,
backoffBaseMinutes: 30,
backoffMaxMinutes: 1440,
+ // Keep these specs on the cheap paged walk: a never-swept channel is
+ // due for a full sweep the moment the default cadence is non-zero, and
+ // writeSettings REPLACES the file, so the e2e default's 0 is gone here.
+ fullSweepIntervalMinutes: 0,
},
});
@@ -109,6 +113,10 @@ test("internal heartbeat cadence is configurable and surfaced", async ({
quietHoursEnd: null,
backoffBaseMinutes: 30,
backoffMaxMinutes: 1440,
+ // Keep these specs on the cheap paged walk: a never-swept channel is
+ // due for a full sweep the moment the default cadence is non-zero, and
+ // writeSettings REPLACES the file, so the e2e default's 0 is gone here.
+ fullSweepIntervalMinutes: 0,
},
};
@@ -171,6 +179,10 @@ async function seedTwoChannels(opts?: { slowAInterval?: number }) {
quietHoursEnd: null,
backoffBaseMinutes: 30,
backoffMaxMinutes: 1440,
+ // Keep these specs on the cheap paged walk: a never-swept channel is
+ // due for a full sweep the moment the default cadence is non-zero, and
+ // writeSettings REPLACES the file, so the e2e default's 0 is gone here.
+ fullSweepIntervalMinutes: 0,
},
});
}
@@ -182,7 +194,9 @@ test("schedule page saves a preset interval to the channel config", async ({
await page.goto("/scheduler");
const rowA = page.getByRole("row", { name: /Slow A/ });
- await rowA.getByLabel(/Auto-sync interval/).selectOption("60"); // Hourly
+ // The cadence cell is collapsed to chips until you click into it.
+ await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click();
+ await rowA.getByLabel("Auto-sync interval for slow-a").selectOption("60");
await rowA.getByRole("button", { name: "Save" }).click();
await expect(rowA.getByText("Saved")).toBeVisible();
@@ -195,8 +209,12 @@ test("schedule page saves a custom minute count", async ({ page }) => {
await page.goto("/scheduler");
const rowA = page.getByRole("row", { name: /Slow A/ });
- await rowA.getByLabel(/Auto-sync interval/).selectOption("custom");
- await rowA.getByLabel(/Custom interval minutes/).fill("45");
+ await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click();
+ await rowA.getByLabel("Auto-sync interval for slow-a").selectOption("custom");
+ await rowA
+ .getByLabel("Auto-sync interval for slow-a unit")
+ .selectOption("minutes");
+ await rowA.getByLabel("Auto-sync interval for slow-a amount").fill("45");
await rowA.getByRole("button", { name: "Save" }).click();
await expect(rowA.getByText("Saved")).toBeVisible();
@@ -212,8 +230,10 @@ test("schedule page clears an interval back to the global default", async ({
await page.goto("/scheduler");
const rowA = page.getByRole("row", { name: /Slow A/ });
- // The editor seeds to the "Every 30 minutes" preset; switch to Default ("").
- await rowA.getByLabel(/Auto-sync interval/).selectOption("");
+ await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click();
+ // The editor seeds to the "Every 30 minutes" preset; switch to Default,
+ // which submits "" so the key is deleted rather than stored as a number.
+ await rowA.getByLabel("Auto-sync interval for slow-a").selectOption("inherit");
await rowA.getByRole("button", { name: "Save" }).click();
await expect(rowA.getByText("Saved")).toBeVisible();
@@ -228,8 +248,9 @@ test("an in-progress custom edit survives the status poll", async ({ page }) =>
await page.goto("/scheduler");
const rowA = page.getByRole("row", { name: /Slow A/ });
- await rowA.getByLabel(/Auto-sync interval/).selectOption("custom");
- const customInput = rowA.getByLabel(/Custom interval minutes/);
+ await rowA.getByRole("button", { name: "Edit cadence for slow-a" }).click();
+ await rowA.getByLabel("Auto-sync interval for slow-a").selectOption("custom");
+ const customInput = rowA.getByLabel("Auto-sync interval for slow-a amount");
await customInput.fill("123");
// Wait past one poll interval (5s) without saving.
await page.waitForTimeout(5500);
@@ -243,7 +264,8 @@ test("schedule page edits the global controls", async ({ page }) => {
await page
.getByRole("checkbox", { name: /Enable scheduled auto-sync/ })
.check();
- await page.getByLabel("Default interval (minutes)").fill("120");
+ await page.getByLabel("Default interval unit").selectOption("minutes");
+ await page.getByLabel("Default interval amount").fill("120");
await page.getByRole("button", { name: "Save controls" }).click();
await expect(page.getByText("Saved.")).toBeVisible();