"use server"; import path from "node:path"; import { revalidatePath } from "next/cache"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { failedTranscriptionsFile, pruneFailedTranscriptions, } from "yt-dlp-transcript-common/controller/failedTranscriptions"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { transcribeBucketAction } from "./whisperActions"; import { retryBucketAction } from "./pipelineActions"; import { clearIncompleteTranscriptsAction, redownloadIncompleteBucketAction, redownloadShortAudioBucketAction, } from "./incompleteTranscriptActions"; import { loadRawMetadataFromDir, summarize, } from "yt-dlp-transcript-common/lib/transcripts-server"; import { recordIdsByVideoDir } from "yt-dlp-transcript-common/controller/curatedTagsPreview"; import { applyTagAssignmentsAction } from "../../tags/actions"; import { deleteOneVideoDir, markVideoUntranscribableAction, removeAudioFilesForVideo, } from "./videos/[id]/videoActions"; export type BulkActionSummary = { ok: boolean; attempted: number; succeeded: number; failures: Array<{ videoId: string; error: string }>; }; // Bulk transcribe/retry submit ONE managed batch job (the same path as // "Transcribe missing" / "Retry bucket"), not one job per selected video. This // keeps the checkbox actions on the same queue and concurrency model as the // rest of the app so they never desync. The selection bar fires these, // refreshes, and lets the resulting job surface in the page's RunningJobsList. export async function bulkTranscribeAction( slug: string, videoIds: string[], queueKey?: string, ): Promise { return transcribeBucketAction(slug, videoIds, queueKey); } export async function bulkRetryDownloadAction( slug: string, videoIds: string[], queueKey?: string, abortOnError?: boolean, ): Promise { return retryBucketAction(slug, videoIds, queueKey, abortOnError); } // Bulk re-fix truncated transcripts: queues ONE batch job that re-downloads + // re-transcribes each selected video in place (same one-job-per-bulk convention // as bulkTranscribeAction). export async function bulkRedownloadIncompleteAction( slug: string, videoIds: string[], queueKey?: string, ): Promise { return redownloadIncompleteBucketAction(slug, videoIds, queueKey); } // Bulk re-download short-audio (source-truncated) downloads: queues ONE batch // job that deletes the kept stub and re-fetches each selected video with the // per-source default format (Original for Odysee), then re-transcribes. export async function bulkRedownloadShortAudioAction( slug: string, videoIds: string[], queueKey?: string, ): Promise { return redownloadShortAudioBucketAction(slug, videoIds, queueKey); } // Bulk clear truncated transcripts: synchronous fs op (delete audio + transcript) // that enables the auto-runners, so it reports a per-id summary like the other // clear/remove bulk actions. Destructive — the caller confirms first. export async function bulkClearIncompleteAction( slug: string, videoIds: string[], ): Promise { return clearIncompleteTranscriptsAction(slug, videoIds); } // Marking videos untranscribable is an instant metadata write, not a queued // batch, so it stays synchronous and reports a per-id summary. export async function bulkMarkUntranscribableAction( slug: string, videoIds: string[], ): Promise { const failures: BulkActionSummary["failures"] = []; let succeeded = 0; for (const id of videoIds) { const r = await markVideoUntranscribableAction(slug, id); if (r.ok) succeeded++; else failures.push({ videoId: id, error: r.error }); } revalidatePath(`/channels/${slug}`); return { ok: failures.length === 0, attempted: videoIds.length, succeeded, failures, }; } // Deleting a video directory is a pure filesystem op (no queued job), so it // reports a per-id summary and never queues a job — the whole point of // the bulk delete button. export async function bulkDeleteVideoDirsAction( slug: string, videoIds: string[], ): Promise { const failures: BulkActionSummary["failures"] = []; let succeeded = 0; for (const id of videoIds) { const r = await deleteOneVideoDir(slug, id); if (r.ok) succeeded++; else failures.push({ videoId: id, error: r.error }); } revalidatePath(`/channels/${slug}`); requestChannelSnapshot(getPaths(), slug); return { ok: failures.length === 0, attempted: videoIds.length, succeeded, failures, }; } // Removing audio files is a pure filesystem op (no queued job): finalized // audio. is deleted, .part partials/transcripts/metadata are kept. Reports // a per-id summary; never queues a job. export async function bulkRemoveAudioAction( slug: string, videoIds: string[], ): Promise { const failures: BulkActionSummary["failures"] = []; let succeeded = 0; for (const id of videoIds) { const r = await removeAudioFilesForVideo(slug, id); if (r.ok) succeeded++; else failures.push({ videoId: id, error: r.error }); } revalidatePath(`/channels/${slug}`); requestChannelSnapshot(getPaths(), slug); return { ok: failures.length === 0, attempted: videoIds.length, succeeded, failures, }; } // Removes finalized audio files whose format isn't the channel's target (e.g. // the cornbreadman audio.m4a/audio.mp4 leftovers that failed yt-dlp's // extract-to-mp3 step), even when it's a video's only audio. Pure filesystem op; // queues no job. Requires the channel to have a configured audioFormat. export async function bulkRemoveWrongFormatAudioAction( slug: string, videoIds: string[], ): Promise { const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config?.audioFormat) { return { ok: false, attempted: videoIds.length, succeeded: 0, failures: videoIds.map((videoId) => ({ videoId, error: "No target audio format configured for this channel", })), }; } const targetAudioFile = `audio.${config.audioFormat}`; const failures: BulkActionSummary["failures"] = []; let succeeded = 0; for (const id of videoIds) { const r = await removeAudioFilesForVideo(slug, id, { wrongFormatOnly: true, targetAudioFile, }); if (r.ok) succeeded++; else failures.push({ videoId: id, error: r.error }); } revalidatePath(`/channels/${slug}`); requestChannelSnapshot(paths, slug); return { ok: failures.length === 0, attempted: videoIds.length, succeeded, failures, }; } // Clearing failure markers is a set operation over the failed-transcriptions // file, so `succeeded` reports how many ids were actually removed (some // selected ids may not have been marked failed). export async function bulkClearFailedMarkersAction( slug: string, videoIds: string[], ): Promise { const paths = getPaths(); const idSet = new Set(videoIds); const trxn = await pruneFailedTranscriptions( failedTranscriptionsFile(paths, slug), idSet, ); revalidatePath(`/channels/${slug}`); requestChannelSnapshot(paths, slug); return { ok: true, attempted: videoIds.length, succeeded: trxn.pruned, failures: [], }; } // Tag (or untag) every selected video in ONE write. // // Not a loop over applyTagAssignmentsAction: the store reads tags.json, // mutates it and renames it once per call, so a per-id loop over four thousand // selected videos would be four thousand reads and four thousand renames of a // file every other surface is reading. The bulk bar's whole job is to be one // gesture, and this is one write. // // THE KEYS ARE RECORD IDS, NOT DIRECTORY NAMES. The list holds directory names; // assignments are keyed by the id the index and the export use, and the two // differ on Rumble, Odysee and Twitch. The index's own directory -> id map // answers that for the whole selection in one ranged read; only a video the // index has never seen costs a metadata read. // // `op` is add | remove | suppress, and the three are not interchangeable: // remove UNPINS (a rule that still matches keeps the tag), suppress REJECTS. export async function bulkApplyTagAction( slug: string, videoIds: string[], tag: string, op: "add" | "remove" | "suppress", ): Promise { const paths = getPaths(); // THE MAP FIRST, THE FILES ONLY FOR WHAT IT MISSES. The index already stores // directory -> record id for every indexed video (the `mtimes` sub-DB), so // one ranged read answers the whole selection; reading a metadata.info.json // per selected video was four thousand serial opens for a four-thousand-video // bulk. A video the index has never seen (never built, just downloaded) still // falls back to its own metadata, and then to its directory name. const byDir = recordIdsByVideoDir(paths, slug); const channelDataDir = path.join(paths.channelsDir, slug, "data"); const videos: { channelSlug: string; id: string }[] = []; for (const dirId of videoIds) { let id = byDir.get(dirId); if (!id) { const meta = await loadRawMetadataFromDir(path.join(channelDataDir, dirId)); id = meta ? summarize(slug, dirId, meta).id : dirId; } videos.push({ channelSlug: slug, id }); } const result = await applyTagAssignmentsAction({ op, tag, videos }); if (!result.ok) { return { ok: false, attempted: videoIds.length, succeeded: 0, failures: videoIds.map((videoId) => ({ videoId, error: result.error })), }; } revalidatePath(`/channels/${slug}/videos`); // `changed` counts the videos whose assignment actually MOVED — re-pinning a // pinned video is not a failure and is not a change. Reporting attempted as // succeeded would claim work that did not happen; reporting `changed` as // succeeded would look like a partial failure. So: attempted is the // selection, succeeded is what moved, and nothing failed. return { ok: true, attempted: videoIds.length, succeeded: result.changed, failures: [], }; }