fix(downloads): harden queue and lifecycle synchronization

Serialize queue controls, make multi-item moves atomic, await stale enqueue cleanup, and guard late media and progress events. Add deterministic table sorting and regression coverage for worst-case lifecycle races.
This commit is contained in:
NimBold
2026-07-14 18:29:14 +03:30
parent 2d9eed99d5
commit e07182fbf2
15 changed files with 1074 additions and 321 deletions
+88 -22
View File
@@ -46,18 +46,29 @@ impl DownloadCoordinator {
.map_err(|_| "download coordinator is unavailable".to_string())
}
pub async fn register_media(&self, id: String) -> Result<watch::Receiver<bool>, String> {
pub async fn register_media(
&self,
id: String,
lifecycle_generation: u64,
) -> Result<watch::Receiver<bool>, String> {
let (cancel_tx, cancel_rx) = watch::channel(false);
self.media_tx
.send(MediaCmd::Register { id, cancel_tx })
.send(MediaCmd::Register {
id,
lifecycle_generation,
cancel_tx,
})
.await
.map_err(|_| "download coordinator is unavailable".to_string())?;
Ok(cancel_rx)
}
pub async fn pause_media(&self, id: String) -> Result<(), String> {
pub async fn pause_media(&self, id: String, lifecycle_generation: u64) -> Result<(), String> {
self.media_tx
.send(MediaCmd::Pause(id))
.send(MediaCmd::Pause {
id,
lifecycle_generation,
})
.await
.map_err(|_| "download coordinator is unavailable".to_string())
}
@@ -65,16 +76,27 @@ impl DownloadCoordinator {
pub async fn pause_media_with_ack(
&self,
id: String,
lifecycle_generation: u64,
ack: tokio::sync::oneshot::Sender<()>,
) -> Result<(), String> {
self.media_tx
.send(MediaCmd::PauseWithAck(id, ack))
.send(MediaCmd::PauseWithAck {
id,
lifecycle_generation,
ack,
})
.await
.map_err(|_| "download coordinator is unavailable".to_string())
}
pub async fn finish_media(&self, id: String) {
let _ = self.media_tx.send(MediaCmd::Finished(id)).await;
pub async fn finish_media(&self, id: String, lifecycle_generation: u64) {
let _ = self
.media_tx
.send(MediaCmd::Finished {
id,
lifecycle_generation,
})
.await;
}
}
@@ -96,11 +118,22 @@ impl CoordinatorEventSink {
enum MediaCmd {
Register {
id: String,
lifecycle_generation: u64,
cancel_tx: watch::Sender<bool>,
},
Pause(String),
PauseWithAck(String, tokio::sync::oneshot::Sender<()>),
Finished(String),
Pause {
id: String,
lifecycle_generation: u64,
},
PauseWithAck {
id: String,
lifecycle_generation: u64,
ack: tokio::sync::oneshot::Sender<()>,
},
Finished {
id: String,
lifecycle_generation: u64,
},
}
async fn run_coordinator(
@@ -108,8 +141,8 @@ async fn run_coordinator(
mut command_rx: mpsc::Receiver<DownloadCmd>,
mut media_rx: mpsc::Receiver<MediaCmd>,
) {
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
let mut pending_media_acks = HashMap::<String, tokio::sync::oneshot::Sender<()>>::new();
let mut active_media = HashMap::<String, (u64, watch::Sender<bool>)>::new();
let mut pending_media_acks = HashMap::<String, (u64, tokio::sync::oneshot::Sender<()>)>::new();
let mut pending_captured_urls = Vec::<String>::new();
let mut frontend_ready = false;
@@ -146,27 +179,34 @@ async fn run_coordinator(
continue;
};
match command {
MediaCmd::Register { id, cancel_tx } => {
if let Some(previous) = active_media.insert(id, cancel_tx) {
MediaCmd::Register { id, lifecycle_generation, cancel_tx } => {
if let Some((_, previous)) = active_media.insert(id, (lifecycle_generation, cancel_tx)) {
let _ = previous.send(true);
}
}
MediaCmd::Pause(id) => {
if let Some(cancel_tx) = active_media.remove(&id) {
MediaCmd::Pause { id, lifecycle_generation } => {
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
if let Some((_, cancel_tx)) = active_media.remove(&id) {
let _ = cancel_tx.send(true);
}
}
MediaCmd::PauseWithAck(id, ack) => {
if let Some(cancel_tx) = active_media.remove(&id) {
}
MediaCmd::PauseWithAck { id, lifecycle_generation, ack } => {
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
if let Some((_, cancel_tx)) = active_media.remove(&id) {
let _ = cancel_tx.send(true);
pending_media_acks.insert(id, ack);
pending_media_acks.insert(id, (lifecycle_generation, ack));
}
} else {
let _ = ack.send(());
}
}
MediaCmd::Finished(id) => {
MediaCmd::Finished { id, lifecycle_generation } => {
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
active_media.remove(&id);
if let Some(ack) = pending_media_acks.remove(&id) {
}
if pending_media_acks.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
if let Some((_, ack)) = pending_media_acks.remove(&id) {
let _ = ack.send(());
}
}
@@ -174,8 +214,9 @@ async fn run_coordinator(
}
}
}
}
for (_, cancel_tx) in active_media {
for (_, (_, cancel_tx)) in active_media {
let _ = cancel_tx.send(true);
}
}
@@ -251,4 +292,29 @@ mod tests {
DownloadEvent::CapturedUrls("https://example.com/startup.zip".to_string())
);
}
#[tokio::test]
async fn stale_media_finish_cannot_remove_a_newer_lifecycle() {
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
let mut old_cancel = coordinator.register_media("same-id".to_string(), 1).await.unwrap();
let mut new_cancel = coordinator.register_media("same-id".to_string(), 2).await.unwrap();
tokio::time::timeout(Duration::from_secs(1), old_cancel.changed())
.await
.unwrap()
.unwrap();
coordinator.finish_media("same-id".to_string(), 1).await;
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
coordinator
.pause_media_with_ack("same-id".to_string(), 2, ack_tx)
.await
.unwrap();
coordinator.finish_media("same-id".to_string(), 2).await;
tokio::time::timeout(Duration::from_secs(1), ack_rx)
.await
.unwrap()
.unwrap();
assert!(*new_cancel.borrow_and_update());
}
}
+33 -4
View File
@@ -3357,6 +3357,11 @@ async fn pause_download(
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
let active_kind = state.queue_manager.active_kind(&id).await;
let media_lifecycle_generation = state
.queue_manager
.registered_lifecycle_generation(&id)
.await
.unwrap_or_default();
let removed_pending = state.queue_manager.remove_from_pending(&id).await;
let gid = state.queue_manager.aria2_gid_for_download(&id);
@@ -3447,7 +3452,7 @@ async fn pause_download(
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
state
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.pause_media_with_ack(id.clone(), media_lifecycle_generation, tx)
.await?;
} else {
let _ = tx.send(());
@@ -3498,6 +3503,7 @@ async fn resume_download(
"paused" => {
let control_epoch = state.queue_manager.next_aria2_control_epoch(&id).await;
state.queue_manager.allow_aria2_retries(&id).await;
state.queue_manager.reset_aria2_retry_strikes(&id).await;
use tauri::Emitter;
let _ = app_handle.emit(
"download-state",
@@ -3713,6 +3719,11 @@ async fn remove_download(
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
let active_kind = state.queue_manager.active_kind(&id).await;
let media_lifecycle_generation = state
.queue_manager
.registered_lifecycle_generation(&id)
.await
.unwrap_or_default();
state.queue_manager.remove_from_pending(&id).await;
state.queue_manager.next_aria2_control_epoch(&id).await;
@@ -3748,7 +3759,7 @@ async fn remove_download(
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
state
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.pause_media_with_ack(id.clone(), media_lifecycle_generation, tx)
.await?;
} else {
let _ = tx.send(());
@@ -3864,6 +3875,11 @@ async fn detach_download_for_reconfigure(
log::info!("detach_download_for_reconfigure called for id: {}", id);
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
let active_kind = state.queue_manager.active_kind(&id).await;
let media_lifecycle_generation = state
.queue_manager
.registered_lifecycle_generation(&id)
.await
.unwrap_or_default();
state.queue_manager.remove_from_pending(&id).await;
state.queue_manager.next_aria2_control_epoch(&id).await;
state.queue_manager.cancel_aria2_retries(&id).await;
@@ -3908,7 +3924,7 @@ async fn detach_download_for_reconfigure(
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
state
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.pause_media_with_ack(id.clone(), media_lifecycle_generation, tx)
.await?;
} else {
let _ = tx.send(()); // Fallback if no task exists
@@ -4461,6 +4477,19 @@ async fn move_in_queue(
.await)
}
#[tauri::command]
async fn move_many_in_queue(
state: tauri::State<'_, AppState>,
ids: Vec<String>,
queue_id: String,
direction: crate::ipc::QueueDirection,
) -> Result<Vec<String>, AppError> {
Ok(state
.queue_manager
.move_many_in_queue(&ids, &queue_id, direction)
.await)
}
#[tauri::command]
async fn remove_from_queue(
app_handle: tauri::AppHandle,
@@ -6717,7 +6746,7 @@ pub fn run() {
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
detach_download_for_reconfigure,
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, remove_from_queue, get_pending_order,
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
commands::reveal_in_file_manager, commands::open_downloaded_file,
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
parity::create_category_directories,
+185 -26
View File
@@ -73,6 +73,9 @@ pub struct QueuedTask {
pub queue_id: String,
pub kind: TaskKind,
pub payload: SpawnPayload,
/// Frontend lifecycle generation that owns this sidecar and its permit.
/// Manual internal/test tasks use generation 0.
pub lifecycle_generation: u64,
}
/// Args mirroring start_download / start_media_download. Kept untyped-loose
@@ -120,17 +123,24 @@ pub trait SidecarSpawner: Send + Sync + 'static {
/// 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>;
async fn run_media(
&self,
id: &str,
payload: &SpawnPayload,
lifecycle_generation: u64,
) -> Result<(), String>;
}
/// The centralized concurrency gatekeeper. One instance lives in AppState.
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
registered_ids: Mutex<HashSet<String>>,
registered_lifecycle_generations: Mutex<HashMap<String, u64>>,
enqueue_cancellations: Mutex<HashMap<String, u64>>,
enqueue_generations: Mutex<HashMap<String, u64>>,
pending: Mutex<VecDeque<QueuedTask>>,
semaphore: Arc<Semaphore>,
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
active_permit_generations: Mutex<HashMap<String, u64>>,
active_kinds: Mutex<HashMap<String, TaskKind>>,
target_capacity: AtomicUsize,
slots_to_retire: AtomicUsize,
@@ -197,11 +207,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
) -> Self {
Self {
registered_ids: Mutex::new(HashSet::new()),
registered_lifecycle_generations: Mutex::new(HashMap::new()),
enqueue_cancellations: Mutex::new(HashMap::new()),
enqueue_generations: Mutex::new(HashMap::new()),
pending: Mutex::new(VecDeque::new()),
semaphore: Arc::new(Semaphore::new(capacity)),
active_permits: Mutex::new(HashMap::new()),
active_permit_generations: Mutex::new(HashMap::new()),
active_kinds: Mutex::new(HashMap::new()),
target_capacity: AtomicUsize::new(capacity),
slots_to_retire: AtomicUsize::new(0),
@@ -237,6 +249,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach).
pub async fn release_registered_id(&self, id: &str) {
self.registered_ids.lock().await.remove(id);
self.registered_lifecycle_generations.lock().await.remove(id);
// A released lifecycle cannot be resumed by a delayed retry worker.
// Epoch checks remain the authoritative guard; removing this marker
// prevents terminal downloads from accumulating cancellation entries.
@@ -247,6 +260,40 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.registered_ids.lock().await.contains(id)
}
pub async fn registered_lifecycle_generation(&self, id: &str) -> Option<u64> {
self.registered_lifecycle_generations
.lock()
.await
.get(id)
.copied()
}
async fn is_registered_generation(&self, id: &str, generation: u64) -> bool {
self.registered_lifecycle_generations
.lock()
.await
.get(id)
.copied()
== Some(generation)
}
async fn release_registered_id_for_generation(&self, id: &str, generation: u64) {
let released = {
let mut registered = self.registered_ids.lock().await;
let mut generations = self.registered_lifecycle_generations.lock().await;
if generations.get(id).copied() == Some(generation) {
registered.remove(id);
generations.remove(id);
true
} else {
false
}
};
if released {
self.aria2_retry_cancelled.lock().await.remove(id);
}
}
/// Reject an in-flight enqueue generation if a newer UI action supersedes it.
pub async fn cancel_enqueue_generation(&self, id: &str, generation: u64) {
let mut cancellations = self.enqueue_cancellations.lock().await;
@@ -282,6 +329,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
return Err("Duplicate task".to_string());
}
registered.insert(id.to_string());
self.registered_lifecycle_generations
.lock()
.await
.insert(id.to_string(), generation);
generations.insert(id.to_string(), generation);
Ok(previous_generation)
}
@@ -298,6 +349,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
return;
}
registered.remove(id);
self.registered_lifecycle_generations.lock().await.remove(id);
match previous_generation {
Some(previous) => {
generations.insert(id.to_string(), previous);
@@ -459,6 +511,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
.insert(id.to_string(), permit);
}
async fn tag_permit_generation(&self, id: &str, generation: u64) {
self.active_permit_generations
.lock()
.await
.insert(id.to_string(), generation);
}
pub async fn active_kind(&self, id: &str) -> Option<TaskKind> {
self.active_kinds.lock().await.get(id).cloned()
}
@@ -492,12 +551,30 @@ impl<R: tauri::Runtime> QueueManager<R> {
pub async fn release_permit(&self, id: &str) {
let removed = self.active_permits.lock().await.remove(id).is_some();
self.active_permit_generations.lock().await.remove(id);
self.active_kinds.lock().await.remove(id);
if removed {
self.notify.notify_one();
}
}
async fn release_permit_for_generation(&self, id: &str, generation: u64) {
let removed = {
let mut permits = self.active_permits.lock().await;
let mut generations = self.active_permit_generations.lock().await;
if generations.get(id).copied() == Some(generation) {
generations.remove(id);
permits.remove(id).is_some()
} else {
false
}
};
if removed {
self.active_kinds.lock().await.remove(id);
self.notify.notify_one();
}
}
pub async fn has_active_permit(&self, id: &str) -> bool {
self.active_permits.lock().await.contains_key(id)
}
@@ -607,10 +684,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
async fn dispatch_one(self: Arc<Self>, permit: OwnedSemaphorePermit, task: QueuedTask) {
let id = task.id.clone();
let lifecycle_generation = task.lifecycle_generation;
// Park the permit BEFORE spawning. Uniform parking:
// aria2's RPC returns instantly, so the permit must outlive the
// dispatch_one call. Media runners release on exit.
self.park_permit(&id, permit).await;
self.tag_permit_generation(&id, lifecycle_generation).await;
self.active_kinds
.lock()
.await
@@ -693,30 +772,55 @@ impl<R: tauri::Runtime> QueueManager<R> {
let payload = task.payload.clone();
let id_for_task = id.clone();
tauri::async_runtime::spawn(async move {
let outcome = this.spawner.run_media(&id_for_task, &payload).await;
this.finish_runner(&id_for_task, outcome).await;
let outcome = this
.spawner
.run_media(&id_for_task, &payload, lifecycle_generation)
.await;
this.finish_runner(&id_for_task, lifecycle_generation, outcome)
.await;
});
}
}
}
/// Terminal handler for non-aria2 transfers. Emits state and frees the permit.
/// Does not emit or release anything on intentional MEDIA_RUN_CANCELLED.
/// Intentional cancellation is silent, but still releases backend ownership.
/// Note: `id` is the frontend download UUID, which survives indefinitely as
/// the terminal state.
async fn finish_runner(self: Arc<Self>, id: &str, outcome: Result<(), String>) {
async fn finish_runner(
self: Arc<Self>,
id: &str,
lifecycle_generation: u64,
outcome: Result<(), String>,
) {
if !self.is_registered_generation(id, lifecycle_generation).await {
log::info!(
"media runner [{}]: ignoring stale terminal outcome for lifecycle {}",
id,
lifecycle_generation
);
self.release_permit_for_generation(id, lifecycle_generation).await;
return;
}
match outcome {
Ok(()) => {
self.emit_state(id, DownloadStatus::Completed);
self.release_registered_id(id).await;
self.release_registered_id_for_generation(id, lifecycle_generation)
.await;
}
Err(error) if error == MEDIA_RUN_CANCELLED => {
self.release_registered_id_for_generation(id, lifecycle_generation)
.await;
}
Err(error) if error == MEDIA_RUN_CANCELLED => {}
Err(error) => {
self.emit_failed(id, error);
self.release_registered_id(id).await;
self.release_registered_id_for_generation(id, lifecycle_generation)
.await;
}
}
self.release_permit(id).await;
self.release_permit_for_generation(id, lifecycle_generation)
.await;
}
fn emit_failed(&self, id: &str, error: String) {
@@ -852,6 +956,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.aria2_retry_cancelled.lock().await.remove(id);
}
/// A user-initiated resume starts a new retry lifecycle while retaining
/// the paused Aria2 payload/GID. Reset only the strike budget here; the
/// payload is still needed for the same-GID resume path.
pub async fn reset_aria2_retry_strikes(&self, id: &str) {
self.aria2_retry_strikes.lock().await.remove(id);
}
async fn finish_aria2_retry(&self, id: &str, gid: &str, retry_epoch: u64) {
self.release_aria2_retry_inflight(id, retry_epoch).await;
self.aria2_retrying_gids.lock().await.remove(gid);
@@ -1276,6 +1387,20 @@ impl<R: tauri::Runtime> QueueManager<R> {
id: &str,
queue_id: &str,
direction: QueueDirection,
) -> Vec<String> {
self.move_many_in_queue(&[id.to_string()], queue_id, direction)
.await
}
/// Atomically move a selected block of pending tasks up or down. The
/// frontend uses the same block semantics for multi-selection, so keeping
/// the operation under one pending-list lock prevents partial RPC moves
/// from leaving the backend in a different order than the UI.
pub async fn move_many_in_queue(
&self,
ids: &[String],
queue_id: &str,
direction: QueueDirection,
) -> Vec<String> {
let mut pending = self.pending.lock().await;
let queue_positions = pending
@@ -1283,22 +1408,46 @@ impl<R: tauri::Runtime> QueueManager<R> {
.enumerate()
.filter_map(|(index, task)| (task.queue_id == queue_id).then_some(index))
.collect::<Vec<_>>();
let queue_pos = queue_positions
let selected_positions = queue_positions
.iter()
.position(|index| pending[*index].id == id);
if let Some(queue_pos) = queue_pos {
let target = match direction {
QueueDirection::Up => queue_pos.checked_sub(1),
QueueDirection::Down => {
if queue_pos + 1 < queue_positions.len() {
Some(queue_pos + 1)
} else {
None
}
}
.enumerate()
.filter_map(|(position, index)| ids.iter().any(|id| id == &pending[*index].id).then_some(position))
.collect::<Vec<_>>();
if !selected_positions.is_empty() {
let queue_tasks = queue_positions
.iter()
.map(|index| pending[*index].clone())
.collect::<Vec<_>>();
let selected_ids = ids.iter().collect::<HashSet<_>>();
let selected_tasks = queue_tasks
.iter()
.filter(|task| selected_ids.contains(&task.id))
.cloned()
.collect::<Vec<_>>();
let unselected_tasks = queue_tasks
.iter()
.filter(|task| !selected_ids.contains(&task.id))
.cloned()
.collect::<Vec<_>>();
let first_selected = *selected_positions.first().unwrap();
let last_selected = *selected_positions.last().unwrap();
let selected_count = selected_tasks.len();
let insert_index = match direction {
QueueDirection::Up => first_selected.saturating_sub(1),
QueueDirection::Down => (last_selected + 1)
.saturating_sub(selected_count)
.saturating_add(1)
.min(unselected_tasks.len()),
};
if let Some(target) = target {
pending.swap(queue_positions[queue_pos], queue_positions[target]);
let mut reordered = Vec::with_capacity(queue_tasks.len());
reordered.extend_from_slice(&unselected_tasks[..insert_index]);
reordered.extend(selected_tasks);
reordered.extend_from_slice(&unselected_tasks[insert_index..]);
for (queue_index, pending_index) in queue_positions.iter().enumerate() {
pending[*pending_index] = reordered[queue_index].clone();
}
}
pending
@@ -1740,11 +1889,16 @@ impl SidecarSpawner for ProductionSpawner {
crate::ensure_aria2_gid_result("unpause", gid, &resumed)
}
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
async fn run_media(
&self,
id: &str,
payload: &SpawnPayload,
lifecycle_generation: u64,
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let mut cancel_rx = state
.download_coordinator
.register_media(id.to_string())
.register_media(id.to_string(), lifecycle_generation)
.await?;
let outcome = crate::start_media_download_internal(
self.app_handle.clone(),
@@ -1777,7 +1931,7 @@ impl SidecarSpawner for ProductionSpawner {
}
let _ = state
.download_coordinator
.finish_media(id.to_string())
.finish_media(id.to_string(), lifecycle_generation)
.await;
outcome.map(|_| ())
}
@@ -1823,6 +1977,11 @@ impl EnqueueItem {
id,
queue_id: self.queue_id,
kind,
lifecycle_generation: self
.lifecycle_generation
.as_deref()
.and_then(|generation| generation.parse().ok())
.unwrap_or_default(),
payload: SpawnPayload {
url: self.url,
destination: self.destination,
+30 -4
View File
@@ -63,7 +63,7 @@ impl SidecarSpawner for DelayedAria2Spawner {
Ok(())
}
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
unreachable!("media is not used by delayed aria2 tests")
}
}
@@ -86,7 +86,7 @@ impl SidecarSpawner for FailFirstAria2Spawner {
Ok(())
}
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
unreachable!("media is not used by fail-first aria2 tests")
}
}
@@ -109,7 +109,7 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
Ok(())
}
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
self.media_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
@@ -131,6 +131,7 @@ fn sample_task(id: &str) -> QueuedTask {
id: id.to_string(),
queue_id: "main".to_string(),
kind: TaskKind::Aria2,
lifecycle_generation: 0,
payload: SpawnPayload::default(),
}
}
@@ -498,6 +499,7 @@ fn aria2_task(id: &str) -> QueuedTask {
id: id.to_string(),
queue_id: "main".to_string(),
kind: TaskKind::Aria2,
lifecycle_generation: 0,
payload: SpawnPayload::default(),
}
}
@@ -507,6 +509,7 @@ fn media_task(id: &str) -> QueuedTask {
id: id.to_string(),
queue_id: "main".to_string(),
kind: TaskKind::Media,
lifecycle_generation: 0,
payload: SpawnPayload::default(),
}
}
@@ -525,7 +528,7 @@ impl SidecarSpawner for FixedMediaSpawner {
unreachable!("aria2 is not used by media terminal-state tests")
}
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
self.outcome.clone()
}
}
@@ -590,6 +593,7 @@ async fn media_cancellation_does_not_emit_completed() {
assert!(!statuses.iter().any(|status| status == "failed"));
assert!(!statuses.iter().any(|status| status == "completed"));
assert_eq!(manager.available_permits(), 1);
assert!(!manager.is_registered("media-cancelled").await);
dispatcher.abort();
}
@@ -1079,6 +1083,28 @@ async fn move_up_down_reorders_pending() {
assert_eq!(mgr_arc.pending_order(None).await, vec!["c", "a", "b"]);
}
#[tokio::test]
async fn multi_move_reorders_selected_items_as_one_atomic_block() {
use firelink_lib::ipc::QueueDirection;
let (mgr, _spawner) = make_manager(3);
for id in ["a", "b", "c", "d", "e"] {
mgr.push(sample_task(id)).await.unwrap();
}
let selected = vec!["b".to_string(), "d".to_string()];
assert_eq!(
mgr.move_many_in_queue(&selected, "main", QueueDirection::Up)
.await,
vec!["b", "d", "a", "c", "e"]
);
assert_eq!(
mgr.move_many_in_queue(&selected, "main", QueueDirection::Down)
.await,
vec!["a", "b", "d", "c", "e"]
);
}
#[tokio::test]
async fn moving_one_queue_does_not_reorder_another_queue() {
use firelink_lib::ipc::QueueDirection;
+3 -7
View File
@@ -1,4 +1,4 @@
import { initMediaDomains, isActiveDownloadStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
import { schedulerCompletionState } from './utils/schedulerCompletion';
import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar";
@@ -112,7 +112,7 @@ function App() {
const logsEnabled = useSettingsStore(state => state.logsEnabled);
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
const downloads = useDownloadStore(state => state.downloads);
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const queuedCount = downloads.filter(download =>
download.status === 'queued' || download.status === 'staged'
).length;
@@ -124,11 +124,7 @@ function App() {
const pendingPostActionTimer = useRef<number | null>(null);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
const activeTransferCount = downloads.filter(download =>
download.status === 'downloading' ||
download.status === 'processing' ||
download.status === 'retrying'
).length;
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
+94 -97
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
@@ -17,8 +17,13 @@ import {
canStartDownload,
startActionLabel
} from '../utils/downloadActions';
import { isActiveDownloadStatus } from '../utils/downloads';
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
import { readClipboardDownloadUrls } from '../utils/clipboard';
import {
sortDownloads,
type DownloadSortColumn,
type DownloadSortConfig
} from '../utils/downloadTableSorting';
interface DownloadTableProps {
filter: SidebarFilter;
@@ -46,7 +51,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const [animationParent] = useAutoAnimate<HTMLDivElement>();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' } | null>({ column: 'Date Added', direction: 'desc' });
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
const selectedIdsRef = useRef(selectedIds);
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
selectedIdsRef.current = selectedIds;
const [columnWidths, setColumnWidths] = useState(() => {
try {
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
@@ -110,15 +119,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
if (!isInput) {
if ((e.key === 'a' || e.key === 'A') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
const allIds = sortedDownloads.map(d => d.id);
const allIds = sortedDownloadsRef.current.map(d => d.id);
setSelectedIds(new Set(allIds));
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (!activeEl || !activeEl.closest('.sidebar-inner')) {
if (selectedIds.size > 0) {
handleDelete(Array.from(selectedIds));
if (selectedIdsRef.current.size > 0) {
handleDelete(Array.from(selectedIdsRef.current));
}
}
}
@@ -126,7 +135,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedIds]);
}, []);
const showInteractionError = (message: string, error: unknown) => {
@@ -192,43 +201,24 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
openProperties(item.id);
};
const filteredDownloads = downloads.filter((d: DownloadItem) => {
if (filter.startsWith('queue:')) {
const isQueueFilter = filter.startsWith('queue:');
const filteredDownloads = useMemo(() => downloads.filter((d: DownloadItem) => {
if (isQueueFilter) {
return d.queueId === filter.replace('queue:', '') && d.status !== 'completed';
}
switch (filter) {
case 'all': return true;
case 'active': return d.status === 'downloading';
case 'active': return isTransferActiveStatus(d.status);
case 'completed': return d.status === 'completed';
case 'unfinished': return d.status !== 'completed';
default: return d.category === filter;
}
});
}), [downloads, filter, isQueueFilter]);
const parseSpeed = (speedStr?: string) => {
if (!speedStr || speedStr === '-') return 0;
const val = parseFloat(speedStr);
if (speedStr.includes('KB/s')) return val * 1024;
if (speedStr.includes('MB/s')) return val * 1024 * 1024;
if (speedStr.includes('GB/s')) return val * 1024 * 1024 * 1024;
return val;
};
const parseEta = (etaStr?: string) => {
if (!etaStr || etaStr === '-') return Infinity;
let seconds = 0;
const hours = etaStr.match(/(\d+)h/);
const minutes = etaStr.match(/(\d+)m/);
const secs = etaStr.match(/(\d+)s/);
if (hours) seconds += parseInt(hours[1]) * 3600;
if (minutes) seconds += parseInt(minutes[1]) * 60;
if (secs) seconds += parseInt(secs[1]);
return seconds;
};
// Sort by queue position when viewing a specific queue so the visual
// order matches the queue order and move-up/down buttons reflect reality.
const sortedDownloads = filter.startsWith('queue:')
// Queue views use the persisted queue order until the user explicitly sorts
// a column. This keeps move-up/down controls truthful while still making
// every header a working sort target.
const sortedDownloads = useMemo(() => isQueueFilter && !queueSortConfig
? [...filteredDownloads].sort((left, right) => {
const leftActive = isActiveDownloadStatus(left.status) && left.status !== 'queued';
const rightActive = isActiveDownloadStatus(right.status) && right.status !== 'queued';
@@ -236,40 +226,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
if (leftActive && !rightActive) return -1;
if (!leftActive && rightActive) return 1;
return (left.queuePosition ?? 0) - (right.queuePosition ?? 0);
const positionComparison = (left.queuePosition ?? Number.MAX_SAFE_INTEGER) -
(right.queuePosition ?? Number.MAX_SAFE_INTEGER);
return positionComparison || left.id.localeCompare(right.id);
})
: sortConfig
? [...filteredDownloads].sort((a, b) => {
const aUnfinished = a.status !== 'completed';
const bUnfinished = b.status !== 'completed';
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
sortedDownloadsRef.current = sortedDownloads;
if (aUnfinished && !bUnfinished) return -1;
if (!aUnfinished && bUnfinished) return 1;
useEffect(() => {
const visibleIds = new Set(sortedDownloads.map(download => download.id));
setSelectedIds(current => {
const next = new Set(Array.from(current).filter(id => visibleIds.has(id)));
return next.size === current.size ? current : next;
});
setLastSelectedId(current => current && visibleIds.has(current) ? current : null);
}, [sortedDownloads]);
let comparison = 0;
switch (sortConfig.column) {
case 'File Name':
comparison = (a.fileName || a.url || '').localeCompare(b.fileName || b.url || '');
break;
case 'Size':
comparison = parseInt(a.size || '0', 10) - parseInt(b.size || '0', 10);
break;
case 'Status':
comparison = a.status.localeCompare(b.status);
break;
case 'Speed':
comparison = parseSpeed(a.speed) - parseSpeed(b.speed);
break;
case 'ETA':
comparison = parseEta(a.eta) - parseEta(b.eta);
break;
case 'Date Added':
comparison = new Date(a.dateAdded || 0).getTime() - new Date(b.dateAdded || 0).getTime();
break;
}
return sortConfig.direction === 'asc' ? comparison : -comparison;
})
: filteredDownloads;
useEffect(() => {
setQueueSortConfig(null);
}, [filter, isQueueFilter]);
const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => {
if (e.detail === 2) {
handleDownloadDoubleClick(item);
@@ -312,15 +288,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
setContextMenu(menu);
};
const handleSort = (column: string) => {
if (filter.startsWith('queue:')) return; // Disable custom sorting in queues
setSortConfig(current => {
if (current?.column === column) {
if (current.direction === 'desc') return null; // Reset sort
return { column, direction: 'desc' };
const handleSort = (column: DownloadSortColumn) => {
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
current?.column === column
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { column, direction: 'asc' };
if (isQueueFilter) {
setQueueSortConfig(update);
} else {
setSortConfig(current => update(current));
}
return { column, direction: 'asc' };
});
};
@@ -356,8 +334,25 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}
};
const handleResume = (item: DownloadItem) => {
useDownloadStore.getState().resumeDownload(item.id);
const handleResume = async (item: DownloadItem) => {
try {
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
if (!resumed) {
throw new Error('The backend rejected the start/resume request.');
}
} catch (error) {
console.error("Failed to resume:", error);
showInteractionError(`Could not resume ${item.fileName}`, error);
}
};
const resumeItemsSequentially = async (items: DownloadItem[]) => {
for (const item of items) {
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
if (current && canStartDownload(current.status)) {
await handleResume(current);
}
}
};
const handleDelete = (ids: string | string[]) => {
@@ -449,9 +444,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
className="main-control-button"
disabled={sortedDownloads.length === 0}
onClick={() => {
sortedDownloads
.filter(d => canStartDownload(d.status))
.forEach(d => handleResume(d));
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
}}
title="Resume All"
>
@@ -497,13 +490,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
<div
key={label}
className={`${index === 5 ? 'download-cell-right' : ''} ${filter.startsWith('queue:') ? 'opacity-70 cursor-default' : 'cursor-pointer hover:text-text-primary transition-colors'} flex items-center justify-between`}
onClick={() => handleSort(label)}
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
onClick={() => handleSort(label as DownloadSortColumn)}
>
<div className="flex items-center gap-1 w-full h-full select-none">
<span>{label}</span>
{sortConfig?.column === label && (
sortConfig.direction === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === label && (
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
? <ChevronUp size={14} />
: <ChevronDown size={14} />
)}
</div>
<div
@@ -519,8 +514,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{sortedDownloads.length === 0 ? (
<div className="downloads-empty-state">
<ArrowDownCircle aria-hidden="true" />
<div className="downloads-empty-title">No Downloads</div>
<div className="downloads-empty-title">
{isQueueFilter ? 'Queue is empty' : filter === 'completed' ? 'No Completed Downloads' : 'No Downloads'}
</div>
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
{isQueueFilter ? (
'Add downloads to this queue from an item menu or the Add window.'
) : filter === 'completed' ? (
'Completed downloads will appear here.'
) : (
<>
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
<span className="flex items-center mx-1.5">
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
@@ -532,6 +535,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
</span>
</span>
to add downloads
</>
)}
</div>
</div>
) : (
@@ -576,6 +581,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const itemsToResume = selectedDownloads.filter(d => canStartDownload(d.status));
const itemsToPause = selectedDownloads.filter(d => canPauseDownload(d.status));
const itemsToQueue = selectedDownloads.filter(d => d.status !== 'completed');
return (
<>
@@ -584,18 +590,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<button
onClick={() => {
setContextMenu(null);
if (itemsToResume.length > 0) {
const activeQueueIds = Array.from(new Set(itemsToResume.map(i => i.queueId).filter(Boolean)));
// We can use assignToQueue for bulk resume to the same queue
if (activeQueueIds.length === 1 && activeQueueIds[0]) {
void assignToQueue(itemsToResume.map(i => i.id), activeQueueIds[0]).catch(error => {
showInteractionError('Could not bulk resume downloads', error);
});
} else {
// Fallback for missing queue or multiple queues
itemsToResume.forEach(handleResume);
}
}
void resumeItemsSequentially(itemsToResume);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
@@ -630,6 +625,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
)}
{itemsToQueue.length > 0 && (
<div className="group relative">
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
Add to Queue
@@ -639,7 +635,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => (
<button key={q.id} onClick={() => {
setContextMenu(null);
void assignToQueue(Array.from(selectedIds), q.id).catch(error => {
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
showInteractionError('Could not move downloads to queue', error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
@@ -648,6 +644,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
))}
</div>
</div>
)}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
+24 -3
View File
@@ -10,6 +10,7 @@ import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadS
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { isTransferActiveStatus } from '../utils/downloads';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -100,7 +101,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
}
switch (filter) {
case 'all': return downloads.length;
case 'active': return downloads.filter(d => d.status === 'downloading').length;
case 'active': return downloads.filter(d => isTransferActiveStatus(d.status)).length;
case 'completed': return downloads.filter(d => d.status === 'completed').length;
case 'unfinished': return downloads.filter(d => d.status !== 'completed').length;
default: return downloads.filter(d => d.category === filter as DownloadCategory).length;
@@ -330,14 +331,34 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => { startQueue(contextMenu.id); setContextMenu(null); }}
onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void startQueue(queueId).catch(error => {
addToast({
message: `Could not start queue: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}}
>
<Play size={14} className="mr-2 text-text-secondary" />
Start Queue
</button>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => { pauseQueue(contextMenu.id); setContextMenu(null); }}
onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void pauseQueue(queueId).catch(error => {
addToast({
message: `Could not pause queue: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}}
>
<Pause size={14} className="mr-2 text-text-secondary" />
Pause Queue
+1
View File
@@ -82,6 +82,7 @@ type CommandMap = {
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down' }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean };
};
+38
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
import { useDownloadStore } from './useDownloadStore';
import * as ipc from '../ipc';
vi.mock('../ipc', () => ({
@@ -45,4 +46,41 @@ describe('useDownloadProgressStore', () => {
releaseSecond();
expect(unlisten).toHaveBeenCalledTimes(3);
});
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'terminal',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'completed',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'terminal',
fraction: 0.1,
speed: '1 MB/s',
eta: '10s',
size: '1 MB',
size_is_final: false
} });
handlers['download-state']({ payload: {
id: 'terminal',
status: 'failed',
error: 'stale failure'
} });
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
release();
});
});
+8 -3
View File
@@ -52,10 +52,15 @@ const startDownloadListeners = async () => {
const registrations = await Promise.allSettled([
listen('download-progress', (event) => {
const payload = event.payload;
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
// A sidecar can flush one last progress chunk after a pause, failure,
// or completion event. Do not let that stale chunk repopulate the live
// progress map or overwrite a later lifecycle's first frame.
if (current && ['completed', 'failed', 'paused'].includes(current.status)) {
return;
}
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
if (current) {
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
@@ -81,7 +86,7 @@ const startDownloadListeners = async () => {
// Prevent race condition: don't transition backwards from terminal state
if ((current.status === 'completed' || current.status === 'failed') &&
(status !== 'completed' && status !== 'failed')) {
status !== current.status) {
return;
}
+136 -1
View File
@@ -248,8 +248,15 @@ describe('useDownloadStore', () => {
);
});
await useDownloadStore.getState().removeDownload('late');
const remove = useDownloadStore.getState().removeDownload('late');
await vi.waitFor(() => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'cancel_enqueue_generation',
expect.objectContaining({ id: 'late' })
);
});
resolveEnqueue({ id: 'late', filename: 'late.bin' });
await remove;
await expect(start).resolves.toEqual([]);
expect(useDownloadStore.getState().downloads).toEqual([]);
@@ -688,6 +695,134 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
});
it('does not reassign an item that completes while queue assignment is awaiting cancellation', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'race-ready', status: 'ready', queueId: 'old' },
{ id: 'race-done', status: 'completed', queueId: 'old' }
] as any[]
});
let resolveCancellation!: () => void;
const cancellation = new Promise<void>(resolve => {
resolveCancellation = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'cancel_enqueue_generation') return cancellation as never;
return Promise.resolve(undefined) as never;
});
const assignment = useDownloadStore.getState().assignToQueue(['race-ready', 'race-done'], 'new');
await vi.waitFor(() => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'cancel_enqueue_generation',
expect.objectContaining({ id: 'race-ready' })
);
});
useDownloadStore.getState().updateDownload('race-ready', { status: 'completed' });
resolveCancellation();
await assignment;
expect(useDownloadStore.getState().downloads.find(item => item.id === 'race-ready')).toMatchObject({
status: 'completed',
queueId: 'old'
});
});
it('cancels the rest of a queue start when pause is requested during dispatch', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'queue-first', url: 'http://test/first', fileName: 'first', destination: '/tmp', status: 'ready', category: 'Other', dateAdded: '', queueId: 'race-queue', queuePosition: 0 },
{ id: 'queue-second', url: 'http://test/second', fileName: 'second', destination: '/tmp', status: 'ready', category: 'Other', dateAdded: '', queueId: 'race-queue', queuePosition: 1 }
] as any[]
});
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
resolveEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'enqueue_download') return enqueue as never;
return Promise.resolve(undefined) as never;
});
const start = useDownloadStore.getState().startQueue('race-queue');
await vi.waitFor(() => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'queue-first' }) })
);
});
const pause = useDownloadStore.getState().pauseQueue('race-queue');
resolveEnqueue({ id: 'queue-first', filename: 'first' });
await expect(pause).resolves.toBe(1);
await expect(start).resolves.toEqual([]);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command, args]) =>
command === 'enqueue_download' && (args as any)?.item?.id === 'queue-second'
)
).toHaveLength(0);
});
it('uses one atomic backend move and keeps queue positions unique around active transfers', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'active', status: 'downloading', queueId: 'move-queue', queuePosition: 0 },
{ id: 'one', status: 'queued', queueId: 'move-queue', queuePosition: 1 },
{ id: 'two', status: 'queued', queueId: 'move-queue', queuePosition: 2 },
{ id: 'three', status: 'queued', queueId: 'move-queue', queuePosition: 3 }
] as any[],
backendRegisteredIds: new Set(['three']),
pendingOrder: ['one', 'two', 'three']
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'move_many_in_queue') return ['one', 'three', 'two'];
if (command === 'get_pending_order') return ['one', 'three', 'two'];
return undefined;
});
await useDownloadStore.getState().moveInQueue('three', 'up');
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith('move_many_in_queue', {
ids: ['three'],
queueId: 'move-queue',
direction: 'up'
});
expect(vi.mocked(ipc.invokeCommand)).not.toHaveBeenCalledWith('move_in_queue', expect.anything());
const positions = useDownloadStore.getState().downloads
.filter(item => item.queueId === 'move-queue')
.map(item => item.queuePosition);
expect(new Set(positions).size).toBe(4);
});
it('detaches a registered queued item through the backend before reassigning it', async () => {
useDownloadStore.setState({
downloads: [{
id: 'registered-queued',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: 'old',
queuePosition: 0
}],
backendRegisteredIds: new Set(['registered-queued']),
pendingOrder: ['registered-queued']
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().assignToQueue(['registered-queued'], 'new');
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'detach_download_for_reconfigure',
{ id: 'registered-queued' }
);
expect(useDownloadStore.getState().pendingOrder).not.toContain('registered-queued');
expect(useDownloadStore.getState().downloads[0].status).toBe('staged');
});
it('disables scheduler when its last selected queue is deleted', async () => {
const originalSettings = useSettingsStore.getState();
const setScheduler = vi.fn();
+167 -62
View File
@@ -7,7 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -17,6 +17,56 @@ export type { DownloadCategory } from '../utils/downloads';
const backendDispatchPromises = new Map<string, Promise<boolean>>();
const downloadLifecycleGenerations = new Map<string, bigint>();
const queueReorderPromises = new Map<string, Promise<void>>();
const queueStartPromises = new Map<string, Promise<string[]>>();
const queueControlGenerations = new Map<string, number>();
const currentQueueControlGeneration = (queueId: string): number =>
queueControlGenerations.get(queueId) ?? 0;
const advanceQueueControlGeneration = (queueId: string): number => {
const nextGeneration = currentQueueControlGeneration(queueId) + 1;
queueControlGenerations.set(queueId, nextGeneration);
return nextGeneration;
};
const isCurrentQueueControlGeneration = (queueId: string, generation: number): boolean =>
currentQueueControlGeneration(queueId) === generation;
const queuePositionComparator = (left: DownloadItem, right: DownloadItem): number =>
(left.queuePosition ?? Number.MAX_SAFE_INTEGER) - (right.queuePosition ?? Number.MAX_SAFE_INTEGER) ||
left.id.localeCompare(right.id);
const queueItemsForReordering = (downloads: DownloadItem[], queueId: string): DownloadItem[] =>
downloads
.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId &&
download.status !== 'completed' &&
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
)
.sort(queuePositionComparator);
const activeQueueItems = (downloads: DownloadItem[], queueId: string): DownloadItem[] =>
downloads
.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId &&
download.status !== 'completed' &&
isActiveDownloadStatus(download.status) &&
download.status !== 'queued'
)
.sort(queuePositionComparator);
const applyQueueOrder = (
downloads: DownloadItem[],
queueId: string,
pendingItems: DownloadItem[]
): DownloadItem[] => {
const orderedItems = [...activeQueueItems(downloads, queueId), ...pendingItems];
const positions = new Map(orderedItems.map((download, position) => [download.id, position]));
return downloads.map(download => positions.has(download.id)
? { ...download, queuePosition: positions.get(download.id) }
: download);
};
const advanceDownloadLifecycle = (id: string): bigint => {
const nextGeneration = (downloadLifecycleGenerations.get(id) ?? 0n) + 1n;
@@ -290,7 +340,7 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
const syncSystemIntegrations = () => {
const settings = useSettingsStore.getState();
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length;
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
};
@@ -395,26 +445,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
pendingOrder: [],
setPendingOrder: (order) => set({ pendingOrder: order }),
moveInQueue: async (idOrIds, direction) => {
moveInQueue: (idOrIds, direction) => {
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
if (ids.length === 0) return;
if (ids.length === 0) return Promise.resolve();
// Assume all items belong to the same queue as the first item
const firstItem = get().downloads.find(d => d.id === ids[0]);
if (!firstItem) return;
// Queue moves must be serialized per queue. Otherwise two optimistic
// moves can calculate from the same order and the last RPC silently wins.
const firstItem = get().downloads.find(download => ids.includes(download.id));
if (!firstItem) return Promise.resolve();
const queueId = firstItem.queueId || MAIN_QUEUE_ID;
const queueItems = get().downloads
.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId &&
download.status !== 'completed' &&
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
)
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve();
const operation = previousOperation.catch(() => undefined).then(async () => {
const allDownloads = get().downloads;
const queueItems = queueItemsForReordering(allDownloads, queueId);
const selectedItems = queueItems.filter(item => ids.includes(item.id));
if (selectedItems.length === 0) return;
const previousPositions = new Map([
...activeQueueItems(allDownloads, queueId),
...queueItems
].map(item => [item.id, item.queuePosition]));
const unselectedItems = queueItems.filter(item => !ids.includes(item.id));
const selectedIndices = selectedItems.map(item => queueItems.findIndex(d => d.id === item.id));
@@ -432,36 +483,49 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
...selectedItems,
...unselectedItems.slice(insertIndex)
];
const positions = new Map(reordered.map((download, position) => [download.id, position]));
const previousDownloads = get().downloads;
set(state => ({
downloads: state.downloads.map(download => positions.has(download.id)
? { ...download, queuePosition: positions.get(download.id) }
: download)
}));
set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
const registeredIdsToMove = selectedItems
.filter(item => get().backendRegisteredIds.has(item.id))
.map(item => item.id);
if (registeredIdsToMove.length === 0) return;
// For backend sync, we must call move_in_queue in the correct order to maintain the block
const idsToMove = direction === 'up' ? registeredIdsToMove : [...registeredIdsToMove].reverse();
try {
let order: string[] = [];
for (const id of idsToMove) {
order = (await invoke('move_in_queue', { id, queueId, direction })) as string[];
const order = await invoke('move_many_in_queue', {
ids: registeredIdsToMove,
queueId,
direction
}) as string[];
if (Array.isArray(order)) {
const globalOrder = await invoke('get_pending_order', { queueId: null })
.catch(() => null) as string[] | null;
set(state => ({
pendingOrder: Array.isArray(globalOrder)
? globalOrder
: [
...state.pendingOrder.filter(id => !order.includes(id)),
...order
]
}));
}
if (order.length > 0) {
set({ pendingOrder: order });
} catch (error) {
console.error("Failed to move in queue backend:", error);
// The backend operation is atomic. Restore only queue positions so a
// progress/state event received while the RPC was in flight survives.
set(state => ({
downloads: state.downloads.map(download => previousPositions.has(download.id)
? { ...download, queuePosition: previousPositions.get(download.id) }
: download)
}));
}
} catch (e) {
console.error("Failed to move in queue backend:", e);
set({ downloads: previousDownloads });
});
const trackedOperation = operation.finally(() => {
if (queueReorderPromises.get(queueId) === trackedOperation) {
queueReorderPromises.delete(queueId);
}
});
queueReorderPromises.set(queueId, trackedOperation);
return trackedOperation;
},
removeFromQueue: async (id) => {
try {
@@ -643,8 +707,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (item.status === 'queued') {
if (isRegistered) {
await invoke('remove_from_queue', { id });
await invoke('detach_download_for_reconfigure', { id });
state.unregisterBackendIds([id]);
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
}
state.updateDownload(id, updates);
if (isRegistered || wasDispatching) {
@@ -692,7 +757,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
},
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
await invalidateDispatch(id);
const { pendingDispatch } = await invalidateDispatch(id);
if (pendingDispatch) {
await pendingDispatch;
}
const item = get().downloads.find(d => d.id === id);
if (item) {
@@ -820,19 +888,26 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return false;
}
},
startQueue: async (queueId) => {
startQueue: (queueId) => {
const requestedGeneration = currentQueueControlGeneration(queueId);
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
const operation = previousOperation.catch(() => []).then(async () => {
const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
.sort(queuePositionComparator);
if (runnable.length === 0) return [];
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
const acceptedIds: string[] = [];
for (const item of runnable) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
const currentItem = get().downloads.find(download => download.id === item.id);
if (!currentItem || currentItem.status === 'completed') continue;
const backendRegistered = get().backendRegisteredIds.has(item.id);
const backendPending = get().pendingOrder.includes(item.id);
if (item.status === 'queued' && backendRegistered && !backendPending) {
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
if (await get().resumeDownload(item.id)) {
acceptedIds.push(item.id);
}
@@ -840,13 +915,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
if (
item.status === 'ready' ||
item.status === 'staged' ||
item.status === 'failed' ||
!item.hasBeenDispatched ||
currentItem.status === 'ready' ||
currentItem.status === 'staged' ||
currentItem.status === 'failed' ||
!currentItem.hasBeenDispatched ||
!backendRegistered
) {
if (await dispatchItem(item.id)) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
const afterDispatch = get().downloads.find(download => download.id === item.id);
if (
backendDispatchPromises.has(item.id) ||
get().backendRegisteredIds.has(item.id) ||
(afterDispatch && canPauseDownload(afterDispatch.status))
) {
await get().pauseDownload(item.id);
}
continue;
}
const current = get().downloads.find(download => download.id === item.id);
get().updateDownload(item.id, {
hasBeenDispatched: true,
@@ -854,10 +940,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
});
acceptedIds.push(item.id);
}
} else if (item.status === 'paused' || item.status === 'queued') {
} else if (currentItem.status === 'paused' || currentItem.status === 'queued') {
// If it's queued but already dispatched, it might be waiting.
// If it's paused, we resume it.
if (item.status === 'paused') {
if (currentItem.status === 'paused') {
if (!await get().resumeDownload(item.id)) continue;
}
acceptedIds.push(item.id);
@@ -866,10 +952,25 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
return acceptedIds;
});
const trackedOperation = operation.finally(() => {
if (queueStartPromises.get(queueId) === trackedOperation) {
queueStartPromises.delete(queueId);
}
});
queueStartPromises.set(queueId, trackedOperation);
return trackedOperation;
},
pauseQueue: async (queueId) => {
// Invalidate queued starts before taking the snapshot. This prevents a
// start loop that is waiting on metadata/IPC from dispatching later rows
// after the user has already requested Pause Queue.
advanceQueueControlGeneration(queueId);
const activeIds = get().downloads
.filter(item => item.queueId === queueId && canPauseDownload(item.status))
.filter(item =>
item.queueId === queueId &&
(canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
)
.map(item => item.id);
if (activeIds.length === 0) return 0;
@@ -899,8 +1000,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return results.reduce((total, ids) => total + ids.length, 0);
},
pauseAll: async () => {
const queueIds = new Set(
get().downloads.map(item => item.queueId || MAIN_QUEUE_ID)
);
for (const queueId of queueIds) {
advanceQueueControlGeneration(queueId);
}
const activeIds = get().downloads
.filter(item => canPauseDownload(item.status))
.filter(item => canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
.map(item => item.id);
if (activeIds.length === 0) return 0;
@@ -917,35 +1024,33 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
}
await Promise.all(
selected
.filter(item => item.status !== 'completed')
.map(item => invalidateAndWaitForDispatch(item.id))
);
const movableSelected = selected.filter(item => item.status !== 'completed');
const movableIds = new Set(movableSelected.map(item => item.id));
await Promise.all(movableSelected.map(item => invalidateAndWaitForDispatch(item.id)));
for (const item of get().downloads.filter(item => selectedIds.has(item.id))) {
for (const item of get().downloads.filter(item => movableIds.has(item.id))) {
if (!get().backendRegisteredIds.has(item.id)) continue;
if (item.status === 'queued') {
await invoke('remove_from_queue', { id: item.id });
} else if (item.status === 'paused') {
// The UI can still say queued while a dispatch has already reached
// Aria2/media. Detach through the backend lifecycle owner for every
// registered item; remove_from_queue only handles the pending list.
await invoke('detach_download_for_reconfigure', { id: item.id });
}
get().unregisterBackendIds([item.id]);
set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== item.id) }));
}
const queueItems = get().downloads.filter(item =>
!selectedIds.has(item.id) &&
!movableIds.has(item.id) &&
(item.queueId || MAIN_QUEUE_ID) === queueId
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const nextPosition = maxPos + 1;
set(state => ({
downloads: state.downloads.map(item =>
selectedIds.has(item.id) && item.status !== 'completed'
movableIds.has(item.id) && item.status !== 'completed'
? {
...item,
queueId,
queuePosition: nextPosition + selected.findIndex(selectedItem => selectedItem.id === item.id),
queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id),
status: 'staged' as const,
hasBeenDispatched: false
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import {
parseDownloadEta,
parseDownloadSize,
sortDownloads,
type DownloadSortConfig
} from './downloadTableSorting';
import { redactDownloadForPersistence } from './downloads';
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
id,
url: `https://example.test/${id}`,
fileName: id,
status: 'queued',
category: 'Other',
dateAdded: '2026-07-14T00:00:00.000Z',
...overrides
});
const sortedIds = (downloads: DownloadItem[], config: DownloadSortConfig): string[] =>
sortDownloads(downloads, config).map(download => download.id);
describe('download table sorting', () => {
it('compares human-readable sizes by bytes instead of their leading number', () => {
expect(parseDownloadSize('1 MB')).toBe(1024 ** 2);
expect(sortedIds([
item('one-mb', { size: '1 MB' }),
item('900-kb', { size: '900 KB' }),
item('two-mb', { size: '2 MB' })
], { column: 'Size', direction: 'asc' })).toEqual(['900-kb', 'one-mb', 'two-mb']);
});
it('supports clock and unit ETA values and keeps unknown values last', () => {
expect(parseDownloadEta('01:02:03')).toBe(3723);
expect(parseDownloadEta('2m 5s')).toBe(125);
expect(sortedIds([
item('unknown', { eta: '-' }),
item('long', { eta: '2m' }),
item('short', { eta: '10s' })
], { column: 'ETA', direction: 'asc' })).toEqual(['short', 'long', 'unknown']);
});
it('sorts descending on the second click without reverting to an unsorted list', () => {
const downloads = [item('b', { fileName: 'Beta' }), item('a', { fileName: 'Alpha' })];
expect(sortedIds(downloads, { column: 'File Name', direction: 'asc' })).toEqual(['a', 'b']);
expect(sortedIds(downloads, { column: 'File Name', direction: 'desc' })).toEqual(['b', 'a']);
});
it('does not persist volatile progress fields', () => {
const persisted = redactDownloadForPersistence(item('volatile', {
fraction: 0.75,
speed: '1 MB/s',
eta: '10s'
}));
expect(persisted.fraction).toBeUndefined();
expect(persisted.speed).toBeUndefined();
expect(persisted.eta).toBeUndefined();
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { DownloadItem } from '../bindings/DownloadItem';
export type DownloadSortColumn = 'File Name' | 'Size' | 'Status' | 'Speed' | 'ETA' | 'Date Added';
export type DownloadSortDirection = 'asc' | 'desc';
export type DownloadSortConfig = {
column: DownloadSortColumn;
direction: DownloadSortDirection;
};
const SIZE_UNITS: Record<string, number> = {
B: 1,
KB: 1024,
KIB: 1024,
MB: 1024 ** 2,
MIB: 1024 ** 2,
GB: 1024 ** 3,
GIB: 1024 ** 3,
TB: 1024 ** 4,
TIB: 1024 ** 4,
};
const valueOrNull = (value?: string): string | null => {
const trimmed = value?.trim();
return trimmed && trimmed !== '-' && !/^unknown$/i.test(trimmed) ? trimmed : null;
};
const parseUnitValue = (value?: string, units = SIZE_UNITS): number | null => {
const normalized = valueOrNull(value);
if (!normalized) return null;
const match = normalized.match(/^([\d.,]+)\s*([KMGT]?I?B)(?:\/s)?$/i);
if (!match) {
const number = Number(normalized.replace(/,/g, ''));
return Number.isFinite(number) ? number : null;
}
const amount = Number(match[1].replace(/,/g, ''));
const multiplier = units[match[2].toUpperCase()];
return Number.isFinite(amount) && multiplier ? amount * multiplier : null;
};
export const parseDownloadSize = (value?: string): number | null => parseUnitValue(value);
export const parseDownloadSpeed = (value?: string): number | null =>
parseUnitValue(value, SIZE_UNITS);
export const parseDownloadEta = (value?: string): number | null => {
const normalized = valueOrNull(value);
if (!normalized) return null;
const clockParts = normalized.split(':').map(part => Number(part));
if (clockParts.length >= 2 && clockParts.every(Number.isFinite)) {
return clockParts.reduce((total, part, index) => total + part * 60 ** (clockParts.length - index - 1), 0);
}
let seconds = 0;
let matched = false;
for (const [pattern, multiplier] of [[/(\d+(?:\.\d+)?)h/i, 3600], [/(\d+(?:\.\d+)?)m/i, 60], [/(\d+(?:\.\d+)?)s/i, 1]] as const) {
const match = normalized.match(pattern);
if (match) {
seconds += Number(match[1]) * multiplier;
matched = true;
}
}
return matched && Number.isFinite(seconds) ? seconds : null;
};
const compareValues = (left: string | number | null, right: string | number | null): number => {
if (left === null && right === null) return 0;
if (left === null) return 1;
if (right === null) return -1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
};
const parseDownloadDate = (value?: string): number | null => {
if (!value?.trim()) return null;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
};
export const sortDownloads = (downloads: DownloadItem[], config: DownloadSortConfig): DownloadItem[] => {
const sorted = [...downloads].sort((left, right) => {
let comparison: number;
switch (config.column) {
case 'File Name':
comparison = compareValues(left.fileName || left.url, right.fileName || right.url);
break;
case 'Size':
comparison = compareValues(parseDownloadSize(left.size), parseDownloadSize(right.size));
break;
case 'Status':
comparison = compareValues(left.status, right.status);
break;
case 'Speed':
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed));
break;
case 'ETA':
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta));
break;
case 'Date Added':
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded));
break;
}
if (comparison === 0) comparison = left.id.localeCompare(right.id);
return config.direction === 'asc' ? comparison : -comparison;
});
return sorted;
};
+5
View File
@@ -36,6 +36,10 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
ACTIVE_DOWNLOAD_STATUSES.has(status);
/** Transfer states that consume a worker/permit. Queued is intentionally excluded. */
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'retrying';
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
@@ -128,6 +132,7 @@ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
*/
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
const copy: DownloadItem = { ...item };
delete copy.fraction;
delete copy.speed;
delete copy.eta;
for (const field of DOWNLOAD_SECRET_FIELDS) {