Skip to main content

fast_down_api/
event.rs

1use crate::{PartialConfig, StateError};
2use fast_down::{ProgressEntry, UrlInfo, WorkerId, reqwest::ReqwestResponseError};
3use std::{path::PathBuf, time::Duration};
4
5/// Events emitted by a download run, consumed through the crossfire channel
6/// returned by [`crate::create_channel`].
7///
8/// Events cover the full lifecycle: prefetch ([`Event::Prefetch`]), pipeline
9/// setup ([`Event::Start`]), per-worker fetch/write progress
10/// ([`Event::Pulling`] … [`Event::Finished`]), aggregated progress
11/// ([`Event::Progress`]), resume ([`Event::Resumed`], [`Event::ResumeError`]),
12/// and completion ([`Event::Renamed`]). Error variants (`*Error`) report failures
13/// without aborting the stream, so a consumer can decide whether to retry,
14/// cancel, or surface them in a UI.
15///
16/// Every run ends with exactly one [`Event::Terminated`], which is always the
17/// last event on the channel. A consumer that only needs the outcome can wait
18/// for it instead of draining until the channel disconnects.
19#[allow(clippy::large_enum_variant)]
20#[derive(Debug)]
21pub enum Event {
22    /// Emitted after the prefetch step resolves the remote file's metadata.
23    ///
24    /// Carries the [`UrlInfo`] (size, identity headers, range support) so a
25    /// caller can inspect the remote before or while the download proceeds.
26    Prefetch(UrlInfo),
27    /// The prefetch request failed (server unreachable, non-2xx response, etc.).
28    ///
29    /// Fatal for the download: without [`UrlInfo`] the engine cannot plan ranges.
30    PrefetchError(ReqwestResponseError),
31    /// Failed to compute the output / `.part` / `.fd` paths.
32    ///
33    /// For example the target directory is not writable or the file name is
34    /// invalid on this platform.
35    GenPathError(std::io::Error),
36    /// Persisting the `.fd` state file failed (see [`StateError`]).
37    ///
38    /// Non-fatal for the current run, but the download can no longer be resumed
39    /// reliably if it is interrupted afterwards.
40    StateSaveError(StateError),
41    /// Building the HTTP client failed (TLS / backend initialization, etc.).
42    BuildClientError(reqwest::Error),
43    /// Creating the output sink — opening the `.part` file — failed.
44    BuildPusherError(std::io::Error),
45    /// Disk space for the whole file is about to be reserved, carrying the
46    /// target size in bytes.
47    ///
48    /// Only emitted when [`crate::Config::pre_alloc`] is enabled and the remote
49    /// size is known. Where the platform has no fast-reservation path this is
50    /// followed by a full-size zero-fill pass, which can take a while — this
51    /// event exists so a UI can say so instead of appearing frozen.
52    Allocating(u64),
53    /// Reserving disk space failed.
54    ///
55    /// Non-fatal: the download continues and the file grows on demand. The
56    /// trade-off is more fragmentation and the chance of running out of space
57    /// mid-download rather than up front.
58    AllocError(std::io::Error),
59    /// The final rename of the `.part` file to its destination failed.
60    ///
61    /// The success counterpart is [`Event::Renamed`]. The bytes are already on
62    /// disk under the `.part` name, so they can still be resumed or retried.
63    RenameFailed(std::io::Error),
64    /// Emitted after the `.part` file is successfully renamed to its final
65    /// destination. Carries the actual landing path, which in unique mode may
66    /// differ from the originally-planned name (e.g. `xxx (1).mp4`) when the
67    /// target got occupied during the download.
68    Renamed(PathBuf),
69    /// Emitted once the pipeline is set up and writing is about to begin.
70    ///
71    /// Carries the `.part` path, the `.fd` state-file path, and the resolved
72    /// [`PartialConfig`] actually used for this run (after merging any resumed
73    /// progress and applying defaults).
74    Start {
75        tmp_path: PathBuf,
76        config_path: PathBuf,
77        parsed_config: PartialConfig,
78    },
79    /// Emitted when a download resumes from a previously-saved state, before
80    /// [`Event::Start`]. Carries the progress that will be continued from and
81    /// the total file size, so a UI can show e.g. "resuming from 42%".
82    Resumed {
83        config_path: PathBuf,
84        progress: Vec<ProgressEntry>,
85        size: u64,
86    },
87    /// Emitted when an explicit `resume()` call cannot continue the download.
88    /// Unlike `download()` (which silently falls back to a full re-download),
89    /// `resume()` reports the failure so the caller can decide what to do.
90    ResumeError(StateError),
91
92    /// Worker `id` started fetching its assigned byte range from the network.
93    Pulling(WorkerId),
94    /// Worker `id` failed to fetch its assigned range (network / decode error).
95    PullError(WorkerId, anyhow::Error),
96    /// Worker `id`'s fetch exceeded its time budget and was aborted.
97    PullTimeout(WorkerId),
98    /// Worker `id` pulled some bytes into memory.
99    ///
100    /// `ProgressEntry` describes the contiguous range that just arrived from the
101    /// network but is not yet written to disk.
102    PullProgress(WorkerId, ProgressEntry),
103    /// Worker `id` is handing a pulled range to the sink (writing it to `.part`).
104    ///
105    /// `ProgressEntry` is the range being written.
106    Pushing(WorkerId, ProgressEntry),
107    /// Worker `id` failed to write `ProgressEntry` to the sink.
108    PushError(WorkerId, ProgressEntry, anyhow::Error),
109    /// A range was written to the sink.
110    ///
111    /// `ProgressEntry` is the range persisted (at least to the OS page cache) by
112    /// this write. This drives the progress bar and the resume bookkeeping; it is
113    /// intentionally emitted before `fsync`, so it reflects "written", not
114    /// "durably flushed".
115    PushProgress(ProgressEntry),
116
117    /// Aggregated download progress, emitted on a fixed cadence
118    /// ([`crate::Config::progress_emit_gap`]) by a dedicated reporter task.
119    ///
120    /// Unlike [`Event::PushProgress`], which fires once per written byte range,
121    /// this carries a full [`ProgressSample`] — the current progress snapshot
122    /// plus computed transfer rates — so a consumer can render a progress bar
123    /// directly without re-accumulating individual ranges.
124    ///
125    /// The reporter runs on its own timer, so the cadence is driven purely by
126    /// `progress_emit_gap` and is never delayed by flushing, state saving, event
127    /// forwarding, or a slow consumer (the channel is unbounded). One final
128    /// `Progress` is sent when the run ends (success, cancellation, or error).
129    Progress(ProgressSample),
130    /// The sink is being flushed and synced to the `.part` file.
131    Flushing,
132    /// Flushing / syncing the sink failed.
133    FlushError(anyhow::Error),
134    /// Worker `id` completed its assigned range and exited.
135    Finished(WorkerId),
136
137    /// The run has ended. Sent exactly once, as the last event on the channel.
138    ///
139    /// Preceding `*Error` events carry the details of whatever went wrong; this
140    /// one only reports the outcome.
141    Terminated(TerminationReason),
142}
143
144/// How a download run ended, carried by [`Event::Terminated`].
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum TerminationReason {
147    /// Every byte was written and the `.part` file was renamed into place.
148    /// [`Event::Renamed`] carries the path it landed on.
149    Completed,
150    /// The [`CancellationToken`](crate::create_cancellation_token) was
151    /// triggered. The `.part` and `.fd` files are left on disk so a later
152    /// resume can pick up where this run stopped.
153    Cancelled,
154    /// The run stopped on its own without completing the file — every worker
155    /// gave up, for example because the connection kept failing. Like
156    /// [`TerminationReason::Cancelled`], the `.part` and `.fd` files are left
157    /// on disk.
158    Incomplete,
159    /// A fatal error ended the run: metadata could not be fetched, the output
160    /// file could not be opened, the rename failed, and so on.
161    Failed,
162}
163
164/// Computed aggregate view of the current download progress, carried by
165/// [`Event::Progress`].
166#[derive(Debug, Clone)]
167pub struct ProgressSample {
168    /// Already-written byte ranges — the same source of truth used for resume
169    /// bookkeeping (normalized and de-duplicated).
170    pub progress: Vec<ProgressEntry>,
171    /// Recent transfer rate in bytes/second, smoothed with an exponential
172    /// moving average (time constant ≈ 3s) over the per-interval deltas, so it
173    /// tracks real throughput changes within a few seconds without the
174    /// sub-second jitter of raw window deltas. `0` on the first emit and on
175    /// the final emit.
176    pub bps: u64,
177    /// Average transfer rate in bytes/second over the whole download session
178    /// (all resume runs combined): total `downloaded` divided by total
179    /// `elapsed`, so it stays correct across a resume instead of resetting to
180    /// zero. `0` while the total elapsed time is still too small to measure.
181    pub avg_bps: u64,
182    /// Total bytes written so far (the sum of all `progress` range lengths).
183    pub downloaded: u64,
184    /// Download percentage as a `0.0..=100.0` value (`downloaded * 100 /
185    /// total`); `0.0` when `total == 0` (an empty file has nothing to
186    /// download).
187    pub percent: f64,
188    /// Remote file size in bytes, so the consumer can derive a percentage
189    /// without tracking it separately.
190    pub total: u64,
191    /// Total active download time accumulated across all resume runs,
192    /// persisted in the `.fd` state so a resume continues the clock.
193    pub elapsed: Duration,
194    /// Estimated time remaining, computed as `(total - downloaded) / rate`
195    /// where `rate` is the smoothed recent `bps` (preferred: it reflects the
196    /// *current* link speed, so the estimate stays honest after a resume at a
197    /// different speed), falling back to `avg_bps` while `bps` is still zero.
198    /// `None` until a rate can be measured (the very first emit, or a stalled
199    /// connection) and `Some(Duration::ZERO)` once `downloaded == total`.
200    pub eta: Option<Duration>,
201}