import { existsSync } from 'fs' import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries' import { execFileAsync } from './lib/exec' import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants' import { fmtBytes } from '@shared/format' import { cleanError } from './log' import { assertHttpUrl } from './url' import { entryUrl, fmtDuration, type RawEntry } from './core/indexerCore' import { BEST_FORMAT_ID, type ProbeResult, type MediaInfo, type FormatOption, type PlaylistInfo, type PlaylistEntry } from '@shared/ipc' // The slice of `yt-dlp -J` output we actually read. interface RawFormat { format_id: string ext?: string vcodec?: string acodec?: string height?: number fps?: number tbr?: number filesize?: number filesize_approx?: number } interface RawInfo { _type?: string title?: string uploader?: string channel?: string duration_string?: string thumbnail?: string formats?: RawFormat[] entries?: RawEntry[] } /** Quality proxy for picking the best format at a given height. */ function score(f: RawFormat): number { return (f.tbr ?? 0) + (f.ext === 'mp4' ? 0.5 : 0) } function toOption(f: RawFormat): FormatOption { const size = f.filesize ?? f.filesize_approx const fpsTag = f.fps && f.fps > 30 ? String(Math.round(f.fps)) : '' const sizeLabel = size ? fmtBytes(size) : undefined const parts = [`${f.height}p${fpsTag}`, f.ext, sizeLabel].filter(Boolean) as string[] return { id: f.format_id, label: parts.join(' ยท '), height: f.height, ext: f.ext, filesizeLabel: sizeLabel, hasAudio: !!f.acodec && f.acodec !== 'none' } } /** One option per distinct height (best at that height), best-first, plus auto. */ function buildVideoFormats(raw: RawFormat[]): FormatOption[] { const bestByHeight = new Map() for (const f of raw) { if (!f.vcodec || f.vcodec === 'none' || !f.height) continue const cur = bestByHeight.get(f.height) if (!cur || score(f) > score(cur)) bestByHeight.set(f.height, f) } const options = [...bestByHeight.values()] .sort((a, b) => (b.height ?? 0) - (a.height ?? 0)) .map(toOption) // No `ext`: the final container for the auto-best pick depends on the user's // videoContainer setting (mp4/mkv/webm), unknown here -- claiming 'mp4' would // mislabel mkv/webm outputs (L62). hasAudio is always true (best merges audio). options.unshift({ id: BEST_FORMAT_ID, label: 'Best available', hasAudio: true }) return options } function buildInfo(data: RawInfo): MediaInfo { return { title: data.title || 'Untitled', channel: data.channel || data.uploader || undefined, durationLabel: data.duration_string || undefined, thumbnail: data.thumbnail || undefined, formats: buildVideoFormats(data.formats ?? []) } } function buildPlaylist(data: RawInfo): PlaylistInfo { const entries: PlaylistEntry[] = [] ;(data.entries ?? []).forEach((e, i) => { const url = entryUrl(e) if (!url) return // skip entries we can't turn into a downloadable URL entries.push({ index: i + 1, id: e.id || String(i + 1), title: e.title || `Item ${i + 1}`, url, durationLabel: fmtDuration(e.duration), uploader: e.uploader || e.channel || undefined }) }) return { title: data.title || 'Playlist', uploader: data.uploader || data.channel || undefined, count: entries.length, entries } } /** * Probe a URL with `yt-dlp -J --flat-playlist`. Resolves to a single video (with * its format list) or, when the URL is a playlist, the flat list of its entries. * `--flat-playlist` is a no-op for a lone video, so one call covers both cases. */ export async function probeMedia(url: string): Promise { const ytdlp = getYtdlpPath() if (!existsSync(ytdlp)) { return { ok: false, error: YTDLP_MISSING_MSG } } let target: string try { target = assertHttpUrl(url) // normalised form (audit F5) } catch (e) { return { ok: false, error: (e as Error).message } } // `--` terminates option parsing so the URL can never be read as a flag. const r = await execFileAsync(ytdlp, ['-J', '--flat-playlist', '--no-warnings', '--', target], { maxBuffer: PROBE_MAX_BUFFER, timeout: PROBE_TIMEOUT_MS }) if (!r.ok) { const msg = r.timedOut ? 'Timed out fetching video info. Check the link or your connection.' : cleanError(r.stderr) || r.error?.message || '' return { ok: false, error: msg } } try { const data = JSON.parse(r.stdout) as RawInfo if (data._type === 'playlist' || Array.isArray(data.entries)) { const playlist = buildPlaylist(data) if (playlist.count === 0) { return { ok: false, error: 'This playlist has no downloadable entries.' } } return { ok: true, kind: 'playlist', playlist } } return { ok: true, kind: 'video', info: buildInfo(data) } } catch { return { ok: false, error: 'Could not parse video info from yt-dlp.' } } }