import { useState, useEffect } from 'react'; import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; 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'; import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads'; interface RawMediaFormat { format_id?: string; ext?: string; resolution?: string; format_note?: string; vcodec?: string; acodec?: string; height?: number; filesize?: number; filesize_approx?: number; } interface ParsedDownloadItem { url: string; file: string; size?: string; sizeBytes?: number; status?: string; isMedia?: boolean; formats?: { name: string; selector: string; ext: string; detail: string; type: string; bytes: number }[]; selectedFormat?: number; } const isVideo = (f: RawMediaFormat) => { const vcodec = f.vcodec?.toLowerCase(); return vcodec && vcodec !== 'none'; }; const isAudio = (f: RawMediaFormat) => { const acodec = f.acodec?.toLowerCase(); const vcodec = f.vcodec?.toLowerCase(); return acodec && acodec !== 'none' && (!vcodec || vcodec === 'none'); }; const formatSize = (f: RawMediaFormat) => f.filesize ?? f.filesize_approx ?? 0; const matchesHeight = (f: RawMediaFormat, height: number | null) => { if (height === null) return true; const note = f.format_note || ""; if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true; if (height === 1440 && note.includes("1440p")) return true; if (height === 1080 && note.includes("1080p")) return true; if (height === 720 && note.includes("720p")) return true; if (height === 480 && note.includes("480p")) return true; if (height === 360 && note.includes("360p")) return true; if (f.resolution) { const parts = f.resolution.split('x').map(n => parseInt(n, 10)); if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { const maxDim = Math.max(parts[0], parts[1]); switch (height) { case 2160: if (maxDim >= 3800) return true; break; case 1440: if (maxDim >= 2500 && maxDim < 3800) return true; break; case 1080: if (maxDim >= 1900 && maxDim < 2500) return true; break; case 720: if (maxDim >= 1200 && maxDim < 1900) return true; break; case 480: if (maxDim >= 800 && maxDim < 1200) return true; break; case 360: if (maxDim >= 600 && maxDim < 800) return true; break; } } } const formatHeight = f.height; if (!formatHeight) return false; let tolerance = 100; if (height >= 2160) tolerance = 600; else if (height >= 1440) tolerance = 400; else if (height >= 1080) tolerance = 300; else if (height >= 720) tolerance = 200; return formatHeight <= height && formatHeight >= height - tolerance; }; const hasVideoFormat = (formats: RawMediaFormat[], height: number | null, container: string) => { return formats.some(f => { if (!isVideo(f) || !matchesHeight(f, height)) return false; return container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase(); }); }; const hasAudioFormat = (formats: RawMediaFormat[], ext: string | null) => { return formats.some(f => { if (!isAudio(f)) return false; if (!ext) return true; return f.ext?.toLowerCase() === ext.toLowerCase(); }); }; const estimatedVideoBytes = (formats: RawMediaFormat[], height: number | null, container: string) => { let maxVideo = 0; for (const f of formats) { if (isVideo(f) && matchesHeight(f, height) && (container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase())) { const size = formatSize(f); if (size > maxVideo) maxVideo = size; } } if (maxVideo === 0) return null; let maxAudio = estimatedAudioBytes(formats, container === 'webm' ? 'webm' : 'm4a') || estimatedAudioBytes(formats, null) || 0; return maxVideo + maxAudio; }; const estimatedAudioBytes = (formats: RawMediaFormat[], ext: string | null): number | null => { let maxPreferred = 0; for (const f of formats) { if (isAudio(f)) { if (!ext || f.ext?.toLowerCase() === ext.toLowerCase()) { const size = formatSize(f); if (size > maxPreferred) maxPreferred = size; } } } if (maxPreferred > 0 || !ext) return maxPreferred > 0 ? maxPreferred : null; return estimatedAudioBytes(formats, null); }; 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 parseMediaFormats = (jsonStr: string) => { try { const data = JSON.parse(jsonStr); let title = data.title || 'Media'; title = title.replace(/[\/\\?%*:|"<>]/g, '-'); const rawFormats: RawMediaFormat[] = data.formats || []; const options = []; const standardResolutions = [ { h: 2160, name: "4K" }, { h: 1440, name: "1440p" }, { h: 1080, name: "1080p" }, { h: 720, name: "720p" }, { h: 480, name: "480p" }, { h: 360, name: "360p" } ]; const availableResolutions = standardResolutions.filter(res => rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h)) ); const videoQualities: { h: number | null, name: string }[] = [{ h: null, name: "Best" }, ...availableResolutions]; const videoContainers = [ { ext: "mp4", name: "MP4" }, { ext: "mkv", name: "MKV" }, { ext: "webm", name: "WebM" } ]; for (const q of videoQualities) { for (const c of videoContainers) { if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue; const est = estimatedVideoBytes(rawFormats, q.h, c.ext); const filter = q.h ? `[height<=${q.h}]` : ''; let selector = `bestvideo${filter}+bestaudio/best${filter}`; if (c.ext === 'mp4') { selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`; } else if (c.ext === 'webm') { selector = `bestvideo${filter}[ext=webm]+bestaudio[ext=webm]/best${filter}[ext=webm]/bestvideo${filter}+bestaudio/best${filter}`; } options.push({ name: `${q.name} ${c.name}`, selector, ext: c.ext, detail: est ? `~${formatBytes(est)}` : '', type: 'Video', bytes: est || 0 }); } } if (hasAudioFormat(rawFormats, null)) { const est = estimatedAudioBytes(rawFormats, null); options.push({ name: "Audio MP3", selector: "bestaudio/best", ext: "mp3", detail: est ? `~${formatBytes(est)}` : '', type: 'Audio', bytes: est || 0 }); } if (hasAudioFormat(rawFormats, "m4a")) { const est = estimatedAudioBytes(rawFormats, "m4a"); options.push({ name: "Audio M4A", selector: "bestaudio[ext=m4a]/bestaudio/best", ext: "m4a", detail: est ? `~${formatBytes(est)}` : '', type: 'Audio', bytes: est || 0 }); } if (hasAudioFormat(rawFormats, "webm") || hasAudioFormat(rawFormats, "opus")) { const est = estimatedAudioBytes(rawFormats, "webm") || estimatedAudioBytes(rawFormats, "opus"); options.push({ name: "Audio Opus", selector: "bestaudio[ext=webm]/bestaudio/best", ext: "opus", detail: est ? `~${formatBytes(est)}` : '', type: 'Audio', bytes: est || 0 }); } return { title, formats: options }; } catch (e) { return null; } }; export const AddDownloadsModal = () => { const { isAddModalOpen, pendingAddUrls, pendingAddReferer, pendingAddFilename, toggleAddModal, addDownload, queues } = useDownloadStore(); const { defaultDownloadPath } = useSettingsStore(); const [selectedQueueId, setSelectedQueueId] = useState(MAIN_QUEUE_ID); const [urls, setUrls] = useState(''); const [selectedItemIndex, setSelectedItemIndex] = useState(null); const [parsedItems, setParsedItems] = useState([]); const [conflicts, setConflicts] = useState([]); 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); 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(defaultDownloadPath); setUrls(pendingAddUrls || ''); setParsedItems([]); setSelectedItemIndex(null); setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID); setUseAuth(false); setUsername(''); setPassword(''); setAdvancedExpanded(false); setChecksumEnabled(false); setChecksumAlgo('SHA-256'); setChecksumValue(''); setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : ''); setCookies(''); setMirrors(''); } }, [ isAddModalOpen, pendingAddUrls, pendingAddReferer, defaultDownloadPath, queues ]); useEffect(() => { if (!saveLocation) return; invoke('get_free_space', { path: saveLocation }) .then(space => setFreeSpace(space)) .catch(() => setFreeSpace('Unknown')); }, [saveLocation, isAddModalOpen]); // Metadata parser useEffect(() => { const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0); // Immediately display items in loading state const initialItems: ParsedDownloadItem[] = lines.map(url => { const fallbackFile = lines.length === 1 && pendingAddFilename ? pendingAddFilename : fileNameFromUrl(url); return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) }; }); setParsedItems(initialItems); if (lines.length === 0) { setSelectedItemIndex(null); return; } else if (selectedItemIndex === null || selectedItemIndex >= lines.length) { setSelectedItemIndex(0); } const timer = setTimeout(async () => { const updatedItems = [...initialItems]; let firstReadyIndex: number | null = null; for (let i = 0; i < lines.length; i++) { const url = lines[i]; try { new URL(url); if (isMediaUrl(url)) { const settingsStore = useSettingsStore.getState(); const { mediaCookieSource } = settingsStore; const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; const login = getSiteLogin(url, 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 jsonStr = await invoke('fetch_media_metadata', { url, cookieBrowser: browserArg, username: login?.username || null, password: keychainPassword }); const mediaData = parseMediaFormats(jsonStr); if (mediaData && mediaData.formats.length > 0) { updatedItems[i] = { url, file: `${mediaData.title}.${mediaData.formats[0].ext}`, size: mediaData.formats[0].detail || 'Unknown (Media)', sizeBytes: mediaData.formats[0].bytes, status: 'Ready', isMedia: true, formats: mediaData.formats, selectedFormat: 0 }; } else { throw new Error("Invalid media metadata or no formats found"); } } else { const settingsStore = useSettingsStore.getState(); const login = getSiteLogin(url, 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<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { url, username: login?.username || null, password: keychainPassword }); updatedItems[i] = { url, file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' }; } if (firstReadyIndex === null) firstReadyIndex = i; } catch (e) { console.error("Meta fetch failed", e); updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' }; } setParsedItems([...updatedItems]); } if (firstReadyIndex !== null) { setSelectedItemIndex(firstReadyIndex); } }, 400); return () => clearTimeout(timer); }, [urls, pendingAddFilename]); 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') { setSaveLocation(selected); } } catch (e) { console.error("Failed to select folder:", e); } }; const handleStart = async (startImmediately: boolean) => { let finalLocation = saveLocation; const settings = useSettingsStore.getState(); if (settings.askWhereToSaveEachFile && parsedItems.length > 0) { try { const selected = await open({ directory: true, multiple: false, defaultPath: finalLocation.startsWith('~') ? undefined : finalLocation }); if (selected && typeof selected === 'string') { finalLocation = selected; } else { return; // Cancelled } } catch (e) { console.error("Failed to select folder:", e); } } setResolvedLocation(finalLocation); const store = useDownloadStore.getState(); const newConflicts: DuplicateConflict[] = []; 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}`; } 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('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' }); } } } 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('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: categoryForFileName(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, checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgo}=${checksumValue.trim()}` : undefined, cookies: cookies.trim() || undefined, mirrors: mirrors.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) => (
{title}
{value}
); const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); const requiredStr = requiredBytes > 0 ? (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'; return ( <> {showingDuplicates && ( { setShowingDuplicates(false); executeAddDownloads(pendingStartFlag, resolvedLocation, resolutions); }} onCancel={() => setShowingDuplicates(false)} /> )}
{/* Main Content Split */}
{/* Left Column: URLs and Preview */}
Download Links