catacomb/src/downloader.rs
Luna 3f611849af docs: rename yt-offline → Catacomb across README, ROADMAP, guides, packaging
Prose + commands updated to the new name: brand reads "Catacomb", while
binary/path/package references are the lowercase `catacomb` (commands, deb/rpm
names, ~/.local/share/catacomb, catacomb.db, catacomb.desktop). Codeberg repo
URLs left pointing at the existing repo. Also folds in the pending doc edits
from earlier in the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:34:18 -07:00

1793 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Running `yt-dlp` in the background and surfacing its progress to the UI.
//!
//! Each call to [`Downloader::start`] spawns a thread that runs `yt-dlp` and
//! pipes stdout/stderr back to the main thread through an `mpsc` channel.
//! The caller polls for updates via [`Downloader::poll`], which drains the
//! channel into each [`Job`]'s log buffer.
//!
//! # yt-dlp command flags used
//!
//! | Flag | Purpose |
//! |---|---|
//! | `--cookies cookies.txt` | Pass browser cookies for age-gated/member videos |
//! | `--write-subs` (+ optionally `--write-auto-subs` / `--sub-langs` / `--convert-subs` / `--embed-subs`) | Subtitles, per the `[subtitles]` config + per-channel overrides |
//! | `--write-thumbnail` | Download channel/video thumbnails |
//! | `--write-description` | Save video description as a sidecar `.description` file |
//! | `--write-info-json` | Save full metadata as a `.info.json` sidecar |
//! | `--remux-video mkv` | Re-container to MKV (no re-encode) |
//! | `--embed-metadata --embed-info-json --embed-chapters` | Embed rich metadata into the MKV |
//! | `--xattrs` | Store metadata in filesystem extended attributes |
//! | `--sponsorblock-mark/-remove all` | SponsorBlock handling, per `sponsorblock_mode` (off/mark/remove; see [`Self::apply_sponsorblock`]) |
//! | `--write-comments` | Fetch the comment tree, per `fetch_comments` global + per-channel override (see [`Self::apply_comments`]) |
//! | `--impersonate <target>` | Browser TLS fingerprint per source platform (see [`crate::platform::Platform::impersonate_target`]) |
//! | `--break-on-existing` | Stop when the archive file records the video as already downloaded |
//! | `--download-archive archive.txt` | Record downloaded IDs to avoid re-downloading |
use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc::{channel, Receiver};
use std::thread;
use crate::download_options::DownloadOptions;
use crate::platform::{self, Platform, UrlInfo, UrlKind};
use crate::ytdlp_bin;
/// Video quality level passed as a `-f` format selector to yt-dlp.
#[derive(Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize, Debug)]
pub enum DownloadQuality {
/// No `-f` flag — yt-dlp picks the best available streams (default).
#[default]
Best,
Res1080,
Res720,
Res480,
Res360,
}
impl DownloadQuality {
pub fn format_spec(self) -> Option<&'static str> {
match self {
Self::Best => None,
Self::Res1080 => Some("bestvideo[height<=1080]+bestaudio/best[height<=1080]"),
Self::Res720 => Some("bestvideo[height<=720]+bestaudio/best[height<=720]"),
Self::Res480 => Some("bestvideo[height<=480]+bestaudio/best[height<=480]"),
Self::Res360 => Some("bestvideo[height<=360]+bestaudio/best[height<=360]"),
}
}
pub fn label(self) -> &'static str {
match self {
Self::Best => "Best",
Self::Res1080 => "1080p",
Self::Res720 => "720p",
Self::Res480 => "480p",
Self::Res360 => "360p",
}
}
pub fn all() -> &'static [DownloadQuality] {
&[Self::Best, Self::Res1080, Self::Res720, Self::Res480, Self::Res360]
}
}
/// Build a YouTube URL from a legacy library folder name. Used as a fallback
/// when a channel folder has no `.source-url` sidecar (i.e. it predates the
/// multi-platform changes).
///
/// Folder names that look like a channel ID (`UC` + 22 chars) use the
/// `/channel/` form; everything else is treated as a handle and gets `/@`.
/// This avoids the mismatch where info.json's canonical `channel_url` field
/// points to `/channel/UCxxx` and yt-dlp creates a second folder.
pub fn check_url_for_folder(folder_name: &str) -> String {
if folder_name.starts_with("UC") && folder_name.len() == 24 {
format!("https://www.youtube.com/channel/{folder_name}")
} else {
format!("https://www.youtube.com/@{folder_name}")
}
}
/// Re-check URL for a [`crate::library::Channel`]. Prefers the stored
/// `.source-url` sidecar when present, falls back to the YouTube heuristic
/// for legacy folders.
pub fn recheck_url(ch: &crate::library::Channel) -> String {
if let Some(url) = ch.source_url.as_deref() {
return url.to_string();
}
// Legacy YouTube libraries: rebuild from folder name.
check_url_for_folder(&ch.name)
}
/// Lifecycle state of a download job.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum JobState {
Running,
Done,
Failed,
}
/// Internal message sent from the yt-dlp thread to the job.
enum Msg {
Line(String),
Progress(f32),
Finished(bool),
}
/// Maximum lines retained in [`Job::log`] before old lines are evicted.
const JOB_LOG_CAP: usize = 800;
/// A single yt-dlp invocation tracked by the downloader.
pub struct Job {
pub url: String,
/// Short human-readable path shown in the UI (e.g. `channels/handle/`).
pub label: String,
pub state: JobState,
/// Download progress as a fraction in `[0.0, 1.0]`.
pub progress: f32,
/// Rolling log buffer — capped at [`JOB_LOG_CAP`] lines via O(1) front-pop.
pub log: VecDeque<String>,
/// Best-effort classification of the failure, populated when `state`
/// transitions to `Failed`. `None` while running or on success. The UI
/// surfaces the class + a one-line suggested fix from
/// [`crate::error_class`].
pub failure_class: Option<crate::error_class::ErrorClass>,
/// Inputs needed to re-run this job, captured at start time. Lets the
/// downloader rebuild the command for an automatic retry after a
/// transient (rate-limit / network) failure without the caller
/// re-issuing it. `None` for jobs that aren't retryable (e.g. the
/// yt-dlp self-update job).
retry_spec: Option<RetrySpec>,
/// How many automatic retries this job has already consumed. Capped at
/// [`MAX_AUTO_RETRIES`] so a permanently-blocked video doesn't loop.
retry_count: u8,
/// Set once we've evaluated this job's failure for auto-retry, so the
/// per-poll scan doesn't schedule the same failure repeatedly.
retry_handled: bool,
/// Post-download conversion to run on this job's finished files.
/// `None` for jobs that don't convert (most jobs). Consumed once when
/// the job transitions to Done.
convert_on_finish: Option<ConvertSpec>,
/// Set once we've enqueued the convert pass for this finished job, so
/// the per-poll scan doesn't enqueue it twice.
convert_handled: bool,
/// OS pid of the child process, captured at spawn. Lets the hang
/// watchdog SIGKILL a stalled process (Unix). `None` for synthetic
/// jobs (preflight failures) that have no process.
child_pid: Option<u32>,
/// Last time this job produced any output/progress. The watchdog kills
/// a Running job whose `last_activity` is older than [`HANG_TIMEOUT`].
last_activity: std::time::Instant,
/// True when the watchdog killed this job (vs. a genuine yt-dlp exit).
/// On the resulting failure we force a retryable class so auto-retry
/// re-queues it — a hang is transient, worth one more try.
watchdog_killed: bool,
/// True when the user explicitly cancelled this job (vs. a genuine
/// failure). Suppresses auto-retry and is surfaced as a distinct
/// "cancelled" state in the UI rather than a misleading error class.
pub cancelled: bool,
rx: Receiver<Msg>,
}
/// Captured inputs for rebuilding a download command on auto-retry.
#[derive(Clone)]
struct RetrySpec {
url: String,
info: UrlInfo,
full_scan: bool,
quality: DownloadQuality,
live: bool,
opts: Option<DownloadOptions>,
}
/// Resolved post-download conversion settings, attached to a download Job
/// so the downloader knows what ffmpeg pass (if any) to run on each
/// finished file. `mode` is one of remux-mp4 / h264-mp4 / audio; the
/// other fields parameterise it.
#[derive(Clone)]
pub struct ConvertSpec {
pub mode: String,
pub crf: u8,
pub preset: String,
pub audio_format: String,
pub keep_original: bool,
}
impl ConvertSpec {
/// Build a [`ConvertSpec`] from the global `[convert]` config merged
/// with a per-channel `convert_mode` override. Returns `None` when the
/// effective mode is off/empty (no transcode pass).
fn resolve(
global: &crate::config::ConvertSection,
opts: Option<&DownloadOptions>,
) -> Option<ConvertSpec> {
let mode = opts
.and_then(|o| o.convert_mode.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(global.mode.as_str())
.trim()
.to_string();
if mode.is_empty() || mode == "off" {
return None;
}
Some(ConvertSpec {
mode,
crf: if global.crf == 0 { 23 } else { global.crf },
preset: if global.preset.is_empty() { "medium".into() } else { global.preset.clone() },
audio_format: if global.audio_format.is_empty() { "mp3".into() } else { global.audio_format.clone() },
keep_original: global.keep_original,
})
}
}
/// Max automatic re-queues per job for transient failures. One retry
/// clears most momentary captchas/resets; more than that and it's a real
/// block the user needs to act on (refresh cookies, wait longer).
const MAX_AUTO_RETRIES: u8 = 2;
/// Cooldown before an auto-retry fires. Long enough to let a momentary
/// rate-limit window pass; short enough that the user isn't left waiting.
const AUTO_RETRY_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(90);
/// A Running job that produces no output/progress for this long is
/// considered hung and gets SIGKILLed by the watchdog. Chosen well above
/// any legitimate quiet gap: yt-dlp prints a progress line per chunk
/// while downloading, polls every 30 s under `--wait-for-video`, and our
/// longest configured sleep (retry-sleep cap / adaptive throttle) is
/// ~30 s — so 5 minutes of total silence means a real stall (a stuck TLS
/// handshake or unresponsive server), not slow-but-alive progress.
const HANG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
impl Job {
/// Whether this job carries the inputs needed for a manual retry (download
/// jobs do; live recordings, self-update, and synthetic preflight failures
/// don't). Drives the UI's "Retry" button.
pub fn has_retry_spec(&self) -> bool {
self.retry_spec.is_some()
}
fn drain(&mut self) {
while let Ok(msg) = self.rx.try_recv() {
match msg {
Msg::Line(line) => {
self.last_activity = std::time::Instant::now();
self.log.push_back(line);
while self.log.len() > JOB_LOG_CAP {
self.log.pop_front();
}
}
Msg::Progress(p) => {
self.last_activity = std::time::Instant::now();
self.progress = p;
}
Msg::Finished(ok) => {
self.state = if ok { JobState::Done } else { JobState::Failed };
// Classify only on the failure transition so the
// classifier doesn't re-run for every poll() call on a
// long-finished job. The log is already in `self.log`
// by this point since we drained Line messages above.
if !ok && self.failure_class.is_none() {
self.failure_class = Some(if self.watchdog_killed {
// A watchdog kill leaves no telltale error line, so
// classify() would return Other (non-retryable).
// Force NetworkError: a hang is transient and the
// auto-retry path should give it another go.
crate::error_class::ErrorClass::NetworkError
} else {
crate::error_class::classify(self.log.iter().map(|s| s.as_str()))
});
}
}
}
}
}
}
/// A download waiting to start once a concurrency slot opens.
struct PendingJob {
cmd: Command,
url: String,
label: String,
/// Retry spec captured at enqueue time (None for non-retryable jobs).
retry_spec: Option<RetrySpec>,
/// Attempt number this command represents (0 = first try).
retry_count: u8,
/// Post-download convert spec for this job (None = no conversion).
convert_spec: Option<ConvertSpec>,
}
/// Manages all active, queued, and recently completed yt-dlp download jobs.
pub struct Downloader {
pub jobs: Vec<Job>,
pending: VecDeque<PendingJob>,
pub channels_root: PathBuf,
/// Browser name passed to `--cookies-from-browser` *when no cookies.txt
/// exists in the working directory*. Set to `"none"` to skip the fallback
/// entirely. Pasted/imported cookies.txt always takes precedence.
pub browser: String,
/// Maximum number of simultaneous yt-dlp processes. 0 = unlimited.
pub max_concurrent: usize,
/// If true, invoke the bundled yt-dlp under [`ytdlp_bin::bundled_dir`]
/// instead of the system PATH yt-dlp.
pub use_bundled_ytdlp: bool,
/// If true and the bundled yt-dlp is in use, spawn the bgutil-pot
/// HTTP server on first download and pass its extractor-args to
/// every yt-dlp invocation. See [`crate::pot_provider`].
pub use_pot_provider: bool,
/// Running bgutil-pot server child. Lazily spawned in [`Self::start`]
/// on the first job after the flag turns on; killed in [`Drop`].
pot_server: Option<std::process::Child>,
/// Global subtitle defaults from `[subtitles]` config. Per-channel
/// [`DownloadOptions`] override individual fields. Set by the app /
/// web layer at construction and on settings save.
pub subtitle_defaults: crate::config::SubtitlesSection,
/// Global `backup.youtube_player_clients` (comma-separated). Empty =
/// yt-dlp defaults. Per-channel options can override. Set at
/// construction + on settings save.
pub youtube_player_clients: String,
/// Global `backup.sponsorblock_mode` ("off" / "mark" / "remove").
/// Per-channel options can override. Set at construction + on save.
pub sponsorblock_mode: String,
/// Global `backup.fetch_comments`. When true, downloads pass
/// `--write-comments`. Per-channel options can override. Set at
/// construction + on settings save.
pub fetch_comments: bool,
/// Global `backup.dedup_enabled`. When false, the perceptual "similar
/// content" scan is hard-disabled in both UIs. Set at construction + save.
pub dedup_enabled: bool,
/// Global `[convert]` config. Drives the post-download ffmpeg pass.
/// Per-channel options override the mode. Set at construction + save.
pub convert_defaults: crate::config::ConvertSection,
/// Scheduled auto-retries: `(fire_at, spec, attempt_number)`. Populated
/// when a job fails with a retryable class; drained in [`Self::poll`]
/// once `fire_at` passes, re-issuing the download. Kept separate from
/// `pending` so the cooldown is honored without blocking a slot.
retry_queue: Vec<(std::time::Instant, RetrySpec, u8)>,
/// True while the most recent rate-limit hit's adaptive backoff is in
/// effect. Set when a job fails rate-limited; makes subsequent jobs in
/// the batch sleep longer. Cleared once downloads go quiet.
pub rate_limited_backoff: bool,
/// Stashed by [`Self::start`] just before it enqueues, consumed by the
/// next [`Self::spawn_job`] so the resulting [`Job`] carries the spec
/// for auto-retry. Avoids threading `Option<RetrySpec>` through the
/// four non-retryable enqueue paths (repair / music / updates).
pending_retry_spec: Option<RetrySpec>,
/// Attempt number for the job [`Self::start`] is about to enqueue.
/// 0 for a user-initiated download; >0 when [`Self::start_retry`] is
/// re-issuing a failed one. Consumed by `enqueue`.
retry_attempt_override: u8,
/// Stashed by [`Self::start`], consumed by the next `enqueue` so the
/// download job carries its convert spec. Like `pending_retry_spec`.
pending_convert_spec: Option<ConvertSpec>,
}
impl Downloader {
pub fn new(
channels_root: PathBuf,
browser: String,
max_concurrent: usize,
use_bundled_ytdlp: bool,
use_pot_provider: bool,
) -> Self {
Self {
jobs: Vec::new(),
pending: VecDeque::new(),
channels_root,
browser,
max_concurrent,
use_bundled_ytdlp,
use_pot_provider,
pot_server: None,
subtitle_defaults: crate::config::SubtitlesSection::default(),
youtube_player_clients: String::new(),
sponsorblock_mode: "mark".to_string(),
fetch_comments: false,
dedup_enabled: true,
convert_defaults: crate::config::ConvertSection::default(),
retry_queue: Vec::new(),
rate_limited_backoff: false,
pending_retry_spec: None,
retry_attempt_override: 0,
pending_convert_spec: None,
}
}
/// Resolve global `[subtitles]` config + per-channel overrides into
/// the yt-dlp subtitle flag set, appending to `cmd`.
///
/// Resolution: a per-channel `Some` wins over the global default for
/// each field. When subtitles are disabled (globally or per-channel),
/// nothing is emitted — yt-dlp then writes no subs.
/// Append `--extractor-args youtube:player_client=…` when a client
/// list is configured (per-channel override or global default). Empty
/// = omit the flag entirely so yt-dlp uses its own default client set
/// (the recommended baseline). Only meaningful for YouTube; the
/// youtube: namespace is ignored by other extractors so it's harmless
/// to always pass.
fn apply_player_client(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) {
let clients = opts
.and_then(|o| o.youtube_player_clients.as_deref())
.filter(|s| !s.trim().is_empty())
.unwrap_or(self.youtube_player_clients.as_str())
.trim();
if clients.is_empty() { return; }
cmd.arg("--extractor-args")
.arg(format!("youtube:player_client={clients}"));
}
/// Apply SponsorBlock flags from the resolved mode (per-channel override
/// or global default). "mark" chapter-marks segments, "remove" cuts them,
/// anything else (incl. "off") omits the flags entirely.
fn apply_sponsorblock(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) {
let mode = opts
.and_then(|o| o.sponsorblock_mode.as_deref())
.filter(|s| !s.trim().is_empty())
.unwrap_or(self.sponsorblock_mode.as_str())
.trim();
match mode {
"mark" => { cmd.arg("--sponsorblock-mark").arg("all"); }
"remove" => { cmd.arg("--sponsorblock-remove").arg("all"); }
_ => {} // "off" or unknown → no SponsorBlock processing
}
}
/// Add `--write-comments` when comment fetching is enabled, merging the
/// per-channel override (`Some(true)`/`Some(false)`) with the global
/// `fetch_comments` default (used when the override is `None`).
fn apply_comments(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) {
let enabled = opts
.and_then(|o| o.fetch_comments)
.unwrap_or(self.fetch_comments);
if enabled {
cmd.arg("--write-comments");
}
}
fn apply_subtitle_flags(&self, cmd: &mut Command, opts: Option<&DownloadOptions>) {
let g = &self.subtitle_defaults;
// Per-channel override-or-global for each knob.
let enabled = opts.and_then(|o| o.subtitles_enabled).unwrap_or(g.enabled);
if !enabled { return; }
let auto = opts.and_then(|o| o.subtitles_auto).unwrap_or(g.auto_generated);
let embed = opts.and_then(|o| o.subtitles_embed).unwrap_or(g.embed);
// Format: per-channel Some(non-empty) wins; else global.
let format = opts
.and_then(|o| o.subtitle_format.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(g.format.as_str());
// Langs: per-channel list wins; else global comma string.
let langs: String = match opts {
Some(o) if !o.subtitle_langs.is_empty() => o.subtitle_langs.join(","),
_ => g.langs.clone(),
};
cmd.arg("--write-subs");
if auto {
cmd.arg("--write-auto-subs");
}
if !langs.is_empty() {
cmd.arg("--sub-langs").arg(langs);
}
if !format.is_empty() {
cmd.arg("--convert-subs").arg(format);
}
if embed {
cmd.arg("--embed-subs");
}
}
/// Lazy-spawn the POT server on first use. We don't spin it up on
/// app start because most users won't have it installed; doing the
/// check + spawn on first download means there's no per-launch
/// penalty when the feature is off, and an obvious place to surface
/// "did you install the binary?" errors when it's on.
fn ensure_pot_server(&mut self) {
if !self.use_pot_provider { return; }
if !self.use_bundled_ytdlp { return; } // plugin is in the bundled venv
if self.pot_server.is_some() { return; } // already running
if !crate::pot_provider::installed() { return; } // not installed, skip silently
match crate::pot_provider::spawn_server() {
Ok(child) => { self.pot_server = Some(child); }
Err(_) => { /* failure surfaces as missing POT → yt-dlp warns */ }
}
}
/// Append platform-specific extra flags. Currently only used to embed
/// album art into the audio file on music-first platforms (Bandcamp,
/// SoundCloud), where music players read embedded tags rather than
/// scanning for sidecar JPEGs.
fn apply_platform_extras(&self, platform: Platform, cmd: &mut Command) {
if platform.is_audio_first() {
cmd.arg("--embed-thumbnail");
}
}
/// Append `--impersonate <target>` chosen per source platform. Both the
/// bundled venv (which pip-installs `curl_cffi`) and a system yt-dlp
/// with curl_cffi can satisfy this. Platforms that prefer no
/// impersonation (e.g. Twitch's OAuth) return `None` from
/// [`Platform::impersonate_target`] and the flag is omitted.
fn apply_impersonation(&self, platform: Platform, cmd: &mut Command) {
if let Some(target) = platform.impersonate_target() {
cmd.arg("--impersonate").arg(target);
}
}
/// Append the cookie-source flags. Prefers `cookies.txt` in the working
/// directory (set up via the cookies UI), falling back to
/// `--cookies-from-browser <browser>` when no cookies.txt exists and the
/// user has chosen a browser (anything other than `"none"`).
fn apply_cookie_flags(&self, cmd: &mut Command) {
let cookies_txt = std::path::Path::new("cookies.txt");
if cookies_txt.exists() {
cmd.arg("--cookies").arg("cookies.txt");
} else if !self.browser.is_empty() && self.browser != "none" {
cmd.arg("--cookies-from-browser").arg(&self.browser);
}
}
/// Append the retry and throttling flags applied to every yt-dlp invocation.
///
/// YouTube occasionally resets connections mid-transfer; with default
/// settings yt-dlp gives up after 10 quick retries. We bump the retry
/// count and add a linear backoff so transient resets self-heal.
///
/// The sleep flags throttle request rate so we don't trip YouTube's
/// bot-detection / captcha wall mid-batch (it tends to fire after
/// ~30 rapid requests):
/// - `--sleep-requests`: pause between metadata/API requests.
/// - `--sleep-interval` / `--max-sleep-interval`: a random pause
/// *between videos*. The jitter is what matters — a fixed cadence
/// looks robotic; a random one looks human and is far less likely
/// to trip the captcha on a long channel scan.
///
/// Adaptive backoff: after a rate-limit hit (`rate_limited_backoff`),
/// the sleeps roughly triple for the rest of the batch so we ease off
/// instead of hammering an already-suspicious endpoint.
fn apply_retry_flags(&self, cmd: &mut Command) {
let (req, lo, hi) = if self.rate_limited_backoff {
("3", "8", "20")
} else {
("1", "2", "6")
};
cmd.arg("--retries").arg("30")
.arg("--fragment-retries").arg("30")
.arg("--retry-sleep").arg("linear=1:30:2")
.arg("--sleep-requests").arg(req)
.arg("--sleep-interval").arg(lo)
.arg("--max-sleep-interval").arg(hi);
}
/// Build a fresh `Command` invoking the currently configured yt-dlp binary.
///
/// In bundled mode, defensively re-applies the executable bit on every
/// binary inside the bundled bin dir. Without this, downloads can fail
/// with EACCES if a previous install left the file un-executable (e.g.
/// because the chmod step of the install script never ran).
fn ytdlp_cmd(&self) -> Command {
let path = ytdlp_bin::ytdlp_invocation(self.use_bundled_ytdlp);
if self.use_bundled_ytdlp {
ytdlp_bin::ensure_bundled_executable();
}
Command::new(path)
}
/// Number of jobs waiting in the queue (not yet started).
pub fn pending_count(&self) -> usize {
self.pending.len()
}
/// Labels and URLs of all queued (not yet started) jobs, in queue order.
pub fn pending_snapshots(&self) -> Vec<(String, String)> {
self.pending.iter().map(|p| (p.label.clone(), p.url.clone())).collect()
}
/// Promote pending jobs into running slots while capacity allows.
fn promote_queued(&mut self) {
while !self.pending.is_empty() {
if self.max_concurrent > 0 {
let running = self.jobs.iter().filter(|j| j.state == JobState::Running).count();
if running >= self.max_concurrent { break; }
}
let p = self.pending.pop_front().unwrap();
self.spawn_job(p.cmd, p.url, p.label, p.retry_spec, p.retry_count, p.convert_spec);
}
}
/// Push a command into the pending queue (or start immediately if a slot is free).
///
/// Consumes `self.pending_retry_spec` + `self.pending_convert_spec`
/// (set by [`Self::start`]) so the resulting job carries them, and
/// `retry_attempt_override` for the attempt number (0 on a first run,
/// >0 from a retry). Other callers (repair, music, updates) leave the
/// specs `None`, making those jobs non-retryable + non-converting.
fn enqueue(&mut self, cmd: Command, url: String, label: String) {
let retry_spec = self.pending_retry_spec.take();
let retry_count = self.retry_attempt_override;
let convert_spec = self.pending_convert_spec.take();
self.pending.push_back(PendingJob { cmd, url, label, retry_spec, retry_count, convert_spec });
self.promote_queued();
}
/// Spawn a yt-dlp process for `url` and track it as a new [`Job`].
///
/// Output path template is derived from `info.platform` + `info.kind` so
/// each platform lands in its own sibling directory (`channels/` for
/// YouTube, `tiktok/`, `twitch/`, etc. for others).
///
/// For channel downloads we also drop a `.source-url` sidecar so future
/// re-checks recover the original URL without folder-name guessing.
///
/// When `full_scan` is false (default / incremental mode) `--break-on-existing`
/// is passed so yt-dlp stops as soon as it hits the first already-archived
/// video — fast for routine channel checks. When `full_scan` is true the
/// flag is omitted so every video is checked individually against the
/// download archive; slower, but correctly fills gaps in the history.
///
/// When `live` is true, the invocation is configured to record a live
/// stream from the start: `--live-from-start --wait-for-video 30` is
/// added, `--break-on-existing` is suppressed (each recording is unique),
/// and the output filename gains a UTC timestamp suffix so re-recordings
/// of the same channel don't collide.
///
/// `channel_options` carries per-channel overrides (rate limit, filters,
/// extra args, …) and is applied after the standard flag set so it can
/// win. Pass `None` when the caller doesn't know which channel the URL
/// belongs to (e.g. an arbitrary URL pasted into the download dialog).
/// The channel-options `quality` field overrides the `quality` parameter
/// only when the caller explicitly opts in by passing it through.
pub fn start(
&mut self,
url: String,
info: &UrlInfo,
full_scan: bool,
quality: DownloadQuality,
live: bool,
channel_options: Option<&DownloadOptions>,
) {
// Make sure the POT server is up before we hand the URL to
// yt-dlp — the Python plugin checks the HTTP endpoint at
// extractor-time, so a too-late start means the first request
// misses POT and YouTube hands back empty formats.
self.ensure_pot_server();
// Capture the inputs so spawn_job can attach a RetrySpec for
// automatic retry on a transient (rate-limit / network) failure.
// Live recordings aren't retried — re-running would start a fresh
// recording, not resume.
self.pending_retry_spec = if live {
None
} else {
Some(RetrySpec {
url: url.clone(),
info: info.clone(),
full_scan,
quality,
live,
opts: channel_options.cloned(),
})
};
let platform_dir = platform::platform_root(&self.channels_root, info.platform);
// Per-platform download archive keeps cross-platform IDs from colliding
// (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.).
let _ = std::fs::create_dir_all(&platform_dir);
// Disk-full preflight. Refuse to spawn yt-dlp when the target
// filesystem has less than the floor of free space — that's a
// near-certain in-progress ENOSPC, which leaves a partial file
// and a confusing failure. We surface it as a synthetic Failed
// job classified as DiskFull so it appears in the Downloads
// panel alongside other classified errors instead of vanishing.
//
// statvfs returning None (non-Unix host, missing path) skips the
// check — better to let yt-dlp run and produce a real error
// than refuse on missing data.
if let Some(free) = crate::disk_space::available_bytes(&self.channels_root) {
if free < crate::disk_space::FREE_SPACE_FLOOR_BYTES {
self.push_synthetic_failure(
url,
platform_dir.display().to_string(),
crate::error_class::ErrorClass::DiskFull,
format!(
"only {} free on {} (need at least {})",
crate::disk_space::fmt_bytes(free),
self.channels_root.display(),
crate::disk_space::fmt_bytes(crate::disk_space::FREE_SPACE_FLOOR_BYTES),
),
);
return;
}
}
let archive_path = platform_dir.join("archive.txt");
let platform_label = info.platform.dir_name();
// Live recordings get a UTC timestamp suffix in the filename so a
// re-recording of the same stream doesn't overwrite the prior one.
// VOD downloads rely on yt-dlp's stable `%(id)s` for uniqueness.
let live_suffix = if live {
format!(" [{}]", format_compact_utc(now_unix()))
} else {
String::new()
};
let live_label = if live { " 🔴 LIVE" } else { "" };
let (out_arg, label) = match &info.kind {
UrlKind::Channel { handle } => {
let dir = platform_dir.join(handle);
let _ = std::fs::create_dir_all(&dir);
// Remember the originating URL so re-checks don't have to
// guess from the folder name.
platform::write_source_url(&dir, &url);
// Bandcamp at the bare artist URL is a whole discography.
// Organize each track into its album subfolder so the
// resulting tree mirrors how Bandcamp itself presents the
// catalog. Other platforms keep their flat per-creator layout.
let template = if info.platform == Platform::Bandcamp {
format!(
"{}/%(album|Unknown)s/%(title)s [%(id)s]{live_suffix}.%(ext)s",
dir.display()
)
} else {
format!("{}/%(title)s [%(id)s]{live_suffix}.%(ext)s", dir.display())
};
(template, format!("{}/{}/{}", platform_label, handle, live_label))
}
UrlKind::Playlist => (
format!(
"{}/%(uploader,channel,creator|Unknown)s/%(playlist_title)s/%(title)s [%(id)s]{live_suffix}.%(ext)s",
platform_dir.display()
),
format!("{}/<creator>/<playlist>/{}", platform_label, live_label),
),
UrlKind::Video | UrlKind::Unknown => (
format!(
"{}/%(uploader,channel,creator|Unknown)s/%(title)s [%(id)s]{live_suffix}.%(ext)s",
platform_dir.display()
),
format!("{}/<creator>/{}", platform_label, live_label),
),
};
let mut cmd = self.ytdlp_cmd();
cmd.arg("--newline").arg("--no-color");
self.apply_cookie_flags(&mut cmd);
cmd.arg("--write-thumbnail")
.arg("--write-description")
.arg("--write-info-json")
// Don't write channel/playlist-level metafiles (avatar, info.json,
// description). They land as "Title [CHANNEL_ID].ext" files that match
// the per-video naming pattern and show up as phantom videos.
.arg("--no-write-playlist-metafiles")
.arg("--remux-video")
.arg("mkv")
.arg("--embed-metadata")
.arg("--embed-info-json")
.arg("--embed-chapters")
.arg("--xattrs")
.arg("--progress");
if let Some(fmt) = quality.format_spec() {
cmd.arg("-f").arg(fmt);
}
if live {
// Record the broadcast from the start instead of joining live.
// `--wait-for-video` polls the URL every 30 s until a stream
// is actually live, so scheduling a recording before the
// stream begins works naturally.
cmd.arg("--live-from-start").arg("--wait-for-video").arg("30");
} else if !full_scan {
// Live recordings should never short-circuit on existing archive
// entries — every recording is its own file. Only honor
// `--break-on-existing` for VOD/channel-check downloads.
cmd.arg("--break-on-existing");
}
cmd.arg("--download-archive")
.arg(archive_path.display().to_string());
self.apply_impersonation(info.platform, &mut cmd);
self.apply_platform_extras(info.platform, &mut cmd);
// Subtitle flags: global [subtitles] config merged with per-channel
// overrides. Done here (not in opts.apply) so it works even when a
// channel has no options row.
self.apply_subtitle_flags(&mut cmd, channel_options);
// YouTube player-client selection (global default + per-channel
// override). Lets the user route around a captcha-walled client.
self.apply_player_client(&mut cmd, channel_options);
// SponsorBlock: global default + per-channel override (off/mark/remove).
self.apply_sponsorblock(&mut cmd, channel_options);
// Comment fetching: global backup.fetch_comments + per-channel override.
self.apply_comments(&mut cmd, channel_options);
// Post-download conversion: resolve global [convert] + per-channel
// override. When active, ask yt-dlp to print each finished file's
// final path (after_move) so we can enqueue an ffmpeg pass on it.
let convert_spec = ConvertSpec::resolve(&self.convert_defaults, channel_options);
if convert_spec.is_some() {
// The CONVERT_PATH: prefix lets spawn_job's stdout reader
// recognise these lines without confusing them for progress.
cmd.arg("--print").arg("after_move:CONVERT_PATH:%(filepath)s");
}
self.pending_convert_spec = convert_spec;
// Per-channel option overrides win when present — they're applied
// last so a `--limit-rate` / `--match-filter` / passthrough arg from
// the channel settings takes priority over the global defaults.
if let Some(opts) = channel_options {
opts.apply(&mut cmd);
}
cmd.arg("-o").arg(&out_arg).arg(&url);
self.apply_retry_flags(&mut cmd);
self.enqueue(cmd, url, label);
}
/// Re-fetch missing sidecar assets (thumbnail, info.json, description,
/// subtitles) for a single video without re-downloading the video itself.
///
/// `dir`/`stem` come from the existing file on disk so the fetched sidecars
/// land with exactly the same filename stem and associate with the video.
pub fn repair(&mut self, video_id: &str, dir: &std::path::Path, stem: &str) {
let _ = std::fs::create_dir_all(dir);
// Escape literal `%` so yt-dlp doesn't treat it as an output field.
let safe_stem = stem.replace('%', "%%");
let out_arg = format!("{}/{}.%(ext)s", dir.display(), safe_stem);
let url = format!("https://www.youtube.com/watch?v={video_id}");
let label = format!("repair {stem}");
let mut cmd = self.ytdlp_cmd();
cmd.arg("--newline").arg("--no-color").arg("--skip-download");
self.apply_cookie_flags(&mut cmd);
cmd.arg("--write-thumbnail")
.arg("--write-info-json")
.arg("--write-description");
// Repair has no channel-options context, so subtitles use the
// global [subtitles] defaults.
self.apply_subtitle_flags(&mut cmd, None);
// `repair()` rebuilds a YouTube watch URL from a stored video ID, so
// the source platform is always YouTube here regardless of where the
// original video lives on disk.
self.apply_impersonation(Platform::YouTube, &mut cmd);
cmd.arg("-o").arg(&out_arg).arg(&url);
self.apply_retry_flags(&mut cmd);
self.enqueue(cmd, url, label);
}
/// Path to the music download directory, nested under `channels_root`
/// alongside the platform folders.
pub fn music_root(&self) -> PathBuf {
self.channels_root.join("music")
}
/// Download `url` as audio-only, storing tracks in `music/<artist>/`.
pub fn start_music(&mut self, url: String) {
let music_root = self.music_root();
let _ = std::fs::create_dir_all(&music_root);
let archive_path = self.channels_root.join("archive.txt");
let out_arg = format!(
"{}/%(artist,channel|Unknown)s/%(title)s [%(id)s].%(ext)s",
music_root.display()
);
let label = "music".to_string();
let mut cmd = self.ytdlp_cmd();
cmd.arg("--newline").arg("--no-color");
self.apply_cookie_flags(&mut cmd);
cmd.arg("--extract-audio")
.arg("--audio-format")
.arg("best")
.arg("--audio-quality")
.arg("0")
.arg("--write-thumbnail")
.arg("--write-info-json")
.arg("--embed-metadata")
.arg("--xattrs");
// Music downloads can come from any audio-first platform — classify
// the URL once so SoundCloud/Bandcamp pulls get their appropriate
// (typically no-op) impersonation profile.
let platform = platform::classify_url(&url).platform;
self.apply_impersonation(platform, &mut cmd);
cmd.arg("--progress")
.arg("--download-archive")
.arg(archive_path.display().to_string())
.arg("-o")
.arg(&out_arg)
.arg(&url);
self.apply_retry_flags(&mut cmd);
self.enqueue(cmd, url, label);
}
/// Enqueue a job that downloads (or updates) the bundled yt-dlp + deno
/// binaries into [`ytdlp_bin::bundled_dir`]. Streams the curl/unzip output
/// into a normal [`Job`] entry so the user sees progress in the UI.
pub fn start_ytdlp_update(&mut self) {
let cmd = ytdlp_bin::install_command();
let url = "https://github.com/yt-dlp/yt-dlp/releases/latest".to_string();
let label = "update bundled yt-dlp + deno".to_string();
self.enqueue(cmd, url, label);
}
/// Enqueue a job that installs (or updates) the bgutil-pot binary
/// and pip-installs the matching Python plugin into the bundled
/// venv. Same UX shape as [`Self::start_ytdlp_update`] — progress
/// streams into the Downloads modal.
///
/// Before enqueueing we kill any already-running server child so
/// the install can overwrite the binary in place without an "ETXTBSY"
/// from the OS. The next download after install completes will
/// re-spawn the server via [`Self::ensure_pot_server`].
pub fn start_pot_provider_update(&mut self) {
if let Some(mut child) = self.pot_server.take() {
crate::pot_provider::kill_server(&mut child);
}
let cmd = crate::pot_provider::install_command();
let url = "https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest".to_string();
let label = "install bgutil-pot + Python plugin".to_string();
self.enqueue(cmd, url, label);
}
/// Spawn `cmd` on a background thread, streaming its output into a new [`Job`].
fn spawn_job(&mut self, mut cmd: Command, url: String, label: String, retry_spec: Option<RetrySpec>, retry_count: u8, convert_on_finish: Option<ConvertSpec>) {
// POT provider extractor-arg. yt-dlp lets us pass multiple
// --extractor-args flags; this one points the bgutil plugin at
// our local server. Only emitted when the user opted in *and*
// the server child is actually running — yt-dlp would warn
// about an unreachable base_url otherwise.
if self.use_pot_provider && self.pot_server.is_some() {
cmd.arg("--extractor-args")
.arg(crate::pot_provider::extractor_args());
}
let (tx, rx) = channel();
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Prepend the bundled bin dir to PATH so yt-dlp can locate the bundled
// `deno` for JavaScript signature deciphering. Harmless when the dir
// doesn't exist or bundled mode is disabled.
let bundled_dir = ytdlp_bin::bundled_dir();
if bundled_dir.exists() {
let sep = if cfg!(windows) { ";" } else { ":" };
let new_path = match std::env::var_os("PATH") {
Some(existing) => format!("{}{}{}", bundled_dir.display(), sep, existing.to_string_lossy()),
None => bundled_dir.display().to_string(),
};
cmd.env("PATH", new_path);
}
// Absolute path that we want to redact out of any log line — yt-dlp
// sometimes echoes the cookie path in errors and that leaks the
// user's home directory into the UI / API responses.
let cookies_abs = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("cookies.txt")
.display()
.to_string();
// Spawn the child *before* the reader thread so we can capture its
// pid for the hang watchdog (the thread then owns the Child and
// does the blocking wait()). On spawn failure, push a synthetic
// failed job and bail.
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(err) => {
self.push_synthetic_failure(
url, label, crate::error_class::ErrorClass::Other,
format!("could not launch yt-dlp: {err}"),
);
return;
}
};
let child_pid = Some(child.id());
thread::spawn(move || {
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
let cookies_abs = cookies_abs.clone();
thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let line = redact_sensitive(&line, &cookies_abs);
let _ = tx.send(Msg::Line(format!("[stderr] {line}")));
}
});
}
if let Some(stdout) = child.stdout.take() {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
// Suppress the alarming "Aborting remaining downloads" line that
// yt-dlp emits for --break-on-existing; we replace it with a
// friendlier message when we detect exit code 101 below.
if line.trim() == "Aborting remaining downloads" {
continue;
}
let line = redact_sensitive(&line, &cookies_abs);
if let Some(p) = parse_progress(&line) {
let _ = tx.send(Msg::Progress(p));
}
let _ = tx.send(Msg::Line(line));
}
}
let ok = match child.wait() {
Ok(status) if status.success() => true,
// yt-dlp exits 101 when it stops early because of --break-on-existing
// (it reached an already-archived video). That's the normal "nothing
// new to download" outcome for a channel re-check, not a failure.
Ok(status) => {
if status.code() == Some(101) {
let _ = tx.send(Msg::Line(
"(up to date — stopped at already-downloaded content)".to_string(),
));
true
} else {
false
}
}
Err(_) => false,
};
let _ = tx.send(Msg::Finished(ok));
});
self.jobs.push(Job {
url,
label,
state: JobState::Running,
progress: 0.0,
log: VecDeque::new(),
failure_class: None,
retry_spec,
retry_count,
retry_handled: false,
convert_on_finish,
convert_handled: false,
child_pid,
last_activity: std::time::Instant::now(),
watchdog_killed: false,
cancelled: false,
rx,
});
}
/// Push a job that was never going to start — used for preflight
/// failures (currently just disk-full). Constructs a closed channel
/// so the immediate `Job::drain` sees no progress, and seeds the log
/// with the human-readable reason so the UI's last_line + the error
/// hint together explain what went wrong.
fn push_synthetic_failure(
&mut self,
url: String,
label: String,
class: crate::error_class::ErrorClass,
reason: String,
) {
let (_tx, rx) = std::sync::mpsc::channel::<Msg>();
// _tx is dropped here, so any subsequent drain hits the
// "channel closed" branch and stays still. We start `state` as
// Failed directly rather than running it through the Finished
// transition.
let mut log = VecDeque::new();
log.push_back(format!("[preflight] {reason}"));
self.jobs.push(Job {
url,
label,
state: JobState::Failed,
progress: 0.0,
log,
failure_class: Some(class),
// Disk-full preflight isn't auto-retried — retrying without
// freeing space would just fail again.
retry_spec: None,
retry_count: 0,
retry_handled: true, // never retry a synthetic preflight failure
convert_on_finish: None,
convert_handled: true,
child_pid: None, // no process to watchdog
last_activity: std::time::Instant::now(),
watchdog_killed: false,
cancelled: false,
rx,
});
}
/// Drain pending messages from all job threads and promote queued jobs.
///
/// Call this regularly from the UI event loop to pick up progress updates.
pub fn poll(&mut self) {
for job in &mut self.jobs {
job.drain();
}
self.check_watchdog();
self.schedule_auto_retries();
self.fire_due_retries();
self.schedule_conversions();
// Re-check after draining: finished jobs free slots for queued ones.
self.promote_queued();
// Once everything's idle, clear the adaptive backoff so the next
// fresh batch starts at normal speed.
if !self.any_running() && self.pending.is_empty() && self.retry_queue.is_empty() {
self.rate_limited_backoff = false;
}
}
/// Hang watchdog: SIGKILL any Running job that has produced no output
/// or progress for [`HANG_TIMEOUT`]. Marks it `watchdog_killed` so the
/// resulting failure is classified retryable (see [`Job::drain`]) and
/// the auto-retry path gives it another go. The reader thread's
/// `child.wait()` returns once the process dies, sending `Finished`
/// normally — we don't reap here, just deliver the kill signal.
fn check_watchdog(&mut self) {
let now = std::time::Instant::now();
for job in &mut self.jobs {
if job.state != JobState::Running || job.watchdog_killed { continue; }
let Some(pid) = job.child_pid else { continue };
if now.duration_since(job.last_activity) < HANG_TIMEOUT { continue; }
kill_pid(pid);
job.watchdog_killed = true;
job.last_activity = now; // don't re-kill before wait() returns
job.log.push_back(format!(
"⏱ watchdog: no output for {}s — killed (will auto-retry)",
HANG_TIMEOUT.as_secs(),
));
}
}
/// Scan freshly-Done download jobs that have a convert spec; for each
/// `CONVERT_PATH:` line their yt-dlp run printed, enqueue an ffmpeg
/// transcode job. Marks the job handled so it only fires once.
fn schedule_conversions(&mut self) {
let mut to_convert: Vec<(std::path::PathBuf, ConvertSpec)> = Vec::new();
for job in &mut self.jobs {
if job.convert_handled || job.state != JobState::Done { continue; }
let Some(spec) = job.convert_on_finish.clone() else {
job.convert_handled = true;
continue;
};
job.convert_handled = true;
for line in &job.log {
// yt-dlp printed: "CONVERT_PATH:/abs/path/to/file.mkv"
if let Some(p) = line.strip_prefix("CONVERT_PATH:") {
let path = std::path::PathBuf::from(p.trim());
if path.is_file() {
to_convert.push((path, spec.clone()));
}
}
}
}
for (path, spec) in to_convert {
self.start_transcode(&path, &spec);
}
}
/// Returns true if `class` warrants an automatic retry. Transient
/// failures (rate-limit / captcha / network) are worth retrying; a
/// removed video or missing codec is not — retrying changes nothing.
fn is_retryable(class: crate::error_class::ErrorClass) -> bool {
use crate::error_class::ErrorClass::*;
matches!(class, RateLimited | NetworkError)
}
/// Scan freshly-failed jobs; schedule a delayed retry for retryable
/// ones under the attempt cap, and engage the adaptive backoff on a
/// rate-limit hit so the rest of the batch slows down.
fn schedule_auto_retries(&mut self) {
let now = std::time::Instant::now();
let mut to_schedule: Vec<(RetrySpec, u8)> = Vec::new();
for job in &mut self.jobs {
if job.retry_handled || job.state != JobState::Failed { continue; }
job.retry_handled = true;
let Some(class) = job.failure_class else { continue };
// A rate-limit hit slows the whole remaining batch.
if class == crate::error_class::ErrorClass::RateLimited {
self.rate_limited_backoff = true;
}
if !Self::is_retryable(class) { continue; }
if job.retry_count >= MAX_AUTO_RETRIES { continue; }
let Some(spec) = job.retry_spec.clone() else { continue };
let next_attempt = job.retry_count + 1;
to_schedule.push((spec, next_attempt));
job.log.push_back(format!(
"↻ auto-retry {next_attempt}/{MAX_AUTO_RETRIES} scheduled in {}s (transient {})",
AUTO_RETRY_COOLDOWN.as_secs(),
class.label(),
));
}
for (spec, attempt) in to_schedule {
// Linear backoff: attempt 1 waits 1× cooldown, attempt 2 waits 2×.
let delay = AUTO_RETRY_COOLDOWN * attempt as u32;
self.retry_queue.push((now + delay, spec, attempt));
}
}
/// Re-issue any retries whose cooldown has elapsed.
fn fire_due_retries(&mut self) {
let now = std::time::Instant::now();
// Partition: due vs not-yet-due. Drain the due ones.
let mut still_waiting = Vec::with_capacity(self.retry_queue.len());
let due: Vec<(RetrySpec, u8)> = std::mem::take(&mut self.retry_queue)
.into_iter()
.filter_map(|(at, spec, attempt)| {
if at <= now { Some((spec, attempt)) }
else { still_waiting.push((at, spec, attempt)); None }
})
.collect();
self.retry_queue = still_waiting;
for (spec, attempt) in due {
self.start_retry(spec, attempt);
}
}
/// Rebuild + enqueue a retry of a previously-failed download. Re-runs
/// `start()` to reconstruct the command identically; the attempt count
/// rides through via `retry_attempt_override` so the new job knows it's
/// a retry (and won't loop past the cap).
fn start_retry(&mut self, spec: RetrySpec, attempt: u8) {
self.retry_attempt_override = attempt;
self.start(
spec.url.clone(),
&spec.info,
spec.full_scan,
spec.quality,
spec.live,
spec.opts.as_ref(),
);
self.retry_attempt_override = 0;
}
/// Build + enqueue an ffmpeg transcode of `src` per `spec`. The
/// output lands next to the source; the source is renamed to
/// `<stem>.original.<ext>` (kept) or deleted, depending on
/// `spec.keep_original`. Runs as its own [`Job`] so the UI shows the
/// transcode as a distinct phase.
fn start_transcode(&mut self, src: &std::path::Path, spec: &ConvertSpec) {
let stem = src.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let parent = src.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
let src_ext = src.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
// Target extension + ffmpeg codec args per mode.
let (out_ext, codec_args): (&str, Vec<String>) = match spec.mode.as_str() {
"remux-mp4" => ("mp4", vec![
// Stream-copy: no re-encode, just re-container.
"-c".into(), "copy".into(),
// Some MKV audio (opus in mkv) isn't valid in mp4; let
// ffmpeg fall back to aac only if copy fails would need a
// second pass — keep it simple: copy video, aac audio.
"-c:a".into(), "aac".into(),
]),
"audio" => {
let af = spec.audio_format.as_str();
let mut a = vec!["-vn".into()]; // drop video
// Pick a reasonable codec per container.
match af {
"mp3" => { a.extend(["-c:a".into(), "libmp3lame".into(), "-q:a".into(), "2".into()]); }
"m4a" | "aac" => { a.extend(["-c:a".into(), "aac".into(), "-b:a".into(), "192k".into()]); }
"opus" => { a.extend(["-c:a".into(), "libopus".into(), "-b:a".into(), "160k".into()]); }
"flac" => { a.extend(["-c:a".into(), "flac".into()]); }
_ => { a.extend(["-c:a".into(), "libmp3lame".into(), "-q:a".into(), "2".into()]); }
}
(if af == "aac" { "m4a" } else { af }, a)
}
// Default "h264-mp4": re-encode to H.264 + AAC at the CRF.
_ => ("mp4", vec![
"-c:v".into(), "libx264".into(),
"-crf".into(), spec.crf.to_string(),
"-preset".into(), spec.preset.clone(),
"-c:a".into(), "aac".into(),
"-b:a".into(), "192k".into(),
"-movflags".into(), "+faststart".into(),
]),
};
let out_path = parent.join(format!("{stem}.{out_ext}"));
// If the source already has the target extension (e.g. remux-mp4
// on an mp4), write to a temp name then swap, so we don't clobber
// the input mid-encode.
let same_ext = src_ext.eq_ignore_ascii_case(out_ext);
let work_out = if same_ext {
parent.join(format!("{stem}.converting.{out_ext}"))
} else {
out_path.clone()
};
// Build the ffmpeg command. -y overwrites a stale partial output.
let mut cmd = Command::new("ffmpeg");
cmd.arg("-hide_banner").arg("-loglevel").arg("error").arg("-stats")
.arg("-y")
.arg("-i").arg(src);
for a in &codec_args { cmd.arg(a); }
cmd.arg(&work_out);
// Post-ffmpeg bookkeeping runs in the job thread via a wrapper
// shell so the rename/delete happens only on ffmpeg success. We
// encode it as a small bash script that runs ffmpeg then fixes up
// the files.
let label = format!("transcode → {out_ext}: {stem}");
let url = src.display().to_string();
self.enqueue_transcode(cmd, url, label, src.to_path_buf(), out_path, work_out, same_ext, spec.keep_original);
}
/// Enqueue a transcode job. We can't easily run post-ffmpeg file
/// bookkeeping (rename original / swap temp) inside the generic
/// spawn_job, so transcode jobs get their own lightweight runner
/// thread that runs ffmpeg then does the file moves on success.
fn enqueue_transcode(
&mut self,
mut cmd: Command,
url: String,
label: String,
src: PathBuf,
out_path: PathBuf,
work_out: PathBuf,
same_ext: bool,
keep_original: bool,
) {
let (tx, rx) = channel();
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
// Spawn before the thread so the watchdog gets a pid (ffmpeg can
// also hang on a bad input). On spawn failure, surface it.
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
self.push_synthetic_failure(
url, label, crate::error_class::ErrorClass::CodecMissing,
format!("could not launch ffmpeg: {e}"),
);
return;
}
};
let child_pid = Some(child.id());
thread::spawn(move || {
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(Msg::Line(format!("[ffmpeg] {line}")));
}
});
}
let ok = matches!(child.wait(), Ok(s) if s.success());
if ok {
// File bookkeeping on success.
if same_ext {
// src and out share an extension. Keep or drop the
// original, then move the temp output into place.
if keep_original {
let orig = src.with_extension(format!("original.{}",
src.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default()));
let _ = std::fs::rename(&src, &orig);
} else {
let _ = std::fs::remove_file(&src);
}
let _ = std::fs::rename(&work_out, &out_path);
} else {
// Different extensions: output already at out_path.
if keep_original {
let orig = src.with_extension(format!("original.{}",
src.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default()));
let _ = std::fs::rename(&src, &orig);
} else {
let _ = std::fs::remove_file(&src);
}
}
let _ = tx.send(Msg::Line(format!("✓ wrote {}", out_path.display())));
} else {
// Clean up a partial temp output on failure.
if same_ext { let _ = std::fs::remove_file(&work_out); }
}
let _ = tx.send(Msg::Finished(ok));
});
self.jobs.push(Job {
url,
label,
state: JobState::Running,
progress: 0.0,
log: VecDeque::new(),
failure_class: None,
retry_spec: None, // don't auto-retry transcodes
retry_count: 0,
retry_handled: true,
convert_on_finish: None, // a transcode doesn't itself convert
convert_handled: true,
child_pid,
last_activity: std::time::Instant::now(),
watchdog_killed: false,
cancelled: false,
rx,
});
}
pub fn any_running(&self) -> bool {
self.jobs.iter().any(|j| j.state == JobState::Running)
}
/// Remove all jobs that have finished (done or failed), keeping only running ones.
pub fn clear_finished(&mut self) {
self.jobs.retain(|j| j.state == JobState::Running);
}
/// Remove a single finished job by index. Silently ignores the request
/// if the job is still running.
pub fn remove_job(&mut self, idx: usize) {
if let Some(j) = self.jobs.get(idx) {
if j.state != JobState::Running {
self.jobs.remove(idx);
}
}
}
/// Cancel a running job by index: SIGKILL its process and mark it
/// `cancelled` so the auto-retry path skips it and the UI shows a
/// distinct "cancelled" state rather than a misleading error class.
/// The reader thread's `wait()` returns once the process dies and
/// delivers `Finished(false)` normally, transitioning it to Failed.
/// No-op for non-running jobs. Returns whether a job was cancelled.
pub fn cancel_job(&mut self, idx: usize) -> bool {
let Some(job) = self.jobs.get_mut(idx) else { return false };
if job.state != JobState::Running { return false; }
if let Some(pid) = job.child_pid {
kill_pid(pid);
}
job.cancelled = true;
job.retry_handled = true; // a user cancel must not auto-retry
job.last_activity = std::time::Instant::now();
job.log.push_back("⛔ cancelled by user".to_string());
true
}
/// Cancel a still-queued (not-yet-started) job by index into the pending
/// queue. Returns whether an entry was removed.
pub fn cancel_queued(&mut self, idx: usize) -> bool {
if idx < self.pending.len() {
self.pending.remove(idx);
true
} else {
false
}
}
/// Manually re-issue a finished (failed/cancelled/done) job by index,
/// reusing the inputs captured at start time. Resets the attempt counter
/// so the user-triggered retry gets a fresh auto-retry budget. The old
/// job row is removed. No-op (returns false) for a running job or one
/// with no captured `retry_spec` (e.g. live recordings, self-update).
pub fn retry_job(&mut self, idx: usize) -> bool {
let Some(job) = self.jobs.get(idx) else { return false };
if job.state == JobState::Running { return false; }
let Some(spec) = job.retry_spec.clone() else { return false };
self.jobs.remove(idx);
self.start_retry(spec, 0);
true
}
/// Full log buffer for a job by index (for the UI's per-job log view).
pub fn job_log(&self, idx: usize) -> Option<Vec<String>> {
self.jobs.get(idx).map(|j| j.log.iter().cloned().collect())
}
}
impl Drop for Downloader {
/// Tear down the bgutil-pot child if we spawned one. Without this
/// the server keeps running after catacomb exits — orphaned and
/// still bound to port 4416, blocking the next launch from
/// re-spawning.
fn drop(&mut self) {
if let Some(mut child) = self.pot_server.take() {
crate::pot_provider::kill_server(&mut child);
}
}
}
/// Current UNIX timestamp in seconds. Used to disambiguate live-recording
/// filenames at job-start time.
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Format a UNIX timestamp as `YYYYMMDD-HHMMSS` (UTC) for embedding in
/// filenames. No `chrono` dep — short manual calendar walk; good enough
/// for human-readable filename suffixes.
fn format_compact_utc(unix: u64) -> String {
let day = unix / 86_400;
let day_secs = unix % 86_400;
let hour = day_secs / 3600;
let minute = (day_secs % 3600) / 60;
let second = day_secs % 60;
let mut year = 1970u32;
let mut remaining_days = day;
loop {
let leap = is_leap(year);
let yd = if leap { 366 } else { 365 };
if remaining_days < yd as u64 { break; }
remaining_days -= yd as u64;
year += 1;
}
let months: [u8; 12] = if is_leap(year) {
[31,29,31,30,31,30,31,31,30,31,30,31]
} else {
[31,28,31,30,31,30,31,31,30,31,30,31]
};
let mut month = 0usize;
while month < 12 && remaining_days >= months[month] as u64 {
remaining_days -= months[month] as u64;
month += 1;
}
let day_of_month = remaining_days as u32 + 1;
format!(
"{:04}{:02}{:02}-{:02}{:02}{:02}",
year, month as u32 + 1, day_of_month, hour, minute, second
)
}
fn is_leap(y: u32) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 }
/// Strip the absolute on-disk path to `cookies.txt` from a log line, leaving
/// the bare filename. yt-dlp occasionally echoes the full path in error
/// messages ("Unable to load cookies from /home/user/.../cookies.txt"); the
/// user's home directory is not something we want to expose in the UI or
/// `/api/progress` responses.
fn redact_sensitive(line: &str, cookies_abs: &str) -> String {
if cookies_abs.is_empty() || !line.contains(cookies_abs) {
return line.to_string();
}
line.replace(cookies_abs, "cookies.txt")
}
/// SIGKILL a child process by pid (used by the hang watchdog). The reader
/// thread's `child.wait()` reaps it; we only deliver the signal here.
#[cfg(unix)]
fn kill_pid(pid: u32) {
// SAFETY: libc::kill with a valid pid + signal is sound; an invalid
// pid just returns ESRCH which we ignore.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL); }
}
/// Non-Unix fallback: no portable pid-kill, so the watchdog can flag the
/// stall but can't force-terminate. (Windows isn't a first-class target.)
#[cfg(not(unix))]
fn kill_pid(_pid: u32) {}
/// Parse a yt-dlp `[download] 42.7% …` line into a `[0.0, 1.0]` fraction.
fn parse_progress(line: &str) -> Option<f32> {
let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
let pct_end = rest.find('%')?;
let value: f32 = rest[..pct_end].trim().parse().ok()?;
Some((value / 100.0).clamp(0.0, 1.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_progress_typical() {
let p = parse_progress("[download] 42.7% of 100MiB at 5MiB/s ETA 00:10").unwrap();
assert!((p - 0.427).abs() < 1e-4);
}
#[test]
fn parse_progress_clamps_to_one() {
let p = parse_progress("[download] 150% of garbage").unwrap();
assert_eq!(p, 1.0);
}
#[test]
fn parse_progress_rejects_non_download_lines() {
assert!(parse_progress("[info] Writing thumbnail").is_none());
assert!(parse_progress("").is_none());
}
#[test]
fn check_url_for_folder_picks_channel_form_for_ids() {
let url = check_url_for_folder("UC1234567890123456789012");
assert_eq!(url, "https://www.youtube.com/channel/UC1234567890123456789012");
}
#[test]
fn check_url_for_folder_picks_handle_form_otherwise() {
let url = check_url_for_folder("LinusTechTips");
assert_eq!(url, "https://www.youtube.com/@LinusTechTips");
}
#[test]
fn redact_sensitive_strips_cookie_path() {
let abs = "/home/luna/.config/catacomb/cookies.txt";
let input = format!("Unable to load cookies from {abs}");
let out = redact_sensitive(&input, abs);
assert_eq!(out, "Unable to load cookies from cookies.txt");
}
#[test]
fn redact_sensitive_pass_through_when_not_present() {
let abs = "/home/luna/cookies.txt";
let input = "[download] 47.2% of 100MiB";
assert_eq!(redact_sensitive(input, abs), input);
}
// ── Auto-retry classification ────────────────────────────────────────
use crate::error_class::ErrorClass;
#[test]
fn retryable_only_for_transient_classes() {
assert!(Downloader::is_retryable(ErrorClass::RateLimited));
assert!(Downloader::is_retryable(ErrorClass::NetworkError));
// Permanent / user-action-required classes must NOT auto-retry.
assert!(!Downloader::is_retryable(ErrorClass::NotFound));
assert!(!Downloader::is_retryable(ErrorClass::MembersOnly));
assert!(!Downloader::is_retryable(ErrorClass::Geoblocked));
assert!(!Downloader::is_retryable(ErrorClass::CodecMissing));
assert!(!Downloader::is_retryable(ErrorClass::DiskFull));
assert!(!Downloader::is_retryable(ErrorClass::BadCookies));
assert!(!Downloader::is_retryable(ErrorClass::Other));
}
#[test]
fn adaptive_backoff_triples_sleeps() {
let mut d = Downloader::new(
std::path::PathBuf::from("/tmp"), "none".into(), 1, false, false);
// Normal: 1/2/6.
let mut cmd = Command::new("yt-dlp");
d.apply_retry_flags(&mut cmd);
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert!(args.windows(2).any(|w| w == ["--sleep-requests", "1"]));
assert!(args.windows(2).any(|w| w == ["--max-sleep-interval", "6"]));
// After a rate-limit hit: backoff engaged → 3/8/20.
d.rate_limited_backoff = true;
let mut cmd2 = Command::new("yt-dlp");
d.apply_retry_flags(&mut cmd2);
let args2: Vec<String> = cmd2.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert!(args2.windows(2).any(|w| w == ["--sleep-requests", "3"]));
assert!(args2.windows(2).any(|w| w == ["--max-sleep-interval", "20"]));
}
// ── Convert resolver ─────────────────────────────────────────────────
use crate::config::ConvertSection;
use crate::download_options::DownloadOptions as DO;
#[test]
fn convert_off_when_global_empty_and_no_override() {
let g = ConvertSection::default(); // mode = ""
assert!(ConvertSpec::resolve(&g, None).is_none());
}
#[test]
fn convert_global_h264_defaults_filled() {
let g = ConvertSection { mode: "h264-mp4".into(), crf: 0, preset: String::new(),
audio_format: String::new(), keep_original: false };
let s = ConvertSpec::resolve(&g, None).unwrap();
assert_eq!(s.mode, "h264-mp4");
assert_eq!(s.crf, 23); // 0 → default 23
assert_eq!(s.preset, "medium"); // empty → default
}
#[test]
fn convert_per_channel_off_overrides_global_on() {
let g = ConvertSection { mode: "h264-mp4".into(), ..Default::default() };
let opts = DO { convert_mode: Some("off".into()), ..Default::default() };
assert!(ConvertSpec::resolve(&g, Some(&opts)).is_none());
}
#[test]
fn convert_per_channel_mode_wins() {
let g = ConvertSection { mode: "h264-mp4".into(), ..Default::default() };
let opts = DO { convert_mode: Some("audio".into()), ..Default::default() };
let s = ConvertSpec::resolve(&g, Some(&opts)).unwrap();
assert_eq!(s.mode, "audio");
assert_eq!(s.audio_format, "mp3"); // empty global → default mp3
}
// ── Hang watchdog ────────────────────────────────────────────────────
#[test]
fn watchdog_killed_job_fails_retryable() {
// A job the watchdog killed leaves no error line; drain() must
// force a retryable class so auto-retry re-queues it (a hang is
// transient), rather than classify() returning Other (terminal).
let (tx, rx) = channel();
let mut job = Job {
url: "u".into(), label: "l".into(), state: JobState::Running,
progress: 0.0, log: VecDeque::new(), failure_class: None,
retry_spec: None, retry_count: 0, retry_handled: false,
convert_on_finish: None, convert_handled: false,
child_pid: Some(999_999),
last_activity: std::time::Instant::now(),
watchdog_killed: true,
cancelled: false,
rx,
};
tx.send(Msg::Finished(false)).unwrap();
job.drain();
assert_eq!(job.state, JobState::Failed);
assert_eq!(job.failure_class, Some(crate::error_class::ErrorClass::NetworkError));
assert!(Downloader::is_retryable(job.failure_class.unwrap()));
}
#[test]
fn normal_failure_still_classified_from_log() {
// Without watchdog_killed, drain() classifies from the log as before.
let (tx, rx) = channel();
let mut job = Job {
url: "u".into(), label: "l".into(), state: JobState::Running,
progress: 0.0, log: VecDeque::new(), failure_class: None,
retry_spec: None, retry_count: 0, retry_handled: false,
convert_on_finish: None, convert_handled: false,
child_pid: Some(1), last_activity: std::time::Instant::now(),
watchdog_killed: false, cancelled: false, rx,
};
tx.send(Msg::Line("ERROR: Video unavailable. This video has been removed".into())).unwrap();
tx.send(Msg::Finished(false)).unwrap();
job.drain();
assert_eq!(job.failure_class, Some(crate::error_class::ErrorClass::NotFound));
}
// ── Subtitle resolver ────────────────────────────────────────────────
fn dl_with_subs(subs: crate::config::SubtitlesSection) -> Downloader {
let mut d = Downloader::new(
std::path::PathBuf::from("/tmp"), "none".into(), 1, false, false);
d.subtitle_defaults = subs;
d
}
fn args_of(d: &Downloader, opts: Option<&DownloadOptions>) -> Vec<String> {
let mut cmd = Command::new("yt-dlp");
d.apply_subtitle_flags(&mut cmd, opts);
cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect()
}
#[test]
fn subs_global_defaults_emit_write_and_auto() {
let d = dl_with_subs(crate::config::SubtitlesSection::default());
let args = args_of(&d, None);
assert!(args.contains(&"--write-subs".to_string()));
assert!(args.contains(&"--write-auto-subs".to_string()));
// No langs/format/embed by default.
assert!(!args.contains(&"--embed-subs".to_string()));
assert!(!args.iter().any(|a| a == "--convert-subs"));
}
#[test]
fn subs_disabled_emits_nothing() {
let mut g = crate::config::SubtitlesSection::default();
g.enabled = false;
let d = dl_with_subs(g);
assert!(args_of(&d, None).is_empty());
}
#[test]
fn subs_global_format_embed_langs() {
let g = crate::config::SubtitlesSection {
enabled: true, auto_generated: false, embed: true,
format: "srt".into(), langs: "en,ja".into(),
};
let d = dl_with_subs(g);
let args = args_of(&d, None);
assert!(args.contains(&"--write-subs".to_string()));
assert!(!args.contains(&"--write-auto-subs".to_string())); // auto off
assert!(args.windows(2).any(|w| w == ["--sub-langs", "en,ja"]));
assert!(args.windows(2).any(|w| w == ["--convert-subs", "srt"]));
assert!(args.contains(&"--embed-subs".to_string()));
}
#[test]
fn subs_per_channel_overrides_global() {
// Global: enabled+auto. Channel: force OFF.
let d = dl_with_subs(crate::config::SubtitlesSection::default());
let opts = DownloadOptions { subtitles_enabled: Some(false), ..Default::default() };
assert!(args_of(&d, Some(&opts)).is_empty());
// Global: auto on. Channel: auto off + embed on + own langs.
let opts2 = DownloadOptions {
subtitles_auto: Some(false),
subtitles_embed: Some(true),
subtitle_langs: vec!["en".into()],
subtitle_format: Some("vtt".into()),
..Default::default()
};
let args = args_of(&d, Some(&opts2));
assert!(!args.contains(&"--write-auto-subs".to_string()));
assert!(args.contains(&"--embed-subs".to_string()));
assert!(args.windows(2).any(|w| w == ["--sub-langs", "en"]));
assert!(args.windows(2).any(|w| w == ["--convert-subs", "vtt"]));
}
// URL classification tests live in `platform` now — see its tests module.
}