diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 067e49c..0c36a99 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -922,6 +922,42 @@ fn delete_keychain_password(id: String) -> Result<(), String> { Ok(()) } +#[tauri::command] +fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool { + use tauri::Manager; + let mut resolved_dest = std::path::PathBuf::from(&path); + if path.starts_with("~/") { + if let Ok(home) = app_handle.path().home_dir() { + resolved_dest = home.join(&path[2..]); + } + } else if path == "~" { + if let Ok(home) = app_handle.path().home_dir() { + resolved_dest = home; + } + } + resolved_dest.exists() +} + +#[tauri::command] +fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> { + use tauri::Manager; + let mut resolved_dest = std::path::PathBuf::from(&path); + if path.starts_with("~/") { + if let Ok(home) = app_handle.path().home_dir() { + resolved_dest = home.join(&path[2..]); + } + } else if path == "~" { + if let Ok(home) = app_handle.path().home_dir() { + resolved_dest = home; + } + } + if resolved_dest.exists() { + std::fs::remove_file(resolved_dest).map_err(|e| e.to_string()) + } else { + Ok(()) + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -936,7 +972,8 @@ pub fn run() { start_download, start_media_download, pause_download, fetch_metadata, fetch_media_metadata, update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action, request_automation_permission, open_automation_settings, - set_keychain_password, get_keychain_password, delete_keychain_password + set_keychain_password, get_keychain_password, delete_keychain_password, + check_file_exists, delete_file ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src/components/AddDownloadsModal.tsx b/apps/desktop/src/components/AddDownloadsModal.tsx index f7526f1..ca371aa 100644 --- a/apps/desktop/src/components/AddDownloadsModal.tsx +++ b/apps/desktop/src/components/AddDownloadsModal.tsx @@ -5,6 +5,7 @@ import { DownloadCategory } from '../store/useDownloadStore'; import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; +import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; function determineCategory(fileName: string): DownloadCategory { const ext = fileName.split('.').pop()?.toLowerCase() || ''; @@ -258,6 +259,11 @@ export const AddDownloadsModal = () => { const [selectedItemIndex, setSelectedItemIndex] = useState(null); const [parsedItems, setParsedItems] = useState([]); + const [conflicts, setConflicts] = useState([]); + const [showingDuplicates, setShowingDuplicates] = useState(false); + const [pendingStartFlag, setPendingStartFlag] = useState(false); + const [resolvedLocation, setResolvedLocation] = useState(''); + // Right Form const [saveLocation, setSaveLocation] = useState(defaultDownloadPath); const [connections, setConnections] = useState(16); @@ -427,42 +433,153 @@ export const AddDownloadsModal = () => { } } - for (const item of parsedItems) { - try { - const id = crypto.randomUUID(); - let finalFile = item.file; - let formatSelector = undefined; + setResolvedLocation(finalLocation); + const store = useDownloadStore.getState(); + const newConflicts: DuplicateConflict[] = []; - if (item.isMedia && item.formats && item.selectedFormat !== undefined) { - const selectedFormat = item.formats[item.selectedFormat]; - formatSelector = selectedFormat.selector; - // Update extension if user selected a different format - const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; - finalFile = `${baseName}.${selectedFormat.ext}`; - } + for (let i = 0; i < parsedItems.length; i++) { + const item = parsedItems[i]; + let finalFile = item.file; + if (item.isMedia && item.formats && item.selectedFormat !== undefined) { + const selectedFormat = item.formats[item.selectedFormat]; + const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; + finalFile = `${baseName}.${selectedFormat.ext}`; + } - addDownload({ - id, - url: item.url, - fileName: finalFile, - status: startImmediately ? 'queued' : 'paused', - category: determineCategory(finalFile), - 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, - destination: finalLocation, - isMedia: item.isMedia, - mediaFormatSelector: formatSelector, - queueId: selectedQueueId - }); - } catch (e) { - console.error("Invalid URL or failed to add:", e); + const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed'); + if (isUrlDupe) { + newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' }); + } else { + const fileExistsInStore = store.downloads.some(d => { + const dest = d.destination || settings.defaultDownloadPath || '~/Downloads'; + return dest === finalLocation && d.fileName === finalFile && d.status !== 'failed'; + }); + + let fileExistsOnDisk = false; + try { + const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation; + fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` }); + } catch (e) {} + + if (fileExistsInStore || fileExistsOnDisk) { + newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: 'File exists at destination' }, resolution: 'rename' }); + } } } - toggleAddModal(false); + + if (newConflicts.length > 0) { + setConflicts(newConflicts); + setPendingStartFlag(startImmediately); + setShowingDuplicates(true); + return; + } + + await executeAddDownloads(startImmediately, finalLocation); + }; + + const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => { + let itemsToAdd = [...parsedItems]; + + if (resolutions) { + for (const res of resolutions) { + const idx = parseInt(res.id); + const item = itemsToAdd[idx]; + if (!item) continue; + + if (res.resolution === 'skip') { + itemsToAdd[idx] = null as any; // mark for skip + } else if (res.resolution === 'rename') { + let finalFile = item.file; + if (item.isMedia && item.formats && item.selectedFormat !== undefined) { + const selectedFormat = item.formats[item.selectedFormat]; + const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; + finalFile = `${baseName}.${selectedFormat.ext}`; + } + const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation; + + let count = 1; + const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; + const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : ''; + let newName = finalFile; + let exists = true; + + while (exists) { + newName = `${base} (${count})${ext}`; + const storeHas = useDownloadStore.getState().downloads.some(d => { + const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads'; + return dest === finalLocation && d.fileName === newName && d.status !== 'failed'; + }); + let diskHas = false; + try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {} + exists = storeHas || diskHas; + count++; + } + + itemsToAdd[idx] = { ...item, file: newName }; + } else if (res.resolution === 'replace') { + let finalFile = item.file; + if (item.isMedia && item.formats && item.selectedFormat !== undefined) { + const selectedFormat = item.formats[item.selectedFormat]; + const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; + finalFile = `${baseName}.${selectedFormat.ext}`; + } + const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation; + const fullPath = `${cleanLocation}/${finalFile}`; + + const store = useDownloadStore.getState(); + const existingItem = store.downloads.find(d => { + const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads'; + return (d.url === item.url || (dest === finalLocation && d.fileName === finalFile)) && d.status !== 'failed'; + }); + + if (existingItem) { + await store.removeDownload(existingItem.id); + } + + try { await invoke('delete_file', { path: fullPath }); } catch(e) {} + } + } + } + + itemsToAdd = itemsToAdd.filter(Boolean); + + for (const item of itemsToAdd) { + try { + const id = crypto.randomUUID(); + let finalFile = item.file; + let formatSelector = undefined; + + if (item.isMedia && item.formats && item.selectedFormat !== undefined) { + const selectedFormat = item.formats[item.selectedFormat]; + formatSelector = selectedFormat.selector; + if (!finalFile.endsWith(`.${selectedFormat.ext}`)) { + const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; + finalFile = `${baseName}.${selectedFormat.ext}`; + } + } + + addDownload({ + id, + url: item.url, + fileName: finalFile, + status: startImmediately ? 'queued' : 'paused', + category: determineCategory(finalFile), + 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, + destination: finalLocation, + isMedia: item.isMedia, + mediaFormatSelector: formatSelector, + queueId: selectedQueueId + }); + } catch (e) { + console.error("Invalid URL or failed to add:", e); + } + } + toggleAddModal(false); }; const SummaryBox = ({ title, value, icon: Icon, color }: any) => ( @@ -483,6 +600,17 @@ export const AddDownloadsModal = () => { : 'Unknown'; return ( + <> + {showingDuplicates && ( + { + setShowingDuplicates(false); + executeAddDownloads(pendingStartFlag, resolvedLocation, resolutions); + }} + onCancel={() => setShowingDuplicates(false)} + /> + )}
@@ -785,5 +913,6 @@ export const AddDownloadsModal = () => {
+ ); }; diff --git a/apps/desktop/src/components/DuplicateResolutionModal.tsx b/apps/desktop/src/components/DuplicateResolutionModal.tsx new file mode 100644 index 0000000..1b15c13 --- /dev/null +++ b/apps/desktop/src/components/DuplicateResolutionModal.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; + +export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string }; + +export interface DuplicateConflict { + id: string; // id of the pending item + fileName: string; + reason: DuplicateReason; + resolution: 'rename' | 'replace' | 'skip'; +} + +interface Props { + conflicts: DuplicateConflict[]; + onConfirm: (resolutions: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => void; + onCancel: () => void; +} + +export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => { + const [conflicts, setConflicts] = useState(initialConflicts); + + const updateResolution = (id: string, resolution: 'rename' | 'replace' | 'skip') => { + setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c)); + }; + + return ( +
+
+
+

Duplicate Downloads Detected

+

Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.

+
+ +
+ {conflicts.map(conflict => ( +
+
+ {conflict.fileName} + {conflict.reason.msg} +
+ +
+ ))} +
+ +
+ + +
+
+
+ ); +};