feat: implement duplicate download resolution

This commit is contained in:
NimBold
2026-06-13 00:09:51 +03:30
parent dc9289fece
commit 6de0cd268c
3 changed files with 266 additions and 33 deletions
+38 -1
View File
@@ -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");
+161 -32
View File
@@ -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<number | null>(null);
const [parsedItems, setParsedItems] = useState<ParsedDownloadItem[]>([]);
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
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<boolean>('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<boolean>('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 && (
<DuplicateResolutionModal
conflicts={conflicts}
onConfirm={(resolutions) => {
setShowingDuplicates(false);
executeAddDownloads(pendingStartFlag, resolvedLocation, resolutions);
}}
onCancel={() => setShowingDuplicates(false)}
/>
)}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md">
<div className="w-[900px] h-[650px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden text-sm">
@@ -785,5 +913,6 @@ export const AddDownloadsModal = () => {
</div>
</div>
</>
);
};
@@ -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<DuplicateConflict[]>(initialConflicts);
const updateResolution = (id: string, resolution: 'rename' | 'replace' | 'skip') => {
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md">
<div className="w-[500px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden text-sm">
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
<p className="text-xs text-text-muted">Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.</p>
</div>
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
{conflicts.map(conflict => (
<div key={conflict.id} className="flex items-center justify-between bg-bg-input/50 p-2.5 rounded-lg border border-border-modal/50 gap-4">
<div className="flex flex-col overflow-hidden min-w-0">
<span className="font-medium text-text-primary truncate" title={conflict.fileName}>{conflict.fileName}</span>
<span className="text-[11px] text-orange-400 mt-0.5">{conflict.reason.msg}</span>
</div>
<select
value={conflict.resolution}
onChange={(e) => updateResolution(conflict.id, e.target.value as any)}
className="w-24 shrink-0 bg-bg-input border border-border-modal rounded text-xs px-2 py-1 text-text-primary focus:border-blue-500 focus:outline-none"
>
<option value="rename">Rename</option>
<option value="replace">Replace</option>
<option value="skip">Skip</option>
</select>
</div>
))}
</div>
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
<button onClick={onCancel} className="px-4 py-1.5 rounded-lg text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-item-hover transition-colors">
Cancel
</button>
<button
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
className="px-5 py-1.5 rounded-lg text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white shadow-md shadow-blue-500/20 transition-all active:scale-95"
>
Continue
</button>
</div>
</div>
</div>
);
};