fast-down-api 0.1.1

A convenient and easy-to-use FFI wrapper for fast-down
Documentation

fast-down-api

GitHub last commit Test codecov Latest version Documentation License

A convenient, high-level wrapper around 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

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.

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

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.

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

Item Purpose
download 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 Resume a specific .part file; hard-error (Event::ResumeError) if it can't. Completion is observed the same way, by draining Rx.
plan Prefetch + probe the disk and return a DownloadPlan without writing anything; start it only when ready.
plan_resume Like plan but targets a specific .part; refuses a Mismatch instead of re-downloading.
DownloadPlan A prepared, not-yet-started download. Inspect with resume_outcome, then call start / start_fresh / start_forced_resume.
create_channel Create the (Tx, Rx) event channel.
create_cancellation_token Create a CancellationToken for cooperative cancellation.
Event The event enum delivered over the channel.
PartialConfig Layered, optional configuration for a download.
StateError 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.