use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex as StdMutex};
use mold_core::download::RecipeAuth;
use mold_core::types::{DownloadEvent, DownloadJob, DownloadsListing, JobStatus};
use tokio::sync::{broadcast, Mutex as AsyncMutex, Notify};
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug)]
pub struct OwnedRecipeFile {
pub url: String,
pub dest: String,
pub sha256: Option<String>,
pub size_bytes: Option<u64>,
}
#[derive(Clone, Debug)]
pub struct RecipePayload {
pub catalog_id: String,
pub files: Vec<OwnedRecipeFile>,
pub auth: RecipeAuth,
}
pub const EVENT_BUFFER: usize = 256;
pub const HISTORY_CAP: usize = 20;
pub const DOWNLOAD_CONCURRENCY: usize = 2;
pub struct ActiveHandle {
pub job: DownloadJob,
pub abort: CancellationToken,
}
#[derive(Debug, Clone)]
struct CatalogGroup {
catalog_id: String,
job_ids: std::collections::HashSet<String>,
outcomes: HashMap<String, JobStatus>,
}
pub struct DownloadQueue {
active: AsyncMutex<HashMap<String, ActiveHandle>>,
queued: StdMutex<VecDeque<DownloadJob>>,
history: StdMutex<VecDeque<DownloadJob>>,
events: broadcast::Sender<DownloadEvent>,
notify: Notify,
recipe_payloads: StdMutex<HashMap<String, RecipePayload>>,
groups: StdMutex<HashMap<String, CatalogGroup>>,
}
#[derive(Debug, thiserror::Error)]
pub enum EnqueueError {
#[error("unknown model '{0}'. Run 'mold list' to see available models.")]
UnknownModel(String),
#[error("download queue lock poisoned")]
LockPoisoned,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnqueueOutcome {
AlreadyPresent,
Created,
}
impl DownloadQueue {
pub fn new() -> Arc<Self> {
let (events, _rx) = broadcast::channel(EVENT_BUFFER);
Arc::new(Self {
active: AsyncMutex::new(HashMap::new()),
queued: StdMutex::new(VecDeque::new()),
history: StdMutex::new(VecDeque::new()),
events,
notify: Notify::new(),
recipe_payloads: StdMutex::new(HashMap::new()),
groups: StdMutex::new(HashMap::new()),
})
}
#[cfg(test)]
pub fn new_for_test() -> Arc<Self> {
Self::new()
}
pub fn subscribe(&self) -> broadcast::Receiver<DownloadEvent> {
self.events.subscribe()
}
pub async fn listing(&self) -> DownloadsListing {
let mut active_jobs: Vec<DownloadJob> = self
.active
.lock()
.await
.values()
.map(|handle| handle.job.clone())
.collect();
active_jobs.sort_by_key(|job| (job.started_at, job.id.clone()));
let active = active_jobs.first().cloned();
let queued: Vec<DownloadJob> = self
.queued
.lock()
.expect("queued lock poisoned")
.iter()
.cloned()
.collect();
let history: Vec<DownloadJob> = self
.history
.lock()
.expect("history lock poisoned")
.iter()
.cloned()
.collect();
DownloadsListing {
active_jobs,
active,
queued,
history,
}
}
pub async fn enqueue(
&self,
model: String,
) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
let canonical = mold_core::manifest::resolve_model_name(&model);
if mold_core::manifest::find_manifest(&canonical).is_none() {
return Err(EnqueueError::UnknownModel(model));
}
{
let active = self.active.lock().await;
if let Some(handle) = active.values().find(|handle| handle.job.model == canonical) {
return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
}
}
{
let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
if let Some((idx, existing)) = queued
.iter()
.enumerate()
.find(|(_, j)| j.model == canonical)
{
return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
}
}
let id = uuid::Uuid::new_v4().to_string();
let job = DownloadJob {
id: id.clone(),
model: canonical.clone(),
catalog_id: None,
status: JobStatus::Queued,
files_done: 0,
files_total: 0,
bytes_done: 0,
bytes_total: 0,
current_file: None,
started_at: None,
completed_at: None,
error: None,
};
let position = {
let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
queued.push_back(job);
queued.len() };
let _ = self.events.send(DownloadEvent::Enqueued {
id: id.clone(),
model: canonical,
position,
});
self.notify.notify_one();
Ok((id, position, EnqueueOutcome::Created))
}
pub async fn enqueue_recipe(
&self,
payload: RecipePayload,
) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
if payload.catalog_id.trim().is_empty() {
return Err(EnqueueError::UnknownModel(payload.catalog_id));
}
{
let active = self.active.lock().await;
if let Some(handle) = active
.values()
.find(|handle| handle.job.model == payload.catalog_id)
{
return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
}
}
{
let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
if let Some((idx, existing)) = queued
.iter()
.enumerate()
.find(|(_, j)| j.model == payload.catalog_id)
{
return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
}
}
let id = uuid::Uuid::new_v4().to_string();
let job = DownloadJob {
id: id.clone(),
model: payload.catalog_id.clone(),
catalog_id: Some(payload.catalog_id.clone()),
status: JobStatus::Queued,
files_done: 0,
files_total: payload.files.len(),
bytes_done: 0,
bytes_total: payload.files.iter().filter_map(|f| f.size_bytes).sum(),
current_file: None,
started_at: None,
completed_at: None,
error: None,
};
{
let mut payloads = self
.recipe_payloads
.lock()
.map_err(|_| EnqueueError::LockPoisoned)?;
payloads.insert(id.clone(), payload.clone());
}
let position = {
let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
queued.push_back(job);
queued.len()
};
let _ = self.events.send(DownloadEvent::Enqueued {
id: id.clone(),
model: payload.catalog_id,
position,
});
self.notify.notify_one();
Ok((id, position, EnqueueOutcome::Created))
}
pub async fn enqueue_in_group(
&self,
model: String,
catalog_id: &str,
) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
let (id, pos, outcome) = self.enqueue(model).await?;
self.register_in_group(catalog_id, &id);
Ok((id, pos, outcome))
}
pub async fn enqueue_recipe_in_group(
&self,
payload: RecipePayload,
catalog_id: &str,
) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
let (id, pos, outcome) = self.enqueue_recipe(payload).await?;
self.register_in_group(catalog_id, &id);
Ok((id, pos, outcome))
}
fn register_in_group(&self, catalog_id: &str, job_id: &str) {
self.tag_job_with_catalog_id(catalog_id, job_id);
let mut groups = match self.groups.lock() {
Ok(g) => g,
Err(_) => return, };
let entry = groups
.entry(catalog_id.to_string())
.or_insert_with(|| CatalogGroup {
catalog_id: catalog_id.to_string(),
job_ids: std::collections::HashSet::new(),
outcomes: HashMap::new(),
});
entry.job_ids.insert(job_id.to_string());
}
fn tag_job_with_catalog_id(&self, catalog_id: &str, job_id: &str) {
if let Ok(mut queued) = self.queued.lock() {
if let Some(job) = queued.iter_mut().find(|job| job.id == job_id) {
job.catalog_id = Some(catalog_id.to_string());
return;
}
}
if let Ok(mut history) = self.history.lock() {
if let Some(job) = history.iter_mut().find(|job| job.id == job_id) {
job.catalog_id = Some(catalog_id.to_string());
return;
}
}
if let Ok(mut active) = self.active.try_lock() {
if let Some(handle) = active.get_mut(job_id) {
handle.job.catalog_id = Some(catalog_id.to_string());
}
}
}
pub(crate) fn settle_for_group(&self, job_id: &str, status: JobStatus) {
let to_emit = {
let mut groups = match self.groups.lock() {
Ok(g) => g,
Err(_) => return,
};
let mut emit_for: Option<(String, bool)> = None;
for group in groups.values_mut() {
if !group.job_ids.contains(job_id) {
continue;
}
group.outcomes.insert(job_id.to_string(), status);
if group.outcomes.len() == group.job_ids.len() {
let ok = group
.outcomes
.values()
.all(|s| matches!(s, JobStatus::Completed));
emit_for = Some((group.catalog_id.clone(), ok));
}
break; }
if let Some((id, _)) = &emit_for {
groups.remove(id);
}
emit_for
};
if let Some((id, ok)) = to_emit {
self.emit(DownloadEvent::CatalogReady { id, ok });
}
}
pub(crate) fn take_recipe_payload(&self, job_id: &str) -> Option<RecipePayload> {
self.recipe_payloads
.lock()
.ok()
.and_then(|mut p| p.remove(job_id))
}
pub async fn cancel(&self, id: &str) -> bool {
{
let mut queued = self.queued.lock().expect("queued lock poisoned");
if let Some(pos) = queued.iter().position(|j| j.id == id) {
let mut job = queued.remove(pos).expect("queued position must exist");
drop(queued);
job.status = JobStatus::Cancelled;
job.completed_at = Some(now_ms());
self.recipe_payloads
.lock()
.expect("recipe payload lock poisoned")
.remove(id);
self.push_history(job);
let _ = self
.events
.send(DownloadEvent::JobCancelled { id: id.to_string() });
let _ = self
.events
.send(DownloadEvent::Dequeued { id: id.to_string() });
self.settle_for_group(id, JobStatus::Cancelled);
return true;
}
}
let active = self.active.lock().await;
if let Some(handle) = active.get(id) {
handle.abort.cancel();
return true;
}
false
}
pub(crate) fn push_history(&self, job: DownloadJob) {
let mut history = self.history.lock().expect("history lock poisoned");
if history.len() >= HISTORY_CAP {
history.pop_front();
}
history.push_back(job);
}
pub(crate) async fn wait_for_work(&self, shutdown: &CancellationToken) {
tokio::select! {
_ = self.notify.notified() => {},
_ = shutdown.cancelled() => {},
}
}
pub(crate) fn take_next_queued(&self) -> Option<DownloadJob> {
self.queued
.lock()
.expect("queued lock poisoned")
.pop_front()
}
pub(crate) async fn set_active(&self, handle: ActiveHandle) {
self.active
.lock()
.await
.insert(handle.job.id.clone(), handle);
}
pub(crate) async fn clear_active(&self, id: &str) -> Option<ActiveHandle> {
self.active.lock().await.remove(id)
}
pub(crate) async fn with_active<F, R>(&self, id: &str, f: F) -> Option<R>
where
F: FnOnce(&mut DownloadJob) -> R,
{
let mut active = self.active.lock().await;
active.get_mut(id).map(|a| f(&mut a.job))
}
async fn cancel_all_active(&self) {
for handle in self.active.lock().await.values() {
handle.abort.cancel();
}
}
pub(crate) fn emit(&self, event: DownloadEvent) {
let _ = self.events.send(event);
}
}
#[async_trait::async_trait]
pub trait PullDriver: Send + Sync + 'static {
async fn pull(
&self,
model: &str,
on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
cancel: CancellationToken,
) -> Result<(), String>;
}
#[async_trait::async_trait]
pub trait RecipePullDriver: Send + Sync + 'static {
async fn pull(
&self,
payload: RecipePayload,
models_dir: std::path::PathBuf,
on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
cancel: CancellationToken,
) -> Result<(), String>;
}
pub struct CivitaiRecipeDriver;
#[async_trait::async_trait]
impl RecipePullDriver for CivitaiRecipeDriver {
async fn pull(
&self,
payload: RecipePayload,
models_dir: std::path::PathBuf,
on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
cancel: CancellationToken,
) -> Result<(), String> {
let on_progress: mold_core::download::DownloadProgressCallback =
std::sync::Arc::from(on_progress);
let opts = mold_core::download::PullOptions::default();
let files: Vec<mold_core::download::RecipeFetchFile<'_>> = payload
.files
.iter()
.map(|f| mold_core::download::RecipeFetchFile {
url: f.url.as_str(),
dest: f.dest.as_str(),
sha256: f.sha256.as_deref(),
size_bytes: f.size_bytes,
})
.collect();
tokio::select! {
res = mold_core::download::fetch_recipe(
&payload.catalog_id,
&files,
payload.auth.clone(),
&models_dir,
Some(on_progress),
&opts,
) => res.map(|_| ()).map_err(|e| e.to_string()),
_ = cancel.cancelled() => Err("cancelled".into()),
}
}
}
pub struct HfPullDriver;
#[async_trait::async_trait]
impl PullDriver for HfPullDriver {
async fn pull(
&self,
model: &str,
on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
cancel: CancellationToken,
) -> Result<(), String> {
let on_progress: mold_core::download::DownloadProgressCallback =
std::sync::Arc::from(on_progress);
let opts = mold_core::download::PullOptions::default();
let model = model.to_string();
tokio::select! {
res = mold_core::download::pull_and_configure_with_callback(&model, on_progress, &opts) => {
res.map(|_| ()).map_err(|e| e.to_string())
}
_ = cancel.cancelled() => {
Err("cancelled".into())
}
}
}
}
fn cleanup_partials_for_model(model: &str) {
use mold_core::manifest::resolve_model_name;
let canonical = resolve_model_name(model);
let sanitized = canonical.replace(':', "-");
mold_core::download::remove_pulling_marker(&canonical);
#[cfg(test)]
test_hooks::record_cleanup(&canonical);
if let Ok(models_dir) = std::env::var("MOLD_MODELS_DIR") {
let target = std::path::PathBuf::from(models_dir).join(&sanitized);
cleanup_partials_in_dir(&target);
return;
}
if let Some(home) = dirs::home_dir() {
let target = home.join(".mold/models").join(&sanitized);
cleanup_partials_in_dir(&target);
}
}
#[cfg(test)]
pub(crate) mod test_hooks {
use std::sync::Mutex;
static CLEANUPS: Mutex<Vec<String>> = Mutex::new(Vec::new());
pub(crate) fn record_cleanup(model: &str) {
if let Ok(mut v) = CLEANUPS.lock() {
v.push(model.to_string());
}
}
pub fn drain_cleanups() -> Vec<String> {
CLEANUPS
.lock()
.map(|mut v| std::mem::take(&mut *v))
.unwrap_or_default()
}
}
use mold_core::download::SHA256_VERIFIED_SUFFIX;
pub(crate) fn cleanup_partials_in_dir(dir: &std::path::Path) {
let root = match dir.canonicalize() {
Ok(p) => p,
Err(_) => return,
};
cleanup_partials_in_dir_under_root(dir, &root);
}
fn cleanup_partials_in_dir_under_root(dir: &std::path::Path, root: &std::path::Path) {
if !dir.is_dir() {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let meta = match path.symlink_metadata() {
Ok(m) => m,
Err(_) => continue,
};
let file_type = meta.file_type();
if file_type.is_symlink() {
tracing::warn!(
path = %path.display(),
"cleanup_partials: skipping symlink (not following)",
);
continue;
}
if file_type.is_dir() {
cleanup_partials_in_dir_under_root(&path, root);
let _ = std::fs::remove_dir(&path);
continue;
}
if !file_type.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name.ends_with(SHA256_VERIFIED_SUFFIX) {
continue;
}
let marker = path.with_file_name(format!("{name}{SHA256_VERIFIED_SUFFIX}"));
if marker.exists() {
continue;
}
match path.canonicalize() {
Ok(canon) => {
if !canon.starts_with(root) {
tracing::warn!(
path = %path.display(),
canon = %canon.display(),
root = %root.display(),
"cleanup_partials: refusing to delete file outside models dir",
);
continue;
}
}
Err(_) => continue,
}
let _ = std::fs::remove_file(&path);
}
}
pub fn spawn_driver(
queue: Arc<DownloadQueue>,
driver: Arc<dyn PullDriver>,
recipe_driver: Arc<dyn RecipePullDriver>,
models_dir: std::path::PathBuf,
shutdown: CancellationToken,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
tracing::info!("download queue driver started");
let mut running = tokio::task::JoinSet::new();
loop {
if shutdown.is_cancelled() {
queue.cancel_all_active().await;
while running.join_next().await.is_some() {}
break;
}
while running.len() < DOWNLOAD_CONCURRENCY {
let Some(job) = queue.take_next_queued() else {
break;
};
let queue = queue.clone();
let driver = driver.clone();
let recipe_driver = recipe_driver.clone();
let models_dir = models_dir.clone();
running.spawn(async move {
run_one_job(&queue, &driver, &recipe_driver, &models_dir, job).await;
});
}
if running.is_empty() {
queue.wait_for_work(&shutdown).await;
} else {
tokio::select! {
_ = queue.notify.notified() => {},
_ = running.join_next() => {},
_ = shutdown.cancelled() => {},
}
}
}
tracing::info!("download queue driver exiting");
})
}
async fn run_one_job(
queue: &Arc<DownloadQueue>,
driver: &Arc<dyn PullDriver>,
recipe_driver: &Arc<dyn RecipePullDriver>,
models_dir: &std::path::Path,
mut job: DownloadJob,
) {
job.status = JobStatus::Active;
job.started_at = Some(now_ms());
let cancel = CancellationToken::new();
let handle_job = job.clone();
let recipe_payload = queue.take_recipe_payload(&job.id);
let active_handle = ActiveHandle {
job: handle_job,
abort: cancel.clone(),
};
queue.set_active(active_handle).await;
let _ = try_pull_with_retry(
queue,
driver,
recipe_driver,
models_dir,
recipe_payload.as_ref(),
&mut job,
cancel.clone(),
)
.await;
let final_job = queue
.with_active(&job.id, |a| a.clone())
.await
.unwrap_or_else(|| job.clone());
queue.clear_active(&job.id).await;
queue.push_history(final_job);
}
#[allow(clippy::too_many_arguments)]
async fn try_pull_with_retry(
queue: &Arc<DownloadQueue>,
driver: &Arc<dyn PullDriver>,
recipe_driver: &Arc<dyn RecipePullDriver>,
models_dir: &std::path::Path,
recipe_payload: Option<&RecipePayload>,
job: &mut DownloadJob,
cancel: CancellationToken,
) -> Result<(), ()> {
for attempt in 0..=1u8 {
let result = run_single_attempt(
queue,
driver,
recipe_driver,
models_dir,
recipe_payload,
job,
cancel.clone(),
)
.await;
match result {
Ok(()) => {
job.status = JobStatus::Completed;
job.completed_at = Some(now_ms());
queue
.with_active(&job.id, |a| {
*a = job.clone();
})
.await;
queue.emit(DownloadEvent::JobDone {
id: job.id.clone(),
model: job.model.clone(),
});
queue.settle_for_group(&job.id, JobStatus::Completed);
return Ok(());
}
Err(AttemptError::Cancelled) => {
job.status = JobStatus::Cancelled;
job.completed_at = Some(now_ms());
queue
.with_active(&job.id, |a| {
*a = job.clone();
})
.await;
cleanup_partials_for_model(&job.model);
queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
queue.settle_for_group(&job.id, JobStatus::Cancelled);
return Err(());
}
Err(AttemptError::Failed(msg)) => {
if attempt == 0 {
tracing::warn!(model = %job.model, "pull attempt 1 failed: {msg} — retrying in 5s");
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {},
_ = cancel.cancelled() => {
job.status = JobStatus::Cancelled;
job.completed_at = Some(now_ms());
queue
.with_active(&job.id, |a| {
*a = job.clone();
})
.await;
cleanup_partials_for_model(&job.model);
queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
queue.settle_for_group(&job.id, JobStatus::Cancelled);
return Err(());
}
}
continue;
}
job.status = JobStatus::Failed;
job.error = Some(msg.clone());
job.completed_at = Some(now_ms());
queue
.with_active(&job.id, |a| {
*a = job.clone();
})
.await;
cleanup_partials_for_model(&job.model);
queue.emit(DownloadEvent::JobFailed {
id: job.id.clone(),
error: msg,
});
queue.settle_for_group(&job.id, JobStatus::Failed);
return Err(());
}
}
}
Err(())
}
enum AttemptError {
Cancelled,
Failed(String),
}
#[allow(clippy::too_many_arguments)]
async fn run_single_attempt(
queue: &Arc<DownloadQueue>,
driver: &Arc<dyn PullDriver>,
recipe_driver: &Arc<dyn RecipePullDriver>,
models_dir: &std::path::Path,
recipe_payload: Option<&RecipePayload>,
job: &mut DownloadJob,
cancel: CancellationToken,
) -> Result<(), AttemptError> {
let (tx, mut rx) =
tokio::sync::mpsc::unbounded_channel::<mold_core::download::DownloadProgressEvent>();
let on_progress = Box::new(move |evt: mold_core::download::DownloadProgressEvent| {
let _ = tx.send(evt);
});
let queue_for_drain = queue.clone();
let job_id = job.id.clone();
let drain_handle = tokio::spawn(async move {
while let Some(evt) = rx.recv().await {
translate_event(&queue_for_drain, &job_id, evt).await;
}
});
let result = match recipe_payload {
Some(payload) => {
recipe_driver
.pull(
payload.clone(),
models_dir.to_path_buf(),
on_progress,
cancel.clone(),
)
.await
}
None => driver.pull(&job.model, on_progress, cancel.clone()).await,
};
let _ = drain_handle.await;
match result {
Ok(()) => Ok(()),
Err(msg) if cancel.is_cancelled() => {
let _ = msg;
Err(AttemptError::Cancelled)
}
Err(msg) => Err(AttemptError::Failed(msg)),
}
}
async fn translate_event(
queue: &Arc<DownloadQueue>,
job_id: &str,
evt: mold_core::download::DownloadProgressEvent,
) {
use mold_core::download::DownloadProgressEvent as P;
let queue = queue.clone();
let id = job_id.to_string();
{
match evt {
P::FileStart {
total_files,
batch_bytes_total,
filename,
file_index,
..
} => {
let emitted_started = queue
.with_active(&id, |j| {
if j.files_total == 0 {
j.files_total = total_files;
j.bytes_total = batch_bytes_total;
true
} else {
false
}
})
.await
.unwrap_or(false);
if emitted_started {
queue.emit(DownloadEvent::Started {
id: id.clone(),
files_total: total_files,
bytes_total: batch_bytes_total,
});
}
let _ = file_index;
queue
.with_active(&id, |j| {
j.current_file = Some(filename.clone());
})
.await;
}
P::FileProgress {
filename,
bytes_downloaded,
batch_bytes_downloaded,
batch_bytes_total,
file_index,
bytes_total,
..
} => {
let _ = (bytes_downloaded, bytes_total, file_index);
let files_done = queue
.with_active(&id, |j| {
j.bytes_done = batch_bytes_downloaded;
if j.bytes_total == 0 {
j.bytes_total = batch_bytes_total;
}
j.current_file = Some(filename.clone());
j.files_done
})
.await
.unwrap_or(0);
queue.emit(DownloadEvent::Progress {
id: id.clone(),
files_done,
bytes_done: batch_bytes_downloaded,
current_file: Some(filename),
});
}
P::FileDone {
filename,
file_index,
total_files,
batch_bytes_downloaded,
batch_bytes_total,
..
} => {
let _ = batch_bytes_total;
queue
.with_active(&id, |j| {
j.files_done = file_index + 1;
j.files_total = total_files;
j.bytes_done = batch_bytes_downloaded;
})
.await;
queue.emit(DownloadEvent::FileDone {
id: id.clone(),
filename,
});
}
P::Status { message } => {
queue
.with_active(&id, |j| {
j.current_file = Some(message.clone());
})
.await;
}
}
}
}
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
#[path = "downloads_test.rs"]
mod tests;