1use super::events::DownloadStatus;
8use super::types::{Quantization, ShardInfo};
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12#[derive(Clone, Debug, Default, Serialize, Deserialize)]
14pub struct QueueSnapshot {
15 pub items: Vec<QueuedDownload>,
17 pub max_size: u32,
19 pub active_count: u32,
21 pub pending_count: u32,
23 pub recent_failures: Vec<FailedDownload>,
25}
26
27impl QueueSnapshot {
28 #[must_use]
30 pub const fn new(max_size: u32) -> Self {
31 Self {
32 items: Vec::new(),
33 max_size,
34 active_count: 0,
35 pending_count: 0,
36 recent_failures: Vec::new(),
37 }
38 }
39
40 #[must_use]
42 pub const fn is_empty(&self) -> bool {
43 self.items.is_empty()
44 }
45
46 #[must_use]
48 pub const fn is_full(&self) -> bool {
49 self.items.len() >= self.max_size as usize
50 }
51
52 #[must_use]
54 pub const fn len(&self) -> usize {
55 self.items.len()
56 }
57
58 pub fn get(&self, id: &str) -> Option<&QueuedDownload> {
60 self.items.iter().find(|item| item.id == id)
61 }
62}
63
64#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct QueuedDownload {
67 pub id: String,
69
70 pub model_id: String,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub quantization: Option<Quantization>,
76
77 pub display_name: String,
79
80 pub status: DownloadStatus,
82
83 pub position: u32,
85
86 pub downloaded_bytes: u64,
88
89 pub total_bytes: u64,
91
92 pub speed_bps: f64,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub eta_seconds: Option<f64>,
98
99 pub progress_percent: f64,
101
102 pub queued_at: u64,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub started_at: Option<u64>,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub group_id: Option<String>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub shard_info: Option<ShardInfo>,
116}
117
118impl QueuedDownload {
119 pub fn new(
121 id: impl Into<String>,
122 model_id: impl Into<String>,
123 display_name: impl Into<String>,
124 position: u32,
125 queued_at: u64,
126 ) -> Self {
127 Self {
128 id: id.into(),
129 model_id: model_id.into(),
130 quantization: None,
131 display_name: display_name.into(),
132 status: DownloadStatus::Queued,
133 position,
134 downloaded_bytes: 0,
135 total_bytes: 0,
136 speed_bps: 0.0,
137 eta_seconds: None,
138 progress_percent: 0.0,
139 queued_at,
140 started_at: None,
141 group_id: None,
142 shard_info: None,
143 }
144 }
145
146 #[must_use]
148 pub const fn with_quantization(mut self, quant: Quantization) -> Self {
149 self.quantization = Some(quant);
150 self
151 }
152
153 #[must_use]
155 pub const fn with_status(mut self, status: DownloadStatus) -> Self {
156 self.status = status;
157 self
158 }
159
160 #[must_use]
162 pub fn with_shard_info(mut self, group_id: String, shard_info: ShardInfo) -> Self {
163 self.group_id = Some(group_id);
164 self.shard_info = Some(shard_info);
165 self
166 }
167
168 pub fn update_progress(&mut self, downloaded: u64, total: u64, speed_bps: f64) {
170 self.downloaded_bytes = downloaded;
171 self.total_bytes = total;
172 self.speed_bps = speed_bps;
173
174 self.progress_percent = if total > 0 {
175 #[expect(
176 clippy::cast_precision_loss,
177 reason = "precision loss acceptable for progress percentage"
178 )]
179 let progress = (downloaded as f64 / total as f64) * 100.0;
180 progress
181 } else {
182 0.0
183 };
184
185 self.eta_seconds = if speed_bps > 0.0 && total > downloaded {
186 #[expect(
187 clippy::cast_precision_loss,
188 reason = "precision loss acceptable for ETA calculation"
189 )]
190 let eta = (total - downloaded) as f64 / speed_bps;
191 Some(eta)
192 } else {
193 None
194 };
195 }
196
197 pub fn is_active(&self) -> bool {
199 self.status == DownloadStatus::Downloading
200 }
201
202 #[must_use]
204 pub const fn is_complete(&self) -> bool {
205 matches!(
206 self.status,
207 DownloadStatus::Completed | DownloadStatus::Cancelled | DownloadStatus::Failed
208 )
209 }
210
211 pub fn speed_display(&self) -> String {
213 format_bytes_per_second(self.speed_bps)
214 }
215
216 #[must_use]
218 pub fn eta_display(&self) -> Option<String> {
219 self.eta_seconds.map(|secs| {
220 #[expect(
221 clippy::cast_possible_truncation,
222 clippy::cast_sign_loss,
223 reason = "ETA seconds are always positive and within u64 range"
224 )]
225 let secs_u64 = secs as u64;
226 format_duration(secs_u64)
227 })
228 }
229}
230
231#[derive(Clone, Debug, Serialize, Deserialize)]
233pub struct FailedDownload {
234 pub id: String,
236
237 pub display_name: String,
239
240 pub error: String,
242
243 pub failed_at: u64,
245
246 pub recoverable: bool,
248
249 pub downloaded_bytes: u64,
251}
252
253impl FailedDownload {
254 pub fn new(
256 id: impl Into<String>,
257 display_name: impl Into<String>,
258 error: impl Into<String>,
259 failed_at: u64,
260 ) -> Self {
261 Self {
262 id: id.into(),
263 display_name: display_name.into(),
264 error: error.into(),
265 failed_at,
266 recoverable: false,
267 downloaded_bytes: 0,
268 }
269 }
270
271 #[must_use]
273 pub const fn with_recoverable(mut self, recoverable: bool) -> Self {
274 self.recoverable = recoverable;
275 self
276 }
277
278 #[must_use]
280 pub const fn with_downloaded_bytes(mut self, bytes: u64) -> Self {
281 self.downloaded_bytes = bytes;
282 self
283 }
284}
285
286fn format_bytes_per_second(bps: f64) -> String {
288 let (value, unit) = if bps >= 1_000_000_000.0 {
289 (bps / 1_000_000_000.0, "GB/s")
290 } else if bps >= 1_000_000.0 {
291 (bps / 1_000_000.0, "MB/s")
292 } else if bps >= 1_000.0 {
293 (bps / 1_000.0, "KB/s")
294 } else {
295 return format!("{bps:.0} B/s");
296 };
297 format!("{value:.1} {unit}")
298}
299
300fn format_duration(secs: u64) -> String {
302 let duration = Duration::from_secs(secs);
303 let hours = duration.as_secs() / 3600;
304 let minutes = (duration.as_secs() % 3600) / 60;
305 let seconds = duration.as_secs() % 60;
306
307 if hours > 0 {
308 format!("{hours}h {minutes}m")
309 } else if minutes > 0 {
310 format!("{minutes}m {seconds}s")
311 } else {
312 format!("{seconds}s")
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn test_queue_snapshot_operations() {
322 let mut snapshot = QueueSnapshot::new(10);
323 assert!(snapshot.is_empty());
324 assert!(!snapshot.is_full());
325
326 snapshot
327 .items
328 .push(QueuedDownload::new("id1", "model", "Display", 1, 0));
329 assert!(!snapshot.is_empty());
330 assert_eq!(snapshot.len(), 1);
331 assert!(snapshot.get("id1").is_some());
332 assert!(snapshot.get("nonexistent").is_none());
333 }
334
335 #[test]
336 fn test_queued_download_progress() {
337 let mut download = QueuedDownload::new("id", "model", "Display", 1, 0);
338 download.update_progress(500, 1000, 100.0);
339
340 assert_eq!(download.downloaded_bytes, 500);
341 assert!((download.progress_percent - 50.0).abs() < 0.01);
342 assert!((download.eta_seconds.unwrap() - 5.0).abs() < 0.01);
343 }
344
345 #[test]
346 fn test_speed_display() {
347 let mut download = QueuedDownload::new("id", "model", "Display", 1, 0);
348
349 download.speed_bps = 5_000_000.0;
350 assert_eq!(download.speed_display(), "5.0 MB/s");
351
352 download.speed_bps = 1_500_000_000.0;
353 assert_eq!(download.speed_display(), "1.5 GB/s");
354
355 download.speed_bps = 500.0;
356 assert_eq!(download.speed_display(), "500 B/s");
357 }
358
359 #[test]
360 fn test_format_duration() {
361 assert_eq!(format_duration(30), "30s");
362 assert_eq!(format_duration(90), "1m 30s");
363 assert_eq!(format_duration(3661), "1h 1m");
364 }
365
366 #[test]
367 fn test_serialization_roundtrip() {
368 let download = QueuedDownload::new("id", "model", "Display", 1, 1_234_567_890)
369 .with_quantization(Quantization::Q4KM);
370
371 let json = serde_json::to_string(&download).unwrap();
372 let parsed: QueuedDownload = serde_json::from_str(&json).unwrap();
373
374 assert_eq!(parsed.id, "id");
375 assert_eq!(parsed.quantization, Some(Quantization::Q4KM));
376 }
377}