1use std::collections::{HashMap, VecDeque};
11use std::sync::{Arc, Mutex as StdMutex};
12
13use mold_core::download::RecipeAuth;
14use mold_core::types::{DownloadEvent, DownloadJob, DownloadsListing, JobStatus};
15use tokio::sync::{broadcast, Mutex as AsyncMutex, Notify};
16use tokio_util::sync::CancellationToken;
17
18#[derive(Clone, Debug)]
22pub struct OwnedRecipeFile {
23 pub url: String,
24 pub dest: String,
25 pub sha256: Option<String>,
26 pub size_bytes: Option<u64>,
27}
28
29#[derive(Clone, Debug)]
34pub struct RecipePayload {
35 pub catalog_id: String,
38 pub files: Vec<OwnedRecipeFile>,
39 pub auth: RecipeAuth,
40}
41
42pub const EVENT_BUFFER: usize = 256;
45
46pub const HISTORY_CAP: usize = 20;
48
49pub struct ActiveHandle {
51 pub job: DownloadJob,
52 pub abort: CancellationToken,
53}
54
55#[derive(Debug, Clone)]
67struct CatalogGroup {
68 catalog_id: String,
69 job_ids: std::collections::HashSet<String>,
71 outcomes: HashMap<String, JobStatus>,
74}
75
76pub struct DownloadQueue {
77 active: AsyncMutex<Option<ActiveHandle>>,
78 queued: StdMutex<VecDeque<DownloadJob>>,
79 history: StdMutex<VecDeque<DownloadJob>>,
80 events: broadcast::Sender<DownloadEvent>,
81 notify: Notify,
83 recipe_payloads: StdMutex<HashMap<String, RecipePayload>>,
87 groups: StdMutex<HashMap<String, CatalogGroup>>,
93}
94
95#[derive(Debug, thiserror::Error)]
96pub enum EnqueueError {
97 #[error("unknown model '{0}'. Run 'mold list' to see available models.")]
98 UnknownModel(String),
99 #[error("download queue lock poisoned")]
100 LockPoisoned,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum EnqueueOutcome {
105 AlreadyPresent,
107 Created,
109}
110
111impl DownloadQueue {
112 pub fn new() -> Arc<Self> {
113 let (events, _rx) = broadcast::channel(EVENT_BUFFER);
114 Arc::new(Self {
115 active: AsyncMutex::new(None),
116 queued: StdMutex::new(VecDeque::new()),
117 history: StdMutex::new(VecDeque::new()),
118 events,
119 notify: Notify::new(),
120 recipe_payloads: StdMutex::new(HashMap::new()),
121 groups: StdMutex::new(HashMap::new()),
122 })
123 }
124
125 #[cfg(test)]
128 pub fn new_for_test() -> Arc<Self> {
129 Self::new()
130 }
131
132 pub fn subscribe(&self) -> broadcast::Receiver<DownloadEvent> {
133 self.events.subscribe()
134 }
135
136 pub async fn listing(&self) -> DownloadsListing {
138 let active = self.active.lock().await.as_ref().map(|a| a.job.clone());
139 let queued: Vec<DownloadJob> = self
140 .queued
141 .lock()
142 .expect("queued lock poisoned")
143 .iter()
144 .cloned()
145 .collect();
146 let history: Vec<DownloadJob> = self
147 .history
148 .lock()
149 .expect("history lock poisoned")
150 .iter()
151 .cloned()
152 .collect();
153 DownloadsListing {
154 active,
155 queued,
156 history,
157 }
158 }
159
160 pub async fn enqueue(
164 &self,
165 model: String,
166 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
167 let canonical = mold_core::manifest::resolve_model_name(&model);
170 if mold_core::manifest::find_manifest(&canonical).is_none() {
171 return Err(EnqueueError::UnknownModel(model));
172 }
173
174 {
176 let active = self.active.lock().await;
177 if let Some(handle) = active.as_ref() {
178 if handle.job.model == canonical {
179 return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
180 }
181 }
182 }
183 {
184 let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
185 if let Some((idx, existing)) = queued
186 .iter()
187 .enumerate()
188 .find(|(_, j)| j.model == canonical)
189 {
190 return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
191 }
192 }
193
194 let id = uuid::Uuid::new_v4().to_string();
195 let job = DownloadJob {
196 id: id.clone(),
197 model: canonical.clone(),
198 status: JobStatus::Queued,
199 files_done: 0,
200 files_total: 0,
201 bytes_done: 0,
202 bytes_total: 0,
203 current_file: None,
204 started_at: None,
205 completed_at: None,
206 error: None,
207 };
208
209 let position = {
210 let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
211 queued.push_back(job);
212 queued.len() };
214 let _ = self.events.send(DownloadEvent::Enqueued {
215 id: id.clone(),
216 model: canonical,
217 position,
218 });
219 self.notify.notify_one();
220 Ok((id, position, EnqueueOutcome::Created))
221 }
222
223 pub async fn enqueue_recipe(
233 &self,
234 payload: RecipePayload,
235 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
236 if payload.catalog_id.trim().is_empty() {
237 return Err(EnqueueError::UnknownModel(payload.catalog_id));
238 }
239
240 {
242 let active = self.active.lock().await;
243 if let Some(handle) = active.as_ref() {
244 if handle.job.model == payload.catalog_id {
245 return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
246 }
247 }
248 }
249 {
250 let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
251 if let Some((idx, existing)) = queued
252 .iter()
253 .enumerate()
254 .find(|(_, j)| j.model == payload.catalog_id)
255 {
256 return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
257 }
258 }
259
260 let id = uuid::Uuid::new_v4().to_string();
261 let job = DownloadJob {
262 id: id.clone(),
263 model: payload.catalog_id.clone(),
264 status: JobStatus::Queued,
265 files_done: 0,
266 files_total: payload.files.len(),
267 bytes_done: 0,
268 bytes_total: payload.files.iter().filter_map(|f| f.size_bytes).sum(),
269 current_file: None,
270 started_at: None,
271 completed_at: None,
272 error: None,
273 };
274
275 {
278 let mut payloads = self
279 .recipe_payloads
280 .lock()
281 .map_err(|_| EnqueueError::LockPoisoned)?;
282 payloads.insert(id.clone(), payload.clone());
283 }
284
285 let position = {
286 let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
287 queued.push_back(job);
288 queued.len()
289 };
290 let _ = self.events.send(DownloadEvent::Enqueued {
291 id: id.clone(),
292 model: payload.catalog_id,
293 position,
294 });
295 self.notify.notify_one();
296 Ok((id, position, EnqueueOutcome::Created))
297 }
298
299 pub async fn enqueue_in_group(
305 &self,
306 model: String,
307 catalog_id: &str,
308 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
309 let (id, pos, outcome) = self.enqueue(model).await?;
310 self.register_in_group(catalog_id, &id);
311 Ok((id, pos, outcome))
312 }
313
314 pub async fn enqueue_recipe_in_group(
317 &self,
318 payload: RecipePayload,
319 catalog_id: &str,
320 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
321 let (id, pos, outcome) = self.enqueue_recipe(payload).await?;
322 self.register_in_group(catalog_id, &id);
323 Ok((id, pos, outcome))
324 }
325
326 fn register_in_group(&self, catalog_id: &str, job_id: &str) {
327 let mut groups = match self.groups.lock() {
328 Ok(g) => g,
329 Err(_) => return, };
331 let entry = groups
332 .entry(catalog_id.to_string())
333 .or_insert_with(|| CatalogGroup {
334 catalog_id: catalog_id.to_string(),
335 job_ids: std::collections::HashSet::new(),
336 outcomes: HashMap::new(),
337 });
338 entry.job_ids.insert(job_id.to_string());
339 }
340
341 pub(crate) fn settle_for_group(&self, job_id: &str, status: JobStatus) {
350 let to_emit = {
352 let mut groups = match self.groups.lock() {
353 Ok(g) => g,
354 Err(_) => return,
355 };
356 let mut emit_for: Option<(String, bool)> = None;
357 for group in groups.values_mut() {
362 if !group.job_ids.contains(job_id) {
363 continue;
364 }
365 group.outcomes.insert(job_id.to_string(), status);
366 if group.outcomes.len() == group.job_ids.len() {
367 let ok = group
368 .outcomes
369 .values()
370 .all(|s| matches!(s, JobStatus::Completed));
371 emit_for = Some((group.catalog_id.clone(), ok));
372 }
373 break; }
375 if let Some((id, _)) = &emit_for {
376 groups.remove(id);
377 }
378 emit_for
379 };
380 if let Some((id, ok)) = to_emit {
381 self.emit(DownloadEvent::CatalogReady { id, ok });
382 }
383 }
384
385 pub(crate) fn take_recipe_payload(&self, job_id: &str) -> Option<RecipePayload> {
389 self.recipe_payloads
390 .lock()
391 .ok()
392 .and_then(|mut p| p.remove(job_id))
393 }
394
395 pub async fn cancel(&self, id: &str) -> bool {
398 {
400 let mut queued = self.queued.lock().expect("queued lock poisoned");
401 if let Some(pos) = queued.iter().position(|j| j.id == id) {
402 queued.remove(pos);
403 drop(queued);
404 let _ = self
405 .events
406 .send(DownloadEvent::Dequeued { id: id.to_string() });
407 return true;
408 }
409 }
410 let active = self.active.lock().await;
412 if let Some(handle) = active.as_ref() {
413 if handle.job.id == id {
414 handle.abort.cancel();
415 return true;
417 }
418 }
419 false
421 }
422
423 pub(crate) fn push_history(&self, job: DownloadJob) {
426 let mut history = self.history.lock().expect("history lock poisoned");
427 if history.len() >= HISTORY_CAP {
428 history.pop_front();
429 }
430 history.push_back(job);
431 }
432
433 pub(crate) async fn wait_for_work(&self, shutdown: &CancellationToken) {
435 tokio::select! {
436 _ = self.notify.notified() => {},
437 _ = shutdown.cancelled() => {},
438 }
439 }
440
441 pub(crate) fn take_next_queued(&self) -> Option<DownloadJob> {
442 self.queued
443 .lock()
444 .expect("queued lock poisoned")
445 .pop_front()
446 }
447
448 pub(crate) async fn set_active(&self, handle: ActiveHandle) {
449 *self.active.lock().await = Some(handle);
450 }
451
452 pub(crate) async fn clear_active(&self) -> Option<ActiveHandle> {
453 self.active.lock().await.take()
454 }
455
456 pub(crate) async fn with_active<F, R>(&self, f: F) -> Option<R>
457 where
458 F: FnOnce(&mut DownloadJob) -> R,
459 {
460 let mut active = self.active.lock().await;
461 active.as_mut().map(|a| f(&mut a.job))
462 }
463
464 pub(crate) fn emit(&self, event: DownloadEvent) {
465 let _ = self.events.send(event);
466 }
467}
468
469#[async_trait::async_trait]
476pub trait PullDriver: Send + Sync + 'static {
477 async fn pull(
478 &self,
479 model: &str,
480 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
481 cancel: CancellationToken,
482 ) -> Result<(), String>;
483}
484
485#[async_trait::async_trait]
490pub trait RecipePullDriver: Send + Sync + 'static {
491 async fn pull(
492 &self,
493 payload: RecipePayload,
494 models_dir: std::path::PathBuf,
495 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
496 cancel: CancellationToken,
497 ) -> Result<(), String>;
498}
499
500pub struct CivitaiRecipeDriver;
502
503#[async_trait::async_trait]
504impl RecipePullDriver for CivitaiRecipeDriver {
505 async fn pull(
506 &self,
507 payload: RecipePayload,
508 models_dir: std::path::PathBuf,
509 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
510 cancel: CancellationToken,
511 ) -> Result<(), String> {
512 let on_progress: mold_core::download::DownloadProgressCallback =
513 std::sync::Arc::from(on_progress);
514 let opts = mold_core::download::PullOptions::default();
515 let files: Vec<mold_core::download::RecipeFetchFile<'_>> = payload
516 .files
517 .iter()
518 .map(|f| mold_core::download::RecipeFetchFile {
519 url: f.url.as_str(),
520 dest: f.dest.as_str(),
521 sha256: f.sha256.as_deref(),
522 size_bytes: f.size_bytes,
523 })
524 .collect();
525 tokio::select! {
526 res = mold_core::download::fetch_recipe(
527 &payload.catalog_id,
528 &files,
529 payload.auth.clone(),
530 &models_dir,
531 Some(on_progress),
532 &opts,
533 ) => res.map(|_| ()).map_err(|e| e.to_string()),
534 _ = cancel.cancelled() => Err("cancelled".into()),
535 }
536 }
537}
538
539pub struct HfPullDriver;
541
542#[async_trait::async_trait]
543impl PullDriver for HfPullDriver {
544 async fn pull(
545 &self,
546 model: &str,
547 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
548 cancel: CancellationToken,
549 ) -> Result<(), String> {
550 let on_progress: mold_core::download::DownloadProgressCallback =
551 std::sync::Arc::from(on_progress);
552 let opts = mold_core::download::PullOptions::default();
553 let model = model.to_string();
554 tokio::select! {
558 res = mold_core::download::pull_and_configure_with_callback(&model, on_progress, &opts) => {
559 res.map(|_| ()).map_err(|e| e.to_string())
560 }
561 _ = cancel.cancelled() => {
562 Err("cancelled".into())
566 }
567 }
568 }
569}
570
571fn cleanup_partials_for_model(model: &str) {
577 use mold_core::manifest::resolve_model_name;
578 let canonical = resolve_model_name(model);
579 let sanitized = canonical.replace(':', "-");
580 mold_core::download::remove_pulling_marker(&canonical);
582 #[cfg(test)]
583 test_hooks::record_cleanup(&canonical);
584 if let Ok(models_dir) = std::env::var("MOLD_MODELS_DIR") {
585 let target = std::path::PathBuf::from(models_dir).join(&sanitized);
586 cleanup_partials_in_dir(&target);
587 return;
588 }
589 if let Some(home) = dirs::home_dir() {
590 let target = home.join(".mold/models").join(&sanitized);
591 cleanup_partials_in_dir(&target);
592 }
593}
594
595#[cfg(test)]
599pub(crate) mod test_hooks {
600 use std::sync::Mutex;
601 static CLEANUPS: Mutex<Vec<String>> = Mutex::new(Vec::new());
602
603 pub(crate) fn record_cleanup(model: &str) {
604 if let Ok(mut v) = CLEANUPS.lock() {
605 v.push(model.to_string());
606 }
607 }
608
609 pub fn drain_cleanups() -> Vec<String> {
613 CLEANUPS
614 .lock()
615 .map(|mut v| std::mem::take(&mut *v))
616 .unwrap_or_default()
617 }
618}
619
620use mold_core::download::SHA256_VERIFIED_SUFFIX;
625
626pub(crate) fn cleanup_partials_in_dir(dir: &std::path::Path) {
640 let root = match dir.canonicalize() {
643 Ok(p) => p,
644 Err(_) => return,
645 };
646 cleanup_partials_in_dir_under_root(dir, &root);
647}
648
649fn cleanup_partials_in_dir_under_root(dir: &std::path::Path, root: &std::path::Path) {
650 if !dir.is_dir() {
651 return;
652 }
653 let entries = match std::fs::read_dir(dir) {
654 Ok(e) => e,
655 Err(_) => return,
656 };
657 for entry in entries.flatten() {
658 let path = entry.path();
659
660 let meta = match path.symlink_metadata() {
665 Ok(m) => m,
666 Err(_) => continue,
667 };
668 let file_type = meta.file_type();
669 if file_type.is_symlink() {
670 tracing::warn!(
671 path = %path.display(),
672 "cleanup_partials: skipping symlink (not following)",
673 );
674 continue;
675 }
676 if file_type.is_dir() {
677 cleanup_partials_in_dir_under_root(&path, root);
678 let _ = std::fs::remove_dir(&path);
680 continue;
681 }
682 if !file_type.is_file() {
683 continue;
684 }
685 let name = match path.file_name().and_then(|n| n.to_str()) {
686 Some(n) => n,
687 None => continue,
688 };
689 if name.ends_with(SHA256_VERIFIED_SUFFIX) {
691 continue;
692 }
693 let marker = path.with_file_name(format!("{name}{SHA256_VERIFIED_SUFFIX}"));
695 if marker.exists() {
696 continue;
697 }
698 match path.canonicalize() {
703 Ok(canon) => {
704 if !canon.starts_with(root) {
705 tracing::warn!(
706 path = %path.display(),
707 canon = %canon.display(),
708 root = %root.display(),
709 "cleanup_partials: refusing to delete file outside models dir",
710 );
711 continue;
712 }
713 }
714 Err(_) => continue,
715 }
716 let _ = std::fs::remove_file(&path);
717 }
718}
719
720pub fn spawn_driver(
723 queue: Arc<DownloadQueue>,
724 driver: Arc<dyn PullDriver>,
725 recipe_driver: Arc<dyn RecipePullDriver>,
726 models_dir: std::path::PathBuf,
727 shutdown: CancellationToken,
728) -> tokio::task::JoinHandle<()> {
729 tokio::spawn(async move {
730 tracing::info!("download queue driver started");
731 loop {
732 queue.wait_for_work(&shutdown).await;
733 if shutdown.is_cancelled() {
734 break;
735 }
736 while let Some(mut job) = queue.take_next_queued() {
737 if shutdown.is_cancelled() {
738 break;
739 }
740 run_one_job(&queue, &driver, &recipe_driver, &models_dir, &mut job).await;
741 if shutdown.is_cancelled() {
742 break;
743 }
744 }
745 }
746 tracing::info!("download queue driver exiting");
747 })
748}
749
750async fn run_one_job(
751 queue: &Arc<DownloadQueue>,
752 driver: &Arc<dyn PullDriver>,
753 recipe_driver: &Arc<dyn RecipePullDriver>,
754 models_dir: &std::path::Path,
755 job: &mut DownloadJob,
756) {
757 job.status = JobStatus::Active;
758 job.started_at = Some(now_ms());
759 let cancel = CancellationToken::new();
760 let handle_job = job.clone();
761
762 let recipe_payload = queue.take_recipe_payload(&job.id);
765
766 let active_handle = ActiveHandle {
769 job: handle_job,
770 abort: cancel.clone(),
771 };
772 queue.set_active(active_handle).await;
773
774 let _ = try_pull_with_retry(
775 queue,
776 driver,
777 recipe_driver,
778 models_dir,
779 recipe_payload.as_ref(),
780 job,
781 cancel.clone(),
782 )
783 .await;
784
785 let final_job = queue
787 .with_active(|a| a.clone())
788 .await
789 .unwrap_or_else(|| job.clone());
790 queue.clear_active().await;
791 queue.push_history(final_job);
792}
793
794#[allow(clippy::too_many_arguments)]
795async fn try_pull_with_retry(
796 queue: &Arc<DownloadQueue>,
797 driver: &Arc<dyn PullDriver>,
798 recipe_driver: &Arc<dyn RecipePullDriver>,
799 models_dir: &std::path::Path,
800 recipe_payload: Option<&RecipePayload>,
801 job: &mut DownloadJob,
802 cancel: CancellationToken,
803) -> Result<(), ()> {
804 for attempt in 0..=1u8 {
806 let result = run_single_attempt(
807 queue,
808 driver,
809 recipe_driver,
810 models_dir,
811 recipe_payload,
812 job,
813 cancel.clone(),
814 )
815 .await;
816 match result {
817 Ok(()) => {
818 job.status = JobStatus::Completed;
819 job.completed_at = Some(now_ms());
820 queue
822 .with_active(|a| {
823 *a = job.clone();
824 })
825 .await;
826 queue.emit(DownloadEvent::JobDone {
827 id: job.id.clone(),
828 model: job.model.clone(),
829 });
830 queue.settle_for_group(&job.id, JobStatus::Completed);
831 return Ok(());
832 }
833 Err(AttemptError::Cancelled) => {
834 job.status = JobStatus::Cancelled;
835 job.completed_at = Some(now_ms());
836 queue
837 .with_active(|a| {
838 *a = job.clone();
839 })
840 .await;
841 cleanup_partials_for_model(&job.model);
842 queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
843 queue.settle_for_group(&job.id, JobStatus::Cancelled);
844 return Err(());
845 }
846 Err(AttemptError::Failed(msg)) => {
847 if attempt == 0 {
848 tracing::warn!(model = %job.model, "pull attempt 1 failed: {msg} — retrying in 5s");
849 tokio::select! {
850 _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {},
851 _ = cancel.cancelled() => {
852 job.status = JobStatus::Cancelled;
853 job.completed_at = Some(now_ms());
854 queue
855 .with_active(|a| {
856 *a = job.clone();
857 })
858 .await;
859 cleanup_partials_for_model(&job.model);
860 queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
861 queue.settle_for_group(&job.id, JobStatus::Cancelled);
862 return Err(());
863 }
864 }
865 continue;
866 }
867 job.status = JobStatus::Failed;
868 job.error = Some(msg.clone());
869 job.completed_at = Some(now_ms());
870 queue
871 .with_active(|a| {
872 *a = job.clone();
873 })
874 .await;
875 cleanup_partials_for_model(&job.model);
876 queue.emit(DownloadEvent::JobFailed {
877 id: job.id.clone(),
878 error: msg,
879 });
880 queue.settle_for_group(&job.id, JobStatus::Failed);
881 return Err(());
882 }
883 }
884 }
885 Err(())
886}
887
888enum AttemptError {
889 Cancelled,
890 Failed(String),
891}
892
893#[allow(clippy::too_many_arguments)]
894async fn run_single_attempt(
895 queue: &Arc<DownloadQueue>,
896 driver: &Arc<dyn PullDriver>,
897 recipe_driver: &Arc<dyn RecipePullDriver>,
898 models_dir: &std::path::Path,
899 recipe_payload: Option<&RecipePayload>,
900 job: &mut DownloadJob,
901 cancel: CancellationToken,
902) -> Result<(), AttemptError> {
903 let (tx, mut rx) =
906 tokio::sync::mpsc::unbounded_channel::<mold_core::download::DownloadProgressEvent>();
907 let on_progress = Box::new(move |evt: mold_core::download::DownloadProgressEvent| {
908 let _ = tx.send(evt);
909 });
910
911 let queue_for_drain = queue.clone();
912 let job_id = job.id.clone();
913 let drain_handle = tokio::spawn(async move {
914 while let Some(evt) = rx.recv().await {
915 translate_event(&queue_for_drain, &job_id, evt).await;
916 }
917 });
918
919 let result = match recipe_payload {
920 Some(payload) => {
921 recipe_driver
922 .pull(
923 payload.clone(),
924 models_dir.to_path_buf(),
925 on_progress,
926 cancel.clone(),
927 )
928 .await
929 }
930 None => driver.pull(&job.model, on_progress, cancel.clone()).await,
931 };
932 let _ = drain_handle.await;
934
935 match result {
936 Ok(()) => Ok(()),
937 Err(msg) if cancel.is_cancelled() => {
938 let _ = msg;
939 Err(AttemptError::Cancelled)
940 }
941 Err(msg) => Err(AttemptError::Failed(msg)),
942 }
943}
944
945async fn translate_event(
946 queue: &Arc<DownloadQueue>,
947 job_id: &str,
948 evt: mold_core::download::DownloadProgressEvent,
949) {
950 use mold_core::download::DownloadProgressEvent as P;
951 let queue = queue.clone();
952 let id = job_id.to_string();
953 {
954 match evt {
955 P::FileStart {
956 total_files,
957 batch_bytes_total,
958 filename,
959 file_index,
960 ..
961 } => {
962 let emitted_started = queue
963 .with_active(|j| {
964 if j.files_total == 0 {
965 j.files_total = total_files;
966 j.bytes_total = batch_bytes_total;
967 true
968 } else {
969 false
970 }
971 })
972 .await
973 .unwrap_or(false);
974 if emitted_started {
975 queue.emit(DownloadEvent::Started {
976 id: id.clone(),
977 files_total: total_files,
978 bytes_total: batch_bytes_total,
979 });
980 }
981 let _ = file_index;
982 queue
983 .with_active(|j| {
984 j.current_file = Some(filename.clone());
985 })
986 .await;
987 }
988 P::FileProgress {
989 filename,
990 bytes_downloaded,
991 batch_bytes_downloaded,
992 batch_bytes_total,
993 file_index,
994 bytes_total,
995 ..
996 } => {
997 let _ = (bytes_downloaded, bytes_total, file_index);
998 let files_done = queue
1005 .with_active(|j| {
1006 j.bytes_done = batch_bytes_downloaded;
1007 if j.bytes_total == 0 {
1008 j.bytes_total = batch_bytes_total;
1009 }
1010 j.current_file = Some(filename.clone());
1011 j.files_done
1012 })
1013 .await
1014 .unwrap_or(0);
1015 queue.emit(DownloadEvent::Progress {
1016 id: id.clone(),
1017 files_done,
1018 bytes_done: batch_bytes_downloaded,
1019 current_file: Some(filename),
1020 });
1021 }
1022 P::FileDone {
1023 filename,
1024 file_index,
1025 total_files,
1026 batch_bytes_downloaded,
1027 batch_bytes_total,
1028 ..
1029 } => {
1030 let _ = batch_bytes_total;
1031 queue
1032 .with_active(|j| {
1033 j.files_done = file_index + 1;
1034 j.files_total = total_files;
1035 j.bytes_done = batch_bytes_downloaded;
1036 })
1037 .await;
1038 queue.emit(DownloadEvent::FileDone {
1039 id: id.clone(),
1040 filename,
1041 });
1042 }
1043 P::Status { message } => {
1044 queue
1047 .with_active(|j| {
1048 j.current_file = Some(message.clone());
1049 })
1050 .await;
1051 }
1052 }
1053 }
1054}
1055
1056fn now_ms() -> i64 {
1057 use std::time::{SystemTime, UNIX_EPOCH};
1058 SystemTime::now()
1059 .duration_since(UNIX_EPOCH)
1060 .map(|d| d.as_millis() as i64)
1061 .unwrap_or(0)
1062}
1063
1064#[cfg(test)]
1065#[path = "downloads_test.rs"]
1066mod tests;