import { useState, useEffect, useRef } from 'react'; import { useDownloadStore, 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 { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/downloads'; import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata'; import { resolveCategoryDestination, resolveDownloadFilePath, downloadLocationEquals } from '../utils/downloadLocations'; import { getPlatformInfo } from '../utils/platform'; import { isTransferLocked } from '../utils/downloadActions'; import { useToast } from '../contexts/ToastContext'; import { canSubmitMetadataRows, mediaFormatSelectorForRow, metadataSummaryMessage, reconcileDownloadRows, refreshFailedMetadataRows, updateRowIfCurrent, type AddDownloadDraftRow } from '../utils/addDownloadMetadata'; const formatBytes = (bytes: number) => { if (bytes === 0) return 'Unknown size'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }; export const AddDownloadsModal = () => { const { addToast } = useToast(); const { isAddModalOpen, pendingAddUrls, pendingAddReferer, pendingAddFilename, pendingAddHeaders, pendingAddCookies, toggleAddModal, addDownload, queues } = useDownloadStore(); const { baseDownloadFolder, perServerConnections } = useSettingsStore(); const [urls, setUrls] = useState(''); const [selectedItemIndex, setSelectedItemIndex] = useState(null); const [parsedItems, setParsedItems] = useState([]); const metadataRequestsRef = useRef(new Set()); const [conflicts, setConflicts] = useState([]); const [showingDuplicates, setShowingDuplicates] = useState(false); const [pendingAction, setPendingAction] = useState({ type: 'start-now' }); const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false); const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState>({}); const [resolvedLocation, setResolvedLocation] = useState(''); const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const actionMenuRef = useRef(null); // Right Form const [saveLocation, setSaveLocation] = useState(baseDownloadFolder); const [isSaveLocationManual, setIsSaveLocationManual] = useState(false); const [connections, setConnections] = useState(perServerConnections); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimit, setSpeedLimit] = useState('1024'); const [freeSpace, setFreeSpace] = useState('Unknown'); const [useAuth, setUseAuth] = useState(false); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [advancedExpanded, setAdvancedExpanded] = useState(false); const [checksumEnabled, setChecksumEnabled] = useState(false); const [checksumAlgo, setChecksumAlgo] = useState('SHA-256'); const [checksumValue, setChecksumValue] = useState(''); const [headers, setHeaders] = useState(''); const [cookies, setCookies] = useState(''); const [mirrors, setMirrors] = useState(''); useEffect(() => { if (isAddModalOpen) { setSaveLocation(baseDownloadFolder); setIsSaveLocationManual(false); setUrls(pendingAddUrls || ''); setParsedItems([]); setSelectedItemIndex(null); setPendingUseSharedDestination(false); setPendingDestinationOverrides({}); setConnections(perServerConnections); setSpeedLimitEnabled(false); setSpeedLimit('1024'); setUseAuth(false); setUsername(''); setPassword(''); setAdvancedExpanded(false); setChecksumEnabled(false); setChecksumAlgo('SHA-256'); setChecksumValue(''); setHeaders([ pendingAddReferer ? `Referer: ${pendingAddReferer}` : '', pendingAddHeaders ].filter(Boolean).join('\n')); setCookies(pendingAddCookies); setMirrors(''); setIsQueueMenuOpen(false); setIsSubmitting(false); } else { setUrls(''); } }, [ isAddModalOpen, pendingAddUrls, pendingAddReferer, pendingAddHeaders, pendingAddCookies, baseDownloadFolder, perServerConnections ]); useEffect(() => { if (!isQueueMenuOpen) return; const closeMenu = (event: PointerEvent) => { if (!actionMenuRef.current?.contains(event.target as Node)) { setIsQueueMenuOpen(false); } }; window.addEventListener('pointerdown', closeMenu); return () => window.removeEventListener('pointerdown', closeMenu); }, [isQueueMenuOpen]); useEffect(() => { if (!isQueueMenuOpen && !showingDuplicates) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; if (showingDuplicates) { setShowingDuplicates(false); } else { setIsQueueMenuOpen(false); } }; window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); }, [isQueueMenuOpen, showingDuplicates]); useEffect(() => { if (!saveLocation) return; invoke('get_free_space', { path: saveLocation }) .then(space => setFreeSpace(space)) .catch(() => setFreeSpace('Unknown')); }, [saveLocation, isAddModalOpen]); useEffect(() => { setParsedItems(current => reconcileDownloadRows(urls, current, pendingAddFilename || undefined) ); }, [urls, pendingAddFilename]); useEffect(() => { for (const row of parsedItems) { if (row.status !== 'loading') continue; const requestKey = `${row.id}:${row.generation}`; if (metadataRequestsRef.current.has(requestKey)) continue; metadataRequestsRef.current.add(requestKey); void (async () => { try { if (row.isMedia) { const settingsStore = useSettingsStore.getState(); const { mediaCookieSource } = settingsStore; const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; const login = getSiteLogin(row.sourceUrl, settingsStore); let keychainPassword = null; if (login) { try { keychainPassword = await invoke('get_keychain_password', { id: login.id }); } catch (e) { console.warn("Could not fetch keychain password:", e); } } const mediaData = await fetchMediaMetadataDeduped({ url: row.sourceUrl, cookieBrowser: browserArg, username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword }); if (mediaData && mediaData.formats.length > 0) { const mappedFormats = mediaData.formats.map(f => { const quality = f.resolution || 'Video'; const container = f.ext.toUpperCase(); const exactBytes = f.filesize || 0; const approxBytes = f.filesize_approx || 0; const bytes = exactBytes || approxBytes; const isApproximate = !exactBytes && approxBytes > 0; return { name: `${quality} ${container}`, ext: f.ext, bytes, isApproximate, formatLabel: f.format_label || f.ext.toUpperCase(), detail: bytes ? `${isApproximate ? '~' : ''}${formatBytes(bytes)}` : 'Unknown size', selector: f.format_id, type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video' }; }); setParsedItems(current => updateRowIfCurrent( current, row.id, row.sourceUrl, row.generation, currentRow => ({ ...currentRow, downloadUrl: row.sourceUrl, file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`), size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined, sizeBytes: mappedFormats[0].bytes || undefined, status: 'ready', formats: mappedFormats, selectedFormat: 0 }) )); } else { throw new Error("Invalid media metadata or no formats found"); } } else { const settingsStore = useSettingsStore.getState(); const login = getSiteLogin(row.sourceUrl, settingsStore); let keychainPassword = null; if (login) { try { keychainPassword = await invoke('get_keychain_password', { id: login.id }); } catch (e) { console.warn("Could not fetch keychain password:", e); } } const meta = await invoke('fetch_metadata', { url: row.sourceUrl, userAgent: settingsStore.customUserAgent || null, username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword }); setParsedItems(current => updateRowIfCurrent( current, row.id, row.sourceUrl, row.generation, currentRow => ({ ...currentRow, downloadUrl: meta.url || currentRow.downloadUrl, file: canonicalizeDownloadFileName( current.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename ), size: meta.size_bytes ? meta.size : undefined, sizeBytes: meta.size_bytes || undefined, status: 'ready' }) )); } } catch (e) { console.error("Meta fetch failed", e); setParsedItems(current => updateRowIfCurrent( current, row.id, row.sourceUrl, row.generation, currentRow => ({ ...currentRow, downloadUrl: currentRow.sourceUrl, size: undefined, sizeBytes: undefined, status: 'metadata-error', formats: undefined, selectedFormat: undefined }) )); } finally { metadataRequestsRef.current.delete(requestKey); } })(); } }, [parsedItems, pendingAddFilename, password, useAuth, username]); useEffect(() => { if (parsedItems.length === 0) { setSelectedItemIndex(null); return; } setSelectedItemIndex(current => current === null || current >= parsedItems.length ? 0 : current ); if (isSaveLocationManual) return; if (parsedItems.length > 1) { setSaveLocation(useSettingsStore.getState().baseDownloadFolder || '~/Downloads'); return; } const first = parsedItems[0]; if (first.status !== 'ready' && first.status !== 'metadata-error') return; void resolveCategoryDestination( useSettingsStore.getState(), categoryForFileName(first.file) ).then(setSaveLocation); }, [isSaveLocationManual, parsedItems]); if (!isAddModalOpen) return null; const handleBrowse = async () => { try { const selected = await open({ directory: true, multiple: false, defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation }); if (selected && typeof selected === 'string') { const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected); setSaveLocation(approvedPath); setIsSaveLocationManual(true); } } catch (e) { console.error("Failed to select folder:", e); } }; const categoryLocationForFile = (fileName: string) => { const category = categoryForFileName(fileName); return resolveCategoryDestination(useSettingsStore.getState(), category); }; const handleAction = async (action: AddDownloadAction) => { if (isSubmitting || !canSubmitMetadataRows(parsedItems)) { return; } if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) { addToast({ message: 'Speed limit must be greater than zero', variant: 'error', isActionable: true }); return; } setIsSubmitting(true); let finalLocation = saveLocation; let useSharedDestination = isSaveLocationManual; const destinationOverrides: Record = {}; const settings = useSettingsStore.getState(); const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' })); if (settings.askWhereToSaveEachFile && parsedItems.length > 0) { for (const [index, item] of parsedItems.entries()) { try { const suggestedLocation = isSaveLocationManual ? finalLocation : await categoryLocationForFile(item.file); const selected = await open({ directory: true, multiple: false, title: `Choose a folder for ${item.file}`, defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation }); if (selected && typeof selected === 'string') { const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected); destinationOverrides[index] = approvedPath; } else { setIsSubmitting(false); return; } } catch (e) { console.error("Failed to select folder:", e); setIsSubmitting(false); return; } } } setResolvedLocation(finalLocation); const store = useDownloadStore.getState(); const newConflicts: DuplicateConflict[] = []; for (let i = 0; i < parsedItems.length; i++) { const item = parsedItems[i]; let finalFile = canonicalizeDownloadFileName(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 itemLocation = useSharedDestination ? finalLocation : destinationOverrides[i] || await categoryLocationForFile(finalFile); const isUrlDupe = store.downloads.some(d => d.url === item.downloadUrl && 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 { let fileExistsInStore = false; for (const download of store.downloads) { const destination = download.destination || await resolveCategoryDestination(settings, download.category); if ( downloadLocationEquals( destination, download.fileName, itemLocation, finalFile, platform.os ) && download.status !== 'failed' ) { fileExistsInStore = true; break; } } let fileExistsOnDisk = false; try { fileExistsOnDisk = await invoke('check_file_exists', { path: await resolveDownloadFilePath(itemLocation, finalFile) }); } catch (e) {} if (fileExistsInStore || fileExistsOnDisk) { newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: fileExistsInStore ? 'Existing Firelink download uses this destination' : 'File exists on disk; rename or skip to avoid deleting unrelated data' }, resolution: 'rename', replaceAllowed: fileExistsInStore }); } } } if (newConflicts.length > 0) { setConflicts(newConflicts); setPendingAction(action); setPendingUseSharedDestination(useSharedDestination); setPendingDestinationOverrides(destinationOverrides); setShowingDuplicates(true); setIsSubmitting(false); return; } try { await executeAddDownloads(action, finalLocation, useSharedDestination, undefined, destinationOverrides); } finally { setIsSubmitting(false); } }; const executeAddDownloads = async ( action: AddDownloadAction, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[], destinationOverrides: Record = {} ) => { let itemsToAdd: Array = [...parsedItems]; const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' })); if (resolutions) { for (const res of resolutions) { const idx = parseInt(res.id); const item = itemsToAdd[idx]; if (!item) continue; const conflict = conflicts.find(c => c.id === res.id); if (res.resolution === 'skip') { itemsToAdd[idx] = null; } else if (res.resolution === 'rename') { let finalFile = canonicalizeDownloadFileName(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 itemLocation = useSharedDestination ? finalLocation : destinationOverrides[idx] || await categoryLocationForFile(finalFile); 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 && count < 1000) { newName = `${base} (${count})${ext}`; let storeHas = false; const currentSettings = useSettingsStore.getState(); for (const download of useDownloadStore.getState().downloads) { const destination = download.destination || await resolveCategoryDestination(currentSettings, download.category); if ( downloadLocationEquals( destination, download.fileName, itemLocation, newName, platform.os ) && download.status !== 'failed' ) { storeHas = true; break; } } let diskHas = false; try { diskHas = await invoke('check_file_exists', { path: await resolveDownloadFilePath(itemLocation, newName) }); } catch(e) {} exists = storeHas || diskHas; count++; } if (exists) { throw new Error(`Could not find an available name for ${finalFile}.`); } itemsToAdd[idx] = { ...item, file: newName }; } else if (res.resolution === 'replace') { if (conflict?.reason.type !== 'file' || !conflict.replaceAllowed) { itemsToAdd[idx] = null; continue; } let finalFile = canonicalizeDownloadFileName(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 itemLocation = useSharedDestination ? finalLocation : destinationOverrides[idx] || await categoryLocationForFile(finalFile); const store = useDownloadStore.getState(); let existingItem; const currentSettings = useSettingsStore.getState(); for (const download of store.downloads) { const destination = download.destination || await resolveCategoryDestination(currentSettings, download.category); if ( downloadLocationEquals( destination, download.fileName, itemLocation, finalFile, platform.os ) && download.status !== 'failed' ) { existingItem = download; break; } } if (existingItem && isTransferLocked(existingItem.status)) { throw new Error(`Pause ${existingItem.fileName} before replacing it.`); } if (!existingItem) { throw new Error(`Cannot replace ${finalFile}: file is not owned by a Firelink download.`); } await store.removeDownload(existingItem.id, true); } } } let addedCount = 0; const failures: string[] = []; for (const [itemIndex, item] of itemsToAdd.entries()) { if (!item) continue; try { const id = crypto.randomUUID(); let finalFile = canonicalizeDownloadFileName(item.file); let formatSelector = mediaFormatSelectorForRow(item); if (item.isMedia && item.formats && item.selectedFormat !== undefined) { const selectedFormat = item.formats[item.selectedFormat]; if (!finalFile.endsWith(`.${selectedFormat.ext}`)) { const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; finalFile = `${baseName}.${selectedFormat.ext}`; } } const category = categoryForFileName(finalFile); const added = await addDownload({ id, url: item.downloadUrl, fileName: finalFile, 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, destination: useSharedDestination ? finalLocation : destinationOverrides[itemIndex], isMedia: item.isMedia, mediaFormatSelector: formatSelector, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined) }, action); if (!added) { throw new Error('Backend rejected download start.'); } addedCount += 1; } catch (e) { console.error("Invalid URL or failed to add:", e); failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`); } } toggleAddModal(false); if (failures.length > 0) { addToast({ message: `${addedCount} added, ${failures.length} failed. ${failures[0]}`, variant: 'error', isActionable: true }); } else if (addedCount > 0) { addToast({ message: `${addedCount} download${addedCount === 1 ? '' : 's'} added`, variant: 'success' }); } }; const SummaryBox = ({ title, value, icon: Icon, color }: { title: string; value: string | number; icon: LucideIcon; color: string; }) => (
{title}
{value}
); const selectMediaFormat = (index: number) => { if (selectedItemIndex === null) return; const selectedItem = parsedItems[selectedItemIndex]; const format = selectedItem?.formats?.[index]; if (!selectedItem || !format) return; const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file; setParsedItems(items => items.map((item, itemIndex) => itemIndex === selectedItemIndex ? { ...item, selectedFormat: index, size: format.bytes ? format.detail : undefined, sizeBytes: format.bytes || undefined, file: canonicalizeDownloadFileName(`${baseName}.${format.ext}`) } : item )); }; const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); const hasApproximateSize = parsedItems.some(item => item.formats?.[item.selectedFormat ?? -1]?.isApproximate ); const requiredStr = requiredBytes > 0 ? `${hasApproximateSize ? '~' : ''}${requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB` : requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB` : `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}` : 'Unknown'; const canSubmit = canSubmitMetadataRows(parsedItems); const failedMetadataCount = parsedItems.filter(item => item.status === 'metadata-error').length; return ( <> {showingDuplicates && ( { setShowingDuplicates(false); setIsSubmitting(true); void executeAddDownloads( pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions, pendingDestinationOverrides ) .catch(error => { addToast({ message: `Could not resolve duplicate downloads: ${String(error)}`, variant: 'error', isActionable: true }); }) .finally(() => setIsSubmitting(false)); }} onCancel={() => setShowingDuplicates(false)} /> )}
{/* Main Content Split */}
{/* Left Column: URLs and Preview */}
Download Links