commit b413851f5b822047e7c318853f53ab582dfe0107
parent 7614b292e5e274558b528443581e94c8addf8033
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 30 Jun 2026 10:28:53 -0400
Download format: selectable + platform-aware default (Odysee -> original)
The yt-dlp `-f` selector was hardcoded to `bestaudio/worst`, which on
Odysee/LBRY picks a CDN-truncated HLS rung (~4 min of a multi-hour video);
the full audio lives only in the `original` format. Make the format
selectable with the same override chain as audioFormat and default Odysee
to `original`.
- common/ytdlp/downloadFormat.ts: DownloadFormatPreset enum + resolvers.
"auto" is platform-aware (odysee -> original/bestaudio/worst).
- Thread downloadFormatOverride/Preset through RunYtdlpOpts +
ManagedDownloadOpts and the three `-f` emit sites (audioFormatSelectionArgs,
transcribeHandlingArgsForAudioCheck, downloadOneAudio); resolve the source
platform from the prefetched metadata extractor.
- SiteSettings.downloadFormat (global) + ChannelConfig.downloadFormat
(per-channel) + per-video redownload override, resolved override > channel
> global > auto.
- UI: global select in /settings, per-channel select in ChannelForm, per-video
Format select in the redownload panel.
- Export platformFromMetadata + isLivestreamMetadata from transcripts-server
(the latter for the upcoming duration guard).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
13 files changed, 309 insertions(+), 19 deletions(-)
diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts
@@ -39,6 +39,7 @@ import { readChannelSnapshot } from "./channelSnapshot";
import { transcribeOneFromQueue } from "./transcribeOneFromQueue";
import { findVideoSourceUrl } from "./undownloadedVideos";
import { downloadOneManaged } from "../ytdlp/downloadOneManaged";
+import { resolveDownloadFormatPreset } from "../ytdlp/downloadFormat";
// The automatic priority-queue runners. Each kind (auto-transcribe /
// auto-download) is ONE long-lived registry job (queueKey "" so it runs in
@@ -624,6 +625,10 @@ async function launchUnit(args: LaunchArgs): Promise<UnitResult> {
globalCookiesFromBrowser: settings.cookiesFromBrowser || undefined,
inlineTranscribeOnFallback: settings.inlineTranscribeOnFallback,
globalSkipLiveDownloads: settings.skipLiveDownloads,
+ downloadFormatPreset: resolveDownloadFormatPreset({
+ channel: config.downloadFormat,
+ global: settings.downloadFormat,
+ }),
appendArchive: true,
});
unitStatus = record.status;
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -1,4 +1,8 @@
import { PLATFORM_VALUES, type Platform } from "./platform";
+import {
+ isDownloadFormatPreset,
+ type DownloadFormatPreset,
+} from "../ytdlp/downloadFormat";
export type ChannelHandling = "youtube" | "transcribe";
@@ -37,6 +41,12 @@ export type ChannelConfig = {
name?: string;
url?: string;
audioFormat?: AudioFormat;
+ // Per-channel override for the yt-dlp `-f` download format (see
+ // common/ytdlp/downloadFormat.ts). Omitted -> inherit the global default
+ // (SiteSettings.downloadFormat), which itself falls back to the per-source
+ // "auto" selector. Lets a channel whose source only serves full-length audio
+ // in its `original` format (e.g. Odysee) force it regardless of the global.
+ downloadFormat?: DownloadFormatPreset;
keepSourceVideo?: boolean;
// Keep-latest retention/persistence window. The newest N videos (by upload
// date) are protected from the Clean-audio sweep AND have their source video
@@ -149,6 +159,9 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
) {
config.audioFormat = r.audioFormat;
}
+ if (isDownloadFormatPreset(r.downloadFormat)) {
+ config.downloadFormat = r.downloadFormat;
+ }
if (typeof r.keepSourceVideo === "boolean") {
config.keepSourceVideo = r.keepSourceVideo;
}
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -3,6 +3,10 @@ import path from "node:path";
import { getPaths } from "./paths";
import { SYNC_INTERVAL_MAX_MINUTES } from "./channelConfig";
import {
+ isDownloadFormatPreset,
+ type DownloadFormatPreset,
+} from "../ytdlp/downloadFormat";
+import {
type AppInstanceConfig,
DEFAULT_TRANSCRIBE_ARGS,
DEFAULT_TRANSCRIPTION_APP_ID,
@@ -67,6 +71,11 @@ export type SiteSettings = {
// within one invocation, so without this the managed loop hammers the
// source IP back-to-back. 0 disables. Per-channel override available.
sleepBetweenDownloadsSeconds: number;
+ // Default yt-dlp `-f` download format for every channel that doesn't set its
+ // own (ChannelConfig.downloadFormat). "auto" picks per-source: `original` for
+ // Odysee (whose HLS rungs are CDN-truncated), `bestaudio/worst` elsewhere. See
+ // common/ytdlp/downloadFormat.ts.
+ downloadFormat: DownloadFormatPreset;
// Minimum free disk space (GB) required on the transcripts data directory for
// downloads to run. When free space is below this floor, a download job is
// prevented from starting and a running batch stops launching new videos
@@ -431,6 +440,7 @@ function defaults(): SiteSettings {
workers: [],
cookiesFromBrowser: "",
sleepBetweenDownloadsSeconds: SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS,
+ downloadFormat: "auto",
minFreeDiskGB: MIN_FREE_DISK_GB_DEFAULT,
parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT,
inlineTranscribeOnFallback: false,
@@ -585,6 +595,9 @@ export function getSettings(): SiteSettings {
merged.sleepBetweenDownloadsSeconds = clampSleepBetweenDownloadsSeconds(
merged.sleepBetweenDownloadsSeconds,
);
+ if (!isDownloadFormatPreset(merged.downloadFormat)) {
+ merged.downloadFormat = "auto";
+ }
merged.minFreeDiskGB = clampMinFreeDiskGB(merged.minFreeDiskGB);
merged.parallelTranscriptions = clampParallelTranscriptions(
merged.parallelTranscriptions,
@@ -761,6 +774,9 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
sleepBetweenDownloadsSeconds: clampSleepBetweenDownloadsSeconds(
next.sleepBetweenDownloadsSeconds,
),
+ downloadFormat: isDownloadFormatPreset(next.downloadFormat)
+ ? next.downloadFormat
+ : "auto",
minFreeDiskGB: clampMinFreeDiskGB(next.minFreeDiskGB),
parallelTranscriptions: clampParallelTranscriptions(
next.parallelTranscriptions,
diff --git a/common/lib/transcripts-server.ts b/common/lib/transcripts-server.ts
@@ -59,7 +59,7 @@ export function loadRawMetadataFromDir(
return loadRawMetadata(path.join(videoDir, "metadata.info.json"));
}
-function detectPlatform(meta: RawMetadata): Platform {
+export function platformFromMetadata(meta: RawMetadata): Platform {
const key = meta.extractor_key ?? meta.extractor ?? "";
if (/^rumble/i.test(key)) return "rumble";
if (/^lbry/i.test(key)) return "odysee";
@@ -67,20 +67,30 @@ function detectPlatform(meta: RawMetadata): Platform {
return "youtube";
}
+// The broad "is this a livestream (or stream VOD/upcoming)" notion used by the
+// coverage detector and the download-time duration guard, so both skip the same
+// content (stream captures have unreliable metadata durations). Distinct from
+// the narrower download-filter `skipLive`, which intentionally lets finished
+// stream VODs through.
+export function isLivestreamMetadata(meta: RawMetadata): boolean {
+ const liveStatus = meta.live_status ?? "";
+ return (
+ meta.was_live === true ||
+ meta.is_live === true ||
+ liveStatus === "was_live" ||
+ liveStatus === "is_live" ||
+ liveStatus === "is_upcoming"
+ );
+}
+
export function summarize(
channelSlug: string,
videoDir: string,
meta: RawMetadata,
configName?: string,
): TranscriptSummary {
- const liveStatus = meta.live_status ?? "";
- const isLivestream =
- meta.was_live === true ||
- meta.is_live === true ||
- liveStatus === "was_live" ||
- liveStatus === "is_live" ||
- liveStatus === "is_upcoming";
- const platform = detectPlatform(meta);
+ const isLivestream = isLivestreamMetadata(meta);
+ const platform = platformFromMetadata(meta);
const id =
platform === "odysee"
? (meta.webpage_url_basename ?? meta.id ?? videoDir)
diff --git a/common/ytdlp/downloadFormat.ts b/common/ytdlp/downloadFormat.ts
@@ -0,0 +1,68 @@
+import type { Platform } from "../lib/platform";
+
+// The user-selectable yt-dlp `-f` download format, as a small preset enum (kept
+// an enum rather than a free-form `-f` string so it validates like audioFormat).
+// "auto" is platform-aware: Odysee/LBRY only serves the full-length audio in its
+// `original` format (every HLS rung is CDN-truncated to a few minutes), so auto
+// prefers `original` there and the historical `bestaudio/worst` everywhere else.
+export type DownloadFormatPreset =
+ | "auto"
+ | "original"
+ | "bestaudio"
+ | "bestvideo_audio";
+
+export const DOWNLOAD_FORMAT_PRESETS: ReadonlyArray<DownloadFormatPreset> = [
+ "auto",
+ "original",
+ "bestaudio",
+ "bestvideo_audio",
+];
+
+export function isDownloadFormatPreset(
+ value: unknown,
+): value is DownloadFormatPreset {
+ return (
+ typeof value === "string" &&
+ (DOWNLOAD_FORMAT_PRESETS as ReadonlyArray<string>).includes(value)
+ );
+}
+
+// Human labels for the UI selects (global / channel / per-video).
+export const DOWNLOAD_FORMAT_LABELS: Record<DownloadFormatPreset, string> = {
+ auto: "Auto (per-source)",
+ original: "Original (full file)",
+ bestaudio: "Best audio only",
+ bestvideo_audio: "Best video + audio",
+};
+
+// Resolve a preset into the concrete yt-dlp `-f` selector string. The platform
+// only matters for "auto"; explicit presets apply literally regardless of
+// source (so an explicit choice always wins over the per-source default).
+export function resolveDownloadFormatSelector(
+ preset: DownloadFormatPreset,
+ platform: Platform | null | undefined,
+): string {
+ switch (preset) {
+ case "original":
+ return "original/bestaudio/worst";
+ case "bestaudio":
+ return "bestaudio/worst";
+ case "bestvideo_audio":
+ return "bestvideo*+bestaudio/best";
+ case "auto":
+ default:
+ return platform === "odysee"
+ ? "original/bestaudio/worst"
+ : "bestaudio/worst";
+ }
+}
+
+// The override chain mirrors audioFormat: per-run override beats the per-channel
+// default beats the global default; "auto" is the baseline when nothing is set.
+export function resolveDownloadFormatPreset(opts: {
+ override?: DownloadFormatPreset | null;
+ channel?: DownloadFormatPreset | null;
+ global?: DownloadFormatPreset | null;
+}): DownloadFormatPreset {
+ return opts.override ?? opts.channel ?? opts.global ?? "auto";
+}
diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts
@@ -33,12 +33,17 @@ import {
} from "../lib/downloadOutcome";
import { writeDownloadOutcome } from "../lib/downloadOutcome-server";
import { recordAvailability } from "../lib/availability-server";
-import { loadRawMetadata } from "../lib/transcripts-server";
+import { loadRawMetadata, platformFromMetadata } from "../lib/transcripts-server";
import { evaluateDownloadFilters } from "../lib/downloadFilters";
+import { detectPlatform, type Platform } from "../lib/platform";
import type { Paths } from "../lib/paths";
import { transcribeWithWorker } from "../controller/transcribeOne";
import { extractVideoId, outputArgsForUrl } from "./runYtdlp";
import { runAudioCheckedYtdlp } from "./audioCheckedDownload";
+import {
+ resolveDownloadFormatSelector,
+ type DownloadFormatPreset,
+} from "./downloadFormat";
import { DOWNLOAD_PROGRESS_TEMPLATE } from "../jobs/progressParsers";
const STDERR_TAIL_BYTES = 64 * 1024;
@@ -95,6 +100,11 @@ export type ManagedDownloadOpts = {
extractImmediately?: boolean;
// Per-run override of channelConfig.audioFormat for the extracted audio.
audioFormatOverride?: AudioFormat;
+ // Resolved yt-dlp `-f` download-format preset (override > channel > global),
+ // already collapsed by the playlist-level caller. Expanded into a concrete
+ // selector here, where the per-video source platform is known (so "auto"
+ // picks `original` for Odysee). Defaults to "auto" when omitted.
+ downloadFormatPreset?: DownloadFormatPreset;
};
// When `reuseInfoJson` is true, the real download reuses the metadata the
@@ -126,15 +136,18 @@ function audioFormatSelectionArgs(
plan: PersistenceDecision,
fmt: AudioFormat,
config: ChannelConfig,
+ selector: string,
): string[] {
if (plan.extractionMode === "ytdlp") {
- const args = ["-f", "bestaudio/worst", "-x", "--audio-format", fmt];
+ const args = ["-f", selector, "-x", "--audio-format", fmt];
if (config.keepSourceVideo) args.push("-k");
return args;
}
+ // Persisting in app mode keeps the full source container (always a complete
+ // video), so the truncation-prone audio-only selector doesn't apply.
return plan.persist
? ["-f", "bestvideo*+bestaudio/best"]
- : ["-f", "bestaudio/worst"];
+ : ["-f", selector];
}
// The media output + info-json + format args for a transcribe-handling download.
@@ -147,12 +160,13 @@ function transcribeMediaArgs(
plan: PersistenceDecision,
fmt: AudioFormat,
reuseInfoJson: boolean,
+ selector: string,
): string[] {
const mediaName = plan.extractionMode === "app" ? "source-media" : "audio";
return [
...outputArgsForUrl(url, { mediaName }),
...(reuseInfoJson ? [] : ["--write-info-json"]),
- ...audioFormatSelectionArgs(plan, fmt, config),
+ ...audioFormatSelectionArgs(plan, fmt, config, selector),
];
}
@@ -241,11 +255,14 @@ function sourceArgs(url: string, loadInfoJson: string | null): string[] {
// Audio-checked mode: we own the audio extraction, so yt-dlp must NOT run
// the ExtractAudio postprocessor. We always pass `-c` so resume picks up
// where the last validated snapshot left off.
-function transcribeHandlingArgsForAudioCheck(_config: ChannelConfig): string[] {
+function transcribeHandlingArgsForAudioCheck(
+ _config: ChannelConfig,
+ selector: string,
+): string[] {
return [
"--write-info-json",
"-f",
- "bestaudio/worst",
+ selector,
"-c",
];
}
@@ -461,6 +478,11 @@ async function runManagedDownload(
// This video's recency key (upload_date), captured from the prefetched
// metadata so the keep-latest cutoff can classify it even before it's on disk.
let videoUploadKey = canonicalId ? uploadKeyFor(undefined, canonicalId) : "";
+ // Source platform for the "auto" download-format selector. Seeded from the
+ // channel config / URL and refined to the metadata extractor once prefetched
+ // (e.g. lbry -> odysee), which is the authoritative signal.
+ let resolvedPlatform: Platform | null =
+ opts.channelConfig.platform ?? detectPlatform(opts.videoUrl);
if (canonicalId && !opts.signal.aborted) {
const videoDir = path.join(channelDir, "data", canonicalId);
const prefetchArgs = [
@@ -498,6 +520,7 @@ async function runManagedDownload(
// the metadata file; a failed prefetch falls through to the legacy path so
// the existing auth-retry logic still gets a chance.
if (metadata) infoJsonPath = metaPath;
+ if (metadata) resolvedPlatform = platformFromMetadata(metadata);
videoUploadKey = uploadKeyFor(metadata?.upload_date, canonicalId);
const decision = evaluateDownloadFilters({
@@ -540,6 +563,12 @@ async function runManagedDownload(
// which switches to transcribe). Cheap: a set/cutoff compare + one stat.
const fmt: AudioFormat =
opts.audioFormatOverride ?? opts.channelConfig.audioFormat ?? "mp3";
+ // The concrete yt-dlp `-f` selector, expanded from the resolved preset against
+ // the source platform (so "auto" downloads `original` for Odysee).
+ const downloadFormatSelector = resolveDownloadFormatSelector(
+ opts.downloadFormatPreset ?? "auto",
+ resolvedPlatform,
+ );
const pinned = canonicalId
? await isDoNotClean(path.join(channelDir, "data", canonicalId))
: false;
@@ -574,6 +603,7 @@ async function runManagedDownload(
plan,
fmt,
reuseInfoJson,
+ downloadFormatSelector,
);
if (opts.channelConfig.handling === "transcribe") {
opts.onLog(
@@ -603,7 +633,10 @@ async function runManagedDownload(
"--restrict-filenames",
...FULL_LOG_PROGRESS_ARGS,
...outputArgsForUrl(opts.videoUrl),
- ...transcribeHandlingArgsForAudioCheck(opts.channelConfig),
+ ...transcribeHandlingArgsForAudioCheck(
+ opts.channelConfig,
+ downloadFormatSelector,
+ ),
"--print",
`after_video:${ARCHIVE_MARKER} %(extractor)s %(id)s`,
...channelConfigArgs(opts.channelConfig),
@@ -794,7 +827,14 @@ async function runManagedDownload(
"--ignore-config",
"--restrict-filenames",
...FULL_LOG_PROGRESS_ARGS,
- ...transcribeMediaArgs(opts.videoUrl, fallbackConfig, plan, fmt, true),
+ ...transcribeMediaArgs(
+ opts.videoUrl,
+ fallbackConfig,
+ plan,
+ fmt,
+ true,
+ downloadFormatSelector,
+ ),
"--print",
`after_video:${ARCHIVE_MARKER} %(extractor)s %(id)s`,
...channelConfigArgs(fallbackConfig, fallbackCookieOverride),
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -25,6 +25,11 @@ import { backfillAvailabilityFromMetadata } from "../controller/backfillAvailabi
import { resolveShardItems } from "../controller/shard";
import { computeKeepWindow, type KeepWindow } from "../controller/keptVideos";
import { downloadOneManaged } from "./downloadOneManaged";
+import {
+ resolveDownloadFormatPreset,
+ resolveDownloadFormatSelector,
+ type DownloadFormatPreset,
+} from "./downloadFormat";
import type { TaskTracker } from "../jobs/taskHooks";
import type { JobProgress } from "../jobs/registry";
@@ -74,6 +79,11 @@ export type RunYtdlpOpts = {
// Override channelConfig.audioFormat for this run. Honored by download-one-audio
// and by the managed download paths (passed through to downloadOneManaged).
audioFormatOverride?: AudioFormat;
+ // Per-run override of the yt-dlp `-f` download format (see
+ // common/ytdlp/downloadFormat.ts). Honored by download-one-audio and the
+ // managed download paths (passed through to downloadOneManaged). Beats the
+ // per-channel and global defaults.
+ downloadFormatOverride?: DownloadFormatPreset;
// Per-run persistence overrides (Phase 2), threaded to downloadOneManaged for
// every managed download in this run. keepSourceVideoOverride forces keep
// (true) / discard (false); extractImmediately forces extract-now + discard
@@ -570,6 +580,14 @@ async function runManagedDownloads(
const globalCookies = settings.cookiesFromBrowser;
const inlineTranscribeOnFallback = settings.inlineTranscribeOnFallback;
const globalSkipLiveDownloads = settings.skipLiveDownloads;
+ // Resolve the download format once per run (override > channel > global),
+ // leaving the platform-aware "auto" expansion to downloadOneManaged where the
+ // per-video extractor is known.
+ const downloadFormatPreset = resolveDownloadFormatPreset({
+ override: opts.downloadFormatOverride,
+ channel: effectiveChannelConfig.downloadFormat,
+ global: settings.downloadFormat,
+ });
const sleepSeconds =
opts.channelConfig.sleepBetweenDownloadsSeconds ??
settings.sleepBetweenDownloadsSeconds;
@@ -634,6 +652,7 @@ async function runManagedDownloads(
keepSourceVideoOverride: opts.keepSourceVideoOverride,
extractImmediately: opts.extractImmediately,
audioFormatOverride: opts.audioFormatOverride,
+ downloadFormatPreset,
});
} finally {
task?.end();
@@ -910,13 +929,24 @@ async function downloadOneAudio(opts: RunYtdlpOpts): Promise<void> {
await mkdir(root, { recursive: true });
const fmt =
opts.audioFormatOverride ?? opts.channelConfig.audioFormat ?? "mp3";
+ // Resolve the download format (override > channel > global) and expand it
+ // against the source platform so the single-video fix path also honors the
+ // per-source default (Odysee -> original).
+ const downloadFormatSelector = resolveDownloadFormatSelector(
+ resolveDownloadFormatPreset({
+ override: opts.downloadFormatOverride,
+ channel: opts.channelConfig.downloadFormat,
+ global: getSettings().downloadFormat,
+ }),
+ detectPlatform(opts.singleVideoUrl) ?? opts.channelConfig.platform,
+ );
const args: string[] = [
"--ignore-config",
"--restrict-filenames",
...outputArgsForUrl(opts.singleVideoUrl),
"--write-info-json",
"-f",
- "bestaudio/worst",
+ downloadFormatSelector,
"-x",
"--audio-format",
fmt,
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -32,6 +32,10 @@ import {
whisperVideoAction,
type DeleteDirActionResult,
} from "../videoActions";
+import {
+ DOWNLOAD_FORMAT_LABELS,
+ DOWNLOAD_FORMAT_PRESETS,
+} from "yt-dlp-transcript-common/ytdlp/downloadFormat";
export type VideoFile = {
name: string;
@@ -538,6 +542,8 @@ function RedownloadSection({
}) {
const [mode, setMode] = useState<DownloadMode>("pipeline");
const [queueKey, setQueueKey] = useState(defaultQueueKey);
+ // "" = inherit the channel/global default; otherwise a DownloadFormatPreset.
+ const [downloadFormat, setDownloadFormat] = useState("");
const desc = (() => {
if (mode === "whisper") {
@@ -566,7 +572,13 @@ function RedownloadSection({
const actionLabel = `${verb} for ${videoId}`;
const trigger = mode === "whisper"
? () => whisperVideoAction(slug, videoId, queueKey)
- : () => downloadVideoPipelineAction(slug, videoId, queueKey);
+ : () =>
+ downloadVideoPipelineAction(
+ slug,
+ videoId,
+ queueKey,
+ downloadFormat || undefined,
+ );
return (
<div className="flex flex-col gap-3">
@@ -583,6 +595,24 @@ function RedownloadSection({
<option value="whisper">Audio + Whisper (skip pipeline)</option>
</select>
</label>
+ {mode === "pipeline" && (
+ <label className="flex items-center gap-2 text-sm">
+ Format
+ <select
+ value={downloadFormat}
+ onChange={(e) => setDownloadFormat(e.target.value)}
+ aria-label="download format"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ <option value="">Inherit (channel / global)</option>
+ {DOWNLOAD_FORMAT_PRESETS.map((p) => (
+ <option key={p} value={p}>
+ {DOWNLOAD_FORMAT_LABELS[p]}
+ </option>
+ ))}
+ </select>
+ </label>
+ )}
<StreamActionLog
trigger={trigger}
cancelAction={cancelJobAction}
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -9,6 +9,11 @@ import type {
ChannelConfig,
} from "yt-dlp-transcript-common/lib/channelConfig";
import { AUDIO_FORMAT_VALUES } from "yt-dlp-transcript-common/lib/channelConfig";
+import {
+ isDownloadFormatPreset,
+ resolveDownloadFormatPreset,
+ type DownloadFormatPreset,
+} from "yt-dlp-transcript-common/ytdlp/downloadFormat";
import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths";
import { checkDiskSpace } from "yt-dlp-transcript-common/lib/diskSpace";
import { formatBytes } from "yt-dlp-transcript-common/lib/format";
@@ -128,6 +133,7 @@ export async function downloadVideoPipelineAction(
slug: string,
videoId: string,
queueKey?: string,
+ downloadFormat?: string,
): Promise<StreamActionResult> {
const r = await loadConfigOrError(slug);
if (!r.ok) return r;
@@ -141,6 +147,10 @@ export async function downloadVideoPipelineAction(
};
}
const settings = getSettings();
+ // Per-download override (from the redownload picker), falling back to the
+ // channel default then the global default. An unrecognized value is ignored.
+ const formatOverride: DownloadFormatPreset | undefined =
+ isDownloadFormatPreset(downloadFormat) ? downloadFormat : undefined;
return runManagedFunction({
kind: "download-one-pipeline",
queueKey: videoQueueKey(r.config, queueKey),
@@ -164,6 +174,11 @@ export async function downloadVideoPipelineAction(
globalCookiesFromBrowser: settings.cookiesFromBrowser || undefined,
inlineTranscribeOnFallback: settings.inlineTranscribeOnFallback,
globalSkipLiveDownloads: settings.skipLiveDownloads,
+ downloadFormatPreset: resolveDownloadFormatPreset({
+ override: formatOverride,
+ channel: r.config.downloadFormat,
+ global: settings.downloadFormat,
+ }),
appendArchive: true,
});
revalidatePath(`/channels/${slug}/videos/${videoId}`);
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -1,5 +1,9 @@
import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
import {
+ DOWNLOAD_FORMAT_LABELS,
+ DOWNLOAD_FORMAT_PRESETS,
+} from "yt-dlp-transcript-common/ytdlp/downloadFormat";
+import {
AUDIO_CHECK_COPY_TIMEOUT_DEFAULT_SECONDS,
AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS,
AUDIO_CHECK_COPY_TIMEOUT_MIN_SECONDS,
@@ -139,6 +143,27 @@ export function ChannelForm({
<option value="opus">opus</option>
</select>
</label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Download format</span>
+ <select
+ name="downloadFormat"
+ defaultValue={c?.downloadFormat ?? ""}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ <option value="">Inherit (global default)</option>
+ {DOWNLOAD_FORMAT_PRESETS.map((p) => (
+ <option key={p} value={p}>
+ {DOWNLOAD_FORMAT_LABELS[p]}
+ </option>
+ ))}
+ </select>
+ <span className="text-xs text-zinc-500">
+ The yt-dlp <code>-f</code> selector for this channel's
+ downloads. Leave on Inherit to use the global default; choose{" "}
+ <strong>Original</strong> for sources that only serve full-length
+ audio in their original file (e.g. Odysee).
+ </span>
+ </label>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts
@@ -17,6 +17,7 @@ import {
detectPlatform,
type Platform,
} from "yt-dlp-transcript-common/lib/platform";
+import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat";
export type ParsedChannelForm = {
name: string;
@@ -32,6 +33,7 @@ export const CHANNEL_FORM_FIELDS = [
"platform",
"url",
"audioFormat",
+ "downloadFormat",
"keepSourceVideo",
"keepLatest",
"extractionMode",
@@ -69,6 +71,9 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
audioFormatRaw === "opus"
? audioFormatRaw
: undefined;
+ const downloadFormatRaw = String(formData.get("downloadFormat") ?? "");
+ const downloadFormat: ChannelConfig["downloadFormat"] | undefined =
+ isDownloadFormatPreset(downloadFormatRaw) ? downloadFormatRaw : undefined;
const keepSourceVideo = formData.get("keepSourceVideo") != null;
// Keep-latest retention/persistence window. Blank = inherit-off (omit), so a
@@ -204,6 +209,7 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
if (platform) config.platform = platform;
if (url) config.url = url;
if (audioFormat) config.audioFormat = audioFormat;
+ if (downloadFormat) config.downloadFormat = downloadFormat;
if (keepSourceVideo) config.keepSourceVideo = true;
if (keepLatest != null) config.keepLatest = keepLatest;
if (extractionMode) config.extractionMode = extractionMode;
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -21,6 +21,7 @@ import {
type SocialLink,
} from "yt-dlp-transcript-common/lib/settings";
import { DEFAULT_TRANSCRIPTION_APP_ID } from "yt-dlp-transcript-common/lib/transcriptionApps";
+import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat";
import {
sanitizeWorkers,
validateWorkers,
@@ -54,6 +55,10 @@ export async function saveSettingsAction(
const reportDebouncePreset = isReportDebouncePreset(reportDebouncePresetRaw)
? reportDebouncePresetRaw
: DEFAULT_REPORT_DEBOUNCE_PRESET;
+ const downloadFormatRaw = String(formData.get("downloadFormat") ?? "").trim();
+ const downloadFormat = isDownloadFormatPreset(downloadFormatRaw)
+ ? downloadFormatRaw
+ : "auto";
if (!adminTitle) return { ok: false, error: "Admin title is required" };
@@ -201,6 +206,7 @@ export async function saveSettingsAction(
workers,
cookiesFromBrowser,
sleepBetweenDownloadsSeconds: sleepParsed,
+ downloadFormat,
minFreeDiskGB: minFreeDiskParsed,
parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT,
inlineTranscribeOnFallback,
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -6,6 +6,10 @@ import {
type SaveResult,
} from "../actions";
import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ DOWNLOAD_FORMAT_LABELS,
+ DOWNLOAD_FORMAT_PRESETS,
+} from "yt-dlp-transcript-common/ytdlp/downloadFormat";
import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps";
import {
SocialLinksField,
@@ -75,6 +79,28 @@ export function SettingsForm({ initial, apps }: Props) {
type="number"
hint="Pause inserted between per-video yt-dlp invocations in managed batch downloads (download-from-playlist, download-missing). 0 disables. Default 10s. Each channel can override this in its Advanced settings."
/>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Default download format</span>
+ <select
+ name="downloadFormat"
+ defaultValue={initial.downloadFormat}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ {DOWNLOAD_FORMAT_PRESETS.map((p) => (
+ <option key={p} value={p}>
+ {DOWNLOAD_FORMAT_LABELS[p]}
+ </option>
+ ))}
+ </select>
+ <span className="text-xs text-zinc-500">
+ The yt-dlp <code>-f</code> selector for managed downloads.{" "}
+ <strong>Auto</strong> picks per-source: Odysee/LBRY downloads its
+ full-length <code>original</code> file (its HLS streams are
+ CDN-truncated to a few minutes), everything else uses{" "}
+ <code>bestaudio/worst</code>. Each channel can override this in its
+ Advanced settings, and a single re-download can override it too.
+ </span>
+ </label>
<Field
label="Minimum free disk space (GB)"
name="minFreeDiskGB"