v0.1.8
Menu
Download
yt-dlp Engine

yt-dlp Engine

Every download and metadata preview shells out to a bundled yt-dlp binary. Rust owns the process; React shows the progress.

Sidecars live under src-tauri/binaries/yt-dlp-* and are declared in tauri.conf.json externalBin. ytdlp_shell_command resolves the right binary for the host OS, then start_download_job in downloader.rs builds args from your Settings format string, output template, cookie options, and audio-only flag.

Before you click Download, the hero calls get_video_info. That command runs two parallel -J -s simulates: one with your video format string, one with bestaudio[ext=m4a]/bestaudio for the audio size column. Partial success is fine (audio-only preview can succeed when video simulate fails). Results are cached on the frontend keyed by URL + format + cookies so rapid tab switches do not respawn yt-dlp.

Stdout parsing drives the UI. Lines containing [download] update percent, speed, and ETA in downloadQueueSlice. When bytes hit 100% but ffmpeg is still muxing, a processing phase latch keeps the row on "Processing…" until the child exits. A stall watchdog in TypeScript marks jobs failed if stdout goes quiet too long.

Finished jobs leave {title}.info.json (and often a .webp thumbnail) next to the media file. scan_gallery uses id from that sidecar for dedupe. Settings → Advanced can check upstream yt-dlp version and download a newer sidecar when one exists.

In the repo

}

/// Scrubber sprites are for video files only (not audio-only library entries).
fn is_video_scrub_ext(ext: &str) -> bool {
    matches!(ext, "mp4" | "mkv" | "webm")
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadOptions {
    pub format: String,
    pub output_dir: String,
    pub filename_template: String,
    pub browser_cookies: Option<String>,
    pub cookie_file: Option<String>,
    /// yt-dlp `--sub-langs` (e.g. `en.*`). Empty skips subtitle download flags.
    #[serde(default)]
    pub sub_langs: String,
    /// When true, download audio only (`-x` / `--extract-audio`).
    #[serde(default)]
    pub audio_only: bool,
    /// yt-dlp `--audio-format` (e.g. m4a, mp3, opus).
    #[serde(default = "default_audio_format")]
    pub audio_format: String,
    /// When true, build ffmpeg scrubber sprite sheets after a successful video download.
    #[serde(default = "default_auto_scrub_previews")]
    pub auto_scrub_previews: bool,
    /// Sanitized subfolder under `output_dir` for per-video playlist batch jobs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub playlist_output_folder: Option<String>,
    /// 1-based index in the playlist for filename ordering (`01 - title.ext`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub playlist_index: Option<u32>,
}

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProgressPayload {
    job_id: String,
    percentage: f32,
    speed: String,
    eta: String,
    status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    current_index: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    total_items: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    current_item_title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    downloaded_bytes: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    total_bytes: Option<u64>,
}
export async function fetchVideoInfoWithTimeout(
  url: string,
  videoFormat: string,
  audioOnly = false,
  cookies?: VideoInfoCookieContext,
  timeoutMs = METADATA_FETCH_TIMEOUT_MS,
): Promise<VideoInfo> {
  const key = url.trim();
  if (!key) {
    throw new Error("URL is empty");
  }

  const inflightKey = videoInfoFetchInflightKey(key, videoFormat, cookies);
  let p = inflightByKey.get(inflightKey);
  if (!p) {
    p = (async () => {
      let timer: ReturnType<typeof setTimeout> | undefined;
      try {
        const result = await Promise.race([
          invoke<VideoInfo>("get_video_info", {
            url: key,
            format: videoFormat,
            audioOnly,
            browserCookies: cookies?.browserCookies ?? "",
            cookieFile: cookies?.cookieFile ?? "",
          }),
          new Promise<VideoInfo>((_, reject) => {
            timer = setTimeout(() => reject(timeoutError()), timeoutMs);
          }),
        ]);
        return sanitizeVideoInfo(result);
      } finally {
        if (timer !== undefined) clearTimeout(timer);
      }
    })();
    inflightByKey.set(inflightKey, p);
    void p.finally(() => {
      inflightByKey.delete(inflightKey);
    });
  }

  return p;
}
    app: AppHandle,
    listing_root: PathBuf,
    since: SystemTime,
) {
    tokio::spawn(async move {
        let paths = tokio::task::spawn_blocking(move || {
            collect_recent_video_paths(&listing_root, since)
        })
        .await
        .unwrap_or_default();
        for path in paths {
            let path_str = path.to_string_lossy().to_string();
            let _ = extract_frames(app.clone(), path_str, Some(true)).await;
        }
    });
}

/// Cap stderr retained per job by dropping oldest lines from the front (UTF-8 safe).
const DOWNLOAD_JOB_YTDLP_STDERR_LOG_MAX_BYTES: usize = 256 * 1024;

fn ceil_utf8_char_boundary(s: &str, byte_idx: usize) -> usize {
    let mut i = byte_idx.min(s.len());
    while i < s.len() && !s.is_char_boundary(i) {
        i += 1;
    }
    i
}

fn append_ytdlp_stderr_line_bounded(log: &mut String, line_bytes: &[u8], max_bytes: usize) {
    let mut line = String::from_utf8_lossy(line_bytes).into_owned();
    let mut additional = line.len().saturating_add(1);

Where it shows up

  • src-tauri/src/commands/downloader.rs spawn, progress IPC, finish events
  • downloadVideoInfoFetch.ts deduped invoke("get_video_info") with timeout
  • downloadFormat.ts for format strings shared by simulate and download
  • downloadQueueSlice.ts for queue state, hero fields, and watchdog hooks