"use server"; import { revalidatePath } from "next/cache"; import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { DIGEST_LOCAL_QUEUE, DIGEST_REMOTE_QUEUE, resolveQueueKey, } from "yt-dlp-transcript-common/lib/queueKeys"; import { countOperationWork, runOperationBatch, } from "yt-dlp-transcript-common/controller/operationBatch"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { loadDigest, loadDigestOverrides, writeDigestOverrides, } from "yt-dlp-transcript-common/lib/digest-server"; import { effectiveDigest, type DigestChapter, type DigestOverrides, type DigestTag, type EffectiveDigest, } from "yt-dlp-transcript-common/lib/digest"; import path from "node:path"; import { runDigestChannelJob } from "yt-dlp-transcript-common/controller/operationJobs"; import type { DigestLaneChoice as CommonDigestLaneChoice } from "yt-dlp-transcript-common/controller/digestTarget"; // AN ALIAS, NOT A RE-EXPORT, and the difference is load-bearing in a // `"use server"` file. `export type { DigestLaneChoice }` is type-correct — tsc // passes — but Next's server-actions transform does not erase a re-export the // way it erases a type DECLARATION: it emits a runtime binding, and every route // that reaches jobReplayRegistry.ts 500s with // `ReferenceError: DigestLaneChoice is not defined`. Caught by e2e, invisible to // tsc. // // So: an alias declaration, which IS erased, pointing at the one definition in // controller/digestTarget.ts. Not a second literal — two declarations of the // same two strings is how they would drift. export type DigestLaneChoice = CommonDigestLaneChoice; // Run the digest lane over one channel, from its stage card. // // A THIN WRAPPER over the shared runner, for the same reasons backfillActions.ts // is: what it keeps is the editor's half — no `background`, so a hand-clicked run // goes in front of the sweep's, and `onDone` for the revalidatePath that common/ // cannot do (it may not import next/cache). The stream comes back UNDRAINED for // the card's live log. // // The remoteEnabled fail-fast MOVED INTO THE RUNNER rather than staying here. // The lane runner can ask for the metered lane too, and a guard only one of two // callers performs is not a guard. export async function digestChannelAction( slug: string, lane: DigestLaneChoice = "local", queueKey?: string, order?: string, limitCount?: number, force?: boolean, // Only these videos — a replay of an ids-scoped run (`archilyzer run digest // `) must not widen to the channel. ids?: string[], ): Promise { return runDigestChannelJob({ paths: getPaths(), channelSlug: slug, lane, queueKey, order, limitCount, force, ids, onDone: () => safeRevalidate([`/channels/${slug}`]), }); } // Digest an explicit id set (a bucket selection, or the pilot's single channel // slice). Shares the batch machinery; scoped ids are intersected with disk. export async function digestBucketAction( slug: string, ids: string[], lane: DigestLaneChoice = "local", queueKey?: string, ): Promise { const paths = getPaths(); const settings = getSettings(); const cleaned = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))); if (cleaned.length === 0) { return { ok: false, error: "No video ids supplied" }; } if (lane === "remote" && !settings.digest.remoteEnabled) { return { ok: false, error: "The metered digest lane is off." }; } const remote = lane === "remote"; const kind = remote ? "digest-channel-remote" : "digest-channel-local"; return runManagedFunction({ kind, queueKey: resolveQueueKey( remote ? DIGEST_REMOTE_QUEUE : DIGEST_LOCAL_QUEUE, queueKey, ), paths, channelSlug: slug, fn: async (onLog, signal, setProgress, ctx) => { // Same shape as the channel action: the bar measures this run from zero // and the batch reports its own `current`, because the disk re-count it // would otherwise use cannot see a digest rewritten in place. const missing = ( await countOperationWork("digest", paths, slug, { ids: cleaned, digestLane: lane, }) ).reachable; const result = await runOperationBatch({ lane: "digest", channelSlug: slug, paths, digestLane: lane, ids: cleaned, setProgress, progressBaseline: 0, progressTarget: missing, onLog, signal, drainSignal: ctx.drainSignal, tracker: makeTaskTracker(ctx, onLog), }); onLog( `Digest bucket: ${result.succeeded} generated, ${result.fresh} already current, ${result.failed} failed.`, ); requestChannelSnapshot(paths, slug); safeRevalidate([`/channels/${slug}`]); }, }); } // --------------------------------------------------------------------------- // Per-video review: read the composed digest, write human corrections // --------------------------------------------------------------------------- export type VideoDigestView = { digest: EffectiveDigest; hasMachineDigest: boolean; overrides: DigestOverrides | null; }; function videoDir(slug: string, id: string): string { return path.join(getPaths().channelsDir, slug, "data", id); } export async function readVideoDigestAction( slug: string, id: string, ): Promise { const dir = videoDir(slug, id); const [machine, overrides] = await Promise.all([ loadDigest(dir), loadDigestOverrides(dir), ]); return { digest: effectiveDigest(machine, overrides), hasMachineDigest: machine !== null, overrides, }; } export type SaveDigestOverridesResult = | { ok: true; digest: EffectiveDigest } | { ok: false; error: string }; // Write human corrections. This ONLY ever touches ai-digest.overrides.json — // never ai-digest.json — which is what makes a correction survive the next // regeneration of a corpus too large to re-generate twice. export async function saveDigestOverridesAction( slug: string, id: string, input: { chapters?: DigestChapter[]; tags?: DigestTag[]; note?: string; }, ): Promise { const dir = videoDir(slug, id); try { await writeDigestOverrides(dir, { version: 1, ...(input.chapters ? { chapters: input.chapters } : {}), ...(input.tags ? { tags: input.tags } : {}), ...(input.note ? { note: input.note } : {}), }); } catch (e) { return { ok: false, error: (e as Error).message }; } const [machine, overrides] = await Promise.all([ loadDigest(dir), loadDigestOverrides(dir), ]); revalidatePath(`/channels/${slug}/videos/${id}`); return { ok: true, digest: effectiveDigest(machine, overrides) }; }