# fast-down-api
[](https://github.com/fast-down/core/commits/main)
[](https://github.com/fast-down/core/actions)
[](https://codecov.io/gh/fast-down/core)
[](https://crates.io/crates/fast-down-api)
[](https://docs.rs/fast-down-api)
[](https://github.com/fast-down/core/blob/main/LICENSE)
A convenient, high-level wrapper around [`fast-down`](https://github.com/fast-down/fast-down)
that turns the pull/push engine into a few lines of async code: spawn a download,
drain progress events, resume after interruption, and cancel cooperatively.
- **Concurrent, resumable downloads** powered by the `fast-down` engine (work-stealing, range requests).
- **Two layers of entry points**: the fire-and-forget `download` / `resume` wrappers, and the lower-level `plan` / `plan_resume` pair that prefetches the remote and inspects the disk _without writing a single byte_ — so you can preview the outcome and decide before committing.
- **Event stream**: a single channel carries prefetch, disk allocation, per-worker progress, resume, rename, and lifecycle events. Every run ends with exactly one `Event::Terminated(TerminationReason)`.
- **Cooperative cancellation**: cancelling mid-flight preserves the `.part` / `.fd` files so you can resume later.
- **Configurable**: threads, chunk size, write method (`Mmap` / `Std`), proxies, headers, retries, disk pre-allocation, and more via `PartialConfig`.
## Quick start
```rust,no_run
use fast_down_api::{create_cancellation_token, create_channel, download, Event, PartialConfig};
use std::path::PathBuf;
use url::Url;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Channel for progress / lifecycle events, plus a cancellation token.
let (tx, rx) = create_channel();
let token = create_cancellation_token();
// 2. Configure the download. Every field is optional; unset fields
// fall back to Config::default(). (save_dir is required at runtime.)
let config = PartialConfig {
save_dir: Some(PathBuf::from("./downloads")),
overwrite: Some(true),
threads: Some(16),
..Default::default()
};
// 3. Start the download. This spawns a detached task and returns at once.
let url = Url::parse("https://example.com/large-file.bin")?;
download(url, config, tx, token.clone());
// 4. Drain events until the task finishes or is cancelled.
while let Ok(event) = rx.recv().await {
match event {
// Aggregated progress on a fixed cadence (Config::progress_emit_gap).
// Convenient for a progress bar — no need to re-accumulate ranges.
Event::Progress(sample) => {
// `downloaded`, `percent`, and `total` are pre-computed for you;
// the fields below are equivalent to deriving them from `progress`.
let written: u64 = sample.progress.iter().map(|r| r.end - r.start).sum();
assert_eq!(written, sample.downloaded);
let pct = if sample.total > 0 {
written * 100 / sample.total
} else {
0
};
// `eta` is the estimated remaining time, or `None` until a rate
// can be measured.
let eta_str = sample
.eta
.map_or_else(|| "?".to_string(), |d| format!("{d:?}"));
println!(
"progress: {pct}% ({:.1}%) {written}/{} bytes \
{} B/s (inst) {} B/s (avg) elapsed {:?} eta {eta_str}",
sample.percent, sample.total, sample.bps, sample.avg_bps, sample.elapsed
);
}
Event::PushProgress(p) => println!("wrote range: {p:?}"),
Event::Renamed(path) => {
println!("done -> {path:?}");
break;
}
Event::RenameFailed(e) => {
eprintln!("rename failed: {e}");
break;
}
Event::ResumeError(e) => eprintln!("resume error: {e}"),
Event::Allocating(size) => println!("pre-allocating {size} bytes on disk"),
Event::AllocError(e) => eprintln!("pre-allocation failed (continuing): {e}"),
Event::Terminated(reason) => {
println!("terminated: {reason:?}");
break;
}
_ => {}
}
}
// 5. Draining `rx` above already waits for completion: the loop ends when the
// task drops its last sender, so no separate join handle is needed.
Ok(())
}
```
### Resuming an interrupted download
`resume` targets the existing `.part` file. The `url` argument is optional: pass
`None` to re-use the initial URL already recorded in the `.fd` state file, so you
can resume purely from the `.part` path. If the download cannot be continued (no
`.fd` state, no range support, or the remote file changed) it emits
`Event::ResumeError` and **does not** silently restart — unlike `download`, which
auto-resumes when it can and otherwise falls back to a fresh download. With
`url = None` and no resolvable URL available it reports `StateError::NoUrl`.
```rust,no_run
use fast_down_api::{PartialConfig, create_cancellation_token, create_channel, resume};
use url::Url;
let url = Url::parse("https://example.com/large-file.bin")?;
let config = PartialConfig::default();
let (tx, _rx) = create_channel();
let token = create_cancellation_token();
resume(
"./downloads/large-file.bin.part", // the .part file from a previous run
Some(url), // pass None to reuse the URL stored in the .fd
config,
tx,
token,
);
# Ok::<(), Box<dyn std::error::Error>>(())
```
### Cancelling cooperatively
```rust,no_run
use fast_down_api::create_cancellation_token;
let token = create_cancellation_token();
token.cancel(); // stops fetching, keeps .part / .fd so you can resume later
```
### Two-phase planning (inspect before you commit)
`plan` and `plan_resume` do everything `download` / `resume` do _except_ touch
the disk: they prefetch the remote metadata, resolve the output path, and probe
the `.fd` / `.part` pair left by a previous run. The returned [`DownloadPlan`]
tells you what starting it would do — [`DownloadPlan::resume_outcome`] reports
`Resumable`, `Fresh`, or `Mismatch` — and nothing is created until you call one
of its `start` methods. Dropping the plan abandons the download with no side
effects.
```rust,no_run
use fast_down_api::{
PartialConfig, ResumeOutcome, create_cancellation_token, create_channel, plan,
};
use url::Url;
# #[tokio::main]
# async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = Url::parse("https://example.com/large-file.bin")?;
let config = PartialConfig::default();
let (tx, _rx) = create_channel();
let token = create_cancellation_token();
let plan = plan(url, config.clone(), tx.clone(), token.clone()).await?;
match plan.resume_outcome() {
ResumeOutcome::Resumable { .. } => println!("will continue from a previous run"),
ResumeOutcome::Fresh => println!("will download the whole file"),
ResumeOutcome::Mismatch(e) => println!("stale state: {e} (use start_forced_resume)"),
}
// Commit when you're ready. Each start method emits exactly one `Event::Terminated`.
plan.start().await; // resume if possible, else fresh (or refuse for plan_resume)
// plan.start_fresh().await; // ignore any saved progress and re-download
// plan.start_forced_resume().await; // continue from a mismatched state when only identity changed
# Ok(())
# }
```
`plan_resume` takes a `.part` path instead of a URL and hard-refuses a
`Mismatch` (sending `Event::ResumeError` + `TerminationReason::Failed`) rather
than restarting — because the caller asked to continue one specific file, not to
fetch it again. Pass a `url` to re-fetch the metadata, or `None` to reuse the URL
recorded in the `.fd`.
## API overview
| [`download`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.download.html) | Start a download; auto-resume when a valid `.fd` + `.part` exist, else fresh. Observe completion by draining the `Rx` from `create_channel` until it disconnects. |
| [`resume`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.resume.html) | Resume a specific `.part` file; hard-error (`Event::ResumeError`) if it can't. Completion is observed the same way, by draining `Rx`. |
| [`plan`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.plan.html) | Prefetch + probe the disk and return a [`DownloadPlan`](https://docs.rs/fast-down-api/latest/fast_down_api/struct.DownloadPlan.html) **without writing anything**; start it only when ready. |
| [`plan_resume`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.plan_resume.html) | Like `plan` but targets a specific `.part`; refuses a `Mismatch` instead of re-downloading. |
| [`DownloadPlan`](https://docs.rs/fast-down-api/latest/fast_down_api/struct.DownloadPlan.html) | A prepared, not-yet-started download. Inspect with `resume_outcome`, then call `start` / `start_fresh` / `start_forced_resume`. |
| [`create_channel`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.create_channel.html) | Create the `(Tx, Rx)` event channel. |
| [`create_cancellation_token`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.create_cancellation_token.html) | Create a `CancellationToken` for cooperative cancellation. |
| [`Event`](https://docs.rs/fast-down-api/latest/fast_down_api/enum.Event.html) | The event enum delivered over the channel. |
| [`PartialConfig`](https://docs.rs/fast-down-api/latest/fast_down_api/struct.PartialConfig.html) | Layered, optional configuration for a download. |
| [`StateError`](https://docs.rs/fast-down-api/latest/fast_down_api/enum.StateError.html) | Errors surfaced via `Event::ResumeError`. |
## How resume works
During a download the engine periodically persists a `.fd` state file next to
the `.part` partial file. That state records the byte ranges already written
(`downloaded_chunk`) and the remote file identity (`etag` / `last_modified` /
size). On the next run:
1. `download` loads the `.fd`, validates it still matches the remote (size + identity), and — if the `.part` file is present — emits `Event::Resumed` and continues from the recorded offset.
2. If validation fails (remote changed) or there is no `.part`, it starts fresh.
3. `resume` applies the same checks but, instead of falling back, reports `Event::ResumeError`.
Cancellation leaves both files in place, so a later `resume` (or `download`) can pick up exactly where it stopped.
Every run — whether it completes, is cancelled, stops incomplete, or fails —
ends with exactly one `Event::Terminated(TerminationReason)` as the last event
on the channel, so draining `Rx` until `Terminated` is the reliable way to know
a run has finished.
## License
MIT — see [LICENSE](https://github.com/fast-down/core/blob/main/LICENSE).