"use server"; import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { SHARD_OPS, clearShardConfig, loadShardConfig, type ShardOp, } from "yt-dlp-transcript-common/controller/shard"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { assertChannelTextReadable } from "yt-dlp-transcript-common/lib/channelMedia"; import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp"; import { runWhisperBatch } from "yt-dlp-transcript-common/controller/whisperBatch"; import { runAvailabilityCheck } from "yt-dlp-transcript-common/controller/checkAvailability"; export type ClearShardResult = | { ok: true; cleared: boolean } | { ok: false; error: string }; export async function clearShardConfigAction( slug: string, op: ShardOp, ): Promise { if (!(SHARD_OPS as readonly string[]).includes(op)) { return { ok: false, error: `Unsupported shard op: ${op}` }; } const cleared = await clearShardConfig(getPaths(), slug, op); revalidatePath(`/channels/${slug}`); return { ok: true, cleared }; } export type SaveShardResult = | { ok: true; totalShards: number; shardIndex: number; itemCount: number } | { ok: false; error: string }; // Persist a shard slice WITHOUT running the job. Each op's runner is invoked // with `saveShardOnly: true`, so the saved slice is computed over the exact // same population a real run would use (transcribe = all video dirs, download // = the post-prefilter missing set, availability = data dirs) and then returns // before doing any work. This backs the "Save shard" button: it lets a slice // be pinned up front before dividing work across synced machines, and proves // the save path independently of a full run. export async function saveShardConfigAction( slug: string, op: ShardOp, total?: number, index?: number, ): Promise { if (!(SHARD_OPS as readonly string[]).includes(op)) { return { ok: false, error: `Unsupported shard op: ${op}` }; } if ( total === undefined || index === undefined || !Number.isInteger(total) || !Number.isInteger(index) || total < 1 || index < 0 || index >= total ) { return { ok: false, error: `Enter a valid shard before saving: total ≥ 1 and 0 ≤ index < total (got ${total}/${index}).`, }; } const paths = getPaths(); const noopLog = () => {}; const signal = new AbortController().signal; // THE ONE TRUE BYPASS (plans/relocate-channel-media.md). Every other path to a // channel's media goes through runManagedFunction and is covered by the guard // there; all three branches below call their runner DIRECTLY, with no job // record, so nothing upstream has checked anything. // // What that costs on an unmounted drive is not a failed save, it is a // confident wrong one: each runner computes its slice over the population it // finds on disk (all video dirs / the post-prefilter missing set / the data // dirs), and an unreachable data/ makes that "every video is missing". The // saved slice then says the whole channel needs re-downloading, is written to // disk, and outlives the mount long enough for another machine to act on it. // A shard is precisely the artifact you do not want silently wrong. // // It is at the top rather than in the download branch alone because all three // read the same dirs for the same reason. // // THE TEXT GUARD (release 17): the slices are computed over the video dirs // in `data/` — the text tier, on the corpus disk — so only an unreadable // text tier (a `legacy` channel) can make them wrong. try { await assertChannelTextReadable(paths, slug); } catch (e) { return { ok: false, error: (e as Error).message }; } try { if (op === "transcribe-missing") { await runWhisperBatch({ channelSlug: slug, paths, shardTotal: total, shardIndex: index, saveShardOnly: true, onLog: noopLog, signal, }); } else if (op === "download-missing") { const channelConfig = await readChannelConfig(paths, slug); if (!channelConfig) { return { ok: false, error: `Channel "${slug}" not found` }; } await runYtdlp({ channelSlug: slug, mode: "download-missing", channelConfig, paths, onLog: noopLog, signal, shardTotal: total, shardIndex: index, saveShardOnly: true, }); } else { await runAvailabilityCheck({ channelSlug: slug, paths, mode: "resume", shardTotal: total, shardIndex: index, saveShardOnly: true, onLog: noopLog, signal, }); } } catch (e) { return { ok: false, error: (e as Error).message }; } const saved = await loadShardConfig(paths, slug, op); revalidatePath(`/channels/${slug}`); if (!saved) { return { ok: false, error: "Shard slice was not saved (no matching population on disk).", }; } return { ok: true, totalShards: saved.totalShards, shardIndex: saved.shardIndex, itemCount: saved.items.length, }; }