mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-28 04:49:39 +00:00
fix(downloads): correct add window actions
This commit is contained in:
@@ -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<u16> = 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<Sha256>;
|
||||
pub type SharedExtensionToken = Arc<RwLock<String>>;
|
||||
@@ -34,6 +33,7 @@ type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
|
||||
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<bool>,
|
||||
) -> 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::<crate::AppState>();
|
||||
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<String> {
|
||||
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<ExtensionDownload> {
|
||||
|
||||
@@ -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<bool>,
|
||||
#[ts(optional)]
|
||||
pub media_format_selector: Option<String>,
|
||||
pub queue_id: String,
|
||||
#[ts(optional)]
|
||||
pub queue_id: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
}
|
||||
|
||||
+1
-19
@@ -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());
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<DuplicateConflict[]>([]);
|
||||
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
||||
const [pendingStartFlag, setPendingStartFlag] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
|
||||
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
|
||||
const [resolvedLocation, setResolvedLocation] = useState('');
|
||||
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
|
||||
const actionMenuRef = useRef<HTMLDivElement>(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<ParsedDownloadItem | null> = [...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 = () => {
|
||||
<Settings size={16} className="text-blue-500" /> Transfer Settings
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{queues.length > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-text-secondary font-medium">Target Queue</label>
|
||||
<label className="text-xs text-text-secondary font-medium">Queue for “Add to Queue”</label>
|
||||
<select value={selectedQueueId} onChange={e=>setSelectedQueueId(e.target.value)} className="add-download-control add-download-select w-32 px-2 py-1 text-xs" aria-label="Target queue">
|
||||
{queues.map(q => (
|
||||
<option key={q.id} value={q.id}>{q.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
|
||||
@@ -1031,20 +1057,63 @@ export const AddDownloadsModal = () => {
|
||||
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStart(false)}
|
||||
disabled={parsedItems.length === 0}
|
||||
className="add-download-button add-download-button-secondary px-4 text-xs"
|
||||
>
|
||||
Add to Queue
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStart(true)}
|
||||
disabled={parsedItems.length === 0}
|
||||
className="add-download-button add-download-button-primary px-5 text-xs"
|
||||
>
|
||||
<Play size={12} fill="currentColor" /> Start Downloads
|
||||
</button>
|
||||
<div ref={actionMenuRef} className="relative flex">
|
||||
<button
|
||||
onClick={() => handleAction({ type: 'start-now' })}
|
||||
disabled={parsedItems.length === 0}
|
||||
className="add-download-button add-download-button-primary rounded-r-none px-5 text-xs"
|
||||
>
|
||||
<Play size={12} fill="currentColor" /> Start Downloads
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActionMenuOpen(open => !open)}
|
||||
disabled={parsedItems.length === 0}
|
||||
className="add-download-button add-download-button-primary rounded-l-none border-l border-white/20 px-2 text-xs"
|
||||
aria-label="More add actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isActionMenuOpen}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{isActionMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="app-modal absolute bottom-full right-0 z-[70] mb-2 min-w-[230px] overflow-hidden py-1.5 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setIsActionMenuOpen(false);
|
||||
void handleAction({ type: 'add-to-list' });
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-item-hover"
|
||||
>
|
||||
<Rows3 size={14} />
|
||||
<span>Add to List</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setIsActionMenuOpen(false);
|
||||
const queueId = queues.length === 1 ? queues[0].id : selectedQueueId;
|
||||
void handleAction({ type: 'add-to-queue', queueId });
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-item-hover"
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
<span className="min-w-0">
|
||||
<span className="block">Add to Queue</span>
|
||||
<span className="block truncate text-[10px] text-text-muted">
|
||||
{queues.find(queue => queue.id === (queues.length === 1 ? queues[0].id : selectedQueueId))?.name}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -200,8 +200,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<Pause size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
{download.status === 'paused' && (
|
||||
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title="Resume">
|
||||
{(download.status === 'ready' || download.status === 'paused') && (
|
||||
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'ready' ? 'Start' : 'Resume'}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -221,7 +221,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ filter }) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && (
|
||||
{(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
@@ -368,7 +370,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Resume
|
||||
{contextItem.status === 'ready' ? 'Start' : 'Resume'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -80,10 +80,14 @@ export async function initDownloadListener() {
|
||||
unlistenTray = await listen<string>('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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DownloadItem, 'status' | 'queueId' | 'hasBeenDispatched'>;
|
||||
|
||||
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<void>;
|
||||
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<void>;
|
||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
|
||||
redownload: (id: string) => Promise<void>;
|
||||
@@ -238,6 +251,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
pendingAddFilename: '',
|
||||
pendingAddHeaders: '',
|
||||
pendingAddCookies: '',
|
||||
selectedPropertiesDownloadId: null,
|
||||
isParsing: false,
|
||||
activeMetadata: null,
|
||||
@@ -250,16 +265,20 @@ export const useDownloadStore = create<DownloadState>((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<DownloadState>((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<DownloadState>((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<DownloadState>((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<DownloadState>((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;
|
||||
|
||||
Reference in New Issue
Block a user