odysee-dl

odysee.com channel content downloader
git clone https://git.ea.contact/odysee-dl
Log | Files | Refs | README

commit b7512ed10c6da2bd23df1352994f6b09d9e75681
parent 62ba058fad473fb02b83e82619cdb92885a1379a
Author: egor-achkasov <eaachkasov@gmail.com>
Date:   Sat,  9 May 2026 05:36:10 +0000

Wait on HTTP 429

Diffstat:
Msrc/bin/cli/main.rs | 1+
Msrc/lib/event.rs | 1+
Msrc/lib/lib.rs | 24+++++++++++++++---------
Msrc/lib/post.rs | 66+++++++++++++++++++++++++++++++++++++++++++++++-------------------
4 files changed, 64 insertions(+), 28 deletions(-)

diff --git a/src/bin/cli/main.rs b/src/bin/cli/main.rs @@ -108,6 +108,7 @@ fn render_event(event: &Event) { Event::DownloadPostFailed(_, err) => println!(" Failed: {}", err), Event::DownloadPostSkipped(_) => println!(" Skipped"), Event::DownloadPostFinished(_) => println!(" Done"), + Event::RateLimited(ctx) => println!("\nRate limited ({}), waiting 60s...", ctx), Event::Done => println!("Done."), } } diff --git a/src/lib/event.rs b/src/lib/event.rs @@ -13,6 +13,7 @@ pub enum Event { DownloadPostFailed(String, String), // post name, error message DownloadPostSkipped(String), // post name (already exists, skipping) DownloadPostFinished(String), // post name + RateLimited(String), // context (post name or "API") Done, } diff --git a/src/lib/lib.rs b/src/lib/lib.rs @@ -11,7 +11,7 @@ pub fn run(config: config::Config, tx: std::sync::mpsc::Sender<Event>) -> Result let channel = url_to_channel(&config.url)?; tx.send(Event::GetPostsStarted(config.url.clone())).ok(); - let posts = match get_posts(&channel) { + let posts = match get_posts(&channel, &tx) { Ok(posts) => { tx.send(Event::GetPostsFinished(config.url.clone())).ok(); posts @@ -28,13 +28,13 @@ pub fn run(config: config::Config, tx: std::sync::mpsc::Sender<Event>) -> Result if config.resume { let local_path = config.output_dir.join(&post.filename); if let Ok(meta) = std::fs::metadata(&local_path) { - if post.content_length().map_or(false, |remote| meta.len() == remote) { + if post.content_length(&tx).map_or(false, |remote| meta.len() == remote) { tx.send(Event::DownloadPostSkipped(post.name.clone())).ok(); continue; } } } - match post.download(&config.output_dir) { + match post.download(&config.output_dir, &tx) { Ok(()) => tx.send(Event::DownloadPostFinished(post.name.clone())).ok(), Err(e) => tx.send(Event::DownloadPostFailed(post.name.clone(), e.to_string())).ok(), }; @@ -55,7 +55,7 @@ fn url_to_channel(url: &str) -> Result<String, Error> { Ok(channel.to_string()) } -fn get_posts(channel: &str) -> Result<Vec<Post>, Error> { +fn get_posts(channel: &str, tx: &std::sync::mpsc::Sender<Event>) -> Result<Vec<Post>, Error> { let mut page = 1usize; let mut posts = Vec::new(); @@ -70,11 +70,17 @@ fn get_posts(channel: &str) -> Result<Vec<Post>, Error> { } }); - let response: serde_json::Value = ureq::post("https://api.na-backend.odysee.com/api/v1/proxy") - .header("Content-Type", "application/json") - .send(serde_json::to_vec(&body)?)? - .body_mut() - .read_json()?; + let body_bytes = serde_json::to_vec(&body)?; + let response: serde_json::Value = post::with_retry( + || { + ureq::post("https://api.na-backend.odysee.com/api/v1/proxy") + .header("Content-Type", "application/json") + .send(body_bytes.clone()) + }, + || { tx.send(Event::RateLimited("API".to_string())).ok(); }, + )? + .body_mut() + .read_json()?; let items = response["result"]["items"] .as_array() diff --git a/src/lib/post.rs b/src/lib/post.rs @@ -1,4 +1,22 @@ use crate::error::Error; +use crate::event::Event; + +pub(crate) fn with_retry<F, T, C>(f: F, on_rate_limited: C) -> Result<T, Error> +where + F: Fn() -> Result<T, ureq::Error>, + C: Fn(), +{ + loop { + match f() { + Ok(val) => return Ok(val), + Err(ureq::Error::StatusCode(429)) => { + on_rate_limited(); + std::thread::sleep(std::time::Duration::from_secs(60)); + } + Err(e) => return Err(e.into()), + } + } +} pub struct Post { pub name: String, @@ -31,28 +49,38 @@ impl Post { Ok(Post { name, filename, streaming_url }) } - pub fn content_length(&self) -> Option<u64> { - ureq::head(&self.streaming_url) - .header("Referer", "https://odysee.com/") - .header("Origin", "https://odysee.com") - .call() - .ok() - .and_then(|r| { - r.headers() - .get("content-length")? - .to_str() - .ok()? - .parse() - .ok() - }) + pub fn content_length(&self, tx: &std::sync::mpsc::Sender<Event>) -> Option<u64> { + with_retry( + || { + ureq::head(&self.streaming_url) + .header("Referer", "https://odysee.com/") + .header("Origin", "https://odysee.com") + .call() + }, + || { tx.send(Event::RateLimited(self.name.clone())).ok(); }, + ) + .ok() + .and_then(|r| { + r.headers() + .get("content-length")? + .to_str() + .ok()? + .parse() + .ok() + }) } - pub fn download(&self, dir: &std::path::Path) -> Result<(), Error> { + pub fn download(&self, dir: &std::path::Path, tx: &std::sync::mpsc::Sender<Event>) -> Result<(), Error> { let path = dir.join(&self.filename); - let mut response = ureq::get(&self.streaming_url) - .header("Referer", "https://odysee.com/") - .header("Origin", "https://odysee.com") - .call()?; + let mut response = with_retry( + || { + ureq::get(&self.streaming_url) + .header("Referer", "https://odysee.com/") + .header("Origin", "https://odysee.com") + .call() + }, + || { tx.send(Event::RateLimited(self.name.clone())).ok(); }, + )?; let mut file = std::fs::File::create(&path)?; let mut reader = response.body_mut().as_reader(); std::io::copy(&mut reader, &mut file)?;