mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
feat: implement duplicate download resolution
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user