1use super::completion::QueueRunSummary;
4use super::types::ShardInfo;
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub struct DownloadSummary {
10 pub id: String,
12 pub display_name: String,
14 pub status: DownloadStatus,
16 pub position: u32,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub error: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub group_id: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub shard_info: Option<ShardInfo>,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum DownloadStatus {
33 Queued,
35 Downloading,
37 Finalizing,
39 Registering,
41 Completed,
43 Failed,
45 Cancelled,
47}
48
49impl DownloadStatus {
50 #[must_use]
52 pub const fn as_str(&self) -> &'static str {
53 match self {
54 Self::Queued => "queued",
55 Self::Downloading => "downloading",
56 Self::Finalizing => "finalizing",
57 Self::Registering => "registering",
58 Self::Completed => "completed",
59 Self::Failed => "failed",
60 Self::Cancelled => "cancelled",
61 }
62 }
63
64 #[must_use]
66 pub fn parse(s: &str) -> Self {
67 match s {
68 "downloading" => Self::Downloading,
69 "finalizing" => Self::Finalizing,
70 "registering" => Self::Registering,
71 "completed" => Self::Completed,
72 "failed" => Self::Failed,
73 "cancelled" => Self::Cancelled,
74 _ => Self::Queued,
76 }
77 }
78
79 #[must_use]
81 pub const fn label(&self) -> &'static str {
82 match self {
83 Self::Queued => "Queued",
84 Self::Downloading => "Downloading",
85 Self::Finalizing => "Finalizing",
86 Self::Registering => "Registering",
87 Self::Completed => "Completed",
88 Self::Failed => "Failed",
89 Self::Cancelled => "Cancelled",
90 }
91 }
92}
93
94#[derive(Clone, Debug, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "snake_case")]
110pub enum DownloadEvent {
111 QueueSnapshot {
113 items: Vec<DownloadSummary>,
115 max_size: u32,
117 },
118
119 DownloadStarted {
121 id: String,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 shard_index: Option<u32>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 total_shards: Option<u32>,
129 },
130
131 DownloadProgress {
133 id: String,
135 downloaded: u64,
137 total: u64,
139 speed_bps: f64,
141 eta_seconds: f64,
143 percentage: f64,
145 },
146
147 ShardProgress {
149 id: String,
151 shard_index: u32,
153 total_shards: u32,
155 shard_filename: String,
157 shard_downloaded: u64,
159 shard_total: u64,
161 aggregate_downloaded: u64,
163 aggregate_total: u64,
165 speed_bps: f64,
167 eta_seconds: f64,
169 percentage: f64,
171 },
172
173 DownloadCompleted {
175 id: String,
177 #[serde(skip_serializing_if = "Option::is_none")]
179 message: Option<String>,
180 },
181
182 DownloadFailed {
184 id: String,
186 error: String,
188 },
189
190 DownloadCancelled {
192 id: String,
194 },
195
196 DownloadStatusChanged {
204 id: String,
206 status: DownloadStatus,
208 },
209
210 QueueRunComplete {
216 summary: QueueRunSummary,
218 },
219}
220
221impl DownloadEvent {
222 #[must_use]
224 pub const fn queue_snapshot(items: Vec<DownloadSummary>, max_size: u32) -> Self {
225 Self::QueueSnapshot { items, max_size }
226 }
227
228 pub fn started(id: impl Into<String>) -> Self {
230 Self::DownloadStarted {
231 id: id.into(),
232 shard_index: None,
233 total_shards: None,
234 }
235 }
236
237 pub fn started_shard(id: impl Into<String>, shard_index: u32, total_shards: u32) -> Self {
239 Self::DownloadStarted {
240 id: id.into(),
241 shard_index: Some(shard_index),
242 total_shards: Some(total_shards),
243 }
244 }
245
246 #[allow(clippy::cast_precision_loss)]
248 pub fn progress(id: impl Into<String>, downloaded: u64, total: u64, speed_bps: f64) -> Self {
249 let percentage = if total > 0 {
250 (downloaded as f64 / total as f64) * 100.0
251 } else {
252 0.0
253 };
254
255 let eta_seconds = if speed_bps > 0.0 && total > downloaded {
256 (total - downloaded) as f64 / speed_bps
257 } else {
258 0.0
259 };
260
261 Self::DownloadProgress {
262 id: id.into(),
263 downloaded,
264 total,
265 speed_bps,
266 eta_seconds,
267 percentage,
268 }
269 }
270
271 #[allow(clippy::too_many_arguments, clippy::cast_precision_loss)]
273 pub fn shard_progress(
274 id: impl Into<String>,
275 shard_index: u32,
276 total_shards: u32,
277 shard_filename: impl Into<String>,
278 shard_downloaded: u64,
279 shard_total: u64,
280 aggregate_downloaded: u64,
281 aggregate_total: u64,
282 speed_bps: f64,
283 ) -> Self {
284 let percentage = if aggregate_total > 0 {
285 (aggregate_downloaded as f64 / aggregate_total as f64) * 100.0
286 } else {
287 0.0
288 };
289
290 let eta_seconds = if speed_bps > 0.0 && aggregate_total > aggregate_downloaded {
291 (aggregate_total - aggregate_downloaded) as f64 / speed_bps
292 } else {
293 0.0
294 };
295
296 Self::ShardProgress {
297 id: id.into(),
298 shard_index,
299 total_shards,
300 shard_filename: shard_filename.into(),
301 shard_downloaded,
302 shard_total,
303 aggregate_downloaded,
304 aggregate_total,
305 speed_bps,
306 eta_seconds,
307 percentage,
308 }
309 }
310
311 pub fn completed(id: impl Into<String>, message: Option<impl Into<String>>) -> Self {
313 Self::DownloadCompleted {
314 id: id.into(),
315 message: message.map(Into::into),
316 }
317 }
318
319 pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
321 Self::DownloadFailed {
322 id: id.into(),
323 error: error.into(),
324 }
325 }
326
327 pub fn cancelled(id: impl Into<String>) -> Self {
329 Self::DownloadCancelled { id: id.into() }
330 }
331
332 pub fn status_changed(id: impl Into<String>, status: DownloadStatus) -> Self {
334 Self::DownloadStatusChanged {
335 id: id.into(),
336 status,
337 }
338 }
339
340 pub const fn queue_run_complete(summary: QueueRunSummary) -> Self {
342 Self::QueueRunComplete { summary }
343 }
344
345 #[must_use]
347 pub fn id(&self) -> Option<&str> {
348 match self {
349 Self::QueueSnapshot { .. } | Self::QueueRunComplete { .. } => None,
350 Self::DownloadStarted { id, .. }
351 | Self::DownloadProgress { id, .. }
352 | Self::ShardProgress { id, .. }
353 | Self::DownloadCompleted { id, .. }
354 | Self::DownloadFailed { id, .. }
355 | Self::DownloadCancelled { id }
356 | Self::DownloadStatusChanged { id, .. } => Some(id),
357 }
358 }
359
360 #[must_use]
366 pub const fn event_name(&self) -> &'static str {
367 match self {
368 Self::QueueSnapshot { .. } => "download:queue_snapshot",
369 Self::DownloadStarted { .. } => "download:started",
370 Self::DownloadProgress { .. } | Self::ShardProgress { .. } => "download:progress",
371 Self::DownloadCompleted { .. } => "download:completed",
372 Self::DownloadFailed { .. } => "download:failed",
373 Self::DownloadCancelled { .. } => "download:cancelled",
374 Self::DownloadStatusChanged { .. } => "download:status_changed",
375 Self::QueueRunComplete { .. } => "download:queue_run_complete",
376 }
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn test_progress_event_calculations() {
386 let event = DownloadEvent::progress("id", 500, 1000, 100.0);
387 match event {
388 DownloadEvent::DownloadProgress {
389 percentage,
390 eta_seconds,
391 ..
392 } => {
393 assert!((percentage - 50.0).abs() < 0.01);
394 assert!((eta_seconds - 5.0).abs() < 0.01);
395 }
396 _ => panic!("Expected DownloadProgress"),
397 }
398 }
399
400 #[test]
401 fn test_event_id_extraction() {
402 assert_eq!(DownloadEvent::started("test").id(), Some("test"));
403 assert_eq!(DownloadEvent::cancelled("test").id(), Some("test"));
404 assert!(DownloadEvent::queue_snapshot(vec![], 10).id().is_none());
405 }
406}