commit 2d9199a9126dec563a5744a93c9c84059fe5d6b1
Author: egor-achkasov <eaachkasov@gmail.com>
Date: Fri, 8 May 2026 19:41:51 +0000
Init commit
Diffstat:
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(())
+ }
+}