Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Run `dl --help` for a full list of commands and flags.
> **Note:** You must provide only one of the following: `-f`, `-H`, `-m`, or direct URLs.

* `-c <concurrency>`: (Optional) Number of concurrent downloads. Defaults to `3`.
* `-l <SPEED>`: (Optional) Limit total download speed across all concurrent downloads (e.g. `500K`, `5M`, `10MB/s`).
* `-f <path_to_urls_file>`: Download from a text file of URLs.
* `-H <repo_id>`: Download from a Hugging Face repo (`owner/repo_name` or full URL).
* `-m <model_alias>`: Download a pre-defined model by alias.
Expand Down
7 changes: 7 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ It also includes utilities for searching Hugging Face models and self-updating."
Download from a list in a file with concurrency 5:
dl -f urls.txt -c 5

Download with a total speed limit of 5 MB/s:
dl -f urls.txt -c 3 -l 5M

Download (and select files) from a Hugging Face repo using token:
dl -H TheBloke/Llama-2-7B-GGUF -s --token

Expand Down Expand Up @@ -61,6 +64,10 @@ pub struct Cli {
/// Enable debug logging to log.log.
#[arg(long)]
pub debug: bool,

/// Limit total download speed (e.g. 500K, 5M, 10MB/s).
#[arg(short = 'l', long, value_name = "SPEED")]
pub rate_limit: Option<String>,
}

#[derive(Subcommand, Debug)]
Expand Down
22 changes: 19 additions & 3 deletions src/downloader.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use crate::{
config::GGUF_SERIES_REGEX,
hf::HFFile,
util::{format_bytes, format_duration_human, generate_actual_filename, get_client, shorten_error},
util::{
format_bytes, format_duration_human, generate_actual_filename, get_client, shorten_error,
RateLimiter,
},
};
use anyhow::{anyhow, Context, Result};
use futures_util::stream::StreamExt;
Expand All @@ -17,6 +20,7 @@ use std::sync::{
};

use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex as AsyncMutex;

// A dedicated, higher concurrency level for fetching metadata.
// This is much faster than the default download concurrency of 3.
Expand All @@ -35,21 +39,29 @@ struct DownloadTask {
overall_progress_bar: ProgressBar,
multi_progress: Arc<MultiProgress>,
client: reqwest::Client,
rate_limiter: Option<Arc<AsyncMutex<RateLimiter>>>,
}

pub async fn run_downloads(
items: Vec<DownloadItem>,
base_dir: PathBuf,
concurrency: usize,
hf_token: String,
rate_limit: Option<u64>,
) -> Result<()> {
let rate_limit_msg = rate_limit
.map(|r| format!(", rate limit {}/s", format_bytes(r)))
.unwrap_or_default();
eprintln!(
"[INFO] Preparing to download {} file(s) to '{}' with concurrency {}.",
"[INFO] Preparing to download {} file(s) to '{}' with concurrency {}{}.",
items.len(),
base_dir.display(),
concurrency
concurrency,
rate_limit_msg
);

let rate_limiter = rate_limit.map(|r| Arc::new(AsyncMutex::new(RateLimiter::new(r))));

let multi_progress = Arc::new(MultiProgress::new());

// --- Pre-scan for file sizes ---
Expand Down Expand Up @@ -149,6 +161,7 @@ pub async fn run_downloads(
overall_progress_bar: overall_pb.clone(),
multi_progress: multi_progress.clone(),
client: download_client.clone(),
rate_limiter: rate_limiter.clone(),
});
}

Expand Down Expand Up @@ -247,6 +260,9 @@ async fn download_file(task: DownloadTask) -> Result<()> {
let chunk = chunk_result.context("Failed to read chunk from download stream")?;
file.write_all(&chunk).await.context("Failed to write chunk to file")?;
let chunk_len = chunk.len() as u64;
if let Some(limiter) = &task.rate_limiter {
limiter.lock().await.acquire(chunk_len).await;
}
pb.inc(chunk_len);
overall_pb.inc(chunk_len);
}
Expand Down
14 changes: 13 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,19 @@ async fn run_downloader_flow(cli: Cli, hf_token: &str) -> Result<()> {
tokio::fs::create_dir_all(&download_dir).await?;
}

run_downloads(download_items, download_dir, cli.concurrency, hf_token.to_string()).await?;
let rate_limit = match cli.rate_limit {
Some(s) => Some(util::parse_speed_limit(&s)?),
None => None,
};

run_downloads(
download_items,
download_dir,
cli.concurrency,
hf_token.to_string(),
rate_limit,
)
.await?;

Ok(())
}
84 changes: 82 additions & 2 deletions src/util.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use anyhow::Result;
use anyhow::{anyhow, Result};
use path_clean::PathClean;
use std::backtrace::Backtrace;
use std::panic::PanicHookInfo;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

/// Formats a size in bytes into a human-readable string (KB, MB, GB, etc. - base 10).
pub fn format_bytes(bytes: u64) -> String {
Expand Down Expand Up @@ -183,6 +183,86 @@ pub fn log_panic(info: &PanicHookInfo<'_>) {
}


/// Parses a human-readable speed limit string into bytes per second.
/// Supports suffixes K, KB, M, MB (base-10), optional `/s`, or raw bytes/sec.
pub fn parse_speed_limit(s: &str) -> Result<u64> {
let mut s = s.trim().to_uppercase();
if s.is_empty() {
return Err(anyhow!("Speed limit cannot be empty"));
}
if s.ends_with("/S") {
s.truncate(s.len() - 2);
}

let (num_str, multiplier) = if s.ends_with("MB") {
(&s[..s.len() - 2], 1_000_000u64)
} else if s.ends_with("KB") {
(&s[..s.len() - 2], 1_000u64)
} else if s.ends_with('M') {
(&s[..s.len() - 1], 1_000_000u64)
} else if s.ends_with('K') {
(&s[..s.len() - 1], 1_000u64)
} else if s.ends_with('B') {
(&s[..s.len() - 1], 1u64)
} else {
(s.as_str(), 1u64)
};

let value: f64 = num_str
.trim()
.parse()
.map_err(|_| anyhow!("Invalid speed limit: '{}'", s))?;
if value <= 0.0 {
return Err(anyhow!("Speed limit must be greater than zero"));
}

let bytes = (value * multiplier as f64) as u64;
if bytes == 0 {
return Err(anyhow!("Speed limit must be greater than zero"));
}
Ok(bytes)
}

/// Token-bucket rate limiter for async download throttling.
pub struct RateLimiter {
rate: u64,
tokens: f64,
last_update: Instant,
}

impl RateLimiter {
pub fn new(rate: u64) -> Self {
Self {
rate,
tokens: rate as f64,
last_update: Instant::now(),
}
}

pub async fn acquire(&mut self, bytes: u64) {
if self.rate == 0 || bytes == 0 {
return;
}

loop {
let now = Instant::now();
let elapsed = now.duration_since(self.last_update).as_secs_f64();
self.tokens = (self.tokens + elapsed * self.rate as f64)
.min(self.rate as f64 * 2.0);
self.last_update = now;

if self.tokens >= bytes as f64 {
self.tokens -= bytes as f64;
return;
}

let deficit = bytes as f64 - self.tokens;
let wait_secs = deficit / self.rate as f64;
tokio::time::sleep(Duration::from_secs_f64(wait_secs)).await;
}
}
}

/// Creates a reqwest client with a default user agent and optional auth token.
pub fn get_client(hf_token: &str) -> Result<reqwest::Client> {
let mut headers = reqwest::header::HeaderMap::new();
Expand Down
Loading