Anti-bot: auto-retry transient failures + adaptive throttle
Two measures to keep a long channel scan going when YouTube's bot-detection trips mid-batch. ## Auto-retry on transient failures When a download fails with a retryable class (RateLimited — which now includes captcha walls — or NetworkError), the downloader re-queues it automatically after a cooldown, up to MAX_AUTO_RETRIES (2). Linear backoff: 1st retry waits 90 s, 2nd waits 180 s. Permanent classes (NotFound, MembersOnly, Geoblocked, CodecMissing, DiskFull, BadCookies) are never retried — re-running changes nothing. Implementation: start() captures a RetrySpec (url/info/flags/quality/ opts) onto the Job; poll() scans freshly-failed jobs, schedules due retries into a cooldown queue, and fires them by rebuilding the command through start(). Live recordings and synthetic preflight failures opt out. A log line announces each scheduled retry so it's visible in the Downloads panel. ## Adaptive throttle After any rate-limit hit, rate_limited_backoff engages and the sleep flags roughly triple for the rest of the batch (--sleep-requests 1→3, --sleep-interval 2-6 s → 8-20 s), easing off an already-suspicious endpoint instead of hammering it. Clears once downloads go idle so the next fresh batch starts at normal speed. 2 new tests (retryable-class matrix, backoff sleep values). 87 pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
71466b21bd
commit
fe229bc98d
1 changed files with 232 additions and 15 deletions
|
|
@ -132,9 +132,41 @@ pub struct Job {
|
||||||
/// surfaces the class + a one-line suggested fix from
|
/// surfaces the class + a one-line suggested fix from
|
||||||
/// [`crate::error_class`].
|
/// [`crate::error_class`].
|
||||||
pub failure_class: Option<crate::error_class::ErrorClass>,
|
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,
|
||||||
rx: Receiver<Msg>,
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
|
||||||
impl Job {
|
impl Job {
|
||||||
fn drain(&mut self) {
|
fn drain(&mut self) {
|
||||||
while let Ok(msg) = self.rx.try_recv() {
|
while let Ok(msg) = self.rx.try_recv() {
|
||||||
|
|
@ -168,6 +200,10 @@ struct PendingJob {
|
||||||
cmd: Command,
|
cmd: Command,
|
||||||
url: String,
|
url: String,
|
||||||
label: 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages all active, queued, and recently completed yt-dlp download jobs.
|
/// Manages all active, queued, and recently completed yt-dlp download jobs.
|
||||||
|
|
@ -195,6 +231,24 @@ pub struct Downloader {
|
||||||
/// [`DownloadOptions`] override individual fields. Set by the app /
|
/// [`DownloadOptions`] override individual fields. Set by the app /
|
||||||
/// web layer at construction and on settings save.
|
/// web layer at construction and on settings save.
|
||||||
pub subtitle_defaults: crate::config::SubtitlesSection,
|
pub subtitle_defaults: crate::config::SubtitlesSection,
|
||||||
|
/// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Downloader {
|
impl Downloader {
|
||||||
|
|
@ -215,6 +269,10 @@ impl Downloader {
|
||||||
use_pot_provider,
|
use_pot_provider,
|
||||||
pot_server: None,
|
pot_server: None,
|
||||||
subtitle_defaults: crate::config::SubtitlesSection::default(),
|
subtitle_defaults: crate::config::SubtitlesSection::default(),
|
||||||
|
retry_queue: Vec::new(),
|
||||||
|
rate_limited_backoff: false,
|
||||||
|
pending_retry_spec: None,
|
||||||
|
retry_attempt_override: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,18 +374,27 @@ impl Downloader {
|
||||||
/// The sleep flags throttle request rate so we don't trip YouTube's
|
/// The sleep flags throttle request rate so we don't trip YouTube's
|
||||||
/// bot-detection / captcha wall mid-batch (it tends to fire after
|
/// bot-detection / captcha wall mid-batch (it tends to fire after
|
||||||
/// ~30 rapid requests):
|
/// ~30 rapid requests):
|
||||||
/// - `--sleep-requests 1`: 1 s between metadata/API requests.
|
/// - `--sleep-requests`: pause between metadata/API requests.
|
||||||
/// - `--sleep-interval` / `--max-sleep-interval`: a random 2-6 s
|
/// - `--sleep-interval` / `--max-sleep-interval`: a random pause
|
||||||
/// pause *between videos*. The jitter is what matters — a fixed
|
/// *between videos*. The jitter is what matters — a fixed cadence
|
||||||
/// cadence looks robotic; a random one looks human and is far less
|
/// looks robotic; a random one looks human and is far less likely
|
||||||
/// likely to trip the captcha on a long channel scan.
|
/// to trip the captcha on a long channel scan.
|
||||||
fn apply_retry_flags(cmd: &mut Command) {
|
///
|
||||||
|
/// 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")
|
cmd.arg("--retries").arg("30")
|
||||||
.arg("--fragment-retries").arg("30")
|
.arg("--fragment-retries").arg("30")
|
||||||
.arg("--retry-sleep").arg("linear=1:30:2")
|
.arg("--retry-sleep").arg("linear=1:30:2")
|
||||||
.arg("--sleep-requests").arg("1")
|
.arg("--sleep-requests").arg(req)
|
||||||
.arg("--sleep-interval").arg("2")
|
.arg("--sleep-interval").arg(lo)
|
||||||
.arg("--max-sleep-interval").arg("6");
|
.arg("--max-sleep-interval").arg(hi);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a fresh `Command` invoking the currently configured yt-dlp binary.
|
/// Build a fresh `Command` invoking the currently configured yt-dlp binary.
|
||||||
|
|
@ -362,13 +429,21 @@ impl Downloader {
|
||||||
if running >= self.max_concurrent { break; }
|
if running >= self.max_concurrent { break; }
|
||||||
}
|
}
|
||||||
let p = self.pending.pop_front().unwrap();
|
let p = self.pending.pop_front().unwrap();
|
||||||
self.spawn_job(p.cmd, p.url, p.label);
|
self.spawn_job(p.cmd, p.url, p.label, p.retry_spec, p.retry_count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a command into the pending queue (or start immediately if a slot is free).
|
/// Push a command into the pending queue (or start immediately if a slot is free).
|
||||||
|
///
|
||||||
|
/// Consumes `self.pending_retry_spec` (set by [`Self::start`]) so the
|
||||||
|
/// resulting job carries its retry spec, and `retry_attempt_override`
|
||||||
|
/// for the attempt number (0 on a first run, >0 from a retry). Other
|
||||||
|
/// callers (repair, music, updates) leave the spec `None`, making
|
||||||
|
/// those jobs non-retryable.
|
||||||
fn enqueue(&mut self, cmd: Command, url: String, label: String) {
|
fn enqueue(&mut self, cmd: Command, url: String, label: String) {
|
||||||
self.pending.push_back(PendingJob { cmd, url, label });
|
let retry_spec = self.pending_retry_spec.take();
|
||||||
|
let retry_count = self.retry_attempt_override;
|
||||||
|
self.pending.push_back(PendingJob { cmd, url, label, retry_spec, retry_count });
|
||||||
self.promote_queued();
|
self.promote_queued();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,6 +489,23 @@ impl Downloader {
|
||||||
// misses POT and YouTube hands back empty formats.
|
// misses POT and YouTube hands back empty formats.
|
||||||
self.ensure_pot_server();
|
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);
|
let platform_dir = platform::platform_root(&self.channels_root, info.platform);
|
||||||
// Per-platform download archive keeps cross-platform IDs from colliding
|
// Per-platform download archive keeps cross-platform IDs from colliding
|
||||||
// (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.).
|
// (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.).
|
||||||
|
|
@ -545,7 +637,7 @@ impl Downloader {
|
||||||
opts.apply(&mut cmd);
|
opts.apply(&mut cmd);
|
||||||
}
|
}
|
||||||
cmd.arg("-o").arg(&out_arg).arg(&url);
|
cmd.arg("-o").arg(&out_arg).arg(&url);
|
||||||
Self::apply_retry_flags(&mut cmd);
|
self.apply_retry_flags(&mut cmd);
|
||||||
|
|
||||||
self.enqueue(cmd, url, label);
|
self.enqueue(cmd, url, label);
|
||||||
}
|
}
|
||||||
|
|
@ -577,7 +669,7 @@ impl Downloader {
|
||||||
// original video lives on disk.
|
// original video lives on disk.
|
||||||
self.apply_impersonation(Platform::YouTube, &mut cmd);
|
self.apply_impersonation(Platform::YouTube, &mut cmd);
|
||||||
cmd.arg("-o").arg(&out_arg).arg(&url);
|
cmd.arg("-o").arg(&out_arg).arg(&url);
|
||||||
Self::apply_retry_flags(&mut cmd);
|
self.apply_retry_flags(&mut cmd);
|
||||||
|
|
||||||
self.enqueue(cmd, url, label);
|
self.enqueue(cmd, url, label);
|
||||||
}
|
}
|
||||||
|
|
@ -622,7 +714,7 @@ impl Downloader {
|
||||||
.arg("-o")
|
.arg("-o")
|
||||||
.arg(&out_arg)
|
.arg(&out_arg)
|
||||||
.arg(&url);
|
.arg(&url);
|
||||||
Self::apply_retry_flags(&mut cmd);
|
self.apply_retry_flags(&mut cmd);
|
||||||
|
|
||||||
self.enqueue(cmd, url, label);
|
self.enqueue(cmd, url, label);
|
||||||
}
|
}
|
||||||
|
|
@ -657,7 +749,7 @@ impl Downloader {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn `cmd` on a background thread, streaming its output into a new [`Job`].
|
/// 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) {
|
fn spawn_job(&mut self, mut cmd: Command, url: String, label: String, retry_spec: Option<RetrySpec>, retry_count: u8) {
|
||||||
// POT provider extractor-arg. yt-dlp lets us pass multiple
|
// POT provider extractor-arg. yt-dlp lets us pass multiple
|
||||||
// --extractor-args flags; this one points the bgutil plugin at
|
// --extractor-args flags; this one points the bgutil plugin at
|
||||||
// our local server. Only emitted when the user opted in *and*
|
// our local server. Only emitted when the user opted in *and*
|
||||||
|
|
@ -759,6 +851,9 @@ impl Downloader {
|
||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
log: VecDeque::new(),
|
log: VecDeque::new(),
|
||||||
failure_class: None,
|
failure_class: None,
|
||||||
|
retry_spec,
|
||||||
|
retry_count,
|
||||||
|
retry_handled: false,
|
||||||
rx,
|
rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -789,6 +884,11 @@ impl Downloader {
|
||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
log,
|
log,
|
||||||
failure_class: Some(class),
|
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
|
||||||
rx,
|
rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -800,8 +900,90 @@ impl Downloader {
|
||||||
for job in &mut self.jobs {
|
for job in &mut self.jobs {
|
||||||
job.drain();
|
job.drain();
|
||||||
}
|
}
|
||||||
|
self.schedule_auto_retries();
|
||||||
|
self.fire_due_retries();
|
||||||
// Re-check after draining: finished jobs free slots for queued ones.
|
// Re-check after draining: finished jobs free slots for queued ones.
|
||||||
self.promote_queued();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn any_running(&self) -> bool {
|
pub fn any_running(&self) -> bool {
|
||||||
|
|
@ -952,6 +1134,41 @@ mod tests {
|
||||||
assert_eq!(redact_sensitive(input, abs), input);
|
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"]));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Subtitle resolver ────────────────────────────────────────────────
|
// ── Subtitle resolver ────────────────────────────────────────────────
|
||||||
fn dl_with_subs(subs: crate::config::SubtitlesSection) -> Downloader {
|
fn dl_with_subs(subs: crate::config::SubtitlesSection) -> Downloader {
|
||||||
let mut d = Downloader::new(
|
let mut d = Downloader::new(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue