diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 618483c..30dddfb 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use sha2::Sha256; use std::collections::{HashMap, HashSet}; use std::path::Path; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager}; @@ -23,7 +23,6 @@ pub const EXTENSION_SERVER_PORT: u16 = 6412; pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive = EXTENSION_SERVER_PORT..=6422; const MAX_URL_COUNT: usize = 200; const SIGNATURE_MAX_AGE_MS: u64 = 60_000; -const MAIN_QUEUE_ID: &str = "00000000-0000-0000-0000-000000000001"; type HmacSha256 = Hmac; pub type SharedExtensionToken = Arc>; @@ -34,6 +33,7 @@ type ReplayCache = Arc>>; pub struct ServerState { pub app_handle: AppHandle, pub pairing_token: SharedExtensionToken, + pub frontend_ready: SharedFrontendReady, pub replay_cache: ReplayCache, } @@ -66,12 +66,13 @@ pub struct ExtensionDownload { pub async fn start_server( app_handle: AppHandle, pairing_token: SharedExtensionToken, - _frontend_ready: SharedFrontendReady, + frontend_ready: SharedFrontendReady, mut shutdown_rx: watch::Receiver, ) -> Result<(), String> { let state = ServerState { app_handle, pairing_token, + frontend_ready, replay_cache: Arc::new(Mutex::new(HashMap::new())), }; @@ -206,8 +207,13 @@ async fn download_handler( } } - if enqueue_extension_download(&state.app_handle, &download) - .await + if !wait_for_frontend(&state.frontend_ready).await { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + if state + .app_handle + .emit("extension-add-download", download) .is_err() { return Err(StatusCode::INTERNAL_SERVER_ERROR); @@ -216,142 +222,14 @@ async fn download_handler( Ok(StatusCode::OK) } -async fn enqueue_extension_download( - app_handle: &AppHandle, - download: &ExtensionDownload, -) -> Result<(), String> { - let Ok(settings) = crate::settings::load_settings(app_handle) else { - return Err("settings unavailable".to_string()); - }; - let state = app_handle.state::(); - let mut created_items = Vec::new(); - let mut tasks = Vec::new(); - - for url in &download.urls { - let filename = download - .filename - .as_deref() - .filter(|_| download.urls.len() == 1) - .map(str::to_string) - .unwrap_or_else(|| filename_from_url(url)); - let category = crate::parity::get_file_category(filename.clone()); - let category_key = format!("{category:?}"); - let destination = settings - .download_directories - .get(&category_key) - .cloned() - .filter(|path| !path.trim().is_empty()) - .unwrap_or_else(|| settings.default_download_path.clone()); - let id = uuid::Uuid::new_v4().to_string(); - let headers = merge_headers(download.referer.as_deref(), download.headers.as_deref()); - let item = crate::ipc::DownloadItem { - id: id.clone(), - url: url.clone(), - file_name: filename.clone(), - status: crate::ipc::DownloadStatus::Queued, - fraction: Some(0.0), - speed: Some("-".to_string()), - eta: Some("-".to_string()), - size: None, - category, - date_added: chrono::Utc::now().to_rfc3339(), - connections: Some(settings.per_server_connections), - speed_limit: (!settings.global_speed_limit.trim().is_empty()) - .then(|| settings.global_speed_limit.clone()), - username: None, - password: None, - headers: headers.clone(), - checksum: None, - cookies: download.cookies.clone(), - mirrors: None, - destination: Some(destination.clone()), - is_media: Some(false), - media_format_selector: None, - queue_id: MAIN_QUEUE_ID.to_string(), - has_been_dispatched: Some(true), - }; - let task = crate::queue::EnqueueItem { - id, - url: url.clone(), - destination, - filename, - connections: Some(settings.per_server_connections), - speed_limit: (!settings.global_speed_limit.trim().is_empty()) - .then(|| settings.global_speed_limit.clone()), - username: None, - password: None, - headers, - checksum: None, - cookies: download.cookies.clone(), - mirrors: None, - user_agent: (!settings.custom_user_agent.trim().is_empty()) - .then(|| settings.custom_user_agent.clone()), - max_tries: Some(settings.max_automatic_retries), - proxy: None, - format_selector: None, - cookie_source: None, - is_media: Some(false), +async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool { + for _ in 0..40 { + if frontend_ready.load(Ordering::Acquire) { + return true; } - .into_task(); - - if let Err(e) = crate::download_ownership::register_expected( - app_handle, - &item.id, - item.destination.as_deref().unwrap_or(""), - &item.file_name, - ) { - log::warn!("extension: ownership registration failed: {}", e); - continue; - } - - created_items.push(item); - tasks.push(task); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - - let results = state.queue_manager.enqueue_many(tasks).await; - let mut emitted_items = Vec::new(); - - for (item, result) in created_items.into_iter().zip(results) { - if result.success { - emitted_items.push(item); - } else { - let _ = crate::download_ownership::remove(app_handle, &item.id); - state.queue_manager.release_registered_id(&item.id).await; - } - } - - if !emitted_items.is_empty() { - let _ = app_handle.emit("extension-downloads-queued", emitted_items); - } - Ok(()) -} - -fn merge_headers(referer: Option<&str>, headers: Option<&str>) -> Option { - let mut values = Vec::new(); - if let Some(referer) = referer { - values.push(format!("Referer: {referer}")); - } - if let Some(headers) = headers { - values.extend( - headers - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(str::to_string), - ); - } - (!values.is_empty()).then(|| values.join("\n")) -} - -fn filename_from_url(raw_url: &str) -> String { - Url::parse(raw_url) - .ok() - .and_then(|url| { - url.path_segments() - .and_then(Iterator::last) - .and_then(sanitize_filename) - }) - .unwrap_or_else(|| "download".to_string()) + false } fn normalize_download(payload: ExtensionRequest) -> Option { diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 9e2e599..6940ce2 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -6,6 +6,8 @@ use ts_rs::TS; #[serde(rename_all = "lowercase")] #[ts(export, export_to = "../../src/bindings/")] pub enum DownloadStatus { + /// Added to the download list but not assigned to a queue or dispatched. + Ready, Downloading, /// Post-download media processing such as yt-dlp/ffmpeg merging or /// extraction. The queue permit is still held. @@ -22,6 +24,7 @@ pub enum DownloadStatus { impl DownloadStatus { pub fn as_str(self) -> &'static str { match self { + Self::Ready => "ready", Self::Downloading => "downloading", Self::Processing => "processing", Self::Paused => "paused", @@ -94,7 +97,8 @@ pub struct DownloadItem { pub is_media: Option, #[ts(optional)] pub media_format_selector: Option, - pub queue_id: String, + #[ts(optional)] + pub queue_id: Option, #[ts(optional)] pub has_been_dispatched: Option, } diff --git a/src/App.tsx b/src/App.tsx index 77a9178..e12a865 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -231,27 +231,10 @@ function App() { const unlistenExtension = listen('extension-add-download', (event) => { useDownloadStore.getState().handleExtensionDownload(event.payload); }); - const unlistenExtensionQueued = listen('extension-downloads-queued', (event) => { - const store = useDownloadStore.getState(); - const incoming = event.payload; - const existing = new Set(store.downloads.map(download => download.id)); - const additions = incoming.filter(download => !existing.has(download.id)); - if (additions.length === 0) return; - useDownloadStore.setState(state => ({ - downloads: [...state.downloads, ...additions], - pendingOrder: [ - ...state.pendingOrder, - ...additions - .filter(download => download.status === 'queued') - .map(download => download.id) - .filter(id => !state.pendingOrder.includes(id)), - ], - })); - }); const unlistenDeepLink = listen('deep-link-add-download', (event) => { useDownloadStore.getState().openAddModalWithUrls(event.payload); }); - Promise.all([unlistenExtension, unlistenExtensionQueued, unlistenDeepLink]) + Promise.all([unlistenExtension, unlistenDeepLink]) .then(() => invoke('set_extension_frontend_ready', { ready: true })) .catch(error => console.error('Failed to activate browser extension integration:', error)); @@ -259,7 +242,6 @@ function App() { invoke('set_extension_frontend_ready', { ready: false }).catch(() => {}); unlistenTerminalState.then(f => f()); unlistenExtension.then(f => f()); - unlistenExtensionQueued.then(f => f()); unlistenDeepLink.then(f => f()); }; }, []); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 906a9dd..a54ddc4 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -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, hasBeenDispatched?: 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, hasBeenDispatched?: boolean, }; diff --git a/src/bindings/DownloadStatus.ts b/src/bindings/DownloadStatus.ts index b59ec88..86c2be6 100644 --- a/src/bindings/DownloadStatus.ts +++ b/src/bindings/DownloadStatus.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DownloadStatus = "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying"; +export type DownloadStatus = "ready" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying"; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 601bcf8..29795a3 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -1,7 +1,12 @@ -import { useState, useEffect } from 'react'; -import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore'; +import { useState, useEffect, useRef } from 'react'; +import { + useDownloadStore, + MAIN_QUEUE_ID, + getSiteLogin, + type AddDownloadAction +} from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; -import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react'; +import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, ListPlus, Rows3, type LucideIcon } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; @@ -260,6 +265,8 @@ export const AddDownloadsModal = () => { pendingAddUrls, pendingAddReferer, pendingAddFilename, + pendingAddHeaders, + pendingAddCookies, toggleAddModal, addDownload, queues @@ -275,9 +282,11 @@ export const AddDownloadsModal = () => { const [conflicts, setConflicts] = useState([]); const [showingDuplicates, setShowingDuplicates] = useState(false); - const [pendingStartFlag, setPendingStartFlag] = useState(false); + const [pendingAction, setPendingAction] = useState({ type: 'start-now' }); const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false); const [resolvedLocation, setResolvedLocation] = useState(''); + const [isActionMenuOpen, setIsActionMenuOpen] = useState(false); + const actionMenuRef = useRef(null); // Right Form const [saveLocation, setSaveLocation] = useState(defaultDownloadPath); @@ -315,18 +324,35 @@ export const AddDownloadsModal = () => { setChecksumEnabled(false); setChecksumAlgo('SHA-256'); setChecksumValue(''); - setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : ''); - setCookies(''); + setHeaders([ + pendingAddReferer ? `Referer: ${pendingAddReferer}` : '', + pendingAddHeaders + ].filter(Boolean).join('\n')); + setCookies(pendingAddCookies); setMirrors(''); + setIsActionMenuOpen(false); } }, [ isAddModalOpen, pendingAddUrls, pendingAddReferer, + pendingAddHeaders, + pendingAddCookies, defaultDownloadPath, queues ]); + useEffect(() => { + if (!isActionMenuOpen) return; + const closeMenu = (event: PointerEvent) => { + if (!actionMenuRef.current?.contains(event.target as Node)) { + setIsActionMenuOpen(false); + } + }; + window.addEventListener('pointerdown', closeMenu); + return () => window.removeEventListener('pointerdown', closeMenu); + }, [isActionMenuOpen]); + useEffect(() => { if (!saveLocation) return; invoke('get_free_space', { path: saveLocation }) @@ -493,7 +519,7 @@ export const AddDownloadsModal = () => { '~/Downloads'; }; - const handleStart = async (startImmediately: boolean) => { + const handleAction = async (action: AddDownloadAction) => { let finalLocation = saveLocation; let useSharedDestination = isSaveLocationManual; const settings = useSettingsStore.getState(); @@ -553,16 +579,16 @@ export const AddDownloadsModal = () => { if (newConflicts.length > 0) { setConflicts(newConflicts); - setPendingStartFlag(startImmediately); + setPendingAction(action); setPendingUseSharedDestination(useSharedDestination); setShowingDuplicates(true); return; } - await executeAddDownloads(startImmediately, finalLocation, useSharedDestination); + await executeAddDownloads(action, finalLocation, useSharedDestination); }; - const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => { + const executeAddDownloads = async (action: AddDownloadAction, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => { let itemsToAdd: Array = [...parsedItems]; if (resolutions) { @@ -651,29 +677,27 @@ export const AddDownloadsModal = () => { } const category = categoryForFileName(finalFile); - addDownload({ + await addDownload({ id, url: item.url, fileName: finalFile, - status: startImmediately ? 'queued' : 'paused', category, - dateAdded: new Date().toISOString(), - connections: Number(connections), - speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, - username: useAuth ? username.trim() : undefined, - password: useAuth ? password.trim() : undefined, - headers: headers.trim() || undefined, - checksum: checksumEnabled && checksumValue.trim() - ? `${checksumAlgo}=${checksumValue.trim()}` - : undefined, - cookies: cookies.trim() || undefined, - mirrors: mirrors.trim() || undefined, + dateAdded: new Date().toISOString(), + connections: Number(connections), + speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, + username: useAuth ? username.trim() : undefined, + password: useAuth ? password.trim() : undefined, + headers: headers.trim() || undefined, + checksum: checksumEnabled && checksumValue.trim() + ? `${checksumAlgo}=${checksumValue.trim()}` + : undefined, + cookies: cookies.trim() || undefined, + mirrors: mirrors.trim() || undefined, destination: useSharedDestination ? finalLocation : undefined, - isMedia: item.isMedia, - mediaFormatSelector: formatSelector, - queueId: selectedQueueId, - size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined) - }); + isMedia: item.isMedia, + mediaFormatSelector: formatSelector, + size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined) + }, action); } catch (e) { console.error("Invalid URL or failed to add:", e); } @@ -725,7 +749,7 @@ export const AddDownloadsModal = () => { conflicts={conflicts} onConfirm={(resolutions) => { setShowingDuplicates(false); - executeAddDownloads(pendingStartFlag, resolvedLocation, pendingUseSharedDestination, resolutions); + executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions); }} onCancel={() => setShowingDuplicates(false)} /> @@ -925,14 +949,16 @@ export const AddDownloadsModal = () => { Transfer Settings
+ {queues.length > 1 && (
- +
+ )}
@@ -1031,20 +1057,63 @@ export const AddDownloadsModal = () => { - - +
+ + + {isActionMenuOpen && ( +
+ + +
+ )} +
diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 62c4030..edcc2d8 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -200,8 +200,8 @@ export const DownloadItem = React.memo(({ )} - {download.status === 'paused' && ( - )} diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 2caa88d..637f34b 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -221,7 +221,9 @@ export const DownloadTable: React.FC = ({ filter }) => { className="main-control-button" disabled={filteredDownloads.length === 0} onClick={() => { - filteredDownloads.filter(d => d.status === 'paused').forEach(d => handleResume(d)); + filteredDownloads + .filter(d => d.status === 'ready' || d.status === 'paused') + .forEach(d => handleResume(d)); }} title="Resume All" > @@ -360,7 +362,7 @@ export const DownloadTable: React.FC = ({ filter }) => { )} - {(contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && ( + {(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && ( )} diff --git a/src/components/QualityModal.tsx b/src/components/QualityModal.tsx index 1a963d4..305a8c9 100644 --- a/src/components/QualityModal.tsx +++ b/src/components/QualityModal.tsx @@ -41,19 +41,17 @@ export const QualityModal = React.memo(() => { url: activeMetadataUrl, fileName: filename, destination, - status: 'queued' as const, fraction: 0, size: format.filesize ? formatBytes(format.filesize) : 'Unknown', speed: '-', eta: '-', + category, dateAdded: new Date().toISOString(), - queueId: '00000000-0000-0000-0000-000000000001', - _dispatched: false, isMedia: true, - selectedFormatId: format.format_id + mediaFormatSelector: format.format_id }; - addDownload(downloadItem as any); + void addDownload(downloadItem, { type: 'start-now' }); clearMetadata(); }; diff --git a/src/ipc.ts b/src/ipc.ts index defa27d..1718bee 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -2,7 +2,6 @@ import { invoke as tauriInvoke } from '@tauri-apps/api/core'; import { error as logError } from '@tauri-apps/plugin-log'; import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event'; import type { DownloadCategory } from './bindings/DownloadCategory'; -import type { DownloadItem } from './bindings/DownloadItem'; import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent'; import type { DownloadStateEvent } from './bindings/DownloadStateEvent'; import type { DownloadStatus } from './bindings/DownloadStatus'; @@ -132,7 +131,6 @@ type EventMap = { 'download-complete': string; 'download-failed': string; 'extension-add-download': ExtensionDownload; - 'extension-downloads-queued': DownloadItem[]; 'deep-link-add-download': string; }; diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 6fba406..7cf145a 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -80,10 +80,14 @@ export async function initDownloadListener() { unlistenTray = await listen('tray-action', (event) => { const mainStore = useDownloadStore.getState(); if (event.payload === 'pause-all') { - const uniqueQueues = Array.from(new Set(mainStore.downloads.map(d => d.queueId))); + const uniqueQueues = Array.from(new Set( + mainStore.downloads.map(d => d.queueId).filter((id): id is string => Boolean(id)) + )); uniqueQueues.forEach(qid => mainStore.pauseQueue(qid)); } else if (event.payload === 'resume-all') { - const uniqueQueues = Array.from(new Set(mainStore.downloads.map(d => d.queueId))); + const uniqueQueues = Array.from(new Set( + mainStore.downloads.map(d => d.queueId).filter((id): id is string => Boolean(id)) + )); uniqueQueues.forEach(qid => mainStore.startQueue(qid)); } }); diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 1be2a34..61b9428 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -91,4 +91,79 @@ describe('useDownloadStore', () => { expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true); expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem }); + + it('adds to the list without assigning a queue or dispatching', async () => { + await useDownloadStore.getState().addDownload({ + id: 'list-1', + url: 'https://example.com/list.bin', + fileName: 'list.bin', + category: 'Other', + dateAdded: '' + }, { type: 'add-to-list' }); + + const item = useDownloadStore.getState().downloads[0]; + expect(item.status).toBe('ready'); + expect(item.queueId).toBeUndefined(); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything()); + }); + + it('adds to the selected queue without dispatching', async () => { + await useDownloadStore.getState().addDownload({ + id: 'queue-1', + url: 'https://example.com/queue.bin', + fileName: 'queue.bin', + category: 'Other', + dateAdded: '' + }, { type: 'add-to-queue', queueId: 'queue-b' }); + + const item = useDownloadStore.getState().downloads[0]; + expect(item.status).toBe('queued'); + expect(item.queueId).toBe('queue-b'); + expect(useDownloadStore.getState().pendingOrder).toContain('queue-1'); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything()); + }); + + it('starts immediately without assigning a user queue', async () => { + vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { + if (cmd === 'get_pending_order') return ['start-1']; + return undefined; + }); + + await useDownloadStore.getState().addDownload({ + id: 'start-1', + url: 'https://example.com/start.bin', + fileName: 'start.bin', + category: 'Other', + dateAdded: '' + }, { type: 'start-now' }); + + const item = useDownloadStore.getState().downloads[0]; + expect(item.queueId).toBeUndefined(); + expect(item.hasBeenDispatched).toBe(true); + expect(ipc.invokeCommand).toHaveBeenCalledWith( + 'enqueue_download', + expect.objectContaining({ + item: expect.objectContaining({ id: 'start-1' }) + }) + ); + }); + + it('preserves extension request headers and cookies for the Add modal', () => { + useDownloadStore.getState().handleExtensionDownload({ + urls: ['https://example.com/file.bin'], + referer: 'https://example.com/page', + silent: false, + filename: 'file.bin', + headers: 'X-Test: value', + cookies: 'session=secret' + }); + + const state = useDownloadStore.getState(); + expect(state.isAddModalOpen).toBe(true); + expect(state.pendingAddUrls).toBe('https://example.com/file.bin'); + expect(state.pendingAddReferer).toBe('https://example.com/page'); + expect(state.pendingAddFilename).toBe('file.bin'); + expect(state.pendingAddHeaders).toBe('X-Test: value'); + expect(state.pendingAddCookies).toBe('session=secret'); + }); }); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 6e52c5e..03b05c2 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -147,6 +147,11 @@ export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export type { DownloadItem, Queue }; export type ExtensionDownloadRequest = ExtensionDownload; +export type AddDownloadAction = + | { type: 'start-now' } + | { type: 'add-to-list' } + | { type: 'add-to-queue'; queueId: string }; +export type DownloadDraft = Omit; export type DeleteModalState = { isOpen: boolean; @@ -170,15 +175,23 @@ interface DownloadState { pendingAddUrls: string; pendingAddReferer: string; pendingAddFilename: string; + pendingAddHeaders: string; + pendingAddCookies: string; selectedPropertiesDownloadId: string | null; toggleAddModal: (isOpen: boolean) => void; - openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void; + openAddModalWithUrls: ( + urls: string, + referer?: string | null, + filename?: string | null, + headers?: string | null, + cookies?: string | null + ) => void; handleExtensionDownload: (request: ExtensionDownloadRequest) => void; deleteModalState: DeleteModalState; openDeleteModal: (downloadId?: string) => void; closeDeleteModal: () => void; setSelectedPropertiesDownloadId: (id: string | null) => void; - addDownload: (item: DownloadItem) => Promise; + addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise; updateDownload: (id: string, updates: Partial) => void; removeDownload: (id: string, deleteFile?: boolean) => Promise; redownload: (id: string) => Promise; @@ -238,6 +251,8 @@ export const useDownloadStore = create((set, get) => ({ pendingAddUrls: '', pendingAddReferer: '', pendingAddFilename: '', + pendingAddHeaders: '', + pendingAddCookies: '', selectedPropertiesDownloadId: null, isParsing: false, activeMetadata: null, @@ -250,16 +265,20 @@ export const useDownloadStore = create((set, get) => ({ isAddModalOpen: isOpen, pendingAddUrls: '', pendingAddReferer: '', - pendingAddFilename: '' + pendingAddFilename: '', + pendingAddHeaders: '', + pendingAddCookies: '' }), - openAddModalWithUrls: (urls, referer, filename) => set((state) => { + openAddModalWithUrls: (urls, referer, filename, headers, cookies) => set((state) => { const existingUrls = state.isAddModalOpen && state.pendingAddUrls ? state.pendingAddUrls : ''; const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls; return { isAddModalOpen: true, pendingAddUrls: mergedUrls, pendingAddReferer: referer?.trim() || state.pendingAddReferer || '', - pendingAddFilename: filename?.trim() || state.pendingAddFilename || '' + pendingAddFilename: filename?.trim() || state.pendingAddFilename || '', + pendingAddHeaders: headers?.trim() || state.pendingAddHeaders || '', + pendingAddCookies: cookies?.trim() || state.pendingAddCookies || '' }; }), handleExtensionDownload: (request) => { @@ -269,7 +288,9 @@ export const useDownloadStore = create((set, get) => ({ get().openAddModalWithUrls( urls.join('\n'), request.referer, - urls.length === 1 ? request.filename : null + urls.length === 1 ? request.filename : null, + request.headers, + request.cookies ); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), @@ -291,23 +312,31 @@ export const useDownloadStore = create((set, get) => ({ info(`Media metadata parsing failed for ${url}: ${e}`); } }, - addDownload: async (item) => { - info(`Download ${item.id} added to queue`); + addDownload: async (item, action) => { const settings = useSettingsStore.getState(); const destPath = effectiveDestinationForItem(item, settings); - const ownedItem = { ...item, destination: destPath, hasBeenDispatched: false }; + const ownedItem: DownloadItem = { + ...item, + destination: destPath, + status: action.type === 'add-to-queue' ? 'queued' : 'ready', + queueId: action.type === 'add-to-queue' ? action.queueId : undefined, + hasBeenDispatched: false + }; set((state) => ({ downloads: [...state.downloads, ownedItem] })); - if (item.status === 'queued') { + if (action.type === 'add-to-queue') { 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') + info(`Download ${item.id} added to queue ${action.queueId}`); + } else if (action.type === 'start-now') { if (await dispatchItem(item.id)) { get().updateDownload(item.id, { hasBeenDispatched: true }); } + info(`Download ${item.id} started`); + } else { + info(`Download ${item.id} added to list`); } }, applyProperties: async (id, updates) => { @@ -319,7 +348,7 @@ export const useDownloadStore = create((set, get) => ({ throw new Error("Cannot change properties while transfer is active. Pause it first."); } - if (item.status === 'completed' || item.status === 'failed') { + if (item.status === 'ready' || item.status === 'completed' || item.status === 'failed') { state.updateDownload(id, updates); return; } @@ -470,6 +499,13 @@ export const useDownloadStore = create((set, get) => ({ if (!targetItem) return; try { + if (targetItem.status === 'ready') { + if (await dispatchItem(id)) { + get().updateDownload(id, { hasBeenDispatched: true }); + } + return; + } + const resumedExisting = await invoke('resume_download', { id }); if (resumedExisting) { return;