mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-28 21:09:37 +00:00
feat(queue): implement concurrent deduplication and safe backend detachment
- Add backendRegisteredIds and backendDispatchPromises for single dispatch enforcement - Add detach_download_for_reconfigure to safely modify properties of active downloads - Add applyProperties logic handling completed, failed, paused, and active queues - Add extractValidDownloadUrls and fix multi-url paste handling - Setup vitest and add useDownloadStore unit tests for backend registration lifecycle
This commit is contained in:
+5
-3
@@ -5,8 +5,8 @@ import { DownloadTable } from "./components/DownloadTable";
|
||||
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
||||
import SettingsView from "./components/SettingsView";
|
||||
import { PropertiesModal } from "./components/PropertiesModal";
|
||||
import { QualityModal } from './components/QualityModal';
|
||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
||||
import { extractValidDownloadUrls } from './utils/url';
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
@@ -165,7 +165,10 @@ function App() {
|
||||
}
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text && text.trim().length > 0) {
|
||||
useDownloadStore.getState().openAddModalWithUrls(text.trim());
|
||||
const urls = extractValidDownloadUrls(text);
|
||||
if (urls.length > 0) {
|
||||
useDownloadStore.getState().openAddModalWithUrls(urls.join('\n'));
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('paste', handlePaste);
|
||||
@@ -310,7 +313,6 @@ function App() {
|
||||
|
||||
<AddDownloadsModal />
|
||||
<PropertiesModal />
|
||||
<QualityModal />
|
||||
<DeleteConfirmationModal />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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, };
|
||||
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, hasBeenDispatched?: boolean, };
|
||||
|
||||
@@ -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 EnqueueResult = { id: string, success: boolean, error?: string, };
|
||||
@@ -9,7 +9,6 @@ type LoginMode = 'matching' | 'custom' | 'none';
|
||||
export const PropertiesModal = () => {
|
||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
||||
const updateDownload = useDownloadStore(state => state.updateDownload);
|
||||
const item = useDownloadStore(state =>
|
||||
selectedPropertiesDownloadId
|
||||
? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null
|
||||
@@ -106,7 +105,7 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (!url.trim()) {
|
||||
setErrorMessage("Enter a valid URL.");
|
||||
return;
|
||||
@@ -130,17 +129,22 @@ export const PropertiesModal = () => {
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
};
|
||||
|
||||
updateDownload(item.id, updates);
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
try {
|
||||
setErrorMessage('');
|
||||
await useDownloadStore.getState().applyProperties(item.id, updates);
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
} catch (e) {
|
||||
setErrorMessage(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const isLocked = ['downloading', 'processing', 'completed'].includes(item.status);
|
||||
const isTransferLocked = item.status === 'downloading' || item.status === 'processing';
|
||||
const isLocked = ['downloading', 'processing', 'completed', 'retrying'].includes(item.status);
|
||||
const isTransferLocked = item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying';
|
||||
|
||||
let statusColor = 'text-text-secondary';
|
||||
let StatusIcon = Info;
|
||||
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
|
||||
else if (item.status === 'downloading') { statusColor = 'text-blue-500'; StatusIcon = Play; }
|
||||
else if (item.status === 'downloading' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; }
|
||||
else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; }
|
||||
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
|
||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||
@@ -339,7 +343,8 @@ export const PropertiesModal = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="app-button app-button-primary px-4 text-xs"
|
||||
disabled={isTransferLocked}
|
||||
className={`app-button app-button-primary px-4 text-xs ${isTransferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
Save
|
||||
|
||||
+2
-4
@@ -73,6 +73,7 @@ type CommandMap = {
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string }; result: boolean };
|
||||
remove_download: { args: { id: string; filepath: string | null }; result: void };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||
@@ -101,14 +102,11 @@ type CommandMap = {
|
||||
result: void;
|
||||
};
|
||||
db_delete_download: { args: { id: string }; result: void };
|
||||
db_get_all_queues: { args: undefined; result: string[] };
|
||||
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 };
|
||||
export_logs: { args: { destPath: string }; result: string };
|
||||
get_pending_order: { args: undefined; result: string[] };
|
||||
enqueue_download: { args: { item: any }; result: string };
|
||||
enqueue_many: { args: { items: any[] }; result: void };
|
||||
enqueue_many: { args: { items: any[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
||||
move_in_queue: { args: { id: string; direction: 'up' | 'down' }; result: string[] };
|
||||
remove_from_queue: { args: { id: string }; result: boolean };
|
||||
};
|
||||
|
||||
@@ -66,6 +66,12 @@ export async function initDownloadListener() {
|
||||
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
|
||||
mainStore.registerBackendIds([payload.id]);
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock window.__TAURI_INTERNALS__ and log to prevent errors
|
||||
vi.mock('@tauri-apps/plugin-log', () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/plugin-store', () => {
|
||||
return {
|
||||
LazyStore: class {
|
||||
get = vi.fn().mockResolvedValue([]);
|
||||
set = vi.fn();
|
||||
save = vi.fn();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./useSettingsStore', () => ({
|
||||
useSettingsStore: {
|
||||
getState: vi.fn(() => ({
|
||||
proxyMode: 'none',
|
||||
siteLogins: [],
|
||||
globalSpeedLimit: '',
|
||||
perServerConnections: 16,
|
||||
customUserAgent: '',
|
||||
maxAutomaticRetries: 3,
|
||||
mediaCookieSource: 'none',
|
||||
})),
|
||||
}
|
||||
}));
|
||||
|
||||
describe('useDownloadStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDownloadStore.setState({
|
||||
downloads: [],
|
||||
backendRegisteredIds: new Set(),
|
||||
pendingOrder: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: '1', url: 'http://test1', fileName: 'f1', status: 'queued', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
{ id: '2', url: 'http://test2', fileName: 'f2', status: 'queued', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['1']), // 1 is already registered, so it skips dispatch
|
||||
});
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_pending_order') return ['1', '2'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const dispatched = await useDownloadStore.getState().startQueue('MAIN');
|
||||
expect(dispatched).toBe(2); // Both items counted as dispatched/handled
|
||||
|
||||
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
||||
const enqueues = calls.filter(c => c[0] === 'enqueue_download');
|
||||
expect(enqueues.length).toBe(1);
|
||||
expect((enqueues[0] as any)[1].item.id).toBe('2');
|
||||
});
|
||||
|
||||
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: '1', url: 'http://test1', fileName: 'f1', status: 'paused', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['1']),
|
||||
});
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'resume_download') return false; // Not resumable
|
||||
if (cmd === 'get_pending_order') return ['1'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().resumeDownload('1');
|
||||
|
||||
// It should have called resume_download, then unregistered, then enqueue_download
|
||||
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
||||
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
|
||||
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem
|
||||
});
|
||||
});
|
||||
+158
-174
@@ -16,6 +16,66 @@ import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
const backendDispatchPromises = new Map<string, Promise<boolean>>();
|
||||
|
||||
export async function dispatchItem(id: string): Promise<boolean> {
|
||||
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const state = useDownloadStore.getState();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
if (!item) return false;
|
||||
if (state.backendRegisteredIds.has(id)) return true;
|
||||
|
||||
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) {}
|
||||
}
|
||||
|
||||
const enqueueItem = {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: item.destination,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
||||
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');
|
||||
useDownloadStore.getState().setPendingOrder(order);
|
||||
useDownloadStore.getState().registerBackendIds([id]);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Failed to dispatch ${id}:`, e);
|
||||
useDownloadStore.getState().updateDownload(id, { status: 'failed' });
|
||||
return false;
|
||||
} finally {
|
||||
backendDispatchPromises.delete(id);
|
||||
}
|
||||
})();
|
||||
|
||||
backendDispatchPromises.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
if (settings.proxyMode === 'system') {
|
||||
try {
|
||||
@@ -98,6 +158,12 @@ interface DownloadState {
|
||||
queues: Queue[];
|
||||
pendingOrder: string[];
|
||||
setPendingOrder: (order: string[]) => void;
|
||||
backendRegisteredIds: Set<string>;
|
||||
registerBackendIds: (ids: string[]) => void;
|
||||
unregisterBackendIds: (ids: string[]) => void;
|
||||
activeDownloadId: string | null;
|
||||
setActiveDownloadId: (id: string | null) => void;
|
||||
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
||||
moveInQueue: (id: string, direction: 'up' | 'down') => Promise<void>;
|
||||
removeFromQueue: (id: string) => Promise<void>;
|
||||
isAddModalOpen: boolean;
|
||||
@@ -137,6 +203,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||
pendingOrder: [],
|
||||
setPendingOrder: (order) => set({ pendingOrder: order }),
|
||||
activeDownloadId: null,
|
||||
setActiveDownloadId: (id) => set({ activeDownloadId: id }),
|
||||
moveInQueue: async (id, direction) => {
|
||||
try {
|
||||
const order = await invoke('move_in_queue', { id, direction });
|
||||
@@ -155,6 +223,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
console.error("Failed to remove item from queue:", e);
|
||||
}
|
||||
},
|
||||
backendRegisteredIds: new Set(),
|
||||
registerBackendIds: (ids) => set((state) => {
|
||||
const nextSet = new Set(state.backendRegisteredIds);
|
||||
for (const id of ids) nextSet.add(id);
|
||||
return { backendRegisteredIds: nextSet };
|
||||
}),
|
||||
unregisterBackendIds: (ids) => set((state) => {
|
||||
const nextSet = new Set(state.backendRegisteredIds);
|
||||
for (const id of ids) nextSet.delete(id);
|
||||
return { backendRegisteredIds: nextSet };
|
||||
}),
|
||||
isAddModalOpen: false,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
@@ -214,48 +293,62 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
},
|
||||
addDownload: async (item) => {
|
||||
info(`Download ${item.id} added to queue`);
|
||||
try {
|
||||
const settings = useSettingsStore.getState();
|
||||
const destPath = effectiveDestinationForItem(item, settings);
|
||||
const ownedItem = { ...item, destination: destPath };
|
||||
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
|
||||
const settings = useSettingsStore.getState();
|
||||
const destPath = effectiveDestinationForItem(item, settings);
|
||||
const ownedItem = { ...item, destination: destPath, hasBeenDispatched: false };
|
||||
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
|
||||
|
||||
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);
|
||||
if (item.status === 'queued') {
|
||||
const order = useDownloadStore.getState().pendingOrder;
|
||||
if (!order.includes(item.id)) {
|
||||
useDownloadStore.getState().setPendingOrder([...order, item.id]);
|
||||
}
|
||||
} else {
|
||||
// Immediate dispatch (e.g., "Start immediately" from UI where status isn't just 'queued')
|
||||
if (await dispatchItem(item.id)) {
|
||||
get().updateDownload(item.id, { hasBeenDispatched: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
applyProperties: async (id, updates) => {
|
||||
const state = get();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
if (!item) return;
|
||||
|
||||
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
|
||||
throw new Error("Cannot change properties while transfer is active. Pause it first.");
|
||||
}
|
||||
|
||||
if (item.status === 'completed' || item.status === 'failed') {
|
||||
state.updateDownload(id, updates);
|
||||
return;
|
||||
}
|
||||
|
||||
// Queued or Paused
|
||||
const isRegistered = state.backendRegisteredIds.has(id);
|
||||
|
||||
if (item.status === 'queued') {
|
||||
if (isRegistered) {
|
||||
await invoke('remove_from_queue', { id });
|
||||
state.unregisterBackendIds([id]);
|
||||
}
|
||||
state.updateDownload(id, updates);
|
||||
if (isRegistered) {
|
||||
if (!await dispatchItem(id)) {
|
||||
state.removeFromQueue(id);
|
||||
}
|
||||
}
|
||||
const enqueueItem = {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
||||
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' });
|
||||
} else if (item.status === 'paused') {
|
||||
if (isRegistered) {
|
||||
try {
|
||||
await invoke('detach_download_for_reconfigure', { id });
|
||||
} catch (e) {
|
||||
console.error("Failed to detach for reconfigure:", e);
|
||||
throw e; // Preserve old properties if detach fails
|
||||
}
|
||||
state.unregisterBackendIds([id]);
|
||||
}
|
||||
state.updateDownload(id, updates);
|
||||
}
|
||||
},
|
||||
updateDownload: (id, updates) => {
|
||||
@@ -341,16 +434,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
throw new Error('Cannot redownload: destination folder is missing.');
|
||||
}
|
||||
|
||||
const login = getSiteLogin(url, settings);
|
||||
let keychainPassword: string | null = 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 redownloadItem: DownloadItem = {
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
@@ -376,35 +459,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
downloads: [...state.downloads, redownloadItem]
|
||||
}));
|
||||
|
||||
try {
|
||||
const enqueueItem = {
|
||||
id: redownloadItem.id,
|
||||
url,
|
||||
destination: destPath,
|
||||
filename,
|
||||
connections: targetItem.connections || settings.perServerConnections || null,
|
||||
speed_limit: targetItem.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
||||
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: 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 });
|
||||
if (!await dispatchItem(redownloadItem.id)) {
|
||||
console.error("Failed to enqueue redownload");
|
||||
} else {
|
||||
info(`Download ${id} redownload requested as ${redownloadItem.id} (queued)`);
|
||||
} catch (e) {
|
||||
console.error("Failed to enqueue redownload:", e);
|
||||
get().updateDownload(redownloadItem.id, { status: 'failed' });
|
||||
throw e instanceof Error ? e : new Error(String(e));
|
||||
}
|
||||
},
|
||||
resumeDownload: async (id) => {
|
||||
@@ -417,6 +475,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
get().unregisterBackendIds([id]);
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
if (d.id === id) {
|
||||
@@ -426,45 +486,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
})
|
||||
}));
|
||||
|
||||
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);
|
||||
}
|
||||
if (!await dispatchItem(id)) {
|
||||
console.error("Failed to re-enqueue for resume");
|
||||
}
|
||||
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 || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
||||
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);
|
||||
console.error("Failed to resume download:", e);
|
||||
}
|
||||
},
|
||||
startQueue: async (queueId) => {
|
||||
@@ -473,69 +499,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
if (runnable.length === 0) return 0;
|
||||
|
||||
const paused = runnable.filter(item => item.status === 'paused');
|
||||
const toEnqueue = runnable.filter(item => item.status !== 'paused');
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
toEnqueue.some(r => r.id === item.id)
|
||||
? { ...item, status: 'queued', speed: '-', eta: '-' }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
|
||||
try {
|
||||
await Promise.all(paused.map(item => get().resumeDownload(item.id)));
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
const itemsToEnqueue = [];
|
||||
for (const item of toEnqueue) {
|
||||
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);
|
||||
}
|
||||
let dispatchedCount = 0;
|
||||
const promises = runnable.map(async (item) => {
|
||||
if (item.status === 'failed' || !item.hasBeenDispatched) {
|
||||
if (await dispatchItem(item.id)) {
|
||||
get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' });
|
||||
dispatchedCount++;
|
||||
}
|
||||
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 || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
||||
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
|
||||
});
|
||||
} else if (item.status === 'paused' || item.status === 'queued') {
|
||||
// If it's queued but already dispatched, it might be waiting.
|
||||
// If it's paused, we resume it.
|
||||
if (item.status === 'paused') {
|
||||
await get().resumeDownload(item.id);
|
||||
}
|
||||
dispatchedCount++;
|
||||
}
|
||||
if (itemsToEnqueue.length > 0) {
|
||||
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;
|
||||
await Promise.all(promises);
|
||||
|
||||
info(`Queue ${queueId} started, ${dispatchedCount} items dispatched/resumed`);
|
||||
return dispatchedCount;
|
||||
},
|
||||
pauseQueue: async (queueId) => {
|
||||
const activeIds = get().downloads
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function extractValidDownloadUrls(text: string): string[] {
|
||||
const lines = text.split('\n');
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Split by whitespace in case multiple URLs are on one line
|
||||
const parts = trimmed.split(/\s+/);
|
||||
for (const part of parts) {
|
||||
try {
|
||||
const url = new URL(part);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:') {
|
||||
urls.push(url.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
Reference in New Issue
Block a user