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:
NimBold
2026-06-20 11:41:55 +03:30
parent 1660dd112d
commit 894c238bb7
17 changed files with 1058 additions and 312 deletions
+5 -3
View File
@@ -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>
);
+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, };
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, };
+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 EnqueueResult = { id: string, success: boolean, error?: string, };
+13 -8
View File
@@ -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
View File
@@ -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 };
};
+6
View File
@@ -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]);
}
}
});
}
+94
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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)];
}