Skip to main content

fast_down_api/core/download/
mod.rs

1use crate::utils::ForceSendExt;
2use crate::{DownloadState, Event, StateError};
3use crate::{PartialConfig, TerminationReason, Tx};
4use fast_down::UrlInfo;
5use std::path::Path;
6use tokio::fs::{self, OpenOptions};
7use tokio_util::sync::CancellationToken;
8use url::Url;
9
10mod overwrite;
11mod pipeline;
12mod plan;
13mod progress_reporter;
14
15pub use plan::*;
16
17fn open_existing() -> OpenOptions {
18    let mut o = OpenOptions::new();
19    o.read(true).write(true).truncate(false).create(false);
20    o
21}
22fn open_create() -> OpenOptions {
23    let mut o = OpenOptions::new();
24    o.read(true).write(true).truncate(false).create(true);
25    o
26}
27fn open_create_new() -> OpenOptions {
28    let mut o = OpenOptions::new();
29    o.read(true).write(true).truncate(false).create_new(true);
30    o
31}
32
33/// Attempt to load and validate a resume state from disk.
34///
35/// This checks that both the `.fd` and `.part` exist, validates the state
36/// against the current server info, and merges the new config into the loaded
37/// state.
38///
39/// Returns `Ok(Some(state))` if resume is possible, `Ok(None)` if there is
40/// nothing usable to resume from (the pair is incomplete, or the `.part` is
41/// shorter than the recorded progress), or `Err(StateError)` if the state exists
42/// but does not describe the current remote file.
43#[allow(clippy::result_large_err)]
44async fn try_load_resume_state(
45    url: &Url,
46    cfg_path: &Path,
47    tmp_path: &Path,
48    info: &UrlInfo,
49    partial_config: &PartialConfig,
50) -> Result<Option<DownloadState>, StateError> {
51    // Check if both .fd and .part exist
52    let fd_exists = fs::try_exists(cfg_path).await.unwrap_or(false);
53    let tmp_exists = fs::try_exists(tmp_path).await.unwrap_or(false);
54
55    if !fd_exists || !tmp_exists {
56        return Ok(None);
57    }
58
59    // Load and validate the state
60    let state = DownloadState::load(cfg_path).await?;
61
62    // Validate the state against current server info
63    state.validate(info)?;
64
65    // Merge the new config into the loaded state
66    state.merge_config(partial_config);
67
68    // Check after merging so caller-supplied progress is validated too. A
69    // `.part` shorter than any claimed range would otherwise be extended with
70    // zeros while the download engine skipped those bytes.
71    if state.part_shortfall(tmp_path).await.is_some() {
72        return Ok(None);
73    }
74
75    state.refresh_identity(url, info);
76
77    Ok(Some(state))
78}
79
80/// Spawn a detached background download task that resumes automatically when
81/// possible.
82///
83/// This is the one-shot form of [`plan`] followed by [`DownloadPlan::start`]:
84/// the task prefetches metadata, then either resumes from a valid `.fd`/`.part`
85/// state or starts a fresh download (falling back silently when resume is
86/// impossible). Use [`plan`] directly when the decision should be shown to a
87/// user first. Progress and lifecycle events are delivered through `tx`.
88///
89/// The run always ends with exactly one [`Event::Terminated`], which is the last
90/// event on the channel — including when planning itself fails or is cancelled.
91/// A caller can wait for it instead of draining `rx` until it disconnects; the
92/// channel still disconnects afterwards, because the spawned task holds the only
93/// `Tx` clones. Keep the
94/// [`CancellationToken`](crate::create_cancellation_token) you passed in if you
95/// need to cancel.
96pub fn download(url: Url, partial_config: PartialConfig, tx: Tx, token: CancellationToken) {
97    tokio::spawn(
98        async move {
99            let token2 = token.clone();
100            let planned =
101                Box::pin(token.run_until_cancelled(plan(url, partial_config, tx.clone(), token2)))
102                    .await;
103            Box::pin(drive(planned, &tx)).await;
104        }
105        .force_send(),
106    );
107}
108
109/// Spawn a detached task that resumes a previously interrupted download from its
110/// `.part` file.
111///
112/// This is the one-shot form of [`plan_resume`] followed by
113/// [`DownloadPlan::start`]. `url` is optional. When `Some`, the resume resolves
114/// and validates against that URL exactly as before. When `None`, the task
115/// reuses the **initial URL** persisted in the `.fd` state file — the one the
116/// original `download` recorded (the durable initial URL, not the transient
117/// redirect/`final_url`). So a caller can resume purely from the `.part` path;
118/// redirects are re-resolved through a fresh prefetch on every resume.
119///
120/// If the download cannot be continued — `tmp_path` is not a `.part` file, the
121/// `.fd` state file is missing, the server does not support range requests, or
122/// the remote file changed — the task emits
123/// [`Event::ResumeError`](crate::Event::ResumeError) and stops **without**
124/// falling back to a full re-download. If `tmp_path` itself does not exist, the
125/// call falls back to a fresh download **only when a `url` is available**; with
126/// `url = None` there is nothing to fetch, so it emits
127/// `ResumeError(StateError::NoUrl)` instead. Likewise, when `url = None` but the
128/// `.fd` carries no resolvable URL, the call reports `StateError::NoUrl`.
129///
130/// Completion is observed the same way as [`download`]: wait for the single
131/// [`Event::Terminated`], or drain the `Rx` until it disconnects.
132pub fn resume(
133    tmp_path: impl AsRef<Path>,
134    url: Option<Url>,
135    partial_config: PartialConfig,
136    tx: Tx,
137    token: CancellationToken,
138) {
139    let tmp_path = tmp_path.as_ref().to_path_buf();
140    tokio::spawn(
141        async move {
142            let token2 = token.clone();
143            let planned = Box::pin(token.run_until_cancelled(plan_resume(
144                tmp_path,
145                url,
146                partial_config,
147                tx.clone(),
148                token2,
149            )))
150            .await;
151            Box::pin(drive(planned, &tx)).await;
152        }
153        .force_send(),
154    );
155}
156
157/// Spawn a detached task that downloads from a `.fd` state file used as a
158/// download manifest.
159///
160/// This is the one-shot form of [`plan_from_fd`]: the task loads the `.fd`,
161/// resolves and validates against the remote file, then either resumes from a
162/// present `.part` or — when the `.part` is missing — reuses the `.fd`'s url /
163/// config and downloads the whole file from scratch. See [`plan_from_fd`] for
164/// the full contract.
165///
166/// Completion is observed the same way as [`download`]: wait for the single
167/// [`Event::Terminated`], or drain the `Rx` until it disconnects.
168pub fn download_from_fd(
169    fd_path: impl AsRef<Path>,
170    url: Option<Url>,
171    partial_config: PartialConfig,
172    tx: Tx,
173    token: CancellationToken,
174) {
175    let fd_path = fd_path.as_ref().to_path_buf();
176    tokio::spawn(
177        async move {
178            let token2 = token.clone();
179            let planned = Box::pin(token.run_until_cancelled(plan_from_fd(
180                fd_path,
181                url,
182                partial_config,
183                tx.clone(),
184                token2,
185            )))
186            .await;
187            Box::pin(drive(planned, &tx)).await;
188        }
189        .force_send(),
190    );
191}
192
193/// Start a freshly-made plan, or report why there is none.
194///
195/// `None` means the cancellation token fired while planning. Either way exactly
196/// one [`Event::Terminated`] reaches the channel.
197async fn drive(planned: Option<Result<DownloadPlan, PlanError>>, tx: &Tx) {
198    match planned {
199        None => {
200            let _ = tx.send(Event::Terminated(TerminationReason::Cancelled));
201        }
202        Some(Err(e)) => {
203            e.emit(tx);
204            let _ = tx.send(Event::Terminated(TerminationReason::Failed));
205        }
206        Some(Ok(prepared)) => Box::pin(prepared.start()).await,
207    }
208}