"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { SchedulerStatusPayload } from "yt-dlp-transcript-common/views/schedulerStatus"; import { usePolledPayload } from "../../../lib/usePolledPayload"; import { BulkCadenceBar } from "./BulkCadenceBar"; import { ChannelCadenceEditor } from "./ChannelCadenceEditor"; // THE SYNC OPERATION'S CONSOLE — the schedule, because a cadence-triggered // operation has no backlog to sweep and no runner to start. It is built as a // server slot in operations/[id]/page.tsx off `op.trigger === "cadence"`, and // OperationDetail renders it where a runner or a sweep lane would otherwise be. // // TWO POLLS ON THIS PAGE, deliberately not merged: this one is 5s over // /api/view/schedulerStatus (the schedule, whose facts move on the order of // minutes) and the rail above is 3s over the auto-queue payload (lane state, // which moves per dispatch). One combined endpoint would make the cheaper // reader pay the more expensive reader's cadence, and buildSchedulerStatusPayload // lists channel configs while the auto-queue payload does not. export function SyncConsole({ initial, }: { initial: SchedulerStatusPayload; }) { const [busy, setBusy] = useState(false); const [message, setMessage] = useState(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>(new Set()); // Seeded by SSR; the first poll waits one interval, as before. const { data: polled, refetch: refresh } = usePolledPayload( "/api/view/schedulerStatus", true, 5000, initial, { immediate: false }, ); // Never null: the hook starts from `initial` and never clears it. const data = polled ?? initial; // Guards the Run-now button's own state (busy/message) after unmount; the // payload's guard is the hook's. const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const runNow = useCallback(async () => { setBusy(true); setMessage(null); try { const res = await fetch("/api/scheduler/tick", { method: "POST" }); const body = (await res.json()) as { queued?: string[]; skipped?: unknown[]; reason?: string; keptChecksQueued?: string[]; savedVideoBackupQueued?: boolean; }; if (!res.ok) { setMessage(`Tick failed (${res.status}).`); } else { const q = body.queued?.length ?? 0; const s = body.skipped?.length ?? 0; // THE STORAGE CHORES, named separately from the syncs. They ride this // heartbeat because it is the one timer the editor has, and a tick that // reported "queued 0" while it had just started a keep-latest deletion // pass would be describing the wrong operation. Read from the POST // body, which has carried both fields all along — nothing on the tick // changed to say this. const k = body.keptChecksQueued?.length ?? 0; const backup = body.savedVideoBackupQueued === true; const chores = k > 0 || backup ? ` · storage chores: ${k} keep-latest check${k === 1 ? "" : "s"}${ backup ? ", saved-video backup" : "" }` : ""; setMessage( body.reason ? `Tick: ${body.reason} (queued ${q}, skipped ${s})${chores}.` : `Tick queued ${q}, skipped ${s}${chores}.`, ); } await refresh(); } catch (err) { setMessage(`Tick failed: ${(err as Error).message}`); } finally { if (mounted.current) setBusy(false); } }, [refresh]); 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 (

Per-channel auto-sync cadence, driven by a heartbeat (pnpm sync:tick or the internal timer). Set each channel's interval inline below — pick a preset or type an exact minute count. A channel auto-syncs on the next heartbeat once it's been longer than its interval since the last sync.

{scheduler.enabled ? "Scheduler enabled" : "Scheduler disabled"} {eligible.length} channel{eligible.length === 1 ? "" : "s"} auto-syncing · default {formatInterval(scheduler.defaultIntervalMinutes)} · full sweep {formatInterval(scheduler.fullSweepIntervalMinutes)} · max{" "} {scheduler.maxConcurrentSyncs} concurrent ·{" "} {heartbeatSeconds > 0 ? `internal heartbeat every ${formatSeconds(heartbeatSeconds)}` : "external heartbeat (cron)"}
{message && (

{message}

)}
{channels.length === 0 && ( )} {channels.map((c) => ( ))}
toggleAll(e.target.checked)} /> Channel Cadence Last synced Next due Status
No channels.
toggleOne(c.slug)} /> {c.name ?? c.slug} {c.lastSyncedAt ? formatAgo(c.lastSyncedAt, now) : "never"} {nextDueLabel(c, now)} {nextSweepLabel(c, now)} {statusBadge(c)}
{selectedSlugs.length > 0 && ( setSelected(new Set())} onApplied={refresh} /> )}

Recent ticks

{/* NOT SYNC, and the console has to say so: a tick that reports a deletion pass or a backup is not a tick that noticed new videos. They ride this heartbeat because it is the one timer the editor has. */}

Two Storage chores ride this heartbeat and are not sync: the keep-latest deletion check (its interval is in the settings below) and the saved-video backup (Saved videos).

{runs.length === 0 ? (

No ticks recorded yet.

) : (
    {runs.slice(0, 15).map((r, i) => (
  • {formatClock(r.at)} queued {r.queued.length} {r.queued.length > 0 && ( ({r.queued.join(", ")}) )} {r.skipped.length > 0 && ( skipped {r.skipped.length} )}
  • ))}
)}
); } type ChannelRow = SchedulerStatusPayload["channels"][number]; function statusBadge(c: ChannelRow) { if (c.intervalMinutes === 0 || !c.autoSyncEligible) { return —; } if (c.nextEligibleAt != null && c.nextEligibleAt > Date.now()) { return ( backoff ({c.consecutiveFailures} fail {c.consecutiveFailures === 1 ? "" : "s"}) ); } if (c.lastOutcome === "failed") { return last failed; } if (c.overdue) { return due; } return ok; } function nextDueLabel(c: ChannelRow, now: number): string { if (c.intervalMinutes === 0) return "—"; if (c.overdue) return "due now"; if (c.nextDueAt == null) return "—"; 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) { const d = minutes / 1440; return d === 1 ? "daily" : `every ${d}d`; } if (minutes % 60 === 0) { const h = minutes / 60; return h === 1 ? "hourly" : `every ${h}h`; } return `every ${minutes}m`; } function formatSeconds(sec: number): string { if (sec < 60) return `${sec}s`; if (sec % 3600 === 0) return `${sec / 3600}h`; if (sec % 60 === 0) return `${sec / 60}m`; return `${sec}s`; } function formatDuration(ms: number): string { const sec = Math.max(0, Math.round(ms / 1000)); if (sec < 60) return `${sec}s`; const min = Math.round(sec / 60); if (min < 60) return `${min}m`; const hr = Math.round(min / 60); if (hr < 48) return `${hr}h`; return `${Math.round(hr / 24)}d`; } function formatAgo(iso: string, now: number): string { const t = Date.parse(iso); if (Number.isNaN(t)) return iso; return `${formatDuration(now - t)} ago`; } function formatClock(ms: number): string { if (!ms) return "—"; return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); }