"use server"; // Write the compact transcript.cues.json sidecar for one channel. // // This exists because the digest lane's `deferred` bucket had a count and no // affordance. A video with a raw transcript and no cues.json is permanently // undigestable — transcribeOne is the only automatic caller of // normalizeTranscript, and a `handling: "youtube"` channel fetches subtitles // with --skip-download and never reaches it — and the only fix in the repo was // a corpus-wide button in the Pool on /sites. So clearing one channel meant // walking all ~79,000 video dirs from a page nobody opens during digest work. // // ON THE CHANNEL QUEUE, not the digest or backfill one: this is exactly the // "channel-local bookkeeping" queueKeys.ts describes channelQueueKey for, and // putting it anywhere else would make the fix for a stalled digest lane queue // up BEHIND the digest lane it is meant to unblock. import path from "node:path"; import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { channelQueueKey, resolveQueueKey, } from "yt-dlp-transcript-common/lib/queueKeys"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { normalizeChannelTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { heldVideoIds } from "yt-dlp-transcript-common/controller/videoCues"; // `ids` narrows the pass to those videos (`pnpm ops build-cues`, release 19 A7) // and every one must be held — a stray is refused, named, before any job. // `force` rewrites cues.json even where it is fresh. export async function normalizeChannelAction( slug: string, queueKey?: string, opts: { ids?: string[]; force?: boolean } = {}, ): Promise { const paths = getPaths(); if (opts.ids !== undefined || opts.force !== undefined) { const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `Channel "${slug}" not found` }; } if (opts.ids !== undefined) { const held = await heldVideoIds(path.join(paths.channelsDir, slug, "data")); const stray = opts.ids.filter((id) => !held.has(id)); if (stray.length) { return { ok: false, error: `${stray.length} of "ids" not held by ${slug}: ${stray.join(", ")}`, }; } } return runManagedFunction({ kind: "normalize-transcripts", queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, fn: async (onLog, signal) => { const result = await normalizeChannelTranscripts({ paths, channelSlug: slug, ...(opts.ids ? { videoIds: opts.ids } : {}), ...(opts.force ? { force: true } : {}), onLog, signal, }); // `skipped` is reported rather than swallowed because it is the honest // floor of this operation: videos with no raw transcript at all, which // normalizing cannot help and which the card counts separately as // `blocked` on transcription. onLog( `Normalize ${slug}: ${result.wrote} written, ${result.fresh} already current, ` + `${result.skipped} skipped (no transcript or no metadata), ${result.failed} failed.`, ); // The digest lane's classification is derived from disk, so the count this // run just moved is only visible once the snapshot is rebuilt. requestChannelSnapshot(paths, slug); safeRevalidate([`/channels/${slug}`]); }, }); }