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
+10 -7
View File
@@ -21,7 +21,7 @@ function App() {
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
});
const updateDownload = useDownloadStore(state => state.updateDownload);
const theme = useSettingsStore(state => state.theme);
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
const activeView = useSettingsStore(state => state.activeView);
@@ -209,8 +209,6 @@ function App() {
initDownloadListener();
const unlistenComplete = listen('download-complete', (event) => {
updateDownload(event.payload, { status: 'completed', fraction: 1.0, speed: '-', eta: '-' });
const settings = useSettingsStore.getState();
if (settings.showNotifications) {
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload);
@@ -225,10 +223,15 @@ function App() {
});
const unlistenFailed = listen('download-failed', (event) => {
// If it's already paused, don't mark as failed (since we aborted it)
const current = useDownloadStore.getState().downloads.find(d => d.id === event.payload);
if (current && current.status !== 'paused') {
updateDownload(event.payload, { status: 'failed', speed: '-', eta: '-' });
const settings = useSettingsStore.getState();
if (settings.showNotifications) {
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload);
const fileName = item?.fileName || 'A file';
sendNotification({
title: 'Download Failed',
body: `${fileName} failed to download.`,
});
}
});
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId: string, _dispatched?: boolean, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStateEvent = { id: string, status: string, error: string | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type QueueDirection = "up" | "down";
+44 -7
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useRef } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadStore';
import { Play, Pause, MoreVertical } from 'lucide-react';
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
interface DownloadItemProps {
@@ -24,6 +24,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
getCategoryIcon,
}) => {
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
const pendingOrder = useDownloadStore(state => state.pendingOrder);
const moveInQueue = useDownloadStore(state => state.moveInQueue);
const queueIndex = pendingOrder.indexOf(downloadId);
const progressBarRef = useRef<HTMLDivElement>(null);
const statusTextRef = useRef<HTMLSpanElement>(null);
@@ -31,7 +34,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const etaTextRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
// We only need transient updates while it's actively downloading
if (!download || download.status !== 'downloading') return;
const unsubscribe = useDownloadProgressStore.subscribe((state) => {
@@ -89,17 +91,32 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div className="download-progress-track">
<div
ref={progressBarRef}
className={`download-progress-fill ${download.status === 'paused' ? 'paused' : ''}`}
className={`download-progress-fill ${
download.status === 'paused' ? 'paused' :
download.status === 'queued' ? 'queued' : ''
}`}
style={{ width: `${(download.fraction || 0) * 100}%` }}
/>
</div>
<span
ref={statusTextRef}
className={`download-status ${download.status === 'paused' ? 'download-status-paused' : download.status === 'failed' ? 'download-status-failed' : download.status === 'downloading' ? 'download-status-downloading' : ''}`}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
download.status === 'failed' ? 'download-status-failed' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' ? 'download-status-queued' : ''
}`}
>
{download.status === 'downloading'
? `${((download.fraction || 0) * 100).toFixed(0)}%`
: download.status.charAt(0).toUpperCase() + download.status.slice(1)}
{download.status === 'queued' && queueIndex !== -1 ? (
<>
<Clock size={12} className="animate-pulse shrink-0" />
<span className="truncate">Queued #{queueIndex + 1}</span>
</>
) : download.status === 'downloading' ? (
`${((download.fraction || 0) * 100).toFixed(0)}%`
) : (
download.status.charAt(0).toUpperCase() + download.status.slice(1)
)}
</span>
</>
)}
@@ -119,6 +136,26 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</span>
<div className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto">
{download.status === 'queued' && queueIndex !== -1 && (
<>
<button
onClick={() => moveInQueue(download.id, 'Up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Up"
>
<ArrowUp size={14} />
</button>
<button
onClick={() => moveInQueue(download.id, 'Down')}
disabled={queueIndex === pendingOrder.length - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down"
>
<ArrowDown size={14} />
</button>
</>
)}
{download.status === 'downloading' && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title="Pause">
<Pause size={14} fill="currentColor" />
+2 -4
View File
@@ -12,7 +12,7 @@ interface DownloadTableProps {
}
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, toggleAddModal, updateDownload, openDeleteModal, redownload } = useDownloadStore();
const { downloads, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
const isMac = navigator.userAgent.includes('Mac');
@@ -93,15 +93,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const handlePause = async (id: string) => {
try {
await invoke('pause_download', { id });
updateDownload(id, { status: 'paused', speed: '-', eta: '-' });
} catch (e) {
console.error("Failed to pause:", e);
}
};
const handleResume = (item: DownloadItem) => {
updateDownload(item.id, { status: 'queued', _dispatched: false, speed: '-', eta: '-' });
useDownloadStore.getState().processQueue();
useDownloadStore.getState().resumeDownload(item.id);
};
const handleDelete = (id: string) => {
+5
View File
@@ -99,6 +99,11 @@ type CommandMap = {
db_save_queue: { args: { id: string; data: string }; result: void };
db_delete_queue: { args: { id: string }; result: void };
create_category_directories: { args: { paths: string[] }; result: void };
get_pending_order: { args: undefined; result: string[] };
enqueue_download: { args: { item: any }; result: string };
enqueue_many: { args: { items: any[] }; result: void };
move_in_queue: { args: { id: string; direction: 'Up' | 'Down' | 'Top' | 'Bottom' }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean };
};
type CommandName = keyof CommandMap;
+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();
+273 -142
View File
@@ -60,8 +60,6 @@ const syncSystemIntegrations = () => {
}
};
// Legacy manual speed limit math removed
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -76,6 +74,10 @@ export type DeleteModalState = {
interface DownloadState {
downloads: DownloadItem[];
queues: Queue[];
pendingOrder: string[];
setPendingOrder: (order: string[]) => void;
moveInQueue: (id: string, direction: 'Up' | 'Down') => Promise<void>;
removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean;
pendingAddUrls: string;
pendingAddReferer: string;
@@ -88,11 +90,11 @@ interface DownloadState {
openDeleteModal: (downloadId?: string) => void;
closeDeleteModal: () => void;
setSelectedPropertiesDownloadId: (id: string | null) => void;
addDownload: (item: DownloadItem) => void;
addDownload: (item: DownloadItem) => Promise<void>;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
redownload: (id: string) => void;
processQueue: () => Promise<void>;
redownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>;
startQueue: (queueId: string) => Promise<number>;
pauseQueue: (queueId: string) => Promise<number>;
addQueue: (name: string) => void;
@@ -108,11 +110,29 @@ interface DownloadState {
clearMetadata: () => void;
}
let isProcessingQueue = false;
export const useDownloadStore = create<DownloadState>((set, get) => ({
downloads: [],
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
pendingOrder: [],
setPendingOrder: (order) => set({ pendingOrder: order }),
moveInQueue: async (id, direction) => {
try {
const order = await invoke('move_in_queue', { id, direction });
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to move item in queue:", e);
}
},
removeFromQueue: async (id) => {
try {
await invoke('remove_from_queue', { id });
set((state) => ({
pendingOrder: state.pendingOrder.filter(x => x !== id)
}));
} catch (e) {
console.error("Failed to remove item from queue:", e);
}
},
isAddModalOpen: false,
pendingAddUrls: '',
pendingAddReferer: '',
@@ -170,10 +190,54 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
info(`Media metadata parsing failed for ${url}: ${e}`);
}
},
addDownload: (item) => {
addDownload: async (item) => {
info(`Download ${item.id} added to queue`);
set((state) => ({ downloads: [...state.downloads, item] }));
get().processQueue();
try {
const settings = useSettingsStore.getState();
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
const enqueueItem = {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to enqueue download:", e);
get().updateDownload(item.id, { status: 'failed' });
}
},
updateDownload: (id, updates) => {
set((state) => ({
@@ -190,10 +254,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
})
}));
// If status changed to something that frees up a slot, process queue
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
info(`Download ${id} status changed to ${updates.status}`);
get().processQueue();
syncSystemIntegrations();
} else if (updates.status === 'downloading') {
info(`Download ${id} status changed to downloading`);
@@ -202,9 +264,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
removeDownload: async (id, deleteFile = false) => {
const item = get().downloads.find(d => d.id === id);
if (item && item.status === 'downloading') {
if (item) {
try {
// Just cancel the active download via the backend, don't delete files via remove_download
await invoke('remove_download', { id, filepath: null });
} catch (e) {
console.error("Failed to terminate download on deletion:", e);
@@ -223,50 +284,196 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
}
set((state) => ({
downloads: state.downloads.filter(d => d.id !== id)
downloads: state.downloads.filter(d => d.id !== id),
pendingOrder: state.pendingOrder.filter(x => x !== id)
}));
info(`Download ${id} removed`);
get().processQueue();
syncSystemIntegrations();
},
redownload: (id) => {
redownload: async (id) => {
let wasDownloading = false;
let targetItem: DownloadItem | undefined;
set((state) => {
targetItem = state.downloads.find(d => d.id === id);
if (targetItem && targetItem.status === 'downloading') {
wasDownloading = true;
}
return {
downloads: state.downloads.map(d => {
if (d.id === id) {
return { ...d, status: 'queued', fraction: 0, speed: '-', eta: '-' };
}
return d;
})
};
});
if (wasDownloading) {
await invoke('pause_download', { id }).catch(console.error);
}
if (targetItem) {
try {
const settings = useSettingsStore.getState();
const login = getSiteLogin(targetItem.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = targetItem.destination ||
(settings.downloadDirectories && settings.downloadDirectories[targetItem.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
const enqueueItem = {
id: targetItem.id,
url: targetItem.url,
destination: destPath,
filename: targetItem.fileName,
connections: targetItem.connections || settings.perServerConnections || null,
speed_limit: targetItem.speedLimit || settings.globalSpeedLimit || null,
username: targetItem.username || (login ? login.username : null),
password: targetItem.password || keychainPassword,
headers: targetItem.headers || null,
checksum: targetItem.checksum || null,
cookies: targetItem.cookies || null,
mirrors: targetItem.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: targetItem.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: targetItem.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to enqueue redownload:", e);
}
}
info(`Download ${id} redownload requested (queued)`);
},
resumeDownload: async (id) => {
let targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) return;
set((state) => ({
downloads: state.downloads.map(d => {
if (d.id === id) {
if (d.status === 'downloading') {
wasDownloading = true;
}
const updated: DownloadItem = { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' };
return updated;
return { ...d, status: 'queued', speed: '-', eta: '-' };
}
return d;
})
}));
if (wasDownloading) {
invoke('pause_download', { id }).catch(console.error);
try {
const settings = useSettingsStore.getState();
const login = getSiteLogin(targetItem.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = targetItem.destination ||
(settings.downloadDirectories && settings.downloadDirectories[targetItem.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
const enqueueItem = {
id: targetItem.id,
url: targetItem.url,
destination: destPath,
filename: targetItem.fileName,
connections: targetItem.connections || settings.perServerConnections || null,
speed_limit: targetItem.speedLimit || settings.globalSpeedLimit || null,
username: targetItem.username || (login ? login.username : null),
password: targetItem.password || keychainPassword,
headers: targetItem.headers || null,
checksum: targetItem.checksum || null,
cookies: targetItem.cookies || null,
mirrors: targetItem.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: targetItem.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: targetItem.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to enqueue resume:", e);
}
info(`Download ${id} redownload requested (queued)`);
get().processQueue();
},
startQueue: async (queueId) => {
const runnableIds = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || item.status === 'paused' || item.status === 'failed'))
.map(item => item.id);
const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || item.status === 'paused' || item.status === 'failed'));
if (runnableIds.length === 0) return 0;
if (runnable.length === 0) return 0;
set((state) => ({
downloads: state.downloads.map(item =>
runnableIds.includes(item.id)
? { ...item, status: 'queued', _dispatched: false, speed: '-', eta: '-' }
runnable.some(r => r.id === item.id)
? { ...item, status: 'queued', speed: '-', eta: '-' }
: item
)
}));
info(`Queue ${queueId} started, ${runnableIds.length} items queued`);
await get().processQueue();
return runnableIds.length;
try {
const settings = useSettingsStore.getState();
const itemsToEnqueue = [];
for (const item of runnable) {
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
itemsToEnqueue.push({
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false
});
}
await invoke('enqueue_many', { items: itemsToEnqueue });
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to start queue:", e);
}
info(`Queue ${queueId} started, ${runnable.length} items queued`);
return runnable.length;
},
pauseQueue: async (queueId) => {
const activeIds = get().downloads
@@ -326,131 +533,56 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
downloads: downloads.length > 0 ? downloads : state.downloads
}));
// Auto resume downloads that were active
const active = get().downloads.filter(d => d.status === 'downloading');
const settings = useSettingsStore.getState();
active.forEach(item => {
if (item.isMedia) {
invoke('start_media_download', {
id: item.id,
url: item.url,
destination: item.destination || '~/Downloads',
filename: item.fileName,
formatSelector: item.mediaFormatSelector || null,
cookieSource: null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || null,
password: item.password || null,
headers: item.headers || null,
proxy: null,
userAgent: null,
maxTries: null
}).catch(console.error);
} else {
invoke('start_download', {
id: item.id,
url: item.url,
destination: item.destination || '~/Downloads',
filename: item.fileName,
connections: item.connections ?? null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || null,
password: item.password || null,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
userAgent: null,
maxTries: null,
proxy: null
}).catch(console.error);
}
});
void get().processQueue();
} catch (e) {
console.error("Failed to init DB", e);
}
},
processQueue: async () => {
if (isProcessingQueue) return;
isProcessingQueue = true;
try {
const { downloads, updateDownload } = get();
const settings = useSettingsStore.getState();
const concurrentLimit = settings.maxConcurrentDownloads || 3;
const activeCount = downloads.filter(d => d.status === 'downloading').length;
let availableSlots = concurrentLimit - activeCount;
if (availableSlots <= 0) return;
const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched);
for (const item of itemsToStart) {
if (availableSlots <= 0) break;
availableSlots--;
// Mark as dispatched so we don't send it again on the next pass
updateDownload(item.id, { _dispatched: true });
// Auto resume downloads that were active or queued
const active = get().downloads.filter(d => d.status === 'downloading' || d.status === 'queued');
if (active.length > 0) {
try {
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
const settings = useSettingsStore.getState();
const itemsToEnqueue = [];
for (const item of active) {
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
if (item.isMedia) {
await invoke('start_media_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
formatSelector: item.mediaFormatSelector || null,
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
proxy: await getProxyArgs(settings),
userAgent: settings.customUserAgent || null,
maxTries: settings.maxAutomaticRetries
});
} else {
await invoke('start_download', {
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
itemsToEnqueue.push({
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
speed_limit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
userAgent: settings.customUserAgent || null,
maxTries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings)
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false
});
}
await invoke('enqueue_many', { items: itemsToEnqueue });
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to start queued download:", e);
updateDownload(item.id, { status: 'failed' });
console.error("Failed to auto-resume active downloads:", e);
}
}
} finally {
isProcessingQueue = false;
} catch (e) {
console.error("Failed to init DB", e);
}
}
}));
@@ -469,7 +601,6 @@ useDownloadStore.subscribe(async (state, prevState) => {
delete copy.fraction;
delete copy.speed;
delete copy.eta;
delete copy._dispatched;
return copy;
});