mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 17:08:26 +00:00
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:
+10
-7
@@ -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.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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";
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user