"use server"; import { revalidatePath } from "next/cache"; import { AUTO_REFRESH_INTERVAL_MAX_SECONDS, getSettings, AUTO_REFRESH_INTERVAL_MIN_SECONDS, DEFAULT_REPORT_DEBOUNCE_PRESET, defaultBuildPipeline, isReportDebouncePreset, MIN_FREE_DISK_GB_MAX, RESUME_MARGIN_GB_DEFAULT, RESUME_MARGIN_GB_MAX, parseSocialLinks, PARALLEL_TRANSCRIPTIONS_DEFAULT, SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS, TRANSCRIPT_PAGE_HARD_CAP_BYTES, TRANSCRIPT_PAGE_MIN_BYTES, type SiteSettings, type SocialLink, } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "./saveSettings"; import { socialLinksForSave } from "yt-dlp-transcript-common/lib/socialLinks"; import { DEFAULT_COOKIE_MODE, isCookieMode, } from "yt-dlp-transcript-common/lib/cookiePolicy"; import { DEFAULT_SOURCE_VIDEO_QUALITY, isDownloadFormatPreset, isSourceVideoQuality, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { formValues, type FormState } from "../lib/formState"; // THE SHARED SAVE RESULT of /settings and the four operation-settings forms. A // failure carries what was submitted, so the form keeps what was typed // (lib/formState.ts). export type SaveResult = FormState; export async function saveSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise { const values = formValues(formData); const adminTitle = String(formData.get("adminTitle") ?? "").trim(); const homepageUrl = String(formData.get("homepageUrl") ?? "").trim(); const maxBytesRaw = String( formData.get("maxTranscriptPageBytes") ?? "", ).trim(); const cookiesFromBrowser = String( formData.get("cookiesFromBrowser") ?? "", ).trim(); const cookieModeRaw = String(formData.get("cookieMode") ?? "").trim(); const cookieMode = isCookieMode(cookieModeRaw) ? cookieModeRaw : DEFAULT_COOKIE_MODE; const sleepRaw = String( formData.get("sleepBetweenDownloadsSeconds") ?? "", ).trim(); const autoRefreshRaw = String( formData.get("autoRefreshIntervalSeconds") ?? "", ).trim(); const minFreeDiskRaw = String(formData.get("minFreeDiskGB") ?? "").trim(); const resumeMarginRaw = String(formData.get("resumeMarginGB") ?? "").trim(); const inlineTranscribeOnFallback = formData.get("inlineTranscribeOnFallback") === "on"; const skipLiveDownloads = formData.get("skipLiveDownloads") === "on"; const verifyAvailabilityBeforeClean = formData.get("verifyAvailabilityBeforeClean") === "on"; const buildArchives = formData.get("buildArchives") === "on"; const archiveStorage = { bucket: String(formData.get("archiveStorageBucket") ?? "").trim(), publicBaseUrl: String( formData.get("archiveStoragePublicBaseUrl") ?? "", ).trim(), }; const reportDebouncePresetRaw = String( formData.get("reportDebouncePreset") ?? "", ).trim(); const reportDebouncePreset = isReportDebouncePreset(reportDebouncePresetRaw) ? reportDebouncePresetRaw : DEFAULT_REPORT_DEBOUNCE_PRESET; const downloadFormatRaw = String(formData.get("downloadFormat") ?? "").trim(); const downloadFormat = isDownloadFormatPreset(downloadFormatRaw) ? downloadFormatRaw : "auto"; const sourceVideoQualityRaw = String( formData.get("sourceVideoQuality") ?? "", ).trim(); const sourceVideoQuality = isSourceVideoQuality(sourceVideoQualityRaw) ? sourceVideoQualityRaw : DEFAULT_SOURCE_VIDEO_QUALITY; if (!adminTitle) { return { ok: false, error: "Admin title is required", values }; } const parsed = Number.parseInt(maxBytesRaw, 10); if (!Number.isFinite(parsed)) { return { ok: false, error: "maxTranscriptPageBytes must be a number", values, }; } if ( parsed < TRANSCRIPT_PAGE_MIN_BYTES || parsed > TRANSCRIPT_PAGE_HARD_CAP_BYTES ) { return { ok: false, error: `maxTranscriptPageBytes must be between ${TRANSCRIPT_PAGE_MIN_BYTES} and ${TRANSCRIPT_PAGE_HARD_CAP_BYTES}`, values, }; } const sleepParsed = Number.parseInt(sleepRaw, 10); if (!Number.isFinite(sleepParsed)) { return { ok: false, error: "sleepBetweenDownloadsSeconds must be a number", values, }; } if (sleepParsed < 0 || sleepParsed > SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS) { return { ok: false, error: `sleepBetweenDownloadsSeconds must be between 0 and ${SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS}`, values, }; } // 0 disables passive refresh; any other value must land in the allowed window. const autoRefreshParsed = Number.parseInt(autoRefreshRaw, 10); if (!Number.isFinite(autoRefreshParsed)) { return { ok: false, error: "autoRefreshIntervalSeconds must be a number", values, }; } if ( autoRefreshParsed !== 0 && (autoRefreshParsed < AUTO_REFRESH_INTERVAL_MIN_SECONDS || autoRefreshParsed > AUTO_REFRESH_INTERVAL_MAX_SECONDS) ) { return { ok: false, error: `autoRefreshIntervalSeconds must be 0 (off) or between ${AUTO_REFRESH_INTERVAL_MIN_SECONDS} and ${AUTO_REFRESH_INTERVAL_MAX_SECONDS}`, values, }; } const minFreeDiskParsed = Number.parseInt(minFreeDiskRaw, 10); if (!Number.isFinite(minFreeDiskParsed)) { return { ok: false, error: "minFreeDiskGB must be a number", values }; } if (minFreeDiskParsed < 0 || minFreeDiskParsed > MIN_FREE_DISK_GB_MAX) { return { ok: false, error: `minFreeDiskGB must be between 0 and ${MIN_FREE_DISK_GB_MAX}`, values, }; } // A settings.json (or a form) predating the field sends nothing; fall back to // the default rather than rejecting the whole save over a field the operator // never saw. const resumeMarginParsed = resumeMarginRaw === "" ? RESUME_MARGIN_GB_DEFAULT : Number.parseInt(resumeMarginRaw, 10); if (!Number.isFinite(resumeMarginParsed)) { return { ok: false, error: "resumeMarginGB must be a number", values }; } if (resumeMarginParsed < 0 || resumeMarginParsed > RESUME_MARGIN_GB_MAX) { return { ok: false, error: `resumeMarginGB must be between 0 and ${RESUME_MARGIN_GB_MAX}`, values, }; } let socialInput: unknown; try { socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); } catch { return { ok: false, error: "Social links payload is malformed", values }; } const socialParsed = parseSocialLinks(socialInput); if ( Array.isArray(socialInput) && socialParsed.length !== socialInput.length ) { return { ok: false, error: "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", values, }; } // A link whose SVG is unchanged from settings.json is kept as it is; a new // or edited one is checked (lib/socialLinks.ts socialLinksForSave). const checked = socialLinksForSave(socialParsed, getSettings().socialLinks); if ("refused" in checked) { return { ok: false, error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`, values, }; } const socialLinks: SocialLink[] = checked.links; // Build pipeline. Values are clamped/coerced by sanitizeBuildPipeline inside // the settings schema on save, so we only read the form here (NaN/blank → default). const dB = defaultBuildPipeline(); const buildPipeline = { maxParallelBuilds: Number.parseInt( String(formData.get("maxParallelBuilds") ?? "").trim(), 10, ), dockerImage: String(formData.get("dockerImage") ?? "").trim() || dB.dockerImage, dockerfile: String(formData.get("dockerfile") ?? "").trim() || dB.dockerfile, }; // ONLY WHAT THIS FORM EDITS. saveSettings merges the patch over the stored // file, so every block edited elsewhere — workers (/workers), syncScheduler // (/operations/sync), autoQueue and channelPriority (the operation pages and // /channels), savedVideoBackup (/saved-videos), storage (/storage) and the // four operation blocks (/operations/) — survives a save here without // being read and re-listed. Before slice 4a this literal named all 31 fields // and carried each foreign block through `getSettings()`, which is how an // unrelated save once disarmed a sweep. const next: Partial = { adminTitle, maxTranscriptPageBytes: parsed, cookiesFromBrowser, cookieMode, sleepBetweenDownloadsSeconds: sleepParsed, downloadFormat, sourceVideoQuality, minFreeDiskGB: minFreeDiskParsed, resumeMarginGB: resumeMarginParsed, // Vestigial (the worker list is the parallelism; kept only for rollback), // and this form has always reset it to the default. parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT, inlineTranscribeOnFallback, skipLiveDownloads, verifyAvailabilityBeforeClean, buildArchives, archiveStorage, reportDebouncePreset, autoRefreshIntervalSeconds: autoRefreshParsed, socialLinks, homepageUrl, buildPipeline, }; try { await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message, values }; } revalidatePath("/settings"); return { ok: true }; }