diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e305fc..e984e33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1396,28 +1396,60 @@ async fn pause_download( state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { - println!("pause_download called for id: {}", id); + log::info!("pause_download called for id: {}", id); let active_kind = state.queue_manager.active_kind(&id).await; state.queue_manager.remove_from_pending(&id).await; - // Emit the paused state. + let gid = state.queue_manager.aria2_gid_for_download(&id); + if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) { + let status = aria2_download_status( + state.aria2_port, + &state.aria2_secret, + gid, + ) + .await?; + match status.as_str() { + "paused" => { + log::info!("aria2 pause [{}]: gid {} was already paused", id, gid); + } + "active" | "waiting" => { + let result = rpc_call( + state.aria2_port, + &state.aria2_secret, + "aria2.forcePause", + serde_json::json!([gid]), + ) + .await + .map_err(|error| format!("failed to pause aria2 gid {gid}: {error}"))?; + ensure_aria2_gid_result("forcePause", gid, &result)?; + log::info!("aria2 pause [{}]: gid {} paused", id, gid); + } + terminal => { + state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_aria2_gid(&id).await; + state.queue_manager.release_permit(&id).await; + return Err(format!( + "cannot pause aria2 gid {gid} in terminal state {terminal}" + )); + } + } + + state.queue_manager.release_permit(&id).await; + use tauri::Emitter; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused), + ); + return Ok(()); + } + use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), ); - let gid = state.queue_manager.aria2_gids.read().unwrap() - .iter() - .find(|(_, v)| **v == id) - .map(|(k, _)| k.clone()); - if let Some(g) = gid { - if !g.starts_with("native:") { - let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.pause", serde_json::json!([g])).await; - } - } - if let Ok(download_id) = Uuid::parse_str(&id) { let _ = state .download_coordinator @@ -1431,6 +1463,84 @@ async fn pause_download( media_result } +#[tauri::command] +async fn resume_download( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + id: String, +) -> Result { + let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else { + log::info!("aria2 resume [{}]: no mapped gid; re-enqueue is permitted", id); + return Ok(false); + }; + if gid.starts_with("native:") { + state.queue_manager.forget_aria2_gid(&id).await; + log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id); + return Ok(false); + } + + let status = aria2_download_status(state.aria2_port, &state.aria2_secret, &gid).await?; + match status.as_str() { + "paused" => { + let acquired = state.queue_manager.ensure_aria2_permit(&id).await; + let result = match rpc_call( + state.aria2_port, + &state.aria2_secret, + "aria2.unpause", + serde_json::json!([gid]), + ) + .await + { + Ok(result) => result, + Err(error) => { + if acquired { + state.queue_manager.release_permit(&id).await; + } + return Err(format!("failed to resume aria2 gid {gid}: {error}")); + } + }; + if let Err(error) = ensure_aria2_gid_result("unpause", &gid, &result) { + if acquired { + state.queue_manager.release_permit(&id).await; + } + return Err(error); + } + log::info!("aria2 resume [{}]: unpaused gid {}", id, gid); + } + "active" | "waiting" => { + state.queue_manager.ensure_aria2_permit(&id).await; + log::info!( + "aria2 resume [{}]: gid {} already {}; no duplicate job created", + id, + gid, + status + ); + } + "complete" | "error" | "removed" => { + state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_aria2_gid(&id).await; + state.queue_manager.release_permit(&id).await; + log::info!( + "aria2 resume [{}]: gid {} is {}; re-enqueue is permitted", + id, + gid, + status + ); + return Ok(false); + } + other => { + return Err(format!("aria2 gid {gid} returned unknown status {other}")); + } + } + + use tauri::Emitter; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Downloading), + ); + Ok(true) +} + #[tauri::command] async fn remove_download( app_handle: tauri::AppHandle, @@ -1438,28 +1548,50 @@ async fn remove_download( id: String, filepath: Option, ) -> Result<(), String> { - println!("remove_download called for id: {}", id); + log::info!("remove_download called for id: {}", id); let active_kind = state.queue_manager.active_kind(&id).await; state.queue_manager.remove_from_pending(&id).await; + state.queue_manager.cancel_aria2_retries(&id).await; + let retry_add_guard = state.queue_manager.lock_aria2_retry_add().await; + let gid = state.queue_manager.aria2_gid_for_download(&id); + if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) { + let removal_result = async { + force_remove_aria2_gid(state.aria2_port, &state.aria2_secret, gid).await?; + wait_for_aria2_stopped(state.aria2_port, &state.aria2_secret, gid).await + } + .await; + if let Err(error) = removal_result { + state.queue_manager.allow_aria2_retries(&id).await; + return Err(error); + } + state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_aria2_gid(&id).await; + state.queue_manager.release_permit(&id).await; + log::info!("aria2 remove [{}]: gid {} stopped and forgotten", id, gid); + } else { + drop(retry_add_guard); + if let Ok(download_id) = Uuid::parse_str(&id) { + state + .download_coordinator + .send(download::DownloadCmd::Cancel(download_id)) + .await?; + } + state.download_coordinator.pause_media(id.clone()).await?; + if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) { + state.queue_manager.release_permit(&id).await; + } + state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_aria2_gid(&id).await; + } + use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), ); - if let Ok(download_id) = Uuid::parse_str(&id) { - state - .download_coordinator - .send(download::DownloadCmd::Cancel(download_id)) - .await?; - } - state.download_coordinator.pause_media(id.clone()).await?; - if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) { - state.queue_manager.release_permit(&id).await; - } - if let Some(path) = filepath { if !path.is_empty() { let p = std::path::Path::new(&path); @@ -1479,6 +1611,86 @@ async fn remove_download( Ok(()) } +fn ensure_aria2_gid_result( + method: &str, + expected_gid: &str, + result: &serde_json::Value, +) -> Result<(), String> { + match result.as_str() { + Some(returned_gid) if returned_gid == expected_gid => Ok(()), + Some(returned_gid) => Err(format!( + "aria2.{method} returned unexpected gid {returned_gid}, expected {expected_gid}" + )), + None => Err(format!("aria2.{method} returned a non-string result")), + } +} + +async fn aria2_download_status(port: u16, secret: &str, gid: &str) -> Result { + let result = rpc_call( + port, + secret, + "aria2.tellStatus", + serde_json::json!([gid, ["status"]]), + ) + .await + .map_err(|error| format!("failed to query aria2 gid {gid}: {error}"))?; + result + .get("status") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .ok_or_else(|| format!("aria2.tellStatus returned no status for gid {gid}")) +} + +fn aria2_gid_not_found(error: &str) -> bool { + let lower = error.to_ascii_lowercase(); + lower.contains("gid") && lower.contains("not found") +} + +async fn force_remove_aria2_gid(port: u16, secret: &str, gid: &str) -> Result<(), String> { + match rpc_call( + port, + secret, + "aria2.forceRemove", + serde_json::json!([gid]), + ) + .await + { + Ok(result) => ensure_aria2_gid_result("forceRemove", gid, &result), + Err(error) if aria2_gid_not_found(&error) => { + log::info!("aria2 forceRemove: gid {} was already absent", gid); + Ok(()) + } + Err(error) => match aria2_download_status(port, secret, gid).await { + Ok(status) if matches!(status.as_str(), "complete" | "error" | "removed") => { + log::info!( + "aria2 forceRemove: gid {} raced to terminal state {}", + gid, + status + ); + Ok(()) + } + _ => Err(format!("failed to remove aria2 gid {gid}: {error}")), + }, + } +} + +async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<(), String> { + for _ in 0..30 { + match aria2_download_status(port, secret, gid).await { + Ok(status) if matches!(status.as_str(), "complete" | "error" | "removed") => { + return Ok(()); + } + Ok(_) => {} + Err(error) if aria2_gid_not_found(&error) => return Ok(()), + Err(error) => return Err(error), + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Err(format!( + "aria2 gid {gid} did not stop within 3 seconds after forceRemove" + )) +} + #[tauri::command] fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) { #[cfg(target_os = "macos")] @@ -2279,7 +2491,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ get_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder, - pause_download, fetch_metadata, fetch_media_metadata, + pause_download, resume_download, fetch_metadata, fetch_media_metadata, update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action, request_automation_permission, open_automation_settings, set_keychain_password, get_keychain_password, delete_keychain_password, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 8eda1e3..7f0178a 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1,7 +1,7 @@ use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection}; use crate::retry::{BackoffOutcome, MAX_RETRIES, backoff_and_emit, is_transient_network_error}; use serde::Deserialize; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -72,6 +72,10 @@ pub trait SidecarSpawner: Send + Sync + 'static { /// permit is already parked before this is called). async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result; + /// Force-remove an aria2 gid created by a retry that raced with user + /// cancellation. + async fn remove_uri(&self, gid: &str) -> Result<(), String>; + /// Run a media download to completion. The permit is parked for the full /// duration; release is handled by QueueManager on the runner's exit. async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>; @@ -103,6 +107,13 @@ pub struct QueueManager { /// 0-based transient-error strike counter per aria2 download id. aria2_retry_strikes: Mutex>, + /// Download ids whose aria2 retry loop must not create another job. + aria2_retry_cancelled: Mutex>, + + /// Serializes retry addUri with remove so a late retry cannot escape + /// cancellation and continue writing after deletion. + aria2_retry_add_lock: Mutex<()>, + spawner: Arc, app_handle: AppHandle, } @@ -136,6 +147,8 @@ impl QueueManager { pending_completion: Arc::new(Mutex::new(HashMap::new())), aria2_payloads: Mutex::new(HashMap::new()), aria2_retry_strikes: Mutex::new(HashMap::new()), + aria2_retry_cancelled: Mutex::new(HashSet::new()), + aria2_retry_add_lock: Mutex::new(()), spawner, app_handle, } @@ -185,6 +198,30 @@ impl QueueManager { self.active_kinds.lock().await.get(id).cloned() } + /// Ensure an aria2 transfer owns exactly one queue permit. Returns true + /// when this call acquired and parked the permit, false when one was + /// already parked. + pub async fn ensure_aria2_permit(&self, id: &str) -> bool { + if self.active_permits.lock().await.contains_key(id) { + return false; + } + + let permit = self.acquire_permit().await; + let mut permits = self.active_permits.lock().await; + if permits.contains_key(id) { + drop(permits); + drop(permit); + return false; + } + permits.insert(id.to_string(), permit); + drop(permits); + self.active_kinds + .lock() + .await + .insert(id.to_string(), TaskKind::Aria2); + true + } + /// Release the permit parked under `id`, if any. Idempotent. Wakes the /// dispatcher so a freed slot is claimed promptly. pub async fn release_permit(&self, id: &str) { @@ -311,6 +348,7 @@ impl QueueManager { match task.kind { TaskKind::Aria2 => { + self.aria2_retry_cancelled.lock().await.remove(&id); self.aria2_payloads .lock() .await @@ -378,8 +416,21 @@ impl QueueManager { pub async fn remember_gid(&self, id: String, gid: String) { { let mut gids = self.aria2_gids.write().unwrap(); + gids.retain(|existing_gid, existing_id| { + let keep = existing_id != &id || existing_gid == &gid; + if !keep { + log::warn!( + "aria2 gid transition [{}]: dropping stale mapping {} before storing {}", + id, + existing_gid, + gid + ); + } + keep + }); gids.insert(gid.clone(), id.clone()); } + log::info!("aria2 gid transition [{}]: mapped {}", id, gid); let buffered = self.pending_completion.lock().await.remove(&gid); if let Some((_buf_id, outcome)) = buffered { self.apply_completion(&id, outcome).await; @@ -391,27 +442,86 @@ impl QueueManager { match outcome { PendingOutcome::Complete => { self.clear_aria2_retry_state(id).await; + self.forget_aria2_gid(id).await; self.emit_state(id, DownloadStatus::Completed); } PendingOutcome::Error(error) => { self.clear_aria2_retry_state(id).await; + self.forget_aria2_gid(id).await; self.emit_failed(id, error); } } self.release_permit(id).await; } - async fn clear_aria2_retry_state(&self, id: &str) { + pub async fn clear_aria2_retry_state(&self, id: &str) { self.aria2_payloads.lock().await.remove(id); self.aria2_retry_strikes.lock().await.remove(id); } + pub async fn cancel_aria2_retries(&self, id: &str) { + self.aria2_retry_cancelled + .lock() + .await + .insert(id.to_string()); + } + + pub async fn allow_aria2_retries(&self, id: &str) { + self.aria2_retry_cancelled.lock().await.remove(id); + } + + pub async fn lock_aria2_retry_add(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.aria2_retry_add_lock.lock().await + } + + pub fn aria2_gid_for_download(&self, id: &str) -> Option { + self.aria2_gids + .read() + .unwrap() + .iter() + .find_map(|(gid, download_id)| (download_id == id).then(|| gid.clone())) + } + + /// Remove every gid mapping for a download and discard buffered terminal + /// events for those gids. Returns the most recently encountered gid. + pub async fn forget_aria2_gid(&self, id: &str) -> Option { + let removed = { + let mut gids = self.aria2_gids.write().unwrap(); + let removed: Vec = gids + .iter() + .filter(|(_, download_id)| *download_id == id) + .map(|(gid, _)| gid.clone()) + .collect(); + for gid in &removed { + gids.remove(gid); + } + removed + }; + + if removed.is_empty() { + return None; + } + + let mut buffered = self.pending_completion.lock().await; + for gid in &removed { + buffered.remove(gid); + log::info!("aria2 gid transition [{}]: forgot {}", id, gid); + } + removed.last().cloned() + } + /// Overwrite a stale aria2 gid with the fresh gid minted by a retry /// `addUri`. Failing to call this after re-add leaks the semaphore permit. pub fn rotate_aria2_gid(&self, id: &str, stale_gid: &str, new_gid: &str) { let mut gids = self.aria2_gids.write().unwrap(); gids.remove(stale_gid); gids.insert(new_gid.to_string(), id.to_string()); + log::info!( + "aria2 gid transition [{}]: rotated {} -> {}", + id, + stale_gid, + new_gid + ); } async fn wait_permit_released(self: &Arc, id: &str) { @@ -441,6 +551,14 @@ impl QueueManager { return; } }; + if self.aria2_retry_cancelled.lock().await.contains(&id) { + log::info!( + "aria2 retry cancellation [{}]: ignoring error for gid {} during removal", + id, + gid + ); + return; + } let strike = { let mut strikes = self.aria2_retry_strikes.lock().await; @@ -485,12 +603,50 @@ impl QueueManager { return; } + let _retry_add_guard = this.aria2_retry_add_lock.lock().await; if !this.active_permits.lock().await.contains_key(&id_for_task) { return; } + if this + .aria2_retry_cancelled + .lock() + .await + .contains(&id_for_task) + { + return; + } match this.spawner.add_uri(&id_for_task, &payload).await { Ok(new_gid) => { + if this + .aria2_retry_cancelled + .lock() + .await + .contains(&id_for_task) + { + if let Err(error) = this.spawner.remove_uri(&new_gid).await { + log::error!( + "aria2 retry cancellation [{}]: failed to remove late gid {}: {}", + id_for_task, + new_gid, + error + ); + } else { + log::info!( + "aria2 retry cancellation [{}]: removed late gid {}", + id_for_task, + new_gid + ); + return; + } + this.rotate_aria2_gid(&id_for_task, &stale_gid, &new_gid); + log::warn!( + "aria2 retry cancellation [{}]: retained late gid {} mapping for remove retry", + id_for_task, + new_gid + ); + return; + } this.aria2_retry_strikes .lock() .await @@ -670,7 +826,12 @@ impl SidecarSpawner for ProductionSpawner { match crate::rpc_call(state.aria2_port, &state.aria2_secret, "aria2.addUri", params).await { Ok(result) => { let gid = result.as_str().unwrap_or("").to_string(); - Ok(gid) + if gid.is_empty() { + Err("aria2.addUri returned an empty gid".to_string()) + } else { + log::info!("aria2 addUri [{}]: created gid {}", id, gid); + Ok(gid) + } } Err(e) => { // aria2 unavailable — fall back to native coordinator. @@ -706,6 +867,24 @@ impl SidecarSpawner for ProductionSpawner { } } + async fn remove_uri(&self, gid: &str) -> Result<(), String> { + let state = self.app_handle.state::(); + let result = crate::rpc_call( + state.aria2_port, + &state.aria2_secret, + "aria2.forceRemove", + serde_json::json!([gid]), + ) + .await?; + match result.as_str() { + Some(returned_gid) if returned_gid == gid => Ok(()), + Some(returned_gid) => Err(format!( + "aria2.forceRemove returned unexpected gid {returned_gid}, expected {gid}" + )), + None => Err("aria2.forceRemove returned a non-string result".to_string()), + } + } + async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> { let state = self.app_handle.state::(); let mut cancel_rx = state diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index 36489b8..25837cf 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -31,6 +31,9 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner { self.add_uri_calls.fetch_add(1, Ordering::SeqCst); Ok(format!("gid-{}", self.add_uri_calls.load(Ordering::SeqCst))) } + async fn remove_uri(&self, _gid: &str) -> Result<(), String> { + Ok(()) + } async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> { self.media_calls.fetch_add(1, Ordering::SeqCst); Ok(()) @@ -83,6 +86,33 @@ async fn release_permit_is_idempotent() { assert_eq!(avail_after_second, avail_after_first, "second release must not free another slot"); } +#[tokio::test] +async fn ensure_aria2_permit_does_not_double_acquire() { + let (mgr, _spawner) = make_manager(2); + assert!(mgr.ensure_aria2_permit("a").await); + assert!(!mgr.ensure_aria2_permit("a").await); + assert_eq!(mgr.available_permits(), 1); + + mgr.release_permit("a").await; + assert_eq!(mgr.available_permits(), 2); +} + +#[tokio::test] +async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() { + let (mgr, _spawner) = make_manager(1); + let permit = mgr.acquire_permit().await; + mgr.park_permit("a", permit).await; + mgr.remember_gid("a".to_string(), "gid-a".to_string()).await; + + assert_eq!(mgr.forget_aria2_gid("a").await.as_deref(), Some("gid-a")); + assert!(mgr.aria2_gid_for_download("a").is_none()); + assert_eq!(mgr.available_permits(), 0); + + mgr.release_permit("a").await; + mgr.release_permit("a").await; + assert_eq!(mgr.available_permits(), 1); +} + #[tokio::test] async fn push_then_pop_front_drains_fifo() { let (mgr, _spawner) = make_manager(2); @@ -232,6 +262,10 @@ impl SidecarSpawner for FixedMediaSpawner { unreachable!("aria2 is not used by media terminal-state tests") } + async fn remove_uri(&self, _gid: &str) -> Result<(), String> { + unreachable!("aria2 is not used by media terminal-state tests") + } + async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> { self.outcome.clone() } @@ -370,6 +404,21 @@ async fn gid_completion_before_store_buffers_and_reconciles() { handle.abort(); } +#[tokio::test] +async fn aria2_completion_forgets_gid_and_releases_permit() { + use firelink_lib::queue::PendingOutcome; + + let (mgr, _spawner) = make_manager(1); + let permit = mgr.acquire_permit().await; + mgr.park_permit("a", permit).await; + mgr.remember_gid("a".to_string(), "gid-a".to_string()).await; + + mgr.apply_completion("a", PendingOutcome::Complete).await; + + assert!(mgr.aria2_gid_for_download("a").is_none()); + assert_eq!(mgr.available_permits(), 1); +} + #[tokio::test] async fn move_up_down_reorders_pending() { use firelink_lib::ipc::QueueDirection; diff --git a/src/ipc.ts b/src/ipc.ts index a1ff9f3..146b831 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -66,6 +66,7 @@ type CommandMap = { start_download: { args: StartDownloadArgs; result: void }; start_media_download: { args: StartMediaDownloadArgs; result: void }; pause_download: { args: { id: string }; result: void }; + resume_download: { args: { id: string }; result: boolean }; remove_download: { args: { id: string; filepath: string | null }; result: void }; update_dock_badge: { args: { count: number }; result: void }; set_prevent_sleep: { args: { prevent: boolean }; result: void }; diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 35190d3..03e9adc 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -270,6 +270,7 @@ export const useDownloadStore = create((set, get) => ({ await invoke('remove_download', { id, filepath: null }); } catch (e) { console.error("Failed to terminate download on deletion:", e); + return; } } @@ -359,19 +360,24 @@ export const useDownloadStore = create((set, get) => ({ info(`Download ${id} redownload requested (queued)`); }, resumeDownload: async (id) => { - let targetItem = get().downloads.find(d => d.id === id); + const targetItem = get().downloads.find(d => d.id === id); if (!targetItem) return; - - set((state) => ({ - downloads: state.downloads.map(d => { - if (d.id === id) { - return { ...d, status: 'queued', speed: '-', eta: '-' }; - } - return d; - }) - })); try { + const resumedExisting = await invoke('resume_download', { id }); + if (resumedExisting) { + return; + } + + set((state) => ({ + downloads: state.downloads.map(d => { + if (d.id === id) { + return { ...d, status: 'queued', speed: '-', eta: '-' }; + } + return d; + }) + })); + const settings = useSettingsStore.getState(); const login = getSiteLogin(targetItem.url, settings); let keychainPassword = null; @@ -419,18 +425,23 @@ export const useDownloadStore = create((set, get) => ({ if (runnable.length === 0) return 0; + const paused = runnable.filter(item => item.status === 'paused'); + const toEnqueue = runnable.filter(item => item.status !== 'paused'); + set((state) => ({ downloads: state.downloads.map(item => - runnable.some(r => r.id === item.id) + toEnqueue.some(r => r.id === item.id) ? { ...item, status: 'queued', speed: '-', eta: '-' } : item ) })); try { + await Promise.all(paused.map(item => get().resumeDownload(item.id))); + const settings = useSettingsStore.getState(); const itemsToEnqueue = []; - for (const item of runnable) { + for (const item of toEnqueue) { const login = getSiteLogin(item.url, settings); let keychainPassword = null; if (login) { @@ -466,7 +477,9 @@ export const useDownloadStore = create((set, get) => ({ is_media: item.isMedia || false }); } - await invoke('enqueue_many', { items: itemsToEnqueue }); + if (itemsToEnqueue.length > 0) { + await invoke('enqueue_many', { items: itemsToEnqueue }); + } const order = await invoke('get_pending_order'); set({ pendingOrder: order }); } catch (e) { @@ -483,18 +496,17 @@ export const useDownloadStore = create((set, get) => ({ if (activeIds.length === 0) return 0; - set((state) => ({ - downloads: state.downloads.map(item => - activeIds.includes(item.id) - ? { ...item, status: 'paused', speed: '-', eta: '-' } - : item - ) - })); - - info(`Queue ${queueId} paused, ${activeIds.length} items paused`); - await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {}))); + const results = await Promise.allSettled( + activeIds.map(id => invoke('pause_download', { id })) + ); + const pausedCount = results.filter(result => result.status === 'fulfilled').length; + const failedCount = activeIds.length - pausedCount; + if (failedCount > 0) { + console.error(`Failed to pause ${failedCount} downloads in queue ${queueId}`); + } + info(`Queue ${queueId} paused, ${pausedCount} items paused`); syncSystemIntegrations(); - return activeIds.length; + return pausedCount; }, addQueue: (name) => { const id = crypto.randomUUID();