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}