"use server"; import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { patchChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { SYNC_INTERVAL_MAX_MINUTES, SYNC_INTERVAL_MIN_MINUTES, type ChannelConfig, } from "yt-dlp-transcript-common/lib/channelConfig"; import { getSettings, type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import { DURATION_KEEP } from "yt-dlp-transcript-common/lib/duration"; import { formValues, type FormState } from "../lib/formState"; // A failure carries what was submitted (lib/formState.ts). export type SaveResult = FormState; // 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 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( `${label} must be 0 (off) or ${SYNC_INTERVAL_MIN_MINUTES}–${SYNC_INTERVAL_MAX_MINUTES} minutes`, ); } return { minutes: n }; } // 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 { const values = formValues(formData); let sync: CadenceEdit; let sweep: CadenceEdit; try { sync = resolveCadenceField( formData, "syncIntervalMinutes", "Auto-sync interval", ); sweep = resolveCadenceField( formData, "fullSweepIntervalMinutes", "Full sweep interval", ); } catch (e) { return { ok: false, error: (e as Error).message, values }; } if (slugs.length === 0) { return { ok: false, error: "No channels selected", values }; } if (sync === "keep" && sweep === "keep") return { ok: true }; // nothing to do const paths = getPaths(); for (const slug of slugs) { // `minutes: undefined` = inherit the global default = unset the override. const patch: Partial = {}; const unset: Array = []; if (sync !== "keep") { if (sync.minutes === undefined) unset.push("syncIntervalMinutes"); else patch.syncIntervalMinutes = sync.minutes; } if (sweep !== "keep") { if (sweep.minutes === undefined) unset.push("fullSweepIntervalMinutes"); else patch.fullSweepIntervalMinutes = sweep.minutes; } const written = await patchChannelConfig(paths, slug, patch, { unset }); if (!written) { return { ok: false, error: `Channel "${slug}" not found`, values }; } // Keep the channel report/badges in sync with the config edit. requestChannelSnapshot(paths, slug); revalidatePath(`/channels/${slug}`); } revalidatePath("/operations/sync"); revalidatePath("/channels"); return { ok: true }; } // THE ONE WRITER OF settings.syncScheduler. Reads the current file and replaces // this block only; every other block is preserved verbatim — the contract // cadence-ui.spec.ts holds every settings writer to. // // It used to be one of TWO: /settings carried the advanced knobs and this // carried the headline three, so one block had two parsers and each relied on // the other remembering to preserve what its own render did not show. The whole // block is the sync operation's settings form now, on the sync operation's // page, and this is what saves it. // // Values are clamped/sanitized by sanitizeSyncScheduler when saveSettings writes, // so we only coerce here. export async function saveSchedulerSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise { const values = formValues(formData); const current = getSettings(); // An absent or blank field keeps the saved value rather than coercing to NaN // and resetting it to the default. It is the whole block's contract now, not // just this form's: a render that ever drops a field must not silently reset // it, and several of these are 0-sentinel ("never") values a default would // quietly overwrite. 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; }; // Quiet hours are the one pair where BLANK IS A VALUE — "no quiet window" — // so they cannot go through intOrKeep. Moved here from settings/actions.ts // with the fields. const hourOrNull = (key: string): number | null => { const raw = String(formData.get(key) ?? "").trim(); if (!raw) return null; const n = Number.parseInt(raw, 10); return Number.isFinite(n) ? n : null; }; const next: Partial = { syncScheduler: { ...current.syncScheduler, enabled: formData.get("syncSchedulerEnabled") === "on", defaultIntervalMinutes: intOrKeep( "syncSchedulerDefaultIntervalMinutes", current.syncScheduler.defaultIntervalMinutes, ), heartbeatSeconds: intOrKeep( "syncSchedulerHeartbeatSeconds", current.syncScheduler.heartbeatSeconds, ), fullSweepIntervalMinutes: intOrKeep( "syncSchedulerFullSweepIntervalMinutes", current.syncScheduler.fullSweepIntervalMinutes, ), fullSweepConfirmMaxSuspects: intOrKeep( "syncSchedulerFullSweepConfirmMaxSuspects", current.syncScheduler.fullSweepConfirmMaxSuspects, ), fullSweepShrinkGuardPercent: intOrKeep( "syncSchedulerFullSweepShrinkGuardPercent", current.syncScheduler.fullSweepShrinkGuardPercent, ), maxConcurrentSyncs: intOrKeep( "syncSchedulerMaxConcurrentSyncs", current.syncScheduler.maxConcurrentSyncs, ), keepLatestCheckIntervalMinutes: intOrKeep( "syncSchedulerKeepLatestCheckIntervalMinutes", current.syncScheduler.keepLatestCheckIntervalMinutes, ), quietHoursStart: hourOrNull("syncSchedulerQuietHoursStart"), quietHoursEnd: hourOrNull("syncSchedulerQuietHoursEnd"), backoffBaseMinutes: intOrKeep( "syncSchedulerBackoffBaseMinutes", current.syncScheduler.backoffBaseMinutes, ), backoffMaxMinutes: intOrKeep( "syncSchedulerBackoffMaxMinutes", current.syncScheduler.backoffMaxMinutes, ), }, }; try { await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message, values }; } revalidatePath("/operations/sync"); revalidatePath("/settings"); return { ok: true }; }