1use super::completion::QueueRunSummary;
4use super::types::ShardInfo;
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
10pub struct DownloadSummary {
11 pub id: String,
13 pub display_name: String,
15 pub status: DownloadStatus,
17 pub position: u32,
19 #[cfg_attr(feature = "ts-bindings", ts(optional))]
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub error: Option<String>,
23 #[cfg_attr(feature = "ts-bindings", ts(optional))]
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub group_id: Option<String>,
27 #[cfg_attr(feature = "ts-bindings", ts(optional))]
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub shard_info: Option<ShardInfo>,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
36#[serde(rename_all = "snake_case")]
37pub enum DownloadStatus {
38 Queued,
40 Downloading,
42 Finalizing,
44 Registering,
46 Completed,
48 Failed,
50 Cancelled,
52}
53
54impl DownloadStatus {
55 #[must_use]
57 pub const fn as_str(&self) -> &'static str {
58 match self {
59 Self::Queued => "queued",
60 Self::Downloading => "downloading",
61 Self::Finalizing => "finalizing",
62 Self::Registering => "registering",
63 Self::Completed => "completed",
64 Self::Failed => "failed",
65 Self::Cancelled => "cancelled",
66 }
67 }
68
69 #[must_use]
71 pub fn parse(s: &str) -> Self {
72 match s {
73 "downloading" => Self::Downloading,
74 "finalizing" => Self::Finalizing,
75 "registering" => Self::Registering,
76 "completed" => Self::Completed,
77 "failed" => Self::Failed,
78 "cancelled" => Self::Cancelled,
79 _ => Self::Queued,
81 }
82 }
83
84 #[must_use]
86 pub const fn label(&self) -> &'static str {
87 match self {
88 Self::Queued => "Queued",
89 Self::Downloading => "Downloading",
90 Self::Finalizing => "Finalizing",
91 Self::Registering => "Registering",
92 Self::Completed => "Completed",
93 Self::Failed => "Failed",
94 Self::Cancelled => "Cancelled",
95 }
96 }
97}
98
99#[derive(Clone, Debug, Serialize, Deserialize)]
124#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
125#[serde(tag = "type", rename_all = "snake_case")]
126pub enum DownloadEvent {
127 QueueSnapshot {
129 items: Vec<DownloadSummary>,
131 max_size: u32,
133 },
134
135 DownloadStarted {
137 id: String,
139 #[cfg_attr(feature = "ts-bindings", ts(optional))]
141 #[serde(skip_serializing_if = "Option::is_none")]
142 shard_index: Option<u32>,
143 #[cfg_attr(feature = "ts-bindings", ts(optional))]
145 #[serde(skip_serializing_if = "Option::is_none")]
146 total_shards: Option<u32>,
147 },
148
149 DownloadProgress {
151 id: String,
153 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
155 downloaded: u64,
156 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
158 total: u64,
159 #[cfg_attr(feature = "ts-bindings", ts(optional))]
165 #[serde(skip_serializing_if = "Option::is_none")]
166 speed_bps: Option<f64>,
167 #[cfg_attr(feature = "ts-bindings", ts(optional))]
169 #[serde(skip_serializing_if = "Option::is_none")]
170 eta_seconds: Option<f64>,
171 percentage: f64,
173 },
174
175 ShardProgress {
177 id: String,
179 shard_index: u32,
181 total_shards: u32,
183 shard_filename: String,
185 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
187 shard_downloaded: u64,
188 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
190 shard_total: u64,
191 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
193 aggregate_downloaded: u64,
194 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
196 aggregate_total: u64,
197 #[cfg_attr(feature = "ts-bindings", ts(optional))]
201 #[serde(skip_serializing_if = "Option::is_none")]
202 speed_bps: Option<f64>,
203 #[cfg_attr(feature = "ts-bindings", ts(optional))]
205 #[serde(skip_serializing_if = "Option::is_none")]
206 eta_seconds: Option<f64>,
207 percentage: f64,
209 },
210
211 DownloadCompleted {
213 id: String,
215 #[cfg_attr(feature = "ts-bindings", ts(optional))]
217 #[serde(skip_serializing_if = "Option::is_none")]
218 message: Option<String>,
219 },
220
221 DownloadFailed {
223 id: String,
225 error: String,
227 },
228
229 DownloadCancelled {
231 id: String,
233 },
234
235 DownloadStatusChanged {
243 id: String,
245 status: DownloadStatus,
247 },
248
249 DownloadNotice {
258 id: String,
260 message: String,
262 },
263
264 QueueRunComplete {
270 summary: QueueRunSummary,
272 },
273}
274
275impl DownloadEvent {
276 #[must_use]
278 pub const fn queue_snapshot(items: Vec<DownloadSummary>, max_size: u32) -> Self {
279 Self::QueueSnapshot { items, max_size }
280 }
281
282 pub fn started(id: impl Into<String>) -> Self {
284 Self::DownloadStarted {
285 id: id.into(),
286 shard_index: None,
287 total_shards: None,
288 }
289 }
290
291 pub fn started_shard(id: impl Into<String>, shard_index: u32, total_shards: u32) -> Self {
293 Self::DownloadStarted {
294 id: id.into(),
295 shard_index: Some(shard_index),
296 total_shards: Some(total_shards),
297 }
298 }
299
300 #[allow(clippy::cast_precision_loss)]
302 fn percent_of(downloaded: u64, total: u64) -> f64 {
303 if total == 0 {
304 return 0.0;
305 }
306 ((downloaded as f64 / total as f64) * 100.0).clamp(0.0, 100.0)
307 }
308
309 pub fn progress(
316 id: impl Into<String>,
317 downloaded: u64,
318 total: u64,
319 speed_bps: Option<f64>,
320 eta_seconds: Option<f64>,
321 ) -> Self {
322 Self::DownloadProgress {
323 id: id.into(),
324 downloaded,
325 total,
326 speed_bps,
327 eta_seconds,
328 percentage: Self::percent_of(downloaded, total),
329 }
330 }
331
332 #[allow(clippy::too_many_arguments)]
336 pub fn shard_progress(
337 id: impl Into<String>,
338 shard_index: u32,
339 total_shards: u32,
340 shard_filename: impl Into<String>,
341 shard_downloaded: u64,
342 shard_total: u64,
343 aggregate_downloaded: u64,
344 aggregate_total: u64,
345 speed_bps: Option<f64>,
346 eta_seconds: Option<f64>,
347 ) -> Self {
348 Self::ShardProgress {
349 id: id.into(),
350 shard_index,
351 total_shards,
352 shard_filename: shard_filename.into(),
353 shard_downloaded,
354 shard_total,
355 aggregate_downloaded,
356 aggregate_total,
357 speed_bps,
358 eta_seconds,
359 percentage: Self::percent_of(aggregate_downloaded, aggregate_total),
360 }
361 }
362
363 pub fn completed(id: impl Into<String>, message: Option<impl Into<String>>) -> Self {
365 Self::DownloadCompleted {
366 id: id.into(),
367 message: message.map(Into::into),
368 }
369 }
370
371 pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
373 Self::DownloadFailed {
374 id: id.into(),
375 error: error.into(),
376 }
377 }
378
379 pub fn cancelled(id: impl Into<String>) -> Self {
381 Self::DownloadCancelled { id: id.into() }
382 }
383
384 pub const fn queue_run_complete(summary: QueueRunSummary) -> Self {
386 Self::QueueRunComplete { summary }
387 }
388
389 #[must_use]
391 pub fn id(&self) -> Option<&str> {
392 match self {
393 Self::QueueSnapshot { .. } | Self::QueueRunComplete { .. } => None,
394 Self::DownloadStarted { id, .. }
395 | Self::DownloadProgress { id, .. }
396 | Self::ShardProgress { id, .. }
397 | Self::DownloadCompleted { id, .. }
398 | Self::DownloadFailed { id, .. }
399 | Self::DownloadCancelled { id }
400 | Self::DownloadStatusChanged { id, .. }
401 | Self::DownloadNotice { id, .. } => Some(id),
402 }
403 }
404
405 #[must_use]
411 pub const fn event_name(&self) -> &'static str {
412 match self {
413 Self::QueueSnapshot { .. } => "download:queue_snapshot",
414 Self::DownloadStarted { .. } => "download:started",
415 Self::DownloadProgress { .. } | Self::ShardProgress { .. } => "download:progress",
416 Self::DownloadCompleted { .. } => "download:completed",
417 Self::DownloadFailed { .. } => "download:failed",
418 Self::DownloadCancelled { .. } => "download:cancelled",
419 Self::DownloadStatusChanged { .. } => "download:status_changed",
420 Self::DownloadNotice { .. } => "download:notice",
421 Self::QueueRunComplete { .. } => "download:queue_run_complete",
422 }
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn test_progress_event_calculations() {
432 let event = DownloadEvent::progress("id", 500, 1000, Some(100.0), Some(5.0));
433 match event {
434 DownloadEvent::DownloadProgress {
435 percentage,
436 eta_seconds,
437 speed_bps,
438 ..
439 } => {
440 assert!((percentage - 50.0).abs() < 0.01);
441 assert_eq!(eta_seconds, Some(5.0), "ETA is passed through, not derived");
442 assert_eq!(speed_bps, Some(100.0));
443 }
444 _ => panic!("Expected DownloadProgress"),
445 }
446 }
447
448 #[test]
449 fn unknown_rate_is_omitted_from_the_wire() {
450 let event = DownloadEvent::progress("id", 500, 1000, None, None);
451 let json = serde_json::to_string(&event).expect("serializes");
452 assert!(
453 !json.contains("speed_bps") && !json.contains("eta_seconds"),
454 "an unknown rate must be absent, never 0: {json}"
455 );
456 }
457
458 #[test]
459 fn percentage_is_clamped_and_safe_at_zero_total() {
460 let over = DownloadEvent::progress("id", 1500, 1000, None, None);
461 let unknown = DownloadEvent::progress("id", 500, 0, None, None);
462 for (event, expected) in [(over, 100.0), (unknown, 0.0)] {
463 match event {
464 DownloadEvent::DownloadProgress { percentage, .. } => {
465 assert!((percentage - expected).abs() < f64::EPSILON);
466 }
467 _ => panic!("Expected DownloadProgress"),
468 }
469 }
470 }
471
472 #[test]
473 fn test_event_id_extraction() {
474 assert_eq!(DownloadEvent::started("test").id(), Some("test"));
475 assert_eq!(DownloadEvent::cancelled("test").id(), Some("test"));
476 assert!(DownloadEvent::queue_snapshot(vec![], 10).id().is_none());
477 }
478}