Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/controller/autoRunner.ts | 5+++++
Mcommon/lib/channelConfig.ts | 13+++++++++++++
Mcommon/lib/settings.ts | 16++++++++++++++++
Mcommon/lib/transcripts-server.ts | 28+++++++++++++++++++---------
Acommon/ytdlp/downloadFormat.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/ytdlp/downloadOneManaged.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcommon/ytdlp/runYtdlp.ts | 32+++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 32+++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 15+++++++++++++++
Meditor/app/channels/components/ChannelForm.tsx | 25+++++++++++++++++++++++++
Meditor/app/channels/components/parseChannelForm.ts | 6++++++
Meditor/app/settings/actions.ts | 6++++++
Meditor/app/settings/components/SettingsForm.tsx | 26++++++++++++++++++++++++++
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&apos;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"