import { useState, useEffect, useRef } from 'react'; import { useDownloadStore, getSiteLogin, getProxyArgs, 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, mediaFileNameForSelectedFormat, 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]; }; const isCrossHostRedirect = (sourceUrl: string, downloadUrl: string) => { try { const sourceHost = new URL(sourceUrl).hostname; const downloadHost = new URL(downloadUrl).hostname; return downloadHost !== sourceHost && !downloadHost.endsWith(`.${sourceHost}`); } catch { return false; } }; const normalizeComparableUrl = (rawUrl: string) => { try { return new URL(rawUrl).href; } catch { return rawUrl.trim(); } }; export const AddDownloadsModal = () => { const { addToast } = useToast(); const { isAddModalOpen, pendingAddUrls, pendingAddReferer, pendingAddFilename, pendingAddHeaders, pendingAddCookies, pendingAddMediaUrls, 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 headersFromExtensionRef = useRef(false); const cookiesFromExtensionRef = useRef(false); 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(''); const extensionHeaders = [ pendingAddReferer ? `Referer: ${pendingAddReferer.replace(/[\r\n]/g, '')}` : '', pendingAddHeaders ].filter(Boolean).join('\n'); setHeaders(extensionHeaders); headersFromExtensionRef.current = Boolean(extensionHeaders.trim()); setCookies(pendingAddCookies); cookiesFromExtensionRef.current = Boolean(pendingAddCookies?.trim()); setMirrors(''); setIsQueueMenuOpen(false); setIsSubmitting(false); } else { setUrls(''); } }, [ isAddModalOpen, pendingAddUrls, pendingAddReferer, pendingAddHeaders, pendingAddCookies, pendingAddMediaUrls, 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(() => { const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => { try { return new URL(url).href; } catch { return url; } })); setParsedItems(current => reconcileDownloadRows(urls, current, pendingAddFilename || undefined, forcedMediaUrls) ); }, [urls, pendingAddFilename, pendingAddMediaUrls]); 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 { const settingsStore = useSettingsStore.getState(); const proxy = await getProxyArgs(settingsStore); if (row.isMedia) { 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 mediaMetadataArgs = { url: row.sourceUrl, cookieBrowser: browserArg, userAgent: settingsStore.customUserAgent.trim() || null, username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword, headers: headers?.trim() || null, cookies: cookies?.trim() || null, proxy }; const cleanMediaMetadataArgs = { ...mediaMetadataArgs, headers: null, cookies: null }; const isExtensionMediaRow = pendingAddMediaUrls .map(normalizeComparableUrl) .includes(normalizeComparableUrl(row.sourceUrl)); const hasExtensionContext = headersFromExtensionRef.current || cookiesFromExtensionRef.current; let mediaData; if (isExtensionMediaRow && hasExtensionContext) { try { mediaData = await fetchMediaMetadataDeduped(cleanMediaMetadataArgs); if (headersFromExtensionRef.current) { setHeaders(''); headersFromExtensionRef.current = false; } if (cookiesFromExtensionRef.current) { setCookies(''); cookiesFromExtensionRef.current = false; } } catch (cleanError) { console.warn( 'Media metadata failed without extension browser context; retrying with extension headers/cookies', cleanError ); mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs); } } else { try { mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs); } catch (error) { if (!hasExtensionContext) { throw error; } console.warn( 'Media metadata failed with extension browser context; retrying without extension headers/cookies', error ); mediaData = await fetchMediaMetadataDeduped(cleanMediaMetadataArgs); if (headersFromExtensionRef.current) { setHeaders(''); headersFromExtensionRef.current = false; } if (cookiesFromExtensionRef.current) { setCookies(''); cookiesFromExtensionRef.current = false; } } } 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 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.trim() || null, username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword, headers: headers?.trim() || null, cookies: cookies?.trim() || null, proxy }); const nextDownloadUrl = meta.url || row.sourceUrl; if (cookiesFromExtensionRef.current && isCrossHostRedirect(row.sourceUrl, nextDownloadUrl)) { setCookies(''); cookiesFromExtensionRef.current = false; } setParsedItems(current => updateRowIfCurrent( current, row.id, row.sourceUrl, row.generation, currentRow => ({ ...currentRow, downloadUrl: nextDownloadUrl || 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', resumable: meta.resumable }) )); } } 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, pendingAddMediaUrls]); 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 = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); 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) { console.error("Failed to check if file exists on disk:", 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 = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); 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 = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); 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 = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); let formatSelector = mediaFormatSelectorForRow(item); 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` : '0', 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, resumable: item.resumable, 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; setParsedItems(items => items.map((item, itemIndex) => itemIndex === selectedItemIndex ? { ...item, selectedFormat: index, size: format.bytes ? format.detail : undefined, sizeBytes: format.bytes || undefined, file: mediaFileNameForSelectedFormat(item.file, { formats: item.formats, selectedFormat: index }) } : 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