Skip to content

Repository files navigation

ODL

Crates.io Docs.rs

Flexible download library and CLI intended to be fast, reliable, and easy to use.

Quick Start

Install

Linux / macOS (installs to ~/.local/bin by default):

curl -fsSL https://raw.githubusercontent.com/jd1378/odl/main/tools/install.sh | sh

Custom directory:

curl -fsSL https://raw.githubusercontent.com/jd1378/odl/main/tools/install.sh | sh -s -- --dir /usr/local/bin

Windows (PowerShell, installs to %LOCALAPPDATA%\Programs\odl and adds it to user PATH):

irm https://raw.githubusercontent.com/jd1378/odl/main/tools/install.ps1 | iex

From source (any platform with Rust toolchain):

cargo install odl

Update

odl update --check   # is there a newer release?
odl update           # replace this binary with it (asks first; -y to skip)

Only an odl that the install script put in place is replaced. A copy from cargo install, Homebrew, Nix or a distribution package is left to the command that owns it, and odl says which one that is. The release archive is verified against the SHA-256 published beside it before anything is overwritten — an update that cannot be verified is refused rather than completed with a warning.

Uninstall

Linux / macOS:

curl -fsSL https://raw.githubusercontent.com/jd1378/odl/main/tools/uninstall.sh | sh
# also drop user config
curl -fsSL https://raw.githubusercontent.com/jd1378/odl/main/tools/uninstall.sh | sh -s -- --purge

Windows (PowerShell):

irm https://raw.githubusercontent.com/jd1378/odl/main/tools/uninstall.ps1 | iex

Use

odl https://example.com/file.zip

Features

Feature Description
⚡ Multi-part downloads Configurable parallel connections for faster downloads
🔄 Automatic resume support Seamlessly continue interrupted downloads (if server supports range requests)
📝 Conflict resolution Handles file changes and existing files intelligently (configurable)
🛡️ Crash resilient Minimizes data loss during unexpected interruptions
🌐 Custom HTTP headers & proxy support Flexible networking options for advanced use cases
🔁 Retry logic Automatic retries with configurable backoff on failures
🕒 Preserve modification times (optional) Optionally keeps server file modification timestamps
🏷️ Server-sent file names Uses server-provided file names when available; otherwise falls back to the URL's last segment
🎬 Media sites via yt-dlp Delegates known media hosts to an installed yt-dlp, with quality selection and resumable downloads

This project provides both a command-line program (odl) and a Rust library (odl crate). Use the CLI for quick downloads and scripting; use the library when you need programmatic control inside an application.

CLI Usage

  • Download a single remote file (URL)
# Download a single URL and use the server-provided filename
odl https://example.com/file.zip

# Specify output file path
odl https://example.com/file.zip -o /path/to/save/file.zip
  • Download from a remote list (URL pointing to a newline-separated list of URLs)
# Treat the input as a remote list of URLs and save downloaded files into a directory
odl --remote-list https://example.com/list.txt -o /downloads
  • Download from a local file containing URLs
# Input file contains one URL per line; output is a directory
odl /path/to/urls.txt -o /downloads
  • Temporary (one-off) configuration via CLI flags
# Limit max connections for this single run
odl --max-connections 4 https://example.com/file.zip

# Temporary speed limit (per run). Accepts either a raw byte count or a human‑readable value with units; input is case‑insensitive.
# All different representations work the same: KiB, K, KB
odl --speed-limit 100K https://example.com/file.zip
  • Persistent configuration (save changes to config file)

The CLI provides a config subcommand that updates the persistent configuration (default config path is odl/config.toml inside the user's appdata directory). Changes made with odl config are saved and used by subsequent runs.

# Show current configuration and its location
odl config --show

# Set persistent max connections
odl config --max-connections 8

# Use a specific config file and change a value there
odl config --config-file ~/.config/odl/config.toml --max-connections 6

# Then you can use it for a new download:
odl --config-file ~/.config/odl/config.toml https://example.com/file.zip

Note: Flags passed directly to odl (for example --max-connections, --speed-limit, --user-agent, etc.) apply only to that invocation and override persistent configuration for that run.

Media sites (yt-dlp)

Links to major media hosts — YouTube, Vimeo, Twitch, Bilibili, X/Twitter, SoundCloud and a handful of others — are handed to yt-dlp when it is installed. Everything else downloads over odl's own multipart HTTP engine exactly as before.

yt-dlp is not bundled. It is looked up on PATH at runtime, and if it is missing the link is simply downloaded over HTTP instead. Installing it is enough to enable this; nothing needs to be configured.

If you paste a media link and the helpers are missing, odl offers to fetch them for you — asking about each separately, saying where they come from, and pointing out that installing them yourself works just as well. You can also do it explicitly:

odl tools status          # what is installed, and where
odl tools install         # offers yt-dlp, then ffmpeg
odl tools install ffmpeg -y

Downloads come from the official yt-dlp releases and the ffmpeg builds the yt-dlp project maintains, land in odl's data directory, and are recorded in your config. odl fetches them with its own downloader — so an install survives a dropped connection and picks up where it left off — and verifies each against the SHA-256 published alongside it. An install that cannot be verified is refused rather than completed with a warning. Note this protects against a corrupted or tampered transfer, not against a compromised upstream repository — the checksums come from the same release as the files.

Versions are never pinned: extractors break as sites change, so odl always takes the current release. On macOS, ffmpeg is not offered automatically — there is no build with a checksum odl can vouch for — so use brew install ffmpeg.

# Delegated automatically: odl asks which quality you want, then downloads
odl "https://www.youtube.com/watch?v=…"

# Take the best available without asking (the default when not on a terminal)
odl --choose-format never "https://www.youtube.com/watch?v=…"

# Change your mind about quality: both discard what was downloaded and restart,
# because two encodings cannot be joined into one file
odl --choose-format always --on-file-changed restart "https://www.youtube.com/watch?v=…"
odl --format-id 137+251     --on-file-changed restart "https://www.youtube.com/watch?v=…"

# Force an engine instead of letting the host decide
odl --engine http  https://example.com/file.zip
odl --engine ytdlp "https://some-other-site.example/video/1"

The menu shows the container each choice produces (mp4, mkv, m4a, …) and, when the site publishes them, transcript tracks — subtitles download as a .srt/.vtt file instead of the media. Author-supplied tracks are listed before machine-generated captions, which are capped at a few languages to keep the menu readable; any other language is reachable with --format-id subs:<lang> or --format-id autosubs:<lang>.

Install ffmpeg alongside it for the best results: without a muxer only formats that come as a single file can be downloaded, which caps quality on sites that serve video and audio separately. The quality menu still lists the qualities you are missing, marked — needs ffmpeg, rather than quietly stopping short and looking like all the site offers.

What differs from a plain HTTP download

The transfer belongs to yt-dlp, so some things odl normally reports do not exist:

  • No multi-part downloading — max_connections only maps to fragment parallelism, and only for fragmented formats.
  • No server checksums, ETag, or last-modified; odl status and probe omit those fields rather than showing them empty.
  • Sizes are estimates for adaptive formats until the download finishes, shown with a leading ~.

max_retries and wait_between_retries do apply, split by which layer can act on the failure most cheaply:

  • Transfer errors stay with yt-dlp (--retries, --fragment-retries, set from your configured number). A retry there re-uses the media URL it already holds — no second process, no extra call on the site's metadata API.
  • Extraction errors are odl's. yt-dlp is told not to retry them, so each failure is counted against your policy, reported as progress, and can be interrupted — none of which is true of a retry hidden inside the process.
  • A finished-but-failed run is restarted once. That costs a fresh extraction (a few seconds), and earns it only for what an internal retry cannot fix: a media URL that expired mid-download, or the tool dying outright. Scaling it with max_retries would multiply against the retries yt-dlp is already doing.

Settled failures — an unsupported URL, a format the site no longer offers, a rate-limited refusal — are not retried at all, since the answer would not change. --max-retries 0 means exactly one attempt everywhere.

Resuming works: the page URL is stored rather than the (short-lived) media URL, so a resume re-resolves it, and the chosen format is pinned so a partial file is never continued in a different encoding. An ordinary re-run therefore keeps the original quality; changing quality is an explicit act that starts the download over.

Playlists are not supported yet — a playlist URL is refused with a clear message rather than partially handled.

Configuration

[ytdlp]
enabled = true              # master switch
binary_path = "/usr/bin/yt-dlp"   # default: found on PATH
ffmpeg_path = "/usr/bin/ffmpeg"   # default: found on PATH
format = "bv*+ba/b"         # default: chosen from ffmpeg availability
extra_hosts = ["some.video.site"] # delegate these too
excluded_hosts = ["reddit.com"]   # never delegate these
extra_args = ["--retries", "5"]   # appended to every yt-dlp invocation
cookies_from_browser = "firefox"  # off by default; reads your cookie store
offer_ytdlp_install = true        # set to false by declining the offer
offer_ffmpeg_install = true       # ditto; `odl tools install` ignores both

Declining an install offer is remembered — odl will not ask again for that tool. Running odl tools install <tool> explicitly overrides the decline.

extra_args and cookies_from_browser are powerful enough to run arbitrary commands or expose browser credentials, so they are settable from the config file only — never from the command line.

Machine interface (--format json)

For scripts and AI agents, pass --format json to get machine-readable output instead of human progress bars. This is a documented, stable contract — the full specification is printed by odl --help.

  • Downloads stream newline-delimited JSON (NDJSON) on stdout, one object per line, each tagged with type and url: phase, filename, progress, message, retry_scheduled, completed, failed, cancelled.
  • retry_scheduled says a transfer is paused and when it resumes, so a pause is distinguishable from a hang: {"type":"retry_scheduled","part":"<ulid|null>","attempt":1, "max_attempts":3,"delay_ms":5000,"server_requested":false}. server_requested is true when the delay is the server's own Retry-After rather than odl's backoff — that one is not worth shortening, and odl honours it up to five minutes.
  • probe, status/list, config --show emit a single JSON document on stdout.
  • Errors print one JSON object on stderr: {"type":"error","kind":...,"message":...,"exit_code":N}.

Exit codes: 0 success, 2 usage/bad input, 3 network, 4 conflict, 5 I/O, 6 metadata, 7 yt-dlp, 130 cancelled, 1 other.

# Probe a URL without downloading (size, filename, resumability)
odl --format json probe https://example.com/file.zip

# List tracked downloads
odl --format json status

Agent skill

This repo ships an Agent Skill (the open SKILL.md standard) that teaches SKILL.md-compatible AI agents to drive odl correctly.

One-liner (no checkout needed; downloads the skill from this repo). Run in a terminal it prompts for the agent and scope; pass them as arguments to skip the prompts. When non-interactive (CI, no terminal) it defaults to Claude Code, global.

# Interactive: asks which agent (claude/codex/…) and global vs. project
curl -fsSL https://raw.githubusercontent.com/jd1378/odl/main/tools/install-skill.sh | sh

# Non-interactive: name agent/scope after `--` to skip the prompts
curl -fsSL https://raw.githubusercontent.com/jd1378/odl/main/tools/install-skill.sh | sh -s -- codex --project
curl -fsSL https://raw.githubusercontent.com/jd1378/odl/main/tools/install-skill.sh | sh -s -- cursor --dir ~/.cursor/skills

For other agents, scopes, --dir, or agents-md output, see tools/install-skill.sh --help (or pass --help after -- in the one-liner).

Once installed, the agent activates the skill automatically when you ask it to download, fetch, resume, or probe something — no extra command. It then drives odl in --format json mode and reads the results.

Claude Code plugin (marketplace): this repo is also a plugin marketplace, so Claude Code users can install and auto-update the skill with:

/plugin marketplace add jd1378/odl
/plugin install odl@jd1378

The canonical skill lives at plugins/odl/skills/odl/; .claude/skills/odl is a symlink to it for in-repo dogfooding.

Checksums

The hashing used to verify downloads is part of the public API, so a program that already depends on odl need not pull in a second crate to check a file:

use odl::hash::{HashAlgorithm, HashDigest, HashEncoding};

// Hash a file, reading it in chunks rather than loading it.
let digest = HashDigest::from_path("big.iso", HashAlgorithm::SHA256, HashEncoding::Hex).await?;
println!("{}", digest.digest());

// Or check one against a value you were given, in whatever form it came.
let expected = HashDigest::parse_cli("sha256:b94d27b9…")?;   // also `sha256:base64:uU0nuZ…`
if !HashDigest::verify_file("big.iso", &expected).await? {
    eprintln!("that is not the file it claims to be");
}

A download verifies itself against whatever the server advertised. A caller that would rather do that on its own schedule — after the download returns, off the critical path, with its own progress — sets verify_checksums = false (or passes --no-verify-checksums). The checksums are still recorded and still readable through Download::checksums; odl simply stops hashing the file to act on them, leaving that to verify_file whenever it suits. Use clear_checksums instead to discard them outright. The final file's size is checked either way, since that costs one stat and catches a truncated download.

ASCII filenames

ascii_filenames = true (or --ascii-filenames) transliterates a filename to ASCII before it is sanitised: Café Münster is saved as Cafe Munster, Приветствие as Privetstvie, and 中文标题 as Zhong Wen Biao Ti. Every script is covered, not just accented Latin.

It is off by default, and worth understanding before turning it on. It is lossy — Café and Cafe collapse to one name, as do titles that differ only in something the transliteration flattens. It is also the name of the per-download directory, so switching it renames that directory and a download already in progress under the other setting starts over rather than resuming.

What it buys is a name that is byte-identical everywhere: no locale, no filesystem normalisation, and no terminal that cannot render it.

verify_file hashes with the expectation's own algorithm, so you only need the value you were handed. Compare digests with matches rather than ==: the same hash written as hex and as base64 is equal under the former and not the latter, and servers and config files disagree about encoding often enough for that to matter.

Library Usage

use odl::config::Config;
use odl::download_manager::{DownloadManager, DownloadRequest, EvaluateRequest};
use reqwest::Url;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
  // Create manager with default config
  let cfg = Config::default();
  let manager = DownloadManager::new(cfg);

  // Implement or reuse a SaveConflictResolver and ServerConflictResolver
  // (omitted for brevity). Then evaluate and download:
  // let instruction = manager
  //     .evaluate(EvaluateRequest::new(url, save_dir, &save_resolver))
  //     .await?;
  // let path = manager
  //     .download(DownloadRequest::new(instruction, &server_resolver))
  //     .await?;
  //
  // Per-job override (one download with different settings):
  // let opts = odl::config::DownloadOptionsBuilder::default()
  //     .max_connections(8)
  //     .speed_limit(Some(1_000_000))
  //     .build()?;
  // let instruction = manager
  //     .evaluate(EvaluateRequest::new(url, save_dir, &save_resolver).options(&opts))
  //     .await?;
  //
  // Fields are private; read via getters:
  // println!("download dir: {}", manager.config().download_dir().display());
  Ok(())
}

Roadmap

  • Open source multi-platform desktop application based on ODL

Credits

Inspired by:

Contribution

Any contribution intentionally submitted for inclusion in the work by you, shall be licensed as MIT as in the LICENSE file, without any additional terms or conditions.

About

flexible download library and CLI intended to be fast, reliable, and easy to use

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages