commit 273ac43ad6dbffda870eb02a83453067cbd3da9c
parent fca8cd098bf185b185eaada4f3e68cb4b8bd36e0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 19 May 2026 00:19:47 -0400
transcode failure handling
Diffstat:
7 files changed, 178 insertions(+), 19 deletions(-)
diff --git a/common/controller/transcodeFailures.ts b/common/controller/transcodeFailures.ts
@@ -35,7 +35,15 @@ function pickSourceAudio(
return [...candidates].sort()[0];
}
-export type TranscodeFailuresOptions = {
+export type TranscodeBatchResult = {
+ attempted: number;
+ succeeded: number;
+ failed: number;
+ skipped: number;
+};
+
+export type TranscodeIdsOptions = {
+ ids: string[];
channelSlug: string;
paths: Paths;
targetFormat: AudioFormat;
@@ -44,33 +52,22 @@ export type TranscodeFailuresOptions = {
signal?: AbortSignal;
};
-export type TranscodeFailuresResult = {
- attempted: number;
- succeeded: number;
- failed: number;
- skipped: number;
-};
-
-export async function runTranscodeFailures({
+export async function transcodeIdsToFormat({
+ ids,
channelSlug,
paths,
targetFormat,
concurrency,
onLog,
signal,
-}: TranscodeFailuresOptions): Promise<TranscodeFailuresResult> {
+}: TranscodeIdsOptions): Promise<TranscodeBatchResult> {
const log = onLog ?? ((m: string) => console.log(m));
const channelDir = path.join(paths.channelsDir, channelSlug);
const dataDir = path.join(channelDir, "data");
- const failureListFile = path.join(channelDir, "failed-transcriptions");
const transcodeFailureFile = failedTranscodingsFile(paths, channelSlug);
const limit = pLimit(concurrency ?? 1);
- await ensureFile(failureListFile);
await ensureFile(transcodeFailureFile);
- const ids = (await readFile(failureListFile, "utf-8"))
- .split("\n")
- .filter(Boolean);
const previousTranscodeFailures = new Set(
(await readFile(transcodeFailureFile, "utf-8")).split("\n").filter(Boolean),
);
@@ -148,3 +145,40 @@ export async function runTranscodeFailures({
return { attempted, succeeded, failed, skipped };
}
+
+export type TranscodeFailuresOptions = {
+ channelSlug: string;
+ paths: Paths;
+ targetFormat: AudioFormat;
+ concurrency?: number;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+};
+
+export type TranscodeFailuresResult = TranscodeBatchResult;
+
+export async function runTranscodeFailures({
+ channelSlug,
+ paths,
+ targetFormat,
+ concurrency,
+ onLog,
+ signal,
+}: TranscodeFailuresOptions): Promise<TranscodeFailuresResult> {
+ const channelDir = path.join(paths.channelsDir, channelSlug);
+ const failureListFile = path.join(channelDir, "failed-transcriptions");
+ await ensureFile(failureListFile);
+ const ids = (await readFile(failureListFile, "utf-8"))
+ .split("\n")
+ .filter(Boolean);
+
+ return transcodeIdsToFormat({
+ ids,
+ channelSlug,
+ paths,
+ targetFormat,
+ concurrency,
+ onLog,
+ signal,
+ });
+}
diff --git a/common/controller/transcodeUntranscoded.ts b/common/controller/transcodeUntranscoded.ts
@@ -0,0 +1,54 @@
+import type { Paths } from "../lib/paths";
+import { readChannelConfig } from "./channels";
+import { readChannelSnapshot } from "./channelSnapshot";
+import {
+ transcodeIdsToFormat,
+ type TranscodeBatchResult,
+} from "./transcodeFailures";
+
+export type TranscodeUntranscodedOptions = {
+ channelSlug: string;
+ paths: Paths;
+ concurrency?: number;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+};
+
+export async function runTranscodeUntranscoded({
+ channelSlug,
+ paths,
+ concurrency,
+ onLog,
+ signal,
+}: TranscodeUntranscodedOptions): Promise<TranscodeBatchResult> {
+ const log = onLog ?? ((m: string) => console.log(m));
+ const config = await readChannelConfig(paths, channelSlug);
+ if (!config?.audioFormat) {
+ throw new Error(
+ `Channel ${channelSlug} has no audioFormat configured; set one before running this action.`,
+ );
+ }
+ const snapshot = await readChannelSnapshot(paths, channelSlug);
+ const ids = snapshot?.buckets?.untranscoded ?? [];
+ if (!snapshot) {
+ log(
+ "No snapshot on disk — regenerate the channel snapshot first so the untranscoded bucket is populated.",
+ );
+ }
+ if (ids.length === 0) {
+ log("Nothing to transcode: untranscoded bucket is empty.");
+ return { attempted: 0, succeeded: 0, failed: 0, skipped: 0 };
+ }
+ log(
+ `Transcoding ${ids.length} untranscoded video(s) → audio.${config.audioFormat}`,
+ );
+ return transcodeIdsToFormat({
+ ids,
+ channelSlug,
+ paths,
+ targetFormat: config.audioFormat,
+ concurrency,
+ onLog: log,
+ signal,
+ });
+}
diff --git a/editor/app/channels/[slug]/components/stages/TranscodeStage.tsx b/editor/app/channels/[slug]/components/stages/TranscodeStage.tsx
@@ -12,6 +12,7 @@ import {
clearFailedTranscodingsAction,
removeFailedTranscodingsAction,
transcodeFailuresAction,
+ transcodeUntranscodedAction,
} from "../../whisperActions";
import { VideoIdList } from "../VideoIdList";
@@ -78,7 +79,12 @@ export function TranscodeStage({
}
/>
</div>
- <UntranscodedList slug={slug} ids={untranscodedIds} />
+ <UntranscodedList
+ slug={slug}
+ ids={untranscodedIds}
+ existingQueues={existingQueues}
+ defaultQueueKey={transcodeDefaultQueueKey}
+ />
<FailedTranscodingsSection
slug={slug}
ids={failedTranscodingIds}
@@ -89,7 +95,18 @@ export function TranscodeStage({
);
}
-function UntranscodedList({ slug, ids }: { slug: string; ids: string[] }) {
+function UntranscodedList({
+ slug,
+ ids,
+ existingQueues,
+ defaultQueueKey,
+}: {
+ slug: string;
+ ids: string[];
+ existingQueues: string[];
+ defaultQueueKey: string;
+}) {
+ const [queue, setQueue] = useState(defaultQueueKey);
if (ids.length === 0) return null;
return (
<div className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3">
@@ -100,7 +117,8 @@ function UntranscodedList({ slug, ids }: { slug: string; ids: string[] }) {
<p className="text-xs text-zinc-500">
Audio file present in a different format than the channel's
configured audioFormat — re-run the transcoder to produce
- audio.<format>.
+ audio.<format>. Failures land in the failed-transcodings list
+ below.
</p>
</div>
<VideoIdList
@@ -111,6 +129,22 @@ function UntranscodedList({ slug, ids }: { slug: string; ids: string[] }) {
emptyMessage="None"
itemAriaLabel={(id) => `untranscoded ${id}`}
/>
+ <StreamActionLog
+ trigger={() => transcodeUntranscodedAction(slug, queue)}
+ cancelAction={cancelJobAction}
+ buttonLabel={`Transcode all (${ids.length}) → channel audioFormat`}
+ runningLabel="Transcoding…"
+ label="Transcode untranscoded"
+ extraControls={
+ <QueueControl
+ value={queue}
+ onChange={setQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Transcode untranscoded"
+ />
+ }
+ />
</div>
);
}
diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts
@@ -55,6 +55,7 @@ const JOB_KIND_TO_STAGE: Record<string, StageId> = {
"download-from-playlist": "download",
"download-missing": "download",
"transcode-failures": "transcode",
+ "transcode-untranscoded": "transcode",
"remove-failed-transcodings": "transcode",
"clear-failed-transcodings": "transcode",
"transcode-audio": "transcode",
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -38,6 +38,7 @@ type Props = {
defaultQueueKey: string;
existingQueues: string[];
downloadOutcome: DownloadOutcomeRecord | null;
+ channelAudioFormat?: AudioFormat;
};
const AUDIO_EXTS = new Set([
@@ -114,6 +115,7 @@ export function VideoPanel({
defaultQueueKey,
existingQueues,
downloadOutcome,
+ channelAudioFormat,
}: Props) {
const audioFiles = files.filter((f) => isAudioFile(f.name));
const transcodeSources = files.filter((f) => isTranscodeSource(f.name));
@@ -173,6 +175,7 @@ export function VideoPanel({
videoId={videoId}
file={f}
existingQueues={existingQueues}
+ channelAudioFormat={channelAudioFormat}
/>
))}
</div>
@@ -377,15 +380,21 @@ function PerFileTranscodeRow({
videoId,
file,
existingQueues,
+ channelAudioFormat,
}: {
slug: string;
videoId: string;
file: VideoFile;
existingQueues: string[];
+ channelAudioFormat?: AudioFormat;
}) {
const ext = audioExt(file.name);
const transcodeTargets = AUDIO_FORMAT_VALUES.filter((f) => f !== ext);
- const [target, setTarget] = useState<AudioFormat>(transcodeTargets[0] ?? "opus");
+ const preferredTarget =
+ channelAudioFormat && transcodeTargets.includes(channelAudioFormat)
+ ? channelAudioFormat
+ : transcodeTargets[0] ?? "opus";
+ const [target, setTarget] = useState<AudioFormat>(preferredTarget);
const [transcodeQueue, setTranscodeQueue] = useState("");
if (transcodeTargets.length === 0) return null;
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -170,6 +170,7 @@ export default async function VideoDetailPage({
defaultQueueKey={defaultQueueKey}
existingQueues={existingQueues}
downloadOutcome={downloadOutcome}
+ channelAudioFormat={config.audioFormat}
/>
</div>
);
diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts
@@ -9,6 +9,7 @@ import {
import { TRANSCRIPTION_QUEUE } from "yt-dlp-transcript-common/lib/platform";
import { runWhisperBatch } from "yt-dlp-transcript-common/controller/whisperBatch";
import { runTranscodeFailures } from "yt-dlp-transcript-common/controller/transcodeFailures";
+import { runTranscodeUntranscoded } from "yt-dlp-transcript-common/controller/transcodeUntranscoded";
import { removeFailedTranscodings } from "yt-dlp-transcript-common/controller/removeFailedTranscodings";
import {
clearFailedTranscodings,
@@ -127,6 +128,31 @@ export async function transcodeFailuresAction(
});
}
+export async function transcodeUntranscodedAction(
+ slug: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "transcode-untranscoded",
+ queueKey: queueKey === undefined ? TRANSCRIPTION_QUEUE : queueKey.trim(),
+ paths,
+ channelSlug: slug,
+ fn: async (onLog, signal) => {
+ const result = await runTranscodeUntranscoded({
+ channelSlug: slug,
+ paths,
+ onLog,
+ signal,
+ });
+ onLog(
+ `Transcode untranscoded: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`,
+ );
+ revalidatePath(`/channels/${slug}`);
+ },
+ });
+}
+
export async function removeFailedTranscodingsAction(
slug: string,
queueKey?: string,