odysee-dl

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

commit 2d9199a9126dec563a5744a93c9c84059fe5d6b1
Author: egor-achkasov <eaachkasov@gmail.com>
Date:   Fri,  8 May 2026 19:41:51 +0000

Init commit

Diffstat:
A.github/workflows/build.yml | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.gitignore | 1+
ACargo.toml | 19+++++++++++++++++++
AREADME.md | 21+++++++++++++++++++++
Asrc/bin/cli/main.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/config.rs | 5+++++
Asrc/lib/error.rs | 35+++++++++++++++++++++++++++++++++++
Asrc/lib/event.rs | 17+++++++++++++++++
Asrc/lib/lib.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/post.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 463 insertions(+), 0 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml @@ -0,0 +1,110 @@ +name: Build Windows Executables + +on: + push: + +jobs: + check: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Cargo check + run: cargo check --all-targets + + build: + needs: check + if: github.ref == 'refs/heads/master' + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build CLI + run: cargo build --release --bin odysee-dl-cli + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: odysee-dl-cli + path: target/release/odysee-dl-cli.exe + + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download CLI artifact + uses: actions/download-artifact@v4 + with: + name: odysee-dl-cli + path: artifacts/ + + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git tag --sort=-version:refname | head -n 1) + if [ -z "$PREV_TAG" ]; then + CHANGELOG=$(git log --pretty=format:"- %s (%h)" | head -20) + else + CHANGELOG=$(git log "$PREV_TAG"..HEAD --pretty=format:"- %s (%h)") + fi + echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create release tag + id: tag + run: | + TAG="release-$(date +'%Y%m%d%H%M%S')" + git tag "$TAG" + git push origin "$TAG" + echo "TAG=$TAG" >> $GITHUB_OUTPUT + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.TAG }} + name: Release ${{ steps.tag.outputs.TAG }} + body: | + ## Changes + ${{ steps.changelog.outputs.CHANGELOG }} + files: | + artifacts/odysee-dl-cli.exe +\ No newline at end of file diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "odysee-dl" +version = "0.1.0" +edition = "2024" + +[profile.release] +lto = true +strip = "symbols" + +[lib] +path = "src/lib/lib.rs" + +[[bin]] +name = "odysee-dl-cli" +path = "src/bin/cli/main.rs" + +[dependencies] +serde_json = "1.0.149" +ureq = { version = "3.3.0", features = ["json"] } diff --git a/README.md b/README.md @@ -0,0 +1,21 @@ +# Odysee.com channel content downloader + +Downloads all content from a channel given an odysee.com url. + +## Usage + +``` +Usage: odysee-dl-cli [OPTIONS] <URL> + +Download all content from an Odysee channel. <URL> should be the URL of the channel, +e.g. https://odysee.com/@channel:1 + +Options: + -h, --help Print help information + -d, --dir <DIR> Output directory (default: .) + -r, --resume Resume an interrupted download +``` + +# Build + +Find current Windows build in Releases or build from source with `cargo build --release`. diff --git a/src/bin/cli/main.rs b/src/bin/cli/main.rs @@ -0,0 +1,113 @@ +use odysee_dl::config::Config; +use odysee_dl::event::Event; +use odysee_dl::run; + +static HELP: &str = "Usage: odysee-dl-cli [OPTIONS] <URL> + +Download all content from an Odysee channel. <URL> should be the URL of the channel, +e.g. https://odysee.com/@channel:1 + +Options: + -h, --help Print help information + -d, --dir <DIR> Output directory (default: .) + -r, --resume Resume an interrupted download +"; + +fn main() { + let config = parse_args().unwrap_or_else(|e| handle_error(e)); + let (tx, rx) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + run(config, tx).unwrap_or_else(|e| handle_error(e)); + }); + + for event in rx { + render_event(&event); + } + + handle.join().map_err(|e| { + eprintln!("ERROR: {:?}", e); + std::process::exit(1); + }).ok(); +} + +enum ParseArgsErr { + MissingUrl, + MissingDirValue, + UnknownOption(String), +} + +impl std::fmt::Display for ParseArgsErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseArgsErr::MissingUrl => write!(f, "Missing URL"), + ParseArgsErr::MissingDirValue => write!(f, "Missing value for --dir"), + ParseArgsErr::UnknownOption(option) => write!(f, "Unknown option: {}", option), + } + } +} + +fn handle_error(e: impl std::fmt::Display) -> ! { + eprintln!("Error: {}", e); + std::process::exit(1); +} + +fn parse_args() -> Result<Config, ParseArgsErr> { + let args: Vec<String> = std::env::args().collect(); + if args.len() < 2 { + return Err(ParseArgsErr::MissingUrl); + } + + let mut output_dir = std::path::PathBuf::from("."); + let mut resume = false; + let mut i = 1; + + while i < args.len() - 1 { + match args[i].as_str() { + "-h" | "--help" => { + println!("{}", HELP); + std::process::exit(0); + } + "-d" | "--dir" => { + i += 1; + if i >= args.len() - 1 { + return Err(ParseArgsErr::MissingDirValue); + } + output_dir = std::path::PathBuf::from(&args[i]); + } + "-r" | "--resume" => { + resume = true; + } + _ => return Err(ParseArgsErr::UnknownOption(args[i].clone())), + } + i += 1; + } + + let url = args.last().unwrap().to_string(); + Ok(Config { url, output_dir, resume }) +} + +fn render_event(event: &Event) { + use std::io::Write; + match event { + Event::GetChannelStarted(url) => { + print!("Fetching channel: {}...", url); + std::io::stdout().flush().ok(); + } + Event::GetChannelFailed(url, err) => eprintln!("\nFailed to fetch channel {}: {}", url, err), + Event::GetChannelFinished(_) => println!(" Done"), + Event::GetPostsStarted(url) => { + print!("Fetching posts from: {}...", url); + std::io::stdout().flush().ok(); + } + Event::GetPostsFailed(url, err) => eprintln!("\nFailed to fetch posts from {}: {}", url, err), + Event::GetPostsFinished(_) => println!(" Done"), + Event::DownloadPostStarted(name) => { + print!("Downloading: {}...", name); + std::io::stdout().flush().ok(); + } + Event::DownloadPostFailed(_, err) => println!(" Failed: {}", err), + Event::DownloadPostSkipped(_) => println!(" Skipped"), + Event::DownloadPostFinished(_) => println!(" Done"), + Event::Done => println!("Done."), + } +} diff --git a/src/lib/config.rs b/src/lib/config.rs @@ -0,0 +1,5 @@ +pub struct Config { + pub url: String, + pub output_dir: std::path::PathBuf, + pub resume: bool, +} diff --git a/src/lib/error.rs b/src/lib/error.rs @@ -0,0 +1,35 @@ +use core::fmt; + +pub enum Error { + Http(String), + InvalidUrl, + Io(std::io::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Http(err) => write!(f, "HTTP error: {}", err), + Error::InvalidUrl => write!(f, "Invalid URL (expected https?://odysee\\.com/@[^:]+:\\d+.*)"), + Error::Io(err) => write!(f, "IO error: {}", err), + } + } +} + +impl From<ureq::Error> for Error { + fn from(err: ureq::Error) -> Self { + Error::Http(err.to_string()) + } +} + +impl From<serde_json::Error> for Error { + fn from(err: serde_json::Error) -> Self { + Error::Http(err.to_string()) + } +} + +impl From<std::io::Error> for Error { + fn from(err: std::io::Error) -> Self { + Error::Io(err) + } +} diff --git a/src/lib/event.rs b/src/lib/event.rs @@ -0,0 +1,17 @@ +/// Events for mpsc +pub enum Event { + GetChannelStarted(String), // channel url + GetChannelFailed(String, String), // channel url, error message + GetChannelFinished(String), // channel url + + GetPostsStarted(String), // channel url + GetPostsFailed(String, String), // channel url, error message + GetPostsFinished(String), // channel url + + DownloadPostStarted(String), // post name + DownloadPostFailed(String, String), // post name, error message + DownloadPostSkipped(String), // post name (already exists, skipping) + DownloadPostFinished(String), // post name + + Done, +} diff --git a/src/lib/lib.rs b/src/lib/lib.rs @@ -0,0 +1,96 @@ +pub mod config; +pub mod error; +pub mod post; +pub mod event; + +use error::Error; +use post::Post; +use event::Event; + +pub fn run(config: config::Config, tx: std::sync::mpsc::Sender<Event>) -> Result<(), Error> { + let channel = url_to_channel(&config.url)?; + + tx.send(Event::GetPostsStarted(config.url.clone())).ok(); + let posts = match get_posts(&channel) { + Ok(posts) => { + tx.send(Event::GetPostsFinished(config.url.clone())).ok(); + posts + } + Err(e) => { + tx.send(Event::GetPostsFailed(config.url.clone(), e.to_string())).ok(); + return Err(e); + } + }; + + std::fs::create_dir_all(&config.output_dir)?; + for post in posts { + tx.send(Event::DownloadPostStarted(post.name.clone())).ok(); + if config.resume && config.output_dir.join(&post.filename).exists() { + tx.send(Event::DownloadPostSkipped(post.name.clone())).ok(); + continue; + } + match post.download(&config.output_dir) { + Ok(()) => tx.send(Event::DownloadPostFinished(post.name.clone())).ok(), + Err(e) => tx.send(Event::DownloadPostFailed(post.name.clone(), e.to_string())).ok(), + }; + } + + tx.send(Event::Done).ok(); + Ok(()) +} + +/// Extract the channel URL part from an Odysee URL, e.g. "https://odysee.com/@channel:1" -> "@channel:1" +fn url_to_channel(url: &str) -> Result<String, Error> { + let rest = url + .split("odysee.com/") + .nth(1) + .ok_or(Error::InvalidUrl)?; + let channel = rest.split('?').next().unwrap_or(rest); + Ok(channel.to_string()) +} + +fn get_posts(channel: &str) -> Result<Vec<Post>, Error> { + let mut page = 1usize; + let mut posts = Vec::new(); + + loop { + let body = serde_json::json!({ + "method": "claim_search", + "params": { + "channel": channel, + "page": page, + "page_size": 50, + "claim_type": ["stream"], + } + }); + + 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 items = response["result"]["items"] + .as_array() + .ok_or_else(|| Error::Http("items not found".to_string()))?; + + if items.is_empty() { + break; + } + + let page_posts: Vec<Post> = items + .iter() + .map(Post::from_json) + .collect::<Result<Vec<Post>, Error>>()?; + + let total_pages = response["result"]["total_pages"].as_u64().unwrap_or(1) as usize; + posts.extend(page_posts); + + if page >= total_pages { + break; + } + page += 1; + } + + Ok(posts) +} diff --git a/src/lib/post.rs b/src/lib/post.rs @@ -0,0 +1,45 @@ +use crate::error::Error; + +pub struct Post { + pub name: String, + pub filename: String, + pub streaming_url: String, +} + +impl Post { + pub fn from_json(json: &serde_json::Value) -> Result<Self, Error> { + let name = json["name"] + .as_str() + .ok_or_else(|| Error::Http("name not found".to_string()))? + .to_string(); + let claim_id = json["claim_id"] + .as_str() + .ok_or_else(|| Error::Http("claim_id not found".to_string()))?; + let sd_hash = json["value"]["source"]["sd_hash"] + .as_str() + .ok_or_else(|| Error::Http("sd_hash not found".to_string()))?; + // URL format: confirmed by inspecting the LBRY `get` API response. + // CDN only checks Referer: https://odysee.com/ — no auth token required. + let streaming_url = format!( + "https://player.odycdn.com/v6/streams/{}/{}.mp4", + claim_id, &sd_hash[..6] + ); + let filename = json["value"]["source"]["name"] + .as_str() + .unwrap_or(&name) + .to_string(); + Ok(Post { name, filename, streaming_url }) + } + + pub fn download(&self, dir: &std::path::Path) -> 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 file = std::fs::File::create(&path)?; + let mut reader = response.body_mut().as_reader(); + std::io::copy(&mut reader, &mut file)?; + Ok(()) + } +}