import { useCallback, useState, useEffect, useRef } from 'react'; import { useDownloadStore, getSiteLogin, getProxyArgs, type AddDownloadAction, type PendingAddRequestContext } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; import type { DownloadItem } from '../bindings/DownloadItem'; import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; import { FolderPlus, Save, 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, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, resolveCategoryDestination, deriveBatchFolderName, resolveDownloadFilePath, resolveSubfolderDestination, sanitizeBatchFolderName, downloadLocationEquals, resolveInitialAddWindowLocation } from '../utils/downloadLocations'; import { getPlatformInfo } from '../utils/platform'; import { isTransferLocked } from '../utils/downloadActions'; import { useToast } from '../contexts/ToastContext'; import { useTranslation } from 'react-i18next'; import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/locales'; import { canSubmitMetadataRows, appendRequestUrlsAfterVersion, mediaFileNameForSelectedFormat, mediaFormatSelectorForRow, metadataSummaryState, playlistFilePrefix, reconcileDownloadRows, refreshFailedMetadataRows, updateRowIfCurrent, type AddDownloadDraftRow } from '../utils/addDownloadMetadata'; const formatBytes = (bytes: number) => { 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 normalizeComparableUrl = (rawUrl: string) => { try { return new URL(rawUrl).href; } catch { return rawUrl.trim(); } }; const urlsHaveDifferentOrigins = (sourceUrl: string, targetUrl: string) => { try { const source = new URL(sourceUrl); const target = new URL(targetUrl); return source.protocol !== target.protocol || source.hostname.toLowerCase() !== target.hostname.toLowerCase() || source.port !== target.port; } catch { return false; } }; const cookieScopeForUrl = (context: PendingAddRequestContext | undefined, targetUrl: string) => { if (!context?.cookieScopes?.length) return ''; try { const target = new URL(targetUrl); return context.cookieScopes.find(scope => { try { const scopeUrl = new URL(scope.url); const sameGoogleusercontentSite = scopeUrl.protocol === target.protocol && scopeUrl.port === target.port && scopeUrl.hostname.toLowerCase() === 'googleusercontent.com' && target.hostname.toLowerCase().endsWith('.googleusercontent.com'); return sameGoogleusercontentSite || (scopeUrl.protocol === target.protocol && scopeUrl.hostname.toLowerCase() === target.hostname.toLowerCase() && scopeUrl.port === target.port); } catch { return false; } })?.cookies.trim() || ''; } catch { return ''; } }; const isGoogleAuthenticatedCaptureUrl = (rawUrl: string) => { try { const hostname = new URL(rawUrl).hostname.toLowerCase(); return hostname === 'mail.google.com' || hostname === 'accounts.google.com' || hostname === 'googleusercontent.com' || hostname.endsWith('.googleusercontent.com'); } catch { return false; } }; const extensionHeaders = (context: PendingAddRequestContext | undefined) => [ context?.referer ? `Referer: ${context.referer.replace(/[\r\n]/g, '')}` : '', context?.media ? (context.headers || '') .split(/\r?\n/) .filter(line => { const separator = line.indexOf(':'); return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie'; }) .join('\n') : context?.headers ].filter(Boolean).join('\n'); export const AddDownloadsModal = () => { const { t, i18n } = useTranslation(); const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl'; const { addToast } = useToast(); const { isAddModalOpen, pendingAddUrls, pendingAddReferer, pendingAddFilename, pendingAddHeaders, pendingAddCookies, pendingAddMediaUrls, pendingAddBatchName, pendingAddRequestContexts, pendingAddRequestVersion, toggleAddModal, addDownload, queues } = useDownloadStore(); const { baseDownloadFolder, rememberLastUsedDownloadDirectory, lastUsedDownloadDirectory, perServerConnections, keychainAccessReady, keychainPromptDismissed, showKeychainModal } = useSettingsStore(); const [urls, setUrls] = useState(''); const [selectedItemIndex, setSelectedItemIndex] = useState(null); const [parsedItems, setParsedItems] = useState([]); const metadataRequestsRef = useRef(new Set()); const playlistRequestsRef = useRef(new Set()); const latestPlaylistRequestRef = useRef(new Map()); const [playlistExpansions, setPlaylistExpansions] = useState>({}); 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 isSubmittingRef = useRef(false); const actionMenuRef = useRef(null); // Right Form const [saveLocation, setSaveLocation] = useState(baseDownloadFolder); const [isSaveLocationManual, setIsSaveLocationManual] = useState(false); const [saveInDedicatedFolder, setSaveInDedicatedFolder] = useState(false); const [dedicatedFolderName, setDedicatedFolderName] = useState(''); const dedicatedFolderNameEditedRef = useRef(false); const locationResolutionRequestRef = useRef(0); const folderPickerRequestRef = useRef(0); const pendingLastUsedDownloadDirectoryRef = useRef(null); const [connections, setConnections] = useState(perServerConnections); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimit, setSpeedLimit] = useState('1024'); const [freeSpace, setFreeSpace] = useState('Unknown'); const freeSpaceRequestRef = useRef(0); 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 headersManuallyEditedRef = useRef(false); const cookiesManuallyEditedRef = useRef(false); const modalSessionRef = useRef(false); const observedRequestVersionRef = useRef(0); const [mirrors, setMirrors] = useState(''); const requestContextForUrl = (url: string) => pendingAddRequestContexts[normalizeComparableUrl(url)]; const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0; const headersForRow = (sourceUrl: string) => { if (headersManuallyEditedRef.current) return headers.trim(); const context = requestContextForUrl(sourceUrl); if (context) return extensionHeaders(context).trim(); return hasExtensionRequestContext ? '' : headers.trim(); }; const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => { if (cookiesManuallyEditedRef.current) return cookies.trim(); const context = requestContextForUrl(sourceUrl); const scopedCookies = cookieScopeForUrl(context, targetUrl); if (scopedCookies) return scopedCookies; if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return ''; if (context) return context.cookies.trim(); return hasExtensionRequestContext ? '' : cookies.trim(); }; const shouldDeferCookiesForRow = (sourceUrl: string) => !cookiesManuallyEditedRef.current && Boolean(requestContextForUrl(sourceUrl)) && !isGoogleAuthenticatedCaptureUrl(sourceUrl); const suggestedFilenameForRow = (sourceUrl: string) => { const context = requestContextForUrl(sourceUrl); if (context?.filename) return context.filename; return hasExtensionRequestContext ? '' : pendingAddFilename; }; const requestContextUrlForRow = (row: AddDownloadDraftRow) => row.playlistSourceUrl || row.sourceUrl; const closeModalFromDismissAction = useCallback(() => { if (isSubmitting || isSubmittingRef.current || showKeychainModal) return; const hasPendingInput = Boolean( urls.trim() || pendingAddUrls.trim() || parsedItems.some(item => item.selected !== false) || headers.trim() || cookies.trim() ); if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return; toggleAddModal(false); }, [cookies, headers, isSubmitting, parsedItems, pendingAddUrls, showKeychainModal, toggleAddModal, urls]); useEffect(() => { if (!isAddModalOpen) { modalSessionRef.current = false; ++folderPickerRequestRef.current; pendingLastUsedDownloadDirectoryRef.current = null; setUrls(''); setPlaylistExpansions({}); playlistRequestsRef.current.clear(); latestPlaylistRequestRef.current.clear(); return; } if (modalSessionRef.current) return; modalSessionRef.current = true; const initialUrls = pendingAddUrls || ''; const initialUrlLines = initialUrls.split('\n').map(url => url.trim()).filter(Boolean); observedRequestVersionRef.current = pendingAddRequestVersion; const initialContext = initialUrlLines.length === 1 ? requestContextForUrl(initialUrlLines[0]) : undefined; const initialLocation = resolveInitialAddWindowLocation( baseDownloadFolder, rememberLastUsedDownloadDirectory, lastUsedDownloadDirectory ); setSaveLocation(initialLocation.path); setIsSaveLocationManual(initialLocation.isManual); setSaveInDedicatedFolder(false); dedicatedFolderNameEditedRef.current = false; setDedicatedFolderName(deriveBatchFolderName(pendingAddBatchName, pendingAddReferer)); setUrls(initialUrls); setParsedItems([]); setPlaylistExpansions({}); metadataRequestsRef.current.clear(); playlistRequestsRef.current.clear(); latestPlaylistRequestRef.current.clear(); setSelectedItemIndex(null); setPendingUseSharedDestination(false); setPendingDestinationOverrides({}); setConnections(perServerConnections); setFreeSpace('Unknown'); setSpeedLimitEnabled(false); setSpeedLimit('1024'); setUseAuth(false); setUsername(''); setPassword(''); setAdvancedExpanded(false); setChecksumEnabled(false); setChecksumAlgo('SHA-256'); setChecksumValue(''); setHeaders(initialContext ? extensionHeaders(initialContext) : [ pendingAddReferer ? `Referer: ${pendingAddReferer.replace(/[\r\n]/g, '')}` : '', pendingAddHeaders ].filter(Boolean).join('\n')); headersManuallyEditedRef.current = false; setCookies(initialContext?.cookies || pendingAddCookies); cookiesManuallyEditedRef.current = false; setMirrors(''); setIsQueueMenuOpen(false); isSubmittingRef.current = false; setIsSubmitting(false); }, [ isAddModalOpen, pendingAddUrls, pendingAddReferer, pendingAddHeaders, pendingAddCookies, pendingAddMediaUrls, pendingAddBatchName, baseDownloadFolder, rememberLastUsedDownloadDirectory, lastUsedDownloadDirectory, perServerConnections ]); useEffect(() => { if (!isAddModalOpen || !modalSessionRef.current || observedRequestVersionRef.current === pendingAddRequestVersion) return; const observedVersion = observedRequestVersionRef.current; observedRequestVersionRef.current = pendingAddRequestVersion; // Playlist membership and entry access can depend on the handoff's // browser context. Re-discover playlists when a newer extension context // arrives instead of reusing entries extracted under stale cookies. setPlaylistExpansions({}); latestPlaylistRequestRef.current.clear(); setUrls(current => appendRequestUrlsAfterVersion( current, pendingAddRequestContexts, observedVersion )); }, [isAddModalOpen, pendingAddRequestContexts, pendingAddRequestVersion]); 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 (!isAddModalOpen) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; if (showKeychainModal) return; if (showingDuplicates) { setShowingDuplicates(false); } else if (isQueueMenuOpen) { setIsQueueMenuOpen(false); } else { closeModalFromDismissAction(); } }; window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); }, [closeModalFromDismissAction, isAddModalOpen, isQueueMenuOpen, showKeychainModal, showingDuplicates]); useEffect(() => { const requestId = ++freeSpaceRequestRef.current; if (!isAddModalOpen || !saveLocation) return; invoke('get_free_space', { path: saveLocation }) .then(space => { if (freeSpaceRequestRef.current === requestId) { setFreeSpace(space); } }) .catch(() => { if (freeSpaceRequestRef.current === requestId) { setFreeSpace('Unknown'); } }); }, [saveLocation, isAddModalOpen]); useEffect(() => { const activeUrls = new Set( urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl) ); for (const sourceUrl of latestPlaylistRequestRef.current.keys()) { if (!activeUrls.has(sourceUrl)) { latestPlaylistRequestRef.current.delete(sourceUrl); } } setPlaylistExpansions(current => { const retained = Object.fromEntries( Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl)) ); return Object.keys(retained).length === Object.keys(current).length ? current : retained; }); const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => { try { return new URL(url).href; } catch { return url; } })); const requestFilenames = Object.fromEntries( Object.entries(pendingAddRequestContexts) .filter(([, context]) => Boolean(context.filename)) .map(([url, context]) => [url, context.filename]) ); const requestContextVersions = Object.fromEntries( Object.entries(pendingAddRequestContexts) .map(([url, context]) => [url, context.version]) ); setParsedItems(current => { const selectedBySourceUrl = Object.fromEntries( current.map(row => [row.sourceUrl, row.selected !== false]) ); for (const row of current) { if (row.playlistSourceUrl && !(row.playlistSourceUrl in selectedBySourceUrl)) { selectedBySourceUrl[row.playlistSourceUrl] = row.selected !== false; } } return reconcileDownloadRows( urls, current, hasExtensionRequestContext ? undefined : pendingAddFilename || undefined, forcedMediaUrls, undefined, requestFilenames, requestContextVersions, playlistExpansions, selectedBySourceUrl ); }); }, [ urls, pendingAddFilename, pendingAddMediaUrls, pendingAddRequestContexts, hasExtensionRequestContext, playlistExpansions ]); useEffect(() => { const maxConcurrentMetadataRequests = 4; for (const row of parsedItems) { if (row.status !== 'loading' || row.selected === false) continue; const requestKey = `${row.id}:${row.generation}`; const requestSet = row.isPlaylist ? playlistRequestsRef.current : metadataRequestsRef.current; if (requestSet.has(requestKey)) continue; if (metadataRequestsRef.current.size + playlistRequestsRef.current.size >= maxConcurrentMetadataRequests) { break; } requestSet.add(requestKey); if (row.isPlaylist) { // Invalidate stale playlist requests before any asynchronous settings, // keychain, or network work can yield. Otherwise an old request can // become the latest request again after the URL or browser context // has already changed. latestPlaylistRequestRef.current.set(row.sourceUrl, requestKey); } void (async () => { try { const settingsStore = useSettingsStore.getState(); const proxy = await getProxyArgs(settingsStore); const login = getSiteLogin(row.sourceUrl, settingsStore); const contextUrl = requestContextUrlForRow(row); const requestContext = requestContextForUrl(contextUrl); if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) { settingsStore.setShowKeychainModal(true); return; } if (row.isMedia) { const { mediaCookieSource } = settingsStore; const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; let keychainPassword = null; if (login && !useAuth && keychainAccessReady) { try { keychainPassword = await invoke('get_keychain_password', { id: login.id }); } catch (e) { console.warn("Could not fetch keychain password:", e); } } const rowHeaders = headersForRow(contextUrl); const rowCookies = cookiesForRow(contextUrl, row.sourceUrl); 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: rowHeaders || null, cookies: rowCookies || null, proxy }; if (row.isPlaylist) { if (playlistExpansions[row.sourceUrl]) return; const playlistData = await fetchMediaPlaylistMetadataDeduped({ ...mediaMetadataArgs, url: contextUrl }); if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return; if (!playlistData.entries.length) { throw new Error(t($ => $.addDownloads.playlistNoEntries)); } setPlaylistExpansions(current => ({ ...current, [row.sourceUrl]: playlistData })); return; } const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs); 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)}` : t($ => $.addDownloads.unknownSize), 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( `${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${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, playlistError: undefined }) )); } else { throw new Error(t($ => $.addDownloads.invalidMediaMetadata)); } } else { let keychainPassword = null; if (login && !useAuth && keychainAccessReady) { 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: headersForRow(contextUrl) || null, cookies: cookiesForRow(contextUrl, row.sourceUrl) || null, cookieScopes: requestContext?.cookieScopes || null, proxy, deferCookies: shouldDeferCookiesForRow(row.sourceUrl) }); const nextDownloadUrl = meta.url || row.sourceUrl; setParsedItems(current => updateRowIfCurrent( current, row.id, row.sourceUrl, row.generation, currentRow => ({ ...currentRow, downloadUrl: nextDownloadUrl || currentRow.downloadUrl, file: canonicalizeDownloadFileName( current.length === 1 && suggestedFilenameForRow(contextUrl) ? suggestedFilenameForRow(contextUrl) : meta.filename ), size: meta.size_bytes ? meta.size : undefined, sizeBytes: meta.size_bytes || undefined, status: 'ready', resumable: meta.resumable, metadataBlockedReason: undefined }) )); } } catch (e) { console.error("Meta fetch failed", e); const errorMessage = e instanceof Error ? e.message : String(e); const metadataBlockedReason = [ 'SSRF blocked: Invalid URL', 'SSRF blocked: No host', 'SSRF blocked: DNS resolution failed', 'SSRF blocked: No DNS records', 'SSRF blocked: Private/local IP not allowed' ].some(prefix => errorMessage.startsWith(prefix)) ? 'unsafe-url' as const : undefined; 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, metadataBlockedReason, playlistError: row.isPlaylist ? errorMessage : undefined }) )); } finally { requestSet.delete(requestKey); } })(); } }, [ keychainAccessReady, keychainPromptDismissed, parsedItems, pendingAddFilename, pendingAddMediaUrls, playlistExpansions, useAuth ]); useEffect(() => { if (!rememberLastUsedDownloadDirectory) { pendingLastUsedDownloadDirectoryRef.current = null; } }, [rememberLastUsedDownloadDirectory]); useEffect(() => { const requestId = ++locationResolutionRequestRef.current; if (parsedItems.length === 0) { setSelectedItemIndex(null); return; } setSelectedItemIndex(current => current === null || current >= parsedItems.length ? 0 : current ); if (isSaveLocationManual) return; if (parsedItems.length > 1) { const baseFolder = useSettingsStore.getState().baseDownloadFolder || '~/Downloads'; void expandTilde(baseFolder).then(location => { if (requestId === locationResolutionRequestRef.current) { setSaveLocation(location); } }); return; } const first = parsedItems[0]; if (first.status !== 'ready' && first.status !== 'metadata-error') return; void resolveCategoryDestination( useSettingsStore.getState(), categoryForFileName(first.file) ).then(location => { if (requestId === locationResolutionRequestRef.current) { setSaveLocation(location); } }); }, [isSaveLocationManual, parsedItems]); useEffect(() => { if ( !isAddModalOpen || parsedItems.length < 2 || dedicatedFolderNameEditedRef.current ) { return; } setDedicatedFolderName(deriveBatchFolderName( pendingAddBatchName, pendingAddReferer, new Date(), parsedItems.map(item => item.file) )); }, [isAddModalOpen, parsedItems, pendingAddBatchName, pendingAddReferer]); if (!isAddModalOpen) return null; const handleBrowse = async () => { try { const requestId = ++folderPickerRequestRef.current; const defaultPath = await expandTilde(saveLocation); const selected = await open({ directory: true, multiple: false, defaultPath }); if (requestId !== folderPickerRequestRef.current) return; if (selected && typeof selected === 'string') { ++locationResolutionRequestRef.current; const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected); if (requestId !== folderPickerRequestRef.current) return; setSaveLocation(approvedPath); setIsSaveLocationManual(true); const settings = useSettingsStore.getState(); if (settings.rememberLastUsedDownloadDirectory) { pendingLastUsedDownloadDirectoryRef.current = approvedPath; } } } catch (e) { console.error("Failed to select folder:", e); } }; const categoryLocationForFile = (fileName: string) => { const category = categoryForFileName(fileName); return resolveCategoryDestination(useSettingsStore.getState(), category); }; const commitDedicatedFolderName = () => { const safeName = sanitizeBatchFolderName(dedicatedFolderName); if (!safeName) { addToast({ message: t($ => $.addDownloads.dedicatedFolderNameRequired), variant: 'error', isActionable: true }); return; } dedicatedFolderNameEditedRef.current = true; setDedicatedFolderName(safeName); }; const destinationForFile = async ( fileName: string, finalLocation: string, useSharedDestination: boolean, destinationOverride?: string ): Promise => { if (destinationOverride) return destinationOverride; const root = useSharedDestination ? finalLocation : await categoryLocationForFile(fileName); return saveInDedicatedFolder ? resolveSubfolderDestination(root, dedicatedFolderName) : root; }; const handleAction = async (action: AddDownloadAction) => { if (isSubmitting || isSubmittingRef.current || !canSubmitMetadataRows(parsedItems)) { return; } if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) { addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true }); return; } if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) { addToast({ message: t($ => $.addDownloads.dedicatedFolderNameRequired), variant: 'error', isActionable: true }); return; } isSubmittingRef.current = true; setIsSubmitting(true); ++folderPickerRequestRef.current; 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()) { if (item.selected === false) continue; try { const suggestedLocation = await destinationForFile( item.file, finalLocation, isSaveLocationManual ); const selected = await open({ directory: true, multiple: false, title: `Choose a folder for ${item.file}`, defaultPath: await expandTilde(suggestedLocation) }); if (selected && typeof selected === 'string') { const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected); destinationOverrides[index] = approvedPath; const currentSettings = useSettingsStore.getState(); if (currentSettings.rememberLastUsedDownloadDirectory) { pendingLastUsedDownloadDirectoryRef.current = approvedPath; } } else { pendingLastUsedDownloadDirectoryRef.current = null; isSubmittingRef.current = false; setIsSubmitting(false); return; } } catch (e) { console.error("Failed to select folder:", e); pendingLastUsedDownloadDirectoryRef.current = null; isSubmittingRef.current = false; setIsSubmitting(false); return; } } } setResolvedLocation(finalLocation); const store = useDownloadStore.getState(); const newConflicts: DuplicateConflict[] = []; const plannedTargets: Array<{ location: string; fileName: string }> = []; const reservedFilenameMatchIds = new Set(); for (let i = 0; i < parsedItems.length; i++) { const item = parsedItems[i]; if (item.selected === false) continue; let finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); const itemLocation = await destinationForFile( finalFile, finalLocation, useSharedDestination, destinationOverrides[i] ); const isUrlDupe = store.downloads.some(d => d.url === item.downloadUrl && d.status !== 'failed' && d.status !== 'completed'); const hasBatchConflict = plannedTargets.some(target => downloadLocationEquals( target.location, target.fileName, itemLocation, finalFile, platform.os ) ); if (isUrlDupe) { newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) }, resolution: 'rename' }); } else if (hasBatchConflict) { newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) }, resolution: 'rename', replaceAllowed: false }); } else { const filenameCandidates: Array<{ download: DownloadItem; sameDestination: boolean; }> = []; for (const download of store.downloads) { if ( download.status === 'completed' || !downloadMediaKindsMatch(download.isMedia, item.isMedia) || !downloadFileNamesMatch(download.fileName, finalFile) ) { continue; } const destination = download.destination || await resolveCategoryDestination(settings, download.category); filenameCandidates.push({ download, sameDestination: downloadLocationEquals( destination, download.fileName, itemLocation, finalFile, platform.os ) }); } filenameCandidates.sort((left, right) => Number(right.sameDestination) - Number(left.sameDestination) || Number(reservedFilenameMatchIds.has(left.download.id)) - Number(reservedFilenameMatchIds.has(right.download.id)) || (right.download.downloadedBytes ?? 0) - (left.download.downloadedBytes ?? 0) || (right.download.fraction ?? 0) - (left.download.fraction ?? 0) || left.download.dateAdded.localeCompare(right.download.dateAdded) ); const filenameMatch = filenameCandidates.find(candidate => !reservedFilenameMatchIds.has(candidate.download.id) )?.download || filenameCandidates[0]?.download; if (filenameMatch) { const canReplace = !reservedFilenameMatchIds.has(filenameMatch.id) && !isTransferLocked(filenameMatch.status); newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: t($ => $.addDownloads.matchingDownloadFilename) }, resolution: canReplace ? 'replace' : 'rename', replaceAllowed: canReplace, existingDownloadId: filenameMatch.id }); reservedFilenameMatchIds.add(filenameMatch.id); plannedTargets.push({ location: itemLocation, fileName: finalFile }); continue; } let existingDownload; for (const download of store.downloads) { const destination = download.destination || await resolveCategoryDestination(settings, download.category); if ( downloadLocationEquals( destination, download.fileName, itemLocation, finalFile, platform.os ) ) { existingDownload = download; 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 (existingDownload || fileExistsOnDisk) { newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: existingDownload ? t($ => $.addDownloads.existingDownloadDestination) : t($ => $.addDownloads.fileExistsOnDisk) }, resolution: 'rename', replaceAllowed: Boolean(existingDownload), existingDownloadId: existingDownload?.id }); } } plannedTargets.push({ location: itemLocation, fileName: finalFile }); } if (newConflicts.length > 0) { setConflicts(newConflicts); setPendingAction(action); setPendingUseSharedDestination(useSharedDestination); setPendingDestinationOverrides(destinationOverrides); setShowingDuplicates(true); isSubmittingRef.current = false; setIsSubmitting(false); return; } try { await executeAddDownloads(action, finalLocation, useSharedDestination, undefined, destinationOverrides); } finally { isSubmittingRef.current = false; setIsSubmitting(false); } }; const executeAddDownloads = async ( action: AddDownloadAction, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[], destinationOverrides: Record = {} ) => { let itemsToAdd: Array = parsedItems.map(item => item.selected === false ? null : item ); const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' })); let updatedCount = 0; 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 = await destinationForFile( finalFile, finalLocation, useSharedDestination, destinationOverrides[idx] ); 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; const batchTargets: Array<{ location: string; fileName: string }> = []; for (const [candidateIndex, candidate] of itemsToAdd.entries()) { if (!candidate || candidateIndex === idx) continue; const candidateFile = candidate.isMedia ? mediaFileNameForSelectedFormat(candidate.file, candidate) : canonicalizeDownloadFileName(candidate.file); const candidateLocation = await destinationForFile( candidateFile, finalLocation, useSharedDestination, destinationOverrides[candidateIndex] ); batchTargets.push({ location: candidateLocation, fileName: candidateFile }); } 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) {} const batchHas = batchTargets.some(target => downloadLocationEquals( target.location, target.fileName, itemLocation, newName, platform.os )); exists = storeHas || diskHas || batchHas; count++; } if (exists) { throw new Error(t($ => $.addDownloads.noAvailableName, { file: finalFile })); } itemsToAdd[idx] = { ...item, file: newName }; } else if (res.resolution === 'replace') { if (conflict?.reason.type !== 'file' || !conflict.replaceAllowed) { itemsToAdd[idx] = null; continue; } const finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); const itemLocation = await destinationForFile( finalFile, finalLocation, useSharedDestination, destinationOverrides[idx] ); const store = useDownloadStore.getState(); let existingItem = conflict?.existingDownloadId ? store.downloads.find(download => download.id === conflict.existingDownloadId) : undefined; const currentSettings = useSettingsStore.getState(); if (!existingItem && !conflict?.existingDownloadId) { for (const download of store.downloads) { const destination = download.destination || await resolveCategoryDestination(currentSettings, download.category); if ( downloadLocationEquals( destination, download.fileName, itemLocation, finalFile, platform.os ) ) { existingItem = download; break; } } } if (existingItem && isTransferLocked(existingItem.status)) { throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName })); } if (!existingItem) { throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile })); } const incomingMediaFormat = mediaFormatSelectorForRow(item); const mediaFormatChanged = item.isMedia && existingItem.mediaFormatSelector !== incomingMediaFormat; if (existingItem.status === 'completed' || mediaFormatChanged) { // Completed replacements must remove the old file so the // new transfer cannot be treated as an already-complete // aria2 target. Unfinished rows use the in-place path to // preserve their resumable assets and progress. await store.removeDownload(existingItem.id, true, false); } else { const contextUrl = requestContextUrlForRow(item); const replaced = await store.replaceDownload(existingItem.id, { url: item.downloadUrl, username: useAuth ? username.trim() : undefined, password: useAuth ? password.trim() : undefined, headers: headersForRow(contextUrl) || undefined, cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined, mirrors: mirrors.trim() || undefined, lastError: undefined }, pendingAction); if (!replaced) { throw new Error(t($ => $.addDownloads.backendRejectedStart)); } // The existing row was updated in place; do not create a // second identity for the same filename. itemsToAdd[idx] = null; updatedCount += 1; continue; } } } } 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 contextUrl = requestContextUrlForRow(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` : undefined, username: useAuth ? username.trim() : undefined, password: useAuth ? password.trim() : undefined, headers: headersForRow(contextUrl) || undefined, checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgo}=${checksumValue.trim()}` : undefined, cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined, mirrors: mirrors.trim() || undefined, destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex] ? await destinationForFile( finalFile, finalLocation, useSharedDestination, destinationOverrides[itemIndex] ) : undefined, isMedia: item.isMedia, resumable: item.resumable, mediaFormatSelector: formatSelector, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes }, action); if (!added) { throw new Error(t($ => $.addDownloads.backendRejectedStart)); } addedCount += 1; } catch (e) { console.error("Invalid URL or failed to add:", e); failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`); } } const currentSettings = useSettingsStore.getState(); if ( addedCount > 0 && currentSettings.rememberLastUsedDownloadDirectory && pendingLastUsedDownloadDirectoryRef.current ) { currentSettings.setLastUsedDownloadDirectory( pendingLastUsedDownloadDirectoryRef.current ); } pendingLastUsedDownloadDirectoryRef.current = null; toggleAddModal(false); if (failures.length > 0) { addToast({ message: t($ => $.addDownloads.addedWithFailures, { added: addedCount, failed: failures.length, detail: failures[0] }), variant: 'error', isActionable: true }); } else if (addedCount > 0) { addToast({ message: addedCount === 1 ? t($ => $.addDownloads.addedOne) : t($ => $.addDownloads.addedMany, { count: addedCount }), variant: 'success' }); } else if (updatedCount > 0) { addToast({ message: updatedCount === 1 ? t($ => $.addDownloads.updatedOne) : t($ => $.addDownloads.updatedMany, { count: updatedCount }), variant: 'success' }); } }; const SummaryBox = ({ title, value, icon: Icon, color }: { title: string; value: string | number; icon: LucideIcon; color: string; }) => (
{title}
{value}
); const toggleRowSelection = (index: number) => { setParsedItems(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, selected: item.selected === false } : item )); }; const toggleAllRows = () => { setParsedItems(items => { const shouldSelect = items.some(item => item.selected === false); return items.map(item => ({ ...item, selected: shouldSelect })); }); }; 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 selectedItems = parsedItems.filter(item => item.selected !== false); const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length; const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); const hasApproximateSize = selectedItems.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 = selectedItems.filter(item => item.status === 'metadata-error').length; const failedMediaMetadataCount = selectedItems.filter( item => item.status === 'metadata-error' && item.isMedia ).length; const blockedMetadataCount = selectedItems.filter( item => item.metadataBlockedReason === 'unsafe-url' ).length; const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount; const activePlaylistUrls = new Set( urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl) ); const playlistSummaries = Object.entries(playlistExpansions) .filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl)); const pluralMessage = ( count: number, one: () => string, few: () => string, many: () => string ): string => { switch (localePluralVariant(i18n.language, count)) { case 'one': return one(); case 'few': return few(); case 'many': return many(); } }; const metadataSummary = (() => { const summary = metadataSummaryState(parsedItems); switch (summary.type) { case 'empty': return t($ => $.addDownloads.pasteOneOrMore); case 'none-selected': return t($ => $.addDownloads.selectAtLeastOne); case 'invalid': return pluralMessage( summary.count, () => t($ => $.addDownloads.correctInvalidOne, { count: summary.count }), () => t($ => $.addDownloads.correctInvalidFew, { count: summary.count }), () => t($ => $.addDownloads.correctInvalidMany, { count: summary.count }) ); case 'loading': return pluralMessage( summary.count, () => t($ => $.addDownloads.waitingForMetadataOne, { count: summary.count }), () => t($ => $.addDownloads.waitingForMetadataFew, { count: summary.count }), () => t($ => $.addDownloads.waitingForMetadataMany, { count: summary.count }) ); case 'unsafe': return pluralMessage( summary.count, () => t($ => $.addDownloads.removeUnsafeOne, { count: summary.count }), () => t($ => $.addDownloads.removeUnsafeFew, { count: summary.count }), () => t($ => $.addDownloads.removeUnsafeMany, { count: summary.count }) ); case 'media-error': return pluralMessage( summary.count, () => t($ => $.addDownloads.mediaMetadataUnavailableSummaryOne, { count: summary.count }), () => t($ => $.addDownloads.mediaMetadataUnavailableSummaryFew, { count: summary.count }), () => t($ => $.addDownloads.mediaMetadataUnavailableSummaryMany, { count: summary.count }) ); case 'all-error': return t($ => $.addDownloads.metadataUnavailableFallback); case 'fallback': return pluralMessage( summary.ready, () => t($ => $.addDownloads.fallbackReadyOne, { ready: summary.ready, failed: summary.failed }), () => t($ => $.addDownloads.fallbackReadyFew, { ready: summary.ready, failed: summary.failed }), () => t($ => $.addDownloads.fallbackReadyMany, { ready: summary.ready, failed: summary.failed }) ); case 'ready': return pluralMessage( summary.count, () => t($ => $.addDownloads.readyToAddOne, { count: summary.count }), () => t($ => $.addDownloads.readyToAddFew, { count: summary.count }), () => t($ => $.addDownloads.readyToAddMany, { count: summary.count }) ); } })(); return ( <> {showingDuplicates && ( { if (isSubmittingRef.current) return; isSubmittingRef.current = true; setShowingDuplicates(false); setIsSubmitting(true); void executeAddDownloads( pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions, pendingDestinationOverrides ) .catch(error => { addToast({ message: t($ => $.addDownloads.duplicateResolveFailed, { detail: String(error) }), variant: 'error', isActionable: true }); }) .finally(() => { isSubmittingRef.current = false; setIsSubmitting(false); }); }} onCancel={() => setShowingDuplicates(false)} /> )}
{ if (event.target === event.currentTarget) closeModalFromDismissAction(); }} role="dialog" aria-modal="true" >
{/* Main Content Split */}
{/* Left Column: URLs and Preview */}
{t($ => $.addDownloads.downloadLinks)}