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 catalog_id: None,
199 status: JobStatus::Queued,
200 files_done: 0,
201 files_total: 0,
202 bytes_done: 0,
203 bytes_total: 0,
204 current_file: None,
205 started_at: None,
206 completed_at: None,
207 error: None,
208 };
209
210 let position = {
211 let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
212 queued.push_back(job);
213 queued.len() };
215 let _ = self.events.send(DownloadEvent::Enqueued {
216 id: id.clone(),
217 model: canonical,
218 position,
219 });
220 self.notify.notify_one();
221 Ok((id, position, EnqueueOutcome::Created))
222 }
223
224 pub async fn enqueue_recipe(
234 &self,
235 payload: RecipePayload,
236 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
237 if payload.catalog_id.trim().is_empty() {
238 return Err(EnqueueError::UnknownModel(payload.catalog_id));
239 }
240
241 {
243 let active = self.active.lock().await;
244 if let Some(handle) = active.as_ref() {
245 if handle.job.model == payload.catalog_id {
246 return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
247 }
248 }
249 }
250 {
251 let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
252 if let Some((idx, existing)) = queued
253 .iter()
254 .enumerate()
255 .find(|(_, j)| j.model == payload.catalog_id)
256 {
257 return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
258 }
259 }
260
261 let id = uuid::Uuid::new_v4().to_string();
262 let job = DownloadJob {
263 id: id.clone(),
264 model: payload.catalog_id.clone(),
265 catalog_id: Some(payload.catalog_id.clone()),
266 status: JobStatus::Queued,
267 files_done: 0,
268 files_total: payload.files.len(),
269 bytes_done: 0,
270 bytes_total: payload.files.iter().filter_map(|f| f.size_bytes).sum(),
271 current_file: None,
272 started_at: None,
273 completed_at: None,
274 error: None,
275 };
276
277 {
280 let mut payloads = self
281 .recipe_payloads
282 .lock()
283 .map_err(|_| EnqueueError::LockPoisoned)?;
284 payloads.insert(id.clone(), payload.clone());
285 }
286
287 let position = {
288 let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
289 queued.push_back(job);
290 queued.len()
291 };
292 let _ = self.events.send(DownloadEvent::Enqueued {
293 id: id.clone(),
294 model: payload.catalog_id,
295 position,
296 });
297 self.notify.notify_one();
298 Ok((id, position, EnqueueOutcome::Created))
299 }
300
301 pub async fn enqueue_in_group(
307 &self,
308 model: String,
309 catalog_id: &str,
310 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
311 let (id, pos, outcome) = self.enqueue(model).await?;
312 self.register_in_group(catalog_id, &id);
313 Ok((id, pos, outcome))
314 }
315
316 pub async fn enqueue_recipe_in_group(
319 &self,
320 payload: RecipePayload,
321 catalog_id: &str,
322 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
323 let (id, pos, outcome) = self.enqueue_recipe(payload).await?;
324 self.register_in_group(catalog_id, &id);
325 Ok((id, pos, outcome))
326 }
327
328 fn register_in_group(&self, catalog_id: &str, job_id: &str) {
329 self.tag_job_with_catalog_id(catalog_id, job_id);
330 let mut groups = match self.groups.lock() {
331 Ok(g) => g,
332 Err(_) => return, };
334 let entry = groups
335 .entry(catalog_id.to_string())
336 .or_insert_with(|| CatalogGroup {
337 catalog_id: catalog_id.to_string(),
338 job_ids: std::collections::HashSet::new(),
339 outcomes: HashMap::new(),
340 });
341 entry.job_ids.insert(job_id.to_string());
342 }
343
344 fn tag_job_with_catalog_id(&self, catalog_id: &str, job_id: &str) {
345 if let Ok(mut queued) = self.queued.lock() {
346 if let Some(job) = queued.iter_mut().find(|job| job.id == job_id) {
347 job.catalog_id = Some(catalog_id.to_string());
348 return;
349 }
350 }
351 if let Ok(mut history) = self.history.lock() {
352 if let Some(job) = history.iter_mut().find(|job| job.id == job_id) {
353 job.catalog_id = Some(catalog_id.to_string());
354 return;
355 }
356 }
357 if let Ok(mut active) = self.active.try_lock() {
358 if let Some(handle) = active.as_mut().filter(|handle| handle.job.id == job_id) {
359 handle.job.catalog_id = Some(catalog_id.to_string());
360 }
361 }
362 }
363
364 pub(crate) fn settle_for_group(&self, job_id: &str, status: JobStatus) {
373 let to_emit = {
375 let mut groups = match self.groups.lock() {
376 Ok(g) => g,
377 Err(_) => return,
378 };
379 let mut emit_for: Option<(String, bool)> = None;
380 for group in groups.values_mut() {
385 if !group.job_ids.contains(job_id) {
386 continue;
387 }
388 group.outcomes.insert(job_id.to_string(), status);
389 if group.outcomes.len() == group.job_ids.len() {
390 let ok = group
391 .outcomes
392 .values()
393 .all(|s| matches!(s, JobStatus::Completed));
394 emit_for = Some((group.catalog_id.clone(), ok));
395 }
396 break; }
398 if let Some((id, _)) = &emit_for {
399 groups.remove(id);
400 }
401 emit_for
402 };
403 if let Some((id, ok)) = to_emit {
404 self.emit(DownloadEvent::CatalogReady { id, ok });
405 }
406 }
407
408 pub(crate) fn take_recipe_payload(&self, job_id: &str) -> Option<RecipePayload> {
412 self.recipe_payloads
413 .lock()
414 .ok()
415 .and_then(|mut p| p.remove(job_id))
416 }
417
418 pub async fn cancel(&self, id: &str) -> bool {
421 {
423 let mut queued = self.queued.lock().expect("queued lock poisoned");
424 if let Some(pos) = queued.iter().position(|j| j.id == id) {
425 queued.remove(pos);
426 drop(queued);
427 let _ = self
428 .events
429 .send(DownloadEvent::Dequeued { id: id.to_string() });
430 return true;
431 }
432 }
433 let active = self.active.lock().await;
435 if let Some(handle) = active.as_ref() {
436 if handle.job.id == id {
437 handle.abort.cancel();
438 return true;
440 }
441 }
442 false
444 }
445
446 pub(crate) fn push_history(&self, job: DownloadJob) {
449 let mut history = self.history.lock().expect("history lock poisoned");
450 if history.len() >= HISTORY_CAP {
451 history.pop_front();
452 }
453 history.push_back(job);
454 }
455
456 pub(crate) async fn wait_for_work(&self, shutdown: &CancellationToken) {
458 tokio::select! {
459 _ = self.notify.notified() => {},
460 _ = shutdown.cancelled() => {},
461 }
462 }
463
464 pub(crate) fn take_next_queued(&self) -> Option<DownloadJob> {
465 self.queued
466 .lock()
467 .expect("queued lock poisoned")
468 .pop_front()
469 }
470
471 pub(crate) async fn set_active(&self, handle: ActiveHandle) {
472 *self.active.lock().await = Some(handle);
473 }
474
475 pub(crate) async fn clear_active(&self) -> Option<ActiveHandle> {
476 self.active.lock().await.take()
477 }
478
479 pub(crate) async fn with_active<F, R>(&self, f: F) -> Option<R>
480 where
481 F: FnOnce(&mut DownloadJob) -> R,
482 {
483 let mut active = self.active.lock().await;
484 active.as_mut().map(|a| f(&mut a.job))
485 }
486
487 pub(crate) fn emit(&self, event: DownloadEvent) {
488 let _ = self.events.send(event);
489 }
490}
491
492#[async_trait::async_trait]
499pub trait PullDriver: Send + Sync + 'static {
500 async fn pull(
501 &self,
502 model: &str,
503 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
504 cancel: CancellationToken,
505 ) -> Result<(), String>;
506}
507
508#[async_trait::async_trait]
513pub trait RecipePullDriver: Send + Sync + 'static {
514 async fn pull(
515 &self,
516 payload: RecipePayload,
517 models_dir: std::path::PathBuf,
518 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
519 cancel: CancellationToken,
520 ) -> Result<(), String>;
521}
522
523pub struct CivitaiRecipeDriver;
525
526#[async_trait::async_trait]
527impl RecipePullDriver for CivitaiRecipeDriver {
528 async fn pull(
529 &self,
530 payload: RecipePayload,
531 models_dir: std::path::PathBuf,
532 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
533 cancel: CancellationToken,
534 ) -> Result<(), String> {
535 let on_progress: mold_core::download::DownloadProgressCallback =
536 std::sync::Arc::from(on_progress);
537 let opts = mold_core::download::PullOptions::default();
538 let files: Vec<mold_core::download::RecipeFetchFile<'_>> = payload
539 .files
540 .iter()
541 .map(|f| mold_core::download::RecipeFetchFile {
542 url: f.url.as_str(),
543 dest: f.dest.as_str(),
544 sha256: f.sha256.as_deref(),
545 size_bytes: f.size_bytes,
546 })
547 .collect();
548 tokio::select! {
549 res = mold_core::download::fetch_recipe(
550 &payload.catalog_id,
551 &files,
552 payload.auth.clone(),
553 &models_dir,
554 Some(on_progress),
555 &opts,
556 ) => res.map(|_| ()).map_err(|e| e.to_string()),
557 _ = cancel.cancelled() => Err("cancelled".into()),
558 }
559 }
560}
561
562pub struct HfPullDriver;
564
565#[async_trait::async_trait]
566impl PullDriver for HfPullDriver {
567 async fn pull(
568 &self,
569 model: &str,
570 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
571 cancel: CancellationToken,
572 ) -> Result<(), String> {
573 let on_progress: mold_core::download::DownloadProgressCallback =
574 std::sync::Arc::from(on_progress);
575 let opts = mold_core::download::PullOptions::default();
576 let model = model.to_string();
577 tokio::select! {
581 res = mold_core::download::pull_and_configure_with_callback(&model, on_progress, &opts) => {
582 res.map(|_| ()).map_err(|e| e.to_string())
583 }
584 _ = cancel.cancelled() => {
585 Err("cancelled".into())
589 }
590 }
591 }
592}
593
594fn cleanup_partials_for_model(model: &str) {
600 use mold_core::manifest::resolve_model_name;
601 let canonical = resolve_model_name(model);
602 let sanitized = canonical.replace(':', "-");
603 mold_core::download::remove_pulling_marker(&canonical);
605 #[cfg(test)]
606 test_hooks::record_cleanup(&canonical);
607 if let Ok(models_dir) = std::env::var("MOLD_MODELS_DIR") {
608 let target = std::path::PathBuf::from(models_dir).join(&sanitized);
609 cleanup_partials_in_dir(&target);
610 return;
611 }
612 if let Some(home) = dirs::home_dir() {
613 let target = home.join(".mold/models").join(&sanitized);
614 cleanup_partials_in_dir(&target);
615 }
616}
617
618#[cfg(test)]
622pub(crate) mod test_hooks {
623 use std::sync::Mutex;
624 static CLEANUPS: Mutex<Vec<String>> = Mutex::new(Vec::new());
625
626 pub(crate) fn record_cleanup(model: &str) {
627 if let Ok(mut v) = CLEANUPS.lock() {
628 v.push(model.to_string());
629 }
630 }
631
632 pub fn drain_cleanups() -> Vec<String> {
636 CLEANUPS
637 .lock()
638 .map(|mut v| std::mem::take(&mut *v))
639 .unwrap_or_default()
640 }
641}
642
643use mold_core::download::SHA256_VERIFIED_SUFFIX;
648
649pub(crate) fn cleanup_partials_in_dir(dir: &std::path::Path) {
663 let root = match dir.canonicalize() {
666 Ok(p) => p,
667 Err(_) => return,
668 };
669 cleanup_partials_in_dir_under_root(dir, &root);
670}
671
672fn cleanup_partials_in_dir_under_root(dir: &std::path::Path, root: &std::path::Path) {
673 if !dir.is_dir() {
674 return;
675 }
676 let entries = match std::fs::read_dir(dir) {
677 Ok(e) => e,
678 Err(_) => return,
679 };
680 for entry in entries.flatten() {
681 let path = entry.path();
682
683 let meta = match path.symlink_metadata() {
688 Ok(m) => m,
689 Err(_) => continue,
690 };
691 let file_type = meta.file_type();
692 if file_type.is_symlink() {
693 tracing::warn!(
694 path = %path.display(),
695 "cleanup_partials: skipping symlink (not following)",
696 );
697 continue;
698 }
699 if file_type.is_dir() {
700 cleanup_partials_in_dir_under_root(&path, root);
701 let _ = std::fs::remove_dir(&path);
703 continue;
704 }
705 if !file_type.is_file() {
706 continue;
707 }
708 let name = match path.file_name().and_then(|n| n.to_str()) {
709 Some(n) => n,
710 None => continue,
711 };
712 if name.ends_with(SHA256_VERIFIED_SUFFIX) {
714 continue;
715 }
716 let marker = path.with_file_name(format!("{name}{SHA256_VERIFIED_SUFFIX}"));
718 if marker.exists() {
719 continue;
720 }
721 match path.canonicalize() {
726 Ok(canon) => {
727 if !canon.starts_with(root) {
728 tracing::warn!(
729 path = %path.display(),
730 canon = %canon.display(),
731 root = %root.display(),
732 "cleanup_partials: refusing to delete file outside models dir",
733 );
734 continue;
735 }
736 }
737 Err(_) => continue,
738 }
739 let _ = std::fs::remove_file(&path);
740 }
741}
742
743pub fn spawn_driver(
746 queue: Arc<DownloadQueue>,
747 driver: Arc<dyn PullDriver>,
748 recipe_driver: Arc<dyn RecipePullDriver>,
749 models_dir: std::path::PathBuf,
750 shutdown: CancellationToken,
751) -> tokio::task::JoinHandle<()> {
752 tokio::spawn(async move {
753 tracing::info!("download queue driver started");
754 loop {
755 queue.wait_for_work(&shutdown).await;
756 if shutdown.is_cancelled() {
757 break;
758 }
759 while let Some(mut job) = queue.take_next_queued() {
760 if shutdown.is_cancelled() {
761 break;
762 }
763 run_one_job(&queue, &driver, &recipe_driver, &models_dir, &mut job).await;
764 if shutdown.is_cancelled() {
765 break;
766 }
767 }
768 }
769 tracing::info!("download queue driver exiting");
770 })
771}
772
773async fn run_one_job(
774 queue: &Arc<DownloadQueue>,
775 driver: &Arc<dyn PullDriver>,
776 recipe_driver: &Arc<dyn RecipePullDriver>,
777 models_dir: &std::path::Path,
778 job: &mut DownloadJob,
779) {
780 job.status = JobStatus::Active;
781 job.started_at = Some(now_ms());
782 let cancel = CancellationToken::new();
783 let handle_job = job.clone();
784
785 let recipe_payload = queue.take_recipe_payload(&job.id);
788
789 let active_handle = ActiveHandle {
792 job: handle_job,
793 abort: cancel.clone(),
794 };
795 queue.set_active(active_handle).await;
796
797 let _ = try_pull_with_retry(
798 queue,
799 driver,
800 recipe_driver,
801 models_dir,
802 recipe_payload.as_ref(),
803 job,
804 cancel.clone(),
805 )
806 .await;
807
808 let final_job = queue
810 .with_active(|a| a.clone())
811 .await
812 .unwrap_or_else(|| job.clone());
813 queue.clear_active().await;
814 queue.push_history(final_job);
815}
816
817#[allow(clippy::too_many_arguments)]
818async fn try_pull_with_retry(
819 queue: &Arc<DownloadQueue>,
820 driver: &Arc<dyn PullDriver>,
821 recipe_driver: &Arc<dyn RecipePullDriver>,
822 models_dir: &std::path::Path,
823 recipe_payload: Option<&RecipePayload>,
824 job: &mut DownloadJob,
825 cancel: CancellationToken,
826) -> Result<(), ()> {
827 for attempt in 0..=1u8 {
829 let result = run_single_attempt(
830 queue,
831 driver,
832 recipe_driver,
833 models_dir,
834 recipe_payload,
835 job,
836 cancel.clone(),
837 )
838 .await;
839 match result {
840 Ok(()) => {
841 job.status = JobStatus::Completed;
842 job.completed_at = Some(now_ms());
843 queue
845 .with_active(|a| {
846 *a = job.clone();
847 })
848 .await;
849 queue.emit(DownloadEvent::JobDone {
850 id: job.id.clone(),
851 model: job.model.clone(),
852 });
853 queue.settle_for_group(&job.id, JobStatus::Completed);
854 return Ok(());
855 }
856 Err(AttemptError::Cancelled) => {
857 job.status = JobStatus::Cancelled;
858 job.completed_at = Some(now_ms());
859 queue
860 .with_active(|a| {
861 *a = job.clone();
862 })
863 .await;
864 cleanup_partials_for_model(&job.model);
865 queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
866 queue.settle_for_group(&job.id, JobStatus::Cancelled);
867 return Err(());
868 }
869 Err(AttemptError::Failed(msg)) => {
870 if attempt == 0 {
871 tracing::warn!(model = %job.model, "pull attempt 1 failed: {msg} — retrying in 5s");
872 tokio::select! {
873 _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {},
874 _ = cancel.cancelled() => {
875 job.status = JobStatus::Cancelled;
876 job.completed_at = Some(now_ms());
877 queue
878 .with_active(|a| {
879 *a = job.clone();
880 })
881 .await;
882 cleanup_partials_for_model(&job.model);
883 queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
884 queue.settle_for_group(&job.id, JobStatus::Cancelled);
885 return Err(());
886 }
887 }
888 continue;
889 }
890 job.status = JobStatus::Failed;
891 job.error = Some(msg.clone());
892 job.completed_at = Some(now_ms());
893 queue
894 .with_active(|a| {
895 *a = job.clone();
896 })
897 .await;
898 cleanup_partials_for_model(&job.model);
899 queue.emit(DownloadEvent::JobFailed {
900 id: job.id.clone(),
901 error: msg,
902 });
903 queue.settle_for_group(&job.id, JobStatus::Failed);
904 return Err(());
905 }
906 }
907 }
908 Err(())
909}
910
911enum AttemptError {
912 Cancelled,
913 Failed(String),
914}
915
916#[allow(clippy::too_many_arguments)]
917async fn run_single_attempt(
918 queue: &Arc<DownloadQueue>,
919 driver: &Arc<dyn PullDriver>,
920 recipe_driver: &Arc<dyn RecipePullDriver>,
921 models_dir: &std::path::Path,
922 recipe_payload: Option<&RecipePayload>,
923 job: &mut DownloadJob,
924 cancel: CancellationToken,
925) -> Result<(), AttemptError> {
926 let (tx, mut rx) =
929 tokio::sync::mpsc::unbounded_channel::<mold_core::download::DownloadProgressEvent>();
930 let on_progress = Box::new(move |evt: mold_core::download::DownloadProgressEvent| {
931 let _ = tx.send(evt);
932 });
933
934 let queue_for_drain = queue.clone();
935 let job_id = job.id.clone();
936 let drain_handle = tokio::spawn(async move {
937 while let Some(evt) = rx.recv().await {
938 translate_event(&queue_for_drain, &job_id, evt).await;
939 }
940 });
941
942 let result = match recipe_payload {
943 Some(payload) => {
944 recipe_driver
945 .pull(
946 payload.clone(),
947 models_dir.to_path_buf(),
948 on_progress,
949 cancel.clone(),
950 )
951 .await
952 }
953 None => driver.pull(&job.model, on_progress, cancel.clone()).await,
954 };
955 let _ = drain_handle.await;
957
958 match result {
959 Ok(()) => Ok(()),
960 Err(msg) if cancel.is_cancelled() => {
961 let _ = msg;
962 Err(AttemptError::Cancelled)
963 }
964 Err(msg) => Err(AttemptError::Failed(msg)),
965 }
966}
967
968async fn translate_event(
969 queue: &Arc<DownloadQueue>,
970 job_id: &str,
971 evt: mold_core::download::DownloadProgressEvent,
972) {
973 use mold_core::download::DownloadProgressEvent as P;
974 let queue = queue.clone();
975 let id = job_id.to_string();
976 {
977 match evt {
978 P::FileStart {
979 total_files,
980 batch_bytes_total,
981 filename,
982 file_index,
983 ..
984 } => {
985 let emitted_started = queue
986 .with_active(|j| {
987 if j.files_total == 0 {
988 j.files_total = total_files;
989 j.bytes_total = batch_bytes_total;
990 true
991 } else {
992 false
993 }
994 })
995 .await
996 .unwrap_or(false);
997 if emitted_started {
998 queue.emit(DownloadEvent::Started {
999 id: id.clone(),
1000 files_total: total_files,
1001 bytes_total: batch_bytes_total,
1002 });
1003 }
1004 let _ = file_index;
1005 queue
1006 .with_active(|j| {
1007 j.current_file = Some(filename.clone());
1008 })
1009 .await;
1010 }
1011 P::FileProgress {
1012 filename,
1013 bytes_downloaded,
1014 batch_bytes_downloaded,
1015 batch_bytes_total,
1016 file_index,
1017 bytes_total,
1018 ..
1019 } => {
1020 let _ = (bytes_downloaded, bytes_total, file_index);
1021 let files_done = queue
1028 .with_active(|j| {
1029 j.bytes_done = batch_bytes_downloaded;
1030 if j.bytes_total == 0 {
1031 j.bytes_total = batch_bytes_total;
1032 }
1033 j.current_file = Some(filename.clone());
1034 j.files_done
1035 })
1036 .await
1037 .unwrap_or(0);
1038 queue.emit(DownloadEvent::Progress {
1039 id: id.clone(),
1040 files_done,
1041 bytes_done: batch_bytes_downloaded,
1042 current_file: Some(filename),
1043 });
1044 }
1045 P::FileDone {
1046 filename,
1047 file_index,
1048 total_files,
1049 batch_bytes_downloaded,
1050 batch_bytes_total,
1051 ..
1052 } => {
1053 let _ = batch_bytes_total;
1054 queue
1055 .with_active(|j| {
1056 j.files_done = file_index + 1;
1057 j.files_total = total_files;
1058 j.bytes_done = batch_bytes_downloaded;
1059 })
1060 .await;
1061 queue.emit(DownloadEvent::FileDone {
1062 id: id.clone(),
1063 filename,
1064 });
1065 }
1066 P::Status { message } => {
1067 queue
1070 .with_active(|j| {
1071 j.current_file = Some(message.clone());
1072 })
1073 .await;
1074 }
1075 }
1076 }
1077}
1078
1079fn now_ms() -> i64 {
1080 use std::time::{SystemTime, UNIX_EPOCH};
1081 SystemTime::now()
1082 .duration_since(UNIX_EPOCH)
1083 .map(|d| d.as_millis() as i64)
1084 .unwrap_or(0)
1085}
1086
1087#[cfg(test)]
1088#[path = "downloads_test.rs"]
1089mod tests;