}
/// 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>,
}
Built with
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
Where it shows up
-
src-tauri/src/commands/downloader.rsspawn, progress IPC, finish events -
downloadVideoInfoFetch.tsdedupedinvoke("get_video_info")with timeout -
downloadFormat.tsfor format strings shared by simulate and download -
downloadQueueSlice.tsfor queue state, hero fields, and watchdog hooks