feat(queue): implement backend-driven download queue coordinator

This commit replaces the frontend-imperative download dispatcher with a centralized backend `QueueManager`. It acts as the sole concurrency gatekeeper using a single `tokio::sync::Semaphore` across all download paths (aria2 RPC, native HTTP, yt-dlp media).

Backend Changes:
- **queue**: Added `QueueManager` to manage an ordered `VecDeque` of tasks, semaphore permits, and retirement debt (CAS resize).
- **commands**: Replaced direct start commands with `enqueue_download`, `enqueue_many`, `move_in_queue`, and `remove_from_queue`.
- **ipc**: Exported `DownloadStateEvent` and `QueueDirection` to the frontend.
- **tests**: Added 11 integration tests covering idle-parking, idempotent releases, CAS underflow prevention, and gid-completion races.

Frontend Changes:
- **store**: Made `useDownloadStore` reactive to the backend via the `download-state` event.
- **store**: Removed `processQueue` and introduced `pendingOrder` to track the accurate sequence of queued items.
- **ui**: Updated `DownloadItem` with queue visuals (clock icon, position badge).
- **ui**: Added Move Up/Down controls to interact with the backend queue reordering API.
This commit is contained in:
NimBold
2026-06-16 17:46:58 +03:30
parent 1ec4d2aa17
commit bb618aef7d
16 changed files with 1583 additions and 393 deletions
+29 -5
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import type { DownloadStateEvent } from '../bindings/DownloadStateEvent';
import type { DownloadStatus } from '../bindings/DownloadStatus';
interface DownloadProgressState {
progressMap: Record<string, DownloadProgressEvent>;
@@ -21,6 +23,7 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
}));
let unlistenProgress: UnlistenFn | null = null;
let unlistenState: UnlistenFn | null = null;
let unlistenTray: UnlistenFn | null = null;
export async function initDownloadListener() {
@@ -31,15 +34,36 @@ export async function initDownloadListener() {
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (current && current.status === 'queued') {
const updates: any = { status: 'downloading' };
if (payload.size && current.size !== payload.size) updates.size = payload.size;
mainStore.updateDownload(payload.id, updates);
} else if (current && payload.size && current.size !== payload.size) {
if (current && payload.size && current.size !== payload.size) {
mainStore.updateDownload(payload.id, { size: payload.size });
}
});
if (!unlistenState) {
unlistenState = await listen<DownloadStateEvent>('download-state', (event) => {
const payload = event.payload;
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (current) {
const status = payload.status as DownloadStatus;
const updates: Partial<any> = { status };
if (status !== 'downloading') {
updates.speed = '-';
updates.eta = '-';
}
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused') {
mainStore.setPendingOrder(mainStore.pendingOrder.filter(id => id !== payload.id));
} else if (status === 'queued') {
if (!mainStore.pendingOrder.includes(payload.id)) {
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
}
}
}
});
}
if (!unlistenTray) {
unlistenTray = await listen<string>('tray-action', (event) => {
const mainStore = useDownloadStore.getState();