"use server"; import path from "node:path"; import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import type { StorageLocation } from "yt-dlp-transcript-common/lib/storageLocations"; import { mountByUuid, probeLocation, } from "yt-dlp-transcript-common/lib/storageVolumes"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { channelsOnLocation, INTERNAL_LOCATION_ID, maybeAutoRepoint, probeLocationMemo, recordProbedIdentity, resetStorageProbeMemo, } from "yt-dlp-transcript-common/controller/storageLocations"; import { clearDirMarker, readDirMarker, } from "yt-dlp-transcript-common/controller/relocateDir"; import { savedVideosMarkerPath } from "yt-dlp-transcript-common/controller/relocateSavedVideos"; import { channelExists, isValidChannelSlug, } from "yt-dlp-transcript-common/controller/channels"; import { channelMediaBusyReason } from "../channels/lib/mediaBusy"; import { enqueueRepointJob } from "./lib/repointJob"; import { enqueueSavedVideosRelocation } from "./lib/savedVideosJob"; import { enqueueEvictClipWindows } from "./lib/evictClipsJob"; import { savedVideosStoreBusyReason } from "./lib/storeBusy"; import { refreshLocationHealth } from "yt-dlp-transcript-common/controller/storageWatch"; import { forgetChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { applyHealthTimings, healthTimings, locationHealth, notAnsweringText, } from "yt-dlp-transcript-common/lib/storageHealth"; import { clearRuleText, secondsText, } from "yt-dlp-transcript-common/lib/storageHealthTimings"; import { parseHealthTimingsForm } from "./lib/healthTimingsForm"; import { formValues, type FormState } from "../lib/formState"; // THE SIX THINGS AN OPERATOR MAY DO TO A STORAGE LOCATION (and, at the end, // the drive-health timings every location is judged by). // // Five of them are small settings writes or one subprocess and run INLINE: // their whole output is a sentence, and a queued job with a log would be a // worse way to show one. The sixth (re-point) rewrites every channel symlink on // the location and is a job, with a record, a log and a cancel button, like // every other long action in the editor. export type LocationResult = { ok: true; note?: string } | { ok: false; error: string }; const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; export type LocationDraft = { id: string; label: string; root: string; autoRepoint: boolean; makeDefault: boolean; }; function normalizeRoot(root: string): string { const trimmed = root.trim().replace(/\/+$/, ""); return trimmed === "" ? root.trim() : trimmed; } function validate(draft: LocationDraft): string | null { if (!ID_RE.test(draft.id)) { return `"${draft.id}" is not a valid id: lower-case letters, digits and dashes, starting with a letter or digit, 64 characters at most.`; } // "internal" IS TAKEN — it is the synthetic row this page assembles for the // corpus volume, and the regex above happily admits it. Refused HERE as well // as in the sanitizer because a settings write that silently drops the entry // reads to the operator as a button that did nothing. if (draft.id === INTERNAL_LOCATION_ID) { return ( `"${INTERNAL_LOCATION_ID}" is reserved: it is the row this page shows ` + `for the corpus volume, which is not a configurable location. Pick ` + `another id.` ); } const root = normalizeRoot(draft.root); if (!root.startsWith("/")) { return `The root must be an absolute path (got "${draft.root}"). A relative root would name a different directory in every process that read it.`; } return null; } function revalidateStorage(): void { revalidatePath("/storage"); revalidatePath("/channels"); revalidatePath("/"); } export async function addStorageLocationAction( draft: LocationDraft, ): Promise { const problem = validate(draft); if (problem) return { ok: false, error: problem }; const settings = getSettings(); if (settings.storage.locations.some((l) => l.id === draft.id)) { return { ok: false, error: `A storage location "${draft.id}" already exists.` }; } const location: StorageLocation = { id: draft.id, label: draft.label.trim() || draft.id, root: normalizeRoot(draft.root), autoRepoint: draft.autoRepoint, }; const locations = [...settings.storage.locations, location]; // A PATCH of the storage block: saveSettings merges these keys over the // stored block, so `savedVideosLocationId` — which this action does not // edit — survives the save. (Before slice 4a it was rebuilt from these two // keys alone, and adding or editing a location erased the store's record.) await saveSettings({ storage: { locations, // The FIRST location is the default whether or not the box was ticked: // with exactly one location, "no default" is never the answer anybody // wanted, and a blank default silently disables every prefill. defaultLocationId: draft.makeDefault || locations.length === 1 ? draft.id : settings.storage.defaultLocationId, }, }); revalidateStorage(); return { ok: true, note: `Added "${location.label}" at ${location.root}.` }; } export async function editStorageLocationAction( id: string, draft: LocationDraft, ): Promise { const problem = validate({ ...draft, id }); if (problem) return { ok: false, error: problem }; const settings = getSettings(); const existing = settings.storage.locations.find((l) => l.id === id); if (!existing) return { ok: false, error: `There is no storage location "${id}".` }; const root = normalizeRoot(draft.root); // THE STORED IDENTITY IS DROPPED WHEN THE ROOT CHANGES, and this is the one // rule in this file that is not obvious. `volume` records the mountpoint the // root was under and the root's path RELATIVE to it, and a probe computes a // candidate root from them (`join(newMountpoint, relPath)`). Keep a relPath // measured against the old root and the next "mounted elsewhere" offers a // candidate that is wrong by exactly the difference — a Re-point button that // points every channel at a directory that does not exist. The next Refresh // relearns identity from the root the operator just typed, which costs one // findmnt and is always right. const keepVolume = root === existing.root; const updated: StorageLocation = { ...existing, label: draft.label.trim() || id, root, autoRepoint: draft.autoRepoint, ...(keepVolume && existing.volume ? { volume: existing.volume } : {}), }; if (!keepVolume) delete (updated as { volume?: unknown }).volume; // A patch of the storage block — `savedVideosLocationId` survives (see above). await saveSettings({ storage: { locations: settings.storage.locations.map((l) => (l.id === id ? updated : l)), defaultLocationId: draft.makeDefault ? id : settings.storage.defaultLocationId, }, }); // The memo keys by id and carries the root it answered for, but clearing it // makes the next render honest rather than merely correct. resetStorageProbeMemo(); revalidateStorage(); return { ok: true, note: keepVolume ? `Saved "${updated.label}".` : `Saved "${updated.label}" at ${root}. The recorded volume identity was dropped — press Refresh to relearn it.`, }; } // DELETING A LOCATION MOVES NOTHING, and that is exactly why it is refused // while channels are on it: their `config.mediaDir` would keep naming an // absolute path on a disk nothing in the corpus remembers the name of, which is // the situation this page exists to end. export async function deleteStorageLocationAction( id: string, ): Promise { const settings = getSettings(); const location = settings.storage.locations.find((l) => l.id === id); if (!location) return { ok: false, error: `There is no storage location "${id}".` }; const rollups = await channelsOnLocation({ paths: getPaths(), locations: [location], }); const roll = rollups[id]; if (roll && roll.total > 0) { return { ok: false, error: `${roll.total} channel(s) still have their media under ${location.root} ` + `(${roll.slugs.slice(0, 5).join(", ")}${roll.slugs.length > 5 ? ", …" : ""}). ` + `Move them back in place, or onto another location, first.`, }; } // AND THE SAVED-VIDEO STORE IS A RESIDENT TOO. The channel check above exists // because deleting the location erases the only record of which disk those // absolute paths belong to; the store is on the location by exactly the same // kind of record (`settings.storage.savedVideosLocationId`) and would be // orphaned by exactly the same delete — reachable only through a symlink // whose target nothing in the corpus can any longer name. if ((settings.storage.savedVideosLocationId ?? "") === id) { return { ok: false, error: `The saved-video store is on ${location.root}. Move it back in place ` + `(or onto another location) on this page first — deleting the location ` + `would leave the store reachable only through a symlink nothing here ` + `remembers the name of.`, }; } const locations = settings.storage.locations.filter((l) => l.id !== id); // A patch of the storage block — `savedVideosLocationId` survives (see above). await saveSettings({ storage: { locations, defaultLocationId: settings.storage.defaultLocationId === id ? (locations[0]?.id ?? "") : settings.storage.defaultLocationId, }, }); resetStorageProbeMemo(); revalidateStorage(); return { ok: true, note: `Deleted "${location.label}".` }; } // A FRESH PROBE, PAST THE MEMO. The operator pressing this has just done // something physical — plugged the disk in, mounted it — and is asking for an // answer taken afterwards. // // It writes IDENTITY and nothing else: availability is never persisted (a // refresh that stored "available" would rewrite settings.json, and so bump the // pulse revision every open tab polls, on every page load). export async function refreshStorageLocationAction( id: string, ): Promise { const paths = getPaths(); const settings = getSettings(); const location = settings.storage.locations.find((l) => l.id === id); if (!location) return { ok: false, error: `There is no storage location "${id}".` }; // IS IT ANSWERING, asked first and without touching the drive (the block // device's counters in /sys, or a child `stat` raced against // `storage.health.probeTimeoutMs` where no device can be named): the probe // below runs in-process, and on a stalled drive it is not asked at all. The // counters give no answer within 10 s (by default) of the pass's last // sample. The channels' remembered answers go too — the operator has just // done something about the drive. await refreshLocationHealth(location); forgetChannelMedia(); const health = locationHealth(location.id); if (health?.state === "stalled") { revalidateStorage(); const timings = healthTimings(); return { ok: true, note: `${location.label}: ${notAnsweringText(health)} — ${health.cause ?? "its root did not answer"}. ` + `Pages skip this drive until it answers ${clearRuleText(timings.clearAfterCleanPasses)} ` + `(checked every ${secondsText(timings.passIntervalMs)}).`, }; } const probe = await probeLocationMemo(location, paths, { refresh: true }); const wrote = await recordProbedIdentity({ locationId: id, probe }); // The opt-in. Off by default, and refused unless the full preflight passes — // so this either starts the job the operator armed or says why it did not. const auto = await maybeAutoRepoint({ paths, location, probe, bins: paths, isBusy: channelMediaBusyReason, }); revalidateStorage(); if (auto.started) { return { ok: true, note: `Auto re-point started: "${location.label}" → ${auto.newRoot}. Watch it on /jobs.`, }; } const identity = probe.identity.known ? ` Identity: ${probe.identity.uuid} at ${probe.identity.mountpoint}.${wrote ? " (recorded)" : ""}` : " Identity unknown — nothing here can ask (no findmnt, or a container)."; return { ok: true, note: `${location.label}: ${probe.status}.${identity}` }; } // NEVER RETRIED, and the error is surfaced verbatim: a polkit denial under a // service session is the expected failure and the operator needs to read the // daemon's own words. export async function mountStorageLocationAction( id: string, ): Promise { const paths = getPaths(); const settings = getSettings(); const location = settings.storage.locations.find((l) => l.id === id); if (!location) return { ok: false, error: `There is no storage location "${id}".` }; const uuid = location.volume?.uuid?.trim() ?? ""; if (!uuid) { return { ok: false, error: `"${location.label}" has no recorded volume identity, so there is nothing to mount by UUID. Mount it from the host and press Refresh.`, }; } const result = await mountByUuid(uuid, paths); if (!result.ok) return { ok: false, error: result.error ?? "udisksctl failed." }; // The mount changed the world the memo answered about. resetStorageProbeMemo(); const after = await probeLocation(location, paths); await recordProbedIdentity({ locationId: id, probe: after }).catch(() => {}); revalidateStorage(); return { ok: true, note: `Mounted ${uuid}${result.mountpoint ? ` at ${result.mountpoint}` : ""}. The location now reads ${after.status}.`, }; } export async function repointStorageLocationAction( id: string, newRoot: string, ): Promise { const root = normalizeRoot(newRoot); if (!root) return { ok: false, error: "No new root given." }; // THE SAME SHAPE CHECK THE FORM MAKES, one click earlier. The preflight // inside the job refuses a relative root too — it is the guard — but a typo // should be a sentence under the button, not a job record and a log the // operator has to open to read the reason. Same argument as // relocateChannelMediaAction's root check. if (!root.startsWith("/")) { return { ok: false, error: `The new root must be an absolute path (got "${newRoot}").`, }; } return enqueueRepointJob({ locationId: id, newRoot: root }); } // --------------------------------------------------------------------------- // The saved-video store // --------------------------------------------------------------------------- // MOVE THE STORE, RESUME AN INTERRUPTED MOVE, OR THROW ITS MARKER AWAY — the // same three verbs a channel's Storage panel has, for the same three states, // and for the reason that file gives: without the last two, a killed copy // leaves a marker nothing will ever clear and the only fix is deleting a // dotfile over SSH. // // The store is not a channel, so `channelMediaBusyReason` has nothing to ask // about it. What guards it instead is the shared relocation queue key: the // registry caps it at concurrency 1, so a second move waits rather than racing, // and the controller re-reads the disk at every phase regardless. export async function relocateSavedVideosAction( locationId: string, ): Promise { // THE RELOCATION QUEUE KEY ONLY SERIALISES RELOCATIONS. It stops a second // move, a channel move and a re-point from running at once, and it says // nothing at all about the download that is about to persist a source // container into the directory this is about to copy, verify, rename and // reclaim. See lib/storeBusy.ts for the three ways that ends badly, and // lib/savedVideoStore.ts for the guard that makes it refuse rather than lose // a container — this is the half that answers before the operator commits to // a multi-hour copy. const busy = savedVideosStoreBusyReason("moving the saved-video store"); if (busy) return { ok: false, error: busy }; return enqueueSavedVideosRelocation({ locationId }); } // RESUME IS THE MARKER'S DIRECTION, NOT THE OPERATOR'S. A marker records which // way the interrupted run was going; resuming it the other way would swap the // wrong way round (the controller refuses that by name, and this never asks it // to). "out" needs the location it was going to, which is the one the settings // still record — the record is written on SUCCESS, so mid-move it still names // where the store was, and for a resumed move-out that is where it is going. export async function resumeSavedVideosRelocationAction(): Promise { const paths = getPaths(); const marker = await readDirMarker(savedVideosMarkerPath(paths)); if (!marker) { return { ok: false, error: "The saved-video store has no relocation marker — there is no " + "interrupted move to resume.", }; } const busy = savedVideosStoreBusyReason("resuming the store's move"); if (busy) return { ok: false, error: busy }; if (marker.direction === "back") { return enqueueSavedVideosRelocation({ locationId: "" }); } // The target is `/saved-videos`, so the root is its parent — checked // rather than assumed, exactly as resumeRelocationAction checks a channel's. const root = path.dirname(marker.target); const loc = getSettings().storage.locations.find((l) => l.root === root); if (!loc) { return { ok: false, error: `The marker points at ${marker.target}, whose root ${root} is not a ` + `configured storage location. Add it on /storage, or clear the marker ` + `and start again.`, }; } return enqueueSavedVideosRelocation({ locationId: loc.id }); } // THE LAST RESORT, and the only one of the three that is not a move. It removes // the marker file and NOTHING else: no link, no settings, no bytes. Whatever // the store reads as afterwards is the truth the disk was already telling // underneath it — which may well be `inconsistent`, and that is the honest // answer rather than a repair nobody asked for. export async function clearSavedVideosMarkerAction(): Promise { try { await clearDirMarker(savedVideosMarkerPath(getPaths())); } catch (e) { return { ok: false, error: (e as Error).message }; } revalidatePath("/storage"); return { ok: true, note: "Marker cleared. Nothing was moved." }; } // EVICT FETCHED CLIP WINDOWS. // // `data//clips/` is the one thing in the corpus with no garbage collection: // the retention sweep is pointer-driven and never sees a window, and the // cleanup lanes are about `audio.*`. This is the only control that removes one. // // BY AGE, and the button says so. Whether a window is still wanted is a fact // about a umtool manifest — a report being rendered to video cites spans — and // the editor cannot see those manifests. There is no reference count to consult // and no honest way to invent one, so the rule is "older than N days" and the // operator is told that before they click, not after. // // NO BUSY CHECK HERE, deliberately: `evict-clips` declares `needsMedia: true`, // so `runManagedFunction` refuses a per-channel run whose media is unreachable // or mid-relocation, and the controller re-asks the same question per channel // for the corpus-wide run (where there is no slug for that guard to check). // A second opinion in an action is the thing the ops API's header warns about. export async function evictClipWindowsAction(opts: { slug?: string; olderThanDays: number; dryRun?: boolean; }): Promise { if (!Number.isFinite(opts.olderThanDays) || opts.olderThanDays < 0) { return { ok: false, error: "The age must be a number of days, zero or more.", }; } // THE SLUG COMES OFF THE WIRE — `/api/ops/evict-clips` passes whatever the // body said — and `evictChannel` path-joins it under `channelsDir` before // walking and DELETING. Shape first, because that is what forbids "/" and // ".." and it runs before any join; then existence, because a typo naming no // channel should be a refusal an agent can read, not a silent zero-byte // "success" over a directory that was never there. A server action is the // guard here: the ops route is an adapter and may hold no rule of its own. if (opts.slug !== undefined) { if (!isValidChannelSlug(opts.slug)) { return { ok: false, error: `"${opts.slug}" is not a valid channel slug` }; } if (!(await channelExists(getPaths(), opts.slug))) { return { ok: false, error: `Channel "${opts.slug}" not found` }; } } return enqueueEvictClipWindows({ ...(opts.slug ? { slug: opts.slug } : {}), olderThanDays: Math.floor(opts.olderThanDays), ...(opts.dryRun ? { dryRun: true } : {}), }); } // --------------------------------------------------------------------------- // The drive-health timings // --------------------------------------------------------------------------- // A failure carries what was submitted, so HealthTimingForm keeps the five // fields as typed (lib/formState.ts). export type HealthTimingsResult = FormState<{ note: string }>; // SAVE `settings.storage.health` FROM THE /storage FORM (HealthTimingForm). // // Parsed by `parseHealthTimingsForm`: an empty field is the default and is not // written, and a value out of range is REFUSED with a sentence (the schema // would clamp it; a save that stored another number than the one typed would // read as a form that did not listen). Then written through the one settings // writer, as a patch of the storage block, and APPLIED AT ONCE to this // process's health state (`applyHealthTimings`, on globalThis): the next read // runs on the new budget and cap, the next answer on the new clear count, the // next pass on the new probe timeout, and a new pass interval re-arms the // pass's timer now. The pass would apply them anyway, at its next run. export async function saveHealthTimingsAction( _prev: HealthTimingsResult | undefined, formData: FormData, ): Promise { const values = formValues(formData); const parsed = parseHealthTimingsForm(formData); if (!parsed.ok) return { ...parsed, values }; const settings = getSettings(); try { await saveSettings({ storage: { ...settings.storage, health: parsed.health } }); } catch (e) { return { ok: false, error: (e as Error).message, values }; } const t = applyHealthTimings(getSettings().storage.health); revalidatePath("/storage"); return { ok: true, note: `Saved. A read may take ${secondsText(t.budgetMs)}; drives are checked every ` + `${secondsText(t.passIntervalMs)} (a check waits up to ${secondsText(t.probeTimeoutMs)}); ` + `a drive marked not answering is used again after ` + `${t.clearAfterCleanPasses === 1 ? "one clean check" : `${t.clearAfterCleanPasses} clean checks in a row`}; ` + `${t.inFlightPerLocation} read(s) at once per drive.`, }; }