From 6b3753949b08c1f303bb3c23257dfd6109b50595 Mon Sep 17 00:00:00 2001 From: NimBold Date: Mon, 22 Jun 2026 17:21:47 +0330 Subject: [PATCH] fix(downloads): support offline fallback drafts - launch Firefox handoffs through authenticated reconnect - preserve usable drafts when metadata lookup fails - reject stale metadata results and retry failed rows only --- Extensions/Firefox | 2 +- implementation_plan.md | 148 ++++++++++++++ src-tauri/src/download.rs | 36 +++- src-tauri/src/lib.rs | 140 +++++++++---- src/components/AddDownloadsModal.tsx | 274 +++++++++++++------------- src/store/useDownloadStore.test.ts | 31 +++ src/store/useDownloadStore.ts | 3 - src/utils/addDownloadMetadata.test.ts | 125 ++++++++++++ src/utils/addDownloadMetadata.ts | 159 +++++++++++++++ vite.config.ts | 10 +- 10 files changed, 745 insertions(+), 183 deletions(-) create mode 100644 implementation_plan.md create mode 100644 src/utils/addDownloadMetadata.test.ts create mode 100644 src/utils/addDownloadMetadata.ts diff --git a/Extensions/Firefox b/Extensions/Firefox index bde440f..c35c001 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit bde440f5e11620a5155a434b4263aca55504493d +Subproject commit c35c001d15585eee9d1aa4487b3a58942a2a8f96 diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000..09db5a2 --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,148 @@ +# Firefox Offline Handoff and Metadata Fallback Plan + +## Constraints + +- No Native Messaging, helper executable, background service, or Apple Developer account dependency. +- Firefox controls first-use external-protocol confirmation. Firelink cannot suppress it. +- First use requires user approval; Firefox may offer “Always allow this extension to open firelink links.” +- Automatic browser-download capture never launches Firelink and resumes Firefox unless `/download` confirms acceptance. + +## 1. Firefox launch-and-reconnect flow + +1. Add launch-only `firelink://launch`. +2. Manual action first sends original authenticated `/download` payload. +3. Launch fallback is allowed only when authenticated discovery finds no Firelink server. + - Never launch for `403`, other `4xx`, or a Firelink server returning `5xx`. + - A startup `503` may be retried within the existing startup deadline. + - Never retry an ambiguous POST failure after request transmission because delivery may already have happened. +4. Offline manual actions use one shared launch operation: + - Create one inactive `firelink://launch` tab. + - Queue immutable original payloads while startup is in progress. + - Poll authenticated `/ping` across `127.0.0.1:6412-6422` with bounded retries. + - Deliver every queued payload exactly once after startup. + - Close the launch tab only after all queued payloads reach a terminal result. +5. Success means authenticated `/download` returned success. Tab creation is never success. +6. Timeout/cancel: + - Stop after defined deadline. + - Close temporary tab where Firefox permits. + - Notify that Firelink was not opened and no download was added. +7. Store consecutive launch-timeout count and cooldown timestamp in `chrome.storage.local`. + - Reset both after successful startup delivery. + - During cooldown, do not open another protocol tab; show concise troubleshooting guidance. +8. Invalid pairing tokens never trigger protocol fallback. + +## 2. Firefox first-use guidance + +- README and popup explain first-use Firefox confirmation and “Always allow.” +- State clearly Firelink cannot bypass browser-controlled prompt. +- Repeated timeout guidance suggests confirming protocol permission, opening Firelink once, and checking installation. + +## 3. Typed desktop deep links + +Use a typed parser result: + +- `Launch` +- `Add(Vec)` +- `Invalid` + +Rules: + +- `firelink://launch` must contain exact scheme and host, with no username, password, port, path beyond `/`, query, or fragment. +- `Launch` restores/focuses window and never enters `CaptureUrls`. +- `firelink://add?url=...` keeps current external integration behavior. +- Unsupported schemes/hosts and malformed nested URLs return `Invalid`. +- Startup and already-running links use same dispatch function. +- Add URLs remain buffered in `DownloadCoordinator` until frontend listeners report ready. + +## 4. Explicit metadata model + +Each draft row has: + +- Stable `id` +- Normalized `sourceUrl` identity +- `downloadUrl`, which metadata redirects may update +- Monotonic request `generation` +- Required status union: `loading | ready | metadata-error | invalid` +- Fallback filename and optional size bytes (`undefined` means unknown) +- Direct/media classification +- Optional successful metadata and selected media format + +Malformed or unsupported URLs are `invalid`. Valid URLs whose metadata request fails remain `metadata-error`. + +## 5. Parsing and enrichment separation + +- Extract pure URL parsing/reconciliation helpers from modal. +- Normalize and deduplicate input by `sourceUrl`, preserving first occurrence order. +- Preserve existing rows, IDs, successful metadata, and selected formats. +- Create `loading` rows only for new valid URLs. +- Metadata results apply only when row ID, `sourceUrl`, and request generation still match. +- Save location, selection, and another row’s result never restart metadata. +- Credential edits affect transfer credentials and future failed-row retries only. Ready metadata remains unchanged. + +## 6. Failed-only refresh + +- Refresh selects only `metadata-error` rows. +- Increment only those rows’ generations and mark only those rows `loading`. +- Preserve every successful row and selected format. +- Failed retry restores fallback row as `metadata-error`. +- Disable refresh when no failed rows exist. + +## 7. Submission eligibility and fallback routing + +Eligible: `ready`, `metadata-error`. + +Blocking: `loading`, `invalid`. + +- Start Downloads and Add to Queue share one eligibility helper. +- Unknown size stays `undefined`; display text is derived and `"Unknown"` is not persisted as size. +- Failed direct rows retain original URL, fallback filename, direct routing, credentials, mirrors, duplicate checks, and destination logic. +- Failed media rows retain `isMedia: true`, send no format selector, and use yt-dlp default selection. +- Remove redownload validation requiring a media selector; selector-less media items remain valid. + +## 8. Add-window messaging + +- Loading: “Waiting for metadata for N downloads.” +- Mixed: “N downloads ready; M will use fallback filename and unknown size.” +- All failed: “Metadata is unavailable. Downloads can still be added using fallback details.” +- Invalid: “Correct or remove N invalid URL(s) before continuing.” +- Failed rows remain visually distinct but usable. + +## 9. Tests + +### Extension + +- Offline manual action opens `firelink://launch`, not `firelink://add`. +- Tab creation is not success. +- Authenticated discovery retries; original payload fields survive startup. +- Shared startup sends concurrent payloads once and closes tab after terminal delivery. +- Timeout/cancel notifies failure and records cooldown. +- Automatic capture never opens protocol tab. +- Invalid token and other server errors never launch. +- Ambiguous POST failure never resends. + +### Desktop + +- Exact launch restores without downloads. +- Add links still parse. +- Unsupported/malformed links reject. +- Startup/running dispatch match. +- Startup Add URLs wait for frontend readiness. + +### Add window + +- Pure helper tests cover reconciliation, stale generations, failed-only refresh, eligibility, fallback routing, unknown size, duplicate filenames, and destinations. +- Component-level behavior uses pure helpers where possible; no DOM test runtime is required unless interaction coverage cannot be expressed through helpers. + +## 10. Acceptance and verification + +Run: + +```bash +npm run test +npm run build +(cd Extensions/Firefox && npm run check) +(cd src-tauri && cargo check) +(cd src-tauri && cargo test --all-targets) +``` + +Then perform Firefox offline and mixed/all-failed metadata acceptance scenarios from original plan. diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index f4ffa25..7ca2cfa 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -51,6 +51,7 @@ pub enum DownloadEvent { strike: usize, reason: String, }, + CapturedUrls(String), } #[derive(Debug)] @@ -240,7 +241,7 @@ impl CoordinatorEventSink { fn emit_captured_urls(&self, payload: String) -> bool { match self { Self::Tauri(app_handle) => app_handle.emit("deep-link-add-download", payload).is_ok(), - Self::Headless(_) => true, + Self::Headless(event_tx) => event_tx.send(DownloadEvent::CapturedUrls(payload)).is_ok(), } } } @@ -772,7 +773,8 @@ fn parse_speed_limit(value: &str) -> Option { #[cfg(test)] mod tests { - use super::parse_speed_limit; + use super::{parse_speed_limit, DownloadCmd, DownloadCoordinator, DownloadEvent}; + use std::time::Duration; #[test] fn parses_aria_style_speed_limits() { @@ -781,4 +783,34 @@ mod tests { assert_eq!(parse_speed_limit("2 MB/s"), Some(2 * 1024 * 1024)); assert_eq!(parse_speed_limit("0"), None); } + + #[tokio::test] + async fn buffers_captured_urls_until_frontend_is_ready() { + let (coordinator, mut events) = DownloadCoordinator::spawn_headless(); + coordinator + .send(DownloadCmd::CaptureUrls(vec![ + "https://example.com/startup.zip".to_string() + ])) + .await + .unwrap(); + + assert!(tokio::time::timeout(Duration::from_millis(20), events.recv()) + .await + .is_err()); + + coordinator + .send(DownloadCmd::FrontendReady(true)) + .await + .unwrap(); + + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .unwrap() + .unwrap(), + DownloadEvent::CapturedUrls( + "https://example.com/startup.zip".to_string() + ) + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0bdd60b..c62fc47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1313,43 +1313,73 @@ pub(crate) fn collect_download_uris(url: &str, mirrors: Option<&str>) -> Vec) -> Vec { +#[derive(Debug, Clone, PartialEq, Eq)] +enum FirelinkDeepLink { + Launch, + Add(Vec), + Invalid, +} + +fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink { + if deep_link.scheme() != "firelink" + || !deep_link.username().is_empty() + || deep_link.password().is_some() + || deep_link.port().is_some() + { + return FirelinkDeepLink::Invalid; + } + + if deep_link.host_str() == Some("launch") { + return if matches!(deep_link.path(), "" | "/") + && deep_link.query().is_none() + && deep_link.fragment().is_none() + { + FirelinkDeepLink::Launch + } else { + FirelinkDeepLink::Invalid + }; + } + + if deep_link.host_str() != Some("add") + || !matches!(deep_link.path(), "" | "/") + || deep_link.fragment().is_some() + { + return FirelinkDeepLink::Invalid; + } + + let Some(raw_urls) = deep_link + .query_pairs() + .find_map(|(key, value)| (key == "url").then(|| value.into_owned())) + else { + return FirelinkDeepLink::Invalid; + }; + if raw_urls.is_empty() || raw_urls.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN { + return FirelinkDeepLink::Invalid; + } + let mut captured = Vec::new(); - - for deep_link in deep_links { - if deep_link.scheme() != "firelink" || deep_link.host_str() != Some("add") { - continue; - } - - let Some(raw_urls) = deep_link - .query_pairs() - .find_map(|(key, value)| (key == "url").then(|| value.into_owned())) - else { + for raw_url in raw_urls.lines() { + let raw_url = raw_url.trim(); + let Ok(url) = url::Url::parse(raw_url) else { continue; }; - if raw_urls.is_empty() || raw_urls.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN { + if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") { continue; } - - for raw_url in raw_urls.lines() { - let raw_url = raw_url.trim(); - let Ok(url) = url::Url::parse(raw_url) else { - continue; - }; - if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") { - continue; - } - let url = url.to_string(); - if !captured.iter().any(|existing| existing == &url) { - captured.push(url); - if captured.len() == MAX_DEEP_LINK_URLS { - return captured; - } + let url = url.to_string(); + if !captured.iter().any(|existing| existing == &url) { + captured.push(url); + if captured.len() == MAX_DEEP_LINK_URLS { + break; } } } - captured + if captured.is_empty() { + FirelinkDeepLink::Invalid + } else { + FirelinkDeepLink::Add(captured) + } } fn restore_main_window(app_handle: &tauri::AppHandle) { @@ -1361,12 +1391,30 @@ fn restore_main_window(app_handle: &tauri::AppHandle) { } fn dispatch_deep_links(app_handle: tauri::AppHandle, deep_links: Vec) { - let urls = parse_firelink_urls(deep_links); - if urls.is_empty() { + let mut should_restore = false; + let mut urls = Vec::new(); + for deep_link in deep_links { + match parse_firelink_deep_link(&deep_link) { + FirelinkDeepLink::Launch => should_restore = true, + FirelinkDeepLink::Add(parsed) => { + should_restore = true; + for url in parsed { + if !urls.contains(&url) && urls.len() < MAX_DEEP_LINK_URLS { + urls.push(url); + } + } + } + FirelinkDeepLink::Invalid => {} + } + } + if !should_restore { return; } restore_main_window(&app_handle); + if urls.is_empty() { + return; + } let coordinator = app_handle.state::().download_coordinator.clone(); tauri::async_runtime::spawn(async move { if let Err(error) = coordinator @@ -3307,7 +3355,8 @@ mod tests { use super::{ aggregate_media_fraction, build_media_format_options, collect_download_uris, is_excluded_yt_dlp_format, json_lower, media_progress_speed, - normalize_speed_limit_for_aria2, parse_firelink_urls, parse_media_progress_line, + normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_media_progress_line, + FirelinkDeepLink, redact_log_line, MediaProgress, MEDIA_PROGRESS_PREFIX, }; use serde_json::json; @@ -3361,23 +3410,38 @@ mod tests { .unwrap(); assert_eq!( - parse_firelink_urls([deep_link]), - vec![ - "https://example.com/one.zip", - "ftp://example.com/two.zip", - ] + parse_firelink_deep_link(&deep_link), + FirelinkDeepLink::Add(vec![ + "https://example.com/one.zip".to_string(), + "ftp://example.com/two.zip".to_string(), + ]) ); } #[test] - fn rejects_unexpected_deep_links_and_nested_schemes() { + fn accepts_exact_launch_without_downloads() { + let deep_link = url::Url::parse("firelink://launch").unwrap(); + assert_eq!( + parse_firelink_deep_link(&deep_link), + FirelinkDeepLink::Launch + ); + } + + #[test] + fn rejects_launch_variants_and_nested_schemes() { let links = [ url::Url::parse("firelink://open?url=https%3A%2F%2Fexample.com").unwrap(), url::Url::parse("firelink://add?url=file%3A%2F%2F%2Ftmp%2Fsecret").unwrap(), url::Url::parse("other://add?url=https%3A%2F%2Fexample.com").unwrap(), + url::Url::parse("firelink://launch?url=https%3A%2F%2Fexample.com").unwrap(), + url::Url::parse("firelink://launch/path").unwrap(), + url::Url::parse("firelink://user@launch").unwrap(), + url::Url::parse("firelink://add/path?url=https%3A%2F%2Fexample.com").unwrap(), ]; - assert!(parse_firelink_urls(links).is_empty()); + assert!(links + .iter() + .all(|link| parse_firelink_deep_link(link) == FirelinkDeepLink::Invalid)); } #[test] diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 1eb8058..828edd1 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -9,7 +9,7 @@ import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/downloads'; import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata'; import { resolveCategoryDestination, @@ -17,28 +17,15 @@ import { } from '../utils/downloadLocations'; import { isTransferLocked } from '../utils/downloadActions'; import { useToast } from '../contexts/ToastContext'; - -interface MediaFormat { - name: string; - selector: string; - ext: string; - formatLabel: string; - detail: string; - type: string; - bytes: number; - isApproximate?: boolean; -} - -interface ParsedDownloadItem { - url: string; - file: string; - size?: string; - sizeBytes?: number; - status?: string; - isMedia?: boolean; - formats?: MediaFormat[]; - selectedFormat?: number; -} +import { + canSubmitMetadataRows, + mediaFormatSelectorForRow, + metadataSummaryMessage, + reconcileDownloadRows, + refreshFailedMetadataRows, + updateRowIfCurrent, + type AddDownloadDraftRow +} from '../utils/addDownloadMetadata'; const formatBytes = (bytes: number) => { if (bytes === 0) return 'Unknown size'; @@ -64,9 +51,9 @@ export const AddDownloadsModal = () => { const { baseDownloadFolder, perServerConnections } = useSettingsStore(); const [urls, setUrls] = useState(''); - const [metadataRefreshNonce, setMetadataRefreshNonce] = useState(0); const [selectedItemIndex, setSelectedItemIndex] = useState(null); - const [parsedItems, setParsedItems] = useState([]); + const [parsedItems, setParsedItems] = useState([]); + const metadataRequestsRef = useRef(new Set()); const [conflicts, setConflicts] = useState([]); const [showingDuplicates, setShowingDuplicates] = useState(false); @@ -170,41 +157,26 @@ export const AddDownloadsModal = () => { .catch(() => setFreeSpace('Unknown')); }, [saveLocation, isAddModalOpen]); - // Metadata parser useEffect(() => { - let active = true; - const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0); + setParsedItems(current => + reconcileDownloadRows(urls, current, pendingAddFilename || undefined) + ); + }, [urls, pendingAddFilename]); - // 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); + 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); - 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++) { - if (!active) break; - const url = lines[i]; + void (async () => { try { - new URL(url); - if (isMediaUrl(url)) { + if (row.isMedia) { const settingsStore = useSettingsStore.getState(); const { mediaCookieSource } = settingsStore; const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; - const login = getSiteLogin(url, settingsStore); + const login = getSiteLogin(row.sourceUrl, settingsStore); let keychainPassword = null; if (login) { try { @@ -215,7 +187,7 @@ export const AddDownloadsModal = () => { } const mediaData = await fetchMediaMetadataDeduped({ - url, + url: row.sourceUrl, cookieBrowser: browserArg, username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword @@ -239,22 +211,28 @@ export const AddDownloadsModal = () => { type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video' }; }); - updatedItems[i] = { - url, - file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`), - size: mappedFormats[0].detail, - sizeBytes: mappedFormats[0].bytes, - status: 'Ready', - isMedia: true, - formats: mappedFormats, - selectedFormat: 0 - }; + 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(url, settingsStore); + const login = getSiteLogin(row.sourceUrl, settingsStore); let keychainPassword = null; if (login) { try { @@ -264,60 +242,74 @@ export const AddDownloadsModal = () => { } } const meta = await invoke('fetch_metadata', { - url, + url: row.sourceUrl, userAgent: settingsStore.customUserAgent || null, username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword }); - updatedItems[i] = { - url: meta.url || url, - file: canonicalizeDownloadFileName( - lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename - ), - size: meta.size, - sizeBytes: meta.size_bytes, - status: 'Ready' - }; + 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' + }) + )); } - if (firstReadyIndex === null) firstReadyIndex = i; } catch (e) { console.error("Meta fetch failed", e); - updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' }; + 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); } - if (active) setParsedItems([...updatedItems]); - } - - if (active && firstReadyIndex !== null && !isSaveLocationManual) { - setSelectedItemIndex(firstReadyIndex); - if (lines.length > 1) { - setSaveLocation(useSettingsStore.getState().baseDownloadFolder || '~/Downloads'); - } else { - const firstFile = updatedItems[firstReadyIndex].file; - if (firstFile) { - const category = categoryForFileName(firstFile); - const categoryDir = await resolveCategoryDestination( - useSettingsStore.getState(), - category - ); - if (active) setSaveLocation(categoryDir); - } - } + })(); } - }, 400); + }, [parsedItems, pendingAddFilename, password, useAuth, username]); - return () => { - active = false; - clearTimeout(timer); - }; - }, [ - urls, - pendingAddFilename, - isSaveLocationManual, - metadataRefreshNonce, - useAuth, - username, - password - ]); + 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; @@ -343,7 +335,7 @@ export const AddDownloadsModal = () => { }; const handleAction = async (action: AddDownloadAction) => { - if (isSubmitting || parsedItems.length === 0 || parsedItems.some(item => item.status !== 'Ready')) { + if (isSubmitting || !canSubmitMetadataRows(parsedItems)) { return; } if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) { @@ -397,7 +389,7 @@ export const AddDownloadsModal = () => { ? finalLocation : destinationOverrides[i] || await categoryLocationForFile(finalFile); - const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed'); + 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 { @@ -452,7 +444,7 @@ export const AddDownloadsModal = () => { resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[], destinationOverrides: Record = {} ) => { - let itemsToAdd: Array = [...parsedItems]; + let itemsToAdd: Array = [...parsedItems]; if (resolutions) { for (const res of resolutions) { @@ -533,7 +525,7 @@ export const AddDownloadsModal = () => { const destination = download.destination || await resolveCategoryDestination(currentSettings, download.category); if ( - (download.url === item.url || + (download.url === item.downloadUrl || (destination === itemLocation && download.fileName === finalFile)) && download.status !== 'failed' ) { @@ -562,11 +554,10 @@ export const AddDownloadsModal = () => { try { const id = crypto.randomUUID(); let finalFile = canonicalizeDownloadFileName(item.file); - let formatSelector = undefined; + let formatSelector = mediaFormatSelectorForRow(item); 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}`; @@ -576,7 +567,7 @@ export const AddDownloadsModal = () => { const category = categoryForFileName(finalFile); await addDownload({ id, - url: item.url, + url: item.downloadUrl, fileName: finalFile, category, dateAdded: new Date().toISOString(), @@ -635,17 +626,22 @@ export const AddDownloadsModal = () => { const selectMediaFormat = (index: number) => { if (selectedItemIndex === null) return; - const newItems = [...parsedItems]; - const selectedItem = newItems[selectedItemIndex]; + const selectedItem = parsedItems[selectedItemIndex]; const format = selectedItem?.formats?.[index]; if (!selectedItem || !format) return; - selectedItem.selectedFormat = index; - selectedItem.size = format.detail || 'Unknown'; - selectedItem.sizeBytes = format.bytes || 0; const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file; - selectedItem.file = canonicalizeDownloadFileName(`${baseName}.${format.ext}`); - setParsedItems(newItems); + 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); @@ -657,7 +653,8 @@ export const AddDownloadsModal = () => { : requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB` : `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}` : 'Unknown'; - const canSubmit = parsedItems.length > 0 && parsedItems.every(item => item.status === 'Ready'); + const canSubmit = canSubmitMetadataRows(parsedItems); + const failedMetadataCount = parsedItems.filter(item => item.status === 'metadata-error').length; return ( <> @@ -711,11 +708,12 @@ export const AddDownloadsModal = () => { />
- {parsedItems.filter(item => item.status === 'Ready').length} ready, {parsedItems.filter(item => item.status === 'Error').length} failed + {parsedItems.filter(item => item.status === 'ready').length} ready, {failedMetadataCount} fallback