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
This commit is contained in:
NimBold
2026-06-22 17:21:47 +03:30
parent f469c5d7eb
commit 6b3753949b
10 changed files with 745 additions and 183 deletions
+148
View File
@@ -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<String>)`
- `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 rows 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.
+34 -2
View File
@@ -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<u64> {
#[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()
)
);
}
}
+84 -20
View File
@@ -1313,24 +1313,51 @@ pub(crate) fn collect_download_uris(url: &str, mirrors: Option<&str>) -> Vec<Str
const MAX_DEEP_LINK_PAYLOAD_LEN: usize = 65_536;
const MAX_DEEP_LINK_URLS: usize = 200;
fn parse_firelink_urls(deep_links: impl IntoIterator<Item = url::Url>) -> Vec<String> {
let mut captured = Vec::new();
#[derive(Debug, Clone, PartialEq, Eq)]
enum FirelinkDeepLink {
Launch,
Add(Vec<String>),
Invalid,
}
for deep_link in deep_links {
if deep_link.scheme() != "firelink" || deep_link.host_str() != Some("add") {
continue;
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 {
continue;
return FirelinkDeepLink::Invalid;
};
if raw_urls.is_empty() || raw_urls.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN {
continue;
return FirelinkDeepLink::Invalid;
}
let mut captured = Vec::new();
for raw_url in raw_urls.lines() {
let raw_url = raw_url.trim();
let Ok(url) = url::Url::parse(raw_url) else {
@@ -1343,13 +1370,16 @@ fn parse_firelink_urls(deep_links: impl IntoIterator<Item = url::Url>) -> Vec<St
if !captured.iter().any(|existing| existing == &url) {
captured.push(url);
if captured.len() == MAX_DEEP_LINK_URLS {
return captured;
}
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<url::Url>) {
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::<AppState>().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]
+129 -131
View File
@@ -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<number | null>(null);
const [parsedItems, setParsedItems] = useState<ParsedDownloadItem[]>([]);
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
const metadataRequestsRef = useRef(new Set<string>());
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
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,
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].detail,
sizeBytes: mappedFormats[0].bytes,
status: 'Ready',
isMedia: true,
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,
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
row.sourceUrl,
row.generation,
currentRow => ({
...currentRow,
downloadUrl: meta.url || currentRow.downloadUrl,
file: canonicalizeDownloadFileName(
lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename
current.length === 1 && pendingAddFilename
? pendingAddFilename
: meta.filename
),
size: meta.size,
sizeBytes: meta.size_bytes,
status: 'Ready'
};
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]);
})();
}
}, [parsedItems, pendingAddFilename, password, useAuth, username]);
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
useEffect(() => {
if (parsedItems.length === 0) {
setSelectedItemIndex(null);
return;
}
setSelectedItemIndex(current =>
current === null || current >= parsedItems.length ? 0 : current
);
if (active) setSaveLocation(categoryDir);
if (isSaveLocationManual) return;
if (parsedItems.length > 1) {
setSaveLocation(useSettingsStore.getState().baseDownloadFolder || '~/Downloads');
return;
}
}
}
}, 400);
return () => {
active = false;
clearTimeout(timer);
};
}, [
urls,
pendingAddFilename,
isSaveLocationManual,
metadataRefreshNonce,
useAuth,
username,
password
]);
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<number, string> = {}
) => {
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...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 = () => {
/>
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">
{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
</span>
<button
type="button"
onClick={() => setMetadataRefreshNonce(value => value + 1)}
onClick={() => setParsedItems(refreshFailedMetadataRows)}
disabled={failedMetadataCount === 0}
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
>
<RefreshCw size={12} /> Refresh Metadata
@@ -749,7 +747,7 @@ export const AddDownloadsModal = () => {
) : (
parsedItems.map((item, i) => (
<div
key={i}
key={item.id}
onClick={() => setSelectedItemIndex(i)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -768,14 +766,18 @@ export const AddDownloadsModal = () => {
>
<div className="flex items-center w-full">
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
<div className={`flex-1 font-mono ${item.status === 'Loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'Error' ? 'text-red-500' : item.status === 'Loading' ? 'text-orange-400' : 'text-blue-500'}`}>
{item.status === 'Loading' ? (
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
{item.status === 'loading' ? (
<div className="flex items-center gap-1.5">
<RefreshCw size={12} className="animate-spin" /> Fetching...
</div>
) : (
item.status || 'Ready'
item.status === 'metadata-error'
? 'Fallback'
: item.status === 'invalid'
? 'Invalid'
: 'Ready'
)}
</div>
</div>
@@ -803,7 +805,7 @@ export const AddDownloadsModal = () => {
<Video size={16} className="text-purple-500" /> Media Format
</div>
{parsedItems[selectedItemIndex].status === 'Loading' ? (
{parsedItems[selectedItemIndex].status === 'loading' ? (
<div className="flex flex-col items-center justify-center py-6 gap-3 relative z-10">
<RefreshCw size={24} className="animate-spin text-purple-500" />
<span className="text-xs text-text-muted font-medium animate-pulse">Fetching media streams...</span>
@@ -849,7 +851,7 @@ export const AddDownloadsModal = () => {
</div>
) : (
<div className="flex flex-col items-center justify-center py-4 relative z-10">
<span className="text-xs text-red-400 font-medium">Failed to load media streams.</span>
<span className="text-xs text-red-400 font-medium">Metadata unavailable. Default media format will be used.</span>
</div>
)}
</section>
@@ -984,11 +986,7 @@ export const AddDownloadsModal = () => {
{/* Footer */}
<div className="add-download-footer p-4 flex items-center shrink-0">
<div className="text-[11px] text-text-muted font-medium flex-1">
{parsedItems.length === 0
? 'Paste one or more links.'
: canSubmit
? `Ready to add ${parsedItems.length} download(s).`
: 'Wait for metadata or remove links that failed validation.'}
{metadataSummaryMessage(parsedItems)}
</div>
<div className="flex gap-2.5">
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
+31
View File
@@ -172,6 +172,37 @@ describe('useDownloadStore', () => {
);
});
it('redownloads fallback media without requiring a format selector', async () => {
useDownloadStore.setState({
downloads: [{
id: 'media-fallback',
url: 'https://youtube.com/watch?v=test',
fileName: 'watch',
destination: '/tmp',
status: 'completed',
category: 'Other',
dateAdded: '',
isMedia: true
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return [];
return undefined;
});
await useDownloadStore.getState().redownload('media-fallback');
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
is_media: true,
format_selector: null
})
})
);
});
it('starts and pauses all items regardless of legacy missing queue ids', async () => {
useDownloadStore.setState({
downloads: [
-3
View File
@@ -444,9 +444,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
const mediaFormatSelector = targetItem.mediaFormatSelector?.trim();
if (targetItem.isMedia && !mediaFormatSelector) {
throw new Error('Cannot redownload: selected media format is missing.');
}
const settings = useSettingsStore.getState();
const destPath = targetItem.destination ||
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
canSubmitMetadataRows,
mediaFormatSelectorForRow,
metadataSummaryMessage,
reconcileDownloadRows,
refreshFailedMetadataRows,
updateRowIfCurrent,
type AddDownloadDraftRow
} from './addDownloadMetadata';
const row = (
overrides: Partial<AddDownloadDraftRow> = {}
): AddDownloadDraftRow => ({
id: 'row-1',
sourceUrl: 'https://example.com/file.zip',
downloadUrl: 'https://example.com/file.zip',
file: 'file.zip',
status: 'ready',
generation: 1,
isMedia: false,
...overrides
});
describe('add download metadata workflow', () => {
it('preserves rows by normalized source URL and creates only new rows', () => {
const existing = row({ file: 'server-name.zip' });
let nextId = 0;
const rows = reconcileDownloadRows(
'https://example.com/file.zip\nhttps://example.com/new.zip',
[existing],
undefined,
() => `new-${nextId++}`
);
expect(rows[0]).toBe(existing);
expect(rows[1]).toMatchObject({
id: 'new-0',
status: 'loading',
file: 'new.zip'
});
});
it('deduplicates normalized URLs and marks malformed or unsupported URLs invalid', () => {
let nextId = 0;
const rows = reconcileDownloadRows(
'https://example.com/a\nhttps://example.com/a\nfile:///tmp/private\nnot-a-url',
[],
undefined,
() => `row-${nextId++}`
);
expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']);
});
it('refreshes only failed metadata and preserves successful format selection', () => {
const ready = row({
id: 'ready',
isMedia: true,
formats: [{
name: '1080p MP4',
selector: 'best',
ext: 'mp4',
formatLabel: '1080p',
detail: '10 MB',
type: 'Video',
bytes: 10
}],
selectedFormat: 0
});
const failed = row({ id: 'failed', status: 'metadata-error', generation: 4 });
const refreshed = refreshFailedMetadataRows([ready, failed]);
expect(refreshed[0]).toBe(ready);
expect(refreshed[1]).toMatchObject({ status: 'loading', generation: 5 });
});
it('ignores stale metadata results after generation changes', () => {
const current = row({ generation: 2, status: 'loading' });
const updated = updateRowIfCurrent(
[current],
current.id,
current.sourceUrl,
1,
value => ({ ...value, status: 'ready' })
);
expect(updated[0]).toBe(current);
});
it('allows ready and failed rows but blocks loading and invalid rows', () => {
expect(canSubmitMetadataRows([
row(),
row({ id: 'fallback', status: 'metadata-error' })
])).toBe(true);
expect(canSubmitMetadataRows([row({ status: 'loading' })])).toBe(false);
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
});
it('keeps failed media routing without a format selector', () => {
const failedMedia = row({
status: 'metadata-error',
isMedia: true,
formats: undefined,
selectedFormat: undefined
});
expect(failedMedia.isMedia).toBe(true);
expect(mediaFormatSelectorForRow(failedMedia)).toBeUndefined();
});
it('reports fallback and invalid states accurately', () => {
expect(metadataSummaryMessage([
row(),
row({ id: 'fallback', status: 'metadata-error' })
])).toBe('1 download ready; 1 will use fallback filename and unknown size.');
expect(metadataSummaryMessage([
row({ status: 'metadata-error' })
])).toContain('can still be added');
expect(metadataSummaryMessage([
row({ status: 'invalid' })
])).toContain('Correct or remove 1 invalid URL');
});
});
+159
View File
@@ -0,0 +1,159 @@
import {
canonicalizeDownloadFileName,
fileNameFromUrl,
isMediaUrl
} from './downloads';
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
export interface AddMediaFormat {
name: string;
selector: string;
ext: string;
formatLabel: string;
detail: string;
type: string;
bytes: number;
isApproximate?: boolean;
}
export interface AddDownloadDraftRow {
id: string;
sourceUrl: string;
downloadUrl: string;
file: string;
size?: string;
sizeBytes?: number;
status: MetadataStatus;
generation: number;
isMedia: boolean;
formats?: AddMediaFormat[];
selectedFormat?: number;
}
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
type ParsedInput = {
identity: string;
sourceUrl: string;
valid: boolean;
};
const parseInputLines = (rawText: string): ParsedInput[] => {
const seen = new Set<string>();
const parsed: ParsedInput[] = [];
for (const rawLine of rawText.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
let sourceUrl = line;
let valid = false;
try {
const url = new URL(line);
valid = ALLOWED_SCHEMES.has(url.protocol);
if (valid) sourceUrl = url.href;
} catch {
valid = false;
}
const identity = valid ? sourceUrl : `invalid:${line}`;
if (seen.has(identity)) continue;
seen.add(identity);
parsed.push({ identity, sourceUrl, valid });
}
return parsed;
};
export const reconcileDownloadRows = (
rawText: string,
currentRows: AddDownloadDraftRow[],
pendingFilename?: string,
createId: () => string = () => crypto.randomUUID()
): AddDownloadDraftRow[] => {
const inputs = parseInputLines(rawText);
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
return inputs.map(input => {
const preserved = existing.get(input.sourceUrl);
if (preserved) return preserved;
const fallback = canonicalizeDownloadFileName(
inputs.length === 1 && pendingFilename
? pendingFilename
: fileNameFromUrl(input.sourceUrl)
);
return {
id: createId(),
sourceUrl: input.sourceUrl,
downloadUrl: input.sourceUrl,
file: fallback,
status: input.valid ? 'loading' : 'invalid',
generation: input.valid ? 1 : 0,
isMedia: input.valid && isMediaUrl(input.sourceUrl)
};
});
};
export const updateRowIfCurrent = (
rows: AddDownloadDraftRow[],
id: string,
sourceUrl: string,
generation: number,
update: (row: AddDownloadDraftRow) => AddDownloadDraftRow
): AddDownloadDraftRow[] => rows.map(row =>
row.id === id && row.sourceUrl === sourceUrl && row.generation === generation
? update(row)
: row
);
export const refreshFailedMetadataRows = (
rows: AddDownloadDraftRow[]
): AddDownloadDraftRow[] => rows.map(row =>
row.status === 'metadata-error'
? {
...row,
status: 'loading',
generation: row.generation + 1
}
: row
);
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean =>
rows.length > 0
&& rows.every(row => row.status === 'ready' || row.status === 'metadata-error');
export const mediaFormatSelectorForRow = (
row: AddDownloadDraftRow
): string | undefined => {
if (!row.isMedia || row.status !== 'ready' || row.selectedFormat === undefined) {
return undefined;
}
return row.formats?.[row.selectedFormat]?.selector;
};
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
if (rows.length === 0) return 'Paste one or more links.';
const invalid = rows.filter(row => row.status === 'invalid').length;
if (invalid > 0) {
return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`;
}
const loading = rows.filter(row => row.status === 'loading').length;
if (loading > 0) {
return `Waiting for metadata for ${loading} download${loading === 1 ? '' : 's'}.`;
}
const failed = rows.filter(row => row.status === 'metadata-error').length;
const ready = rows.filter(row => row.status === 'ready').length;
if (failed === rows.length) {
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
}
if (failed > 0) {
return `${ready} download${ready === 1 ? '' : 's'} ready; ${failed} will use fallback filename and unknown size.`;
}
return `Ready to add ${ready} download${ready === 1 ? '' : 's'}.`;
};
+9 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from '@tailwindcss/vite';
@@ -8,6 +8,14 @@ const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
test: {
exclude: [
"Extensions/**",
"**/node_modules/**",
"**/dist/**",
"**/.{idea,git,cache,output,temp}/**"
]
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//