mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
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:
+1
-1
Submodule Extensions/Firefox updated: bde440f5e1...c35c001d15
@@ -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 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.
|
||||||
@@ -51,6 +51,7 @@ pub enum DownloadEvent {
|
|||||||
strike: usize,
|
strike: usize,
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
CapturedUrls(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -240,7 +241,7 @@ impl CoordinatorEventSink {
|
|||||||
fn emit_captured_urls(&self, payload: String) -> bool {
|
fn emit_captured_urls(&self, payload: String) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Tauri(app_handle) => app_handle.emit("deep-link-add-download", payload).is_ok(),
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::parse_speed_limit;
|
use super::{parse_speed_limit, DownloadCmd, DownloadCoordinator, DownloadEvent};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_aria_style_speed_limits() {
|
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("2 MB/s"), Some(2 * 1024 * 1024));
|
||||||
assert_eq!(parse_speed_limit("0"), None);
|
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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-38
@@ -1313,43 +1313,73 @@ 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_PAYLOAD_LEN: usize = 65_536;
|
||||||
const MAX_DEEP_LINK_URLS: usize = 200;
|
const MAX_DEEP_LINK_URLS: usize = 200;
|
||||||
|
|
||||||
fn parse_firelink_urls(deep_links: impl IntoIterator<Item = url::Url>) -> Vec<String> {
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum FirelinkDeepLink {
|
||||||
|
Launch,
|
||||||
|
Add(Vec<String>),
|
||||||
|
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();
|
let mut captured = Vec::new();
|
||||||
|
for raw_url in raw_urls.lines() {
|
||||||
for deep_link in deep_links {
|
let raw_url = raw_url.trim();
|
||||||
if deep_link.scheme() != "firelink" || deep_link.host_str() != Some("add") {
|
let Ok(url) = url::Url::parse(raw_url) else {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(raw_urls) = deep_link
|
|
||||||
.query_pairs()
|
|
||||||
.find_map(|(key, value)| (key == "url").then(|| value.into_owned()))
|
|
||||||
else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if raw_urls.is_empty() || raw_urls.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN {
|
if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let url = url.to_string();
|
||||||
for raw_url in raw_urls.lines() {
|
if !captured.iter().any(|existing| existing == &url) {
|
||||||
let raw_url = raw_url.trim();
|
captured.push(url);
|
||||||
let Ok(url) = url::Url::parse(raw_url) else {
|
if captured.len() == MAX_DEEP_LINK_URLS {
|
||||||
continue;
|
break;
|
||||||
};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
captured
|
if captured.is_empty() {
|
||||||
|
FirelinkDeepLink::Invalid
|
||||||
|
} else {
|
||||||
|
FirelinkDeepLink::Add(captured)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_main_window(app_handle: &tauri::AppHandle) {
|
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>) {
|
fn dispatch_deep_links(app_handle: tauri::AppHandle, deep_links: Vec<url::Url>) {
|
||||||
let urls = parse_firelink_urls(deep_links);
|
let mut should_restore = false;
|
||||||
if urls.is_empty() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
restore_main_window(&app_handle);
|
restore_main_window(&app_handle);
|
||||||
|
if urls.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let coordinator = app_handle.state::<AppState>().download_coordinator.clone();
|
let coordinator = app_handle.state::<AppState>().download_coordinator.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
if let Err(error) = coordinator
|
if let Err(error) = coordinator
|
||||||
@@ -3307,7 +3355,8 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
aggregate_media_fraction, build_media_format_options, collect_download_uris,
|
aggregate_media_fraction, build_media_format_options, collect_download_uris,
|
||||||
is_excluded_yt_dlp_format, json_lower, media_progress_speed,
|
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,
|
redact_log_line, MediaProgress, MEDIA_PROGRESS_PREFIX,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -3361,23 +3410,38 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_firelink_urls([deep_link]),
|
parse_firelink_deep_link(&deep_link),
|
||||||
vec![
|
FirelinkDeepLink::Add(vec![
|
||||||
"https://example.com/one.zip",
|
"https://example.com/one.zip".to_string(),
|
||||||
"ftp://example.com/two.zip",
|
"ftp://example.com/two.zip".to_string(),
|
||||||
]
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 = [
|
let links = [
|
||||||
url::Url::parse("firelink://open?url=https%3A%2F%2Fexample.com").unwrap(),
|
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("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("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]
|
#[test]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database,
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
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 { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
|
||||||
import {
|
import {
|
||||||
resolveCategoryDestination,
|
resolveCategoryDestination,
|
||||||
@@ -17,28 +17,15 @@ import {
|
|||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { isTransferLocked } from '../utils/downloadActions';
|
import { isTransferLocked } from '../utils/downloadActions';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import {
|
||||||
interface MediaFormat {
|
canSubmitMetadataRows,
|
||||||
name: string;
|
mediaFormatSelectorForRow,
|
||||||
selector: string;
|
metadataSummaryMessage,
|
||||||
ext: string;
|
reconcileDownloadRows,
|
||||||
formatLabel: string;
|
refreshFailedMetadataRows,
|
||||||
detail: string;
|
updateRowIfCurrent,
|
||||||
type: string;
|
type AddDownloadDraftRow
|
||||||
bytes: number;
|
} from '../utils/addDownloadMetadata';
|
||||||
isApproximate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedDownloadItem {
|
|
||||||
url: string;
|
|
||||||
file: string;
|
|
||||||
size?: string;
|
|
||||||
sizeBytes?: number;
|
|
||||||
status?: string;
|
|
||||||
isMedia?: boolean;
|
|
||||||
formats?: MediaFormat[];
|
|
||||||
selectedFormat?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return 'Unknown size';
|
if (bytes === 0) return 'Unknown size';
|
||||||
@@ -64,9 +51,9 @@ export const AddDownloadsModal = () => {
|
|||||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
||||||
|
|
||||||
const [urls, setUrls] = useState('');
|
const [urls, setUrls] = useState('');
|
||||||
const [metadataRefreshNonce, setMetadataRefreshNonce] = useState(0);
|
|
||||||
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
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 [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
|
||||||
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
||||||
@@ -170,41 +157,26 @@ export const AddDownloadsModal = () => {
|
|||||||
.catch(() => setFreeSpace('Unknown'));
|
.catch(() => setFreeSpace('Unknown'));
|
||||||
}, [saveLocation, isAddModalOpen]);
|
}, [saveLocation, isAddModalOpen]);
|
||||||
|
|
||||||
// Metadata parser
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
setParsedItems(current =>
|
||||||
const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0);
|
reconcileDownloadRows(urls, current, pendingAddFilename || undefined)
|
||||||
|
);
|
||||||
|
}, [urls, pendingAddFilename]);
|
||||||
|
|
||||||
// Immediately display items in loading state
|
useEffect(() => {
|
||||||
const initialItems: ParsedDownloadItem[] = lines.map(url => {
|
for (const row of parsedItems) {
|
||||||
const fallbackFile = lines.length === 1 && pendingAddFilename
|
if (row.status !== 'loading') continue;
|
||||||
? pendingAddFilename
|
const requestKey = `${row.id}:${row.generation}`;
|
||||||
: fileNameFromUrl(url);
|
if (metadataRequestsRef.current.has(requestKey)) continue;
|
||||||
return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) };
|
metadataRequestsRef.current.add(requestKey);
|
||||||
});
|
|
||||||
setParsedItems(initialItems);
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
void (async () => {
|
||||||
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];
|
|
||||||
try {
|
try {
|
||||||
new URL(url);
|
if (row.isMedia) {
|
||||||
if (isMediaUrl(url)) {
|
|
||||||
const settingsStore = useSettingsStore.getState();
|
const settingsStore = useSettingsStore.getState();
|
||||||
const { mediaCookieSource } = settingsStore;
|
const { mediaCookieSource } = settingsStore;
|
||||||
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
|
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
|
||||||
const login = getSiteLogin(url, settingsStore);
|
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
||||||
let keychainPassword = null;
|
let keychainPassword = null;
|
||||||
if (login) {
|
if (login) {
|
||||||
try {
|
try {
|
||||||
@@ -215,7 +187,7 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaData = await fetchMediaMetadataDeduped({
|
const mediaData = await fetchMediaMetadataDeduped({
|
||||||
url,
|
url: row.sourceUrl,
|
||||||
cookieBrowser: browserArg,
|
cookieBrowser: browserArg,
|
||||||
username: useAuth ? username.trim() || null : login?.username || null,
|
username: useAuth ? username.trim() || null : login?.username || null,
|
||||||
password: useAuth ? password || null : keychainPassword
|
password: useAuth ? password || null : keychainPassword
|
||||||
@@ -239,22 +211,28 @@ export const AddDownloadsModal = () => {
|
|||||||
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
|
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
updatedItems[i] = {
|
setParsedItems(current => updateRowIfCurrent(
|
||||||
url,
|
current,
|
||||||
file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`),
|
row.id,
|
||||||
size: mappedFormats[0].detail,
|
row.sourceUrl,
|
||||||
sizeBytes: mappedFormats[0].bytes,
|
row.generation,
|
||||||
status: 'Ready',
|
currentRow => ({
|
||||||
isMedia: true,
|
...currentRow,
|
||||||
formats: mappedFormats,
|
downloadUrl: row.sourceUrl,
|
||||||
selectedFormat: 0
|
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 {
|
} else {
|
||||||
throw new Error("Invalid media metadata or no formats found");
|
throw new Error("Invalid media metadata or no formats found");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const settingsStore = useSettingsStore.getState();
|
const settingsStore = useSettingsStore.getState();
|
||||||
const login = getSiteLogin(url, settingsStore);
|
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
||||||
let keychainPassword = null;
|
let keychainPassword = null;
|
||||||
if (login) {
|
if (login) {
|
||||||
try {
|
try {
|
||||||
@@ -264,60 +242,74 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const meta = await invoke('fetch_metadata', {
|
const meta = await invoke('fetch_metadata', {
|
||||||
url,
|
url: row.sourceUrl,
|
||||||
userAgent: settingsStore.customUserAgent || null,
|
userAgent: settingsStore.customUserAgent || null,
|
||||||
username: useAuth ? username.trim() || null : login?.username || null,
|
username: useAuth ? username.trim() || null : login?.username || null,
|
||||||
password: useAuth ? password || null : keychainPassword
|
password: useAuth ? password || null : keychainPassword
|
||||||
});
|
});
|
||||||
updatedItems[i] = {
|
setParsedItems(current => updateRowIfCurrent(
|
||||||
url: meta.url || url,
|
current,
|
||||||
file: canonicalizeDownloadFileName(
|
row.id,
|
||||||
lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename
|
row.sourceUrl,
|
||||||
),
|
row.generation,
|
||||||
size: meta.size,
|
currentRow => ({
|
||||||
sizeBytes: meta.size_bytes,
|
...currentRow,
|
||||||
status: 'Ready'
|
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) {
|
} catch (e) {
|
||||||
console.error("Meta fetch failed", 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 () => {
|
useEffect(() => {
|
||||||
active = false;
|
if (parsedItems.length === 0) {
|
||||||
clearTimeout(timer);
|
setSelectedItemIndex(null);
|
||||||
};
|
return;
|
||||||
}, [
|
}
|
||||||
urls,
|
setSelectedItemIndex(current =>
|
||||||
pendingAddFilename,
|
current === null || current >= parsedItems.length ? 0 : current
|
||||||
isSaveLocationManual,
|
);
|
||||||
metadataRefreshNonce,
|
if (isSaveLocationManual) return;
|
||||||
useAuth,
|
if (parsedItems.length > 1) {
|
||||||
username,
|
setSaveLocation(useSettingsStore.getState().baseDownloadFolder || '~/Downloads');
|
||||||
password
|
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;
|
if (!isAddModalOpen) return null;
|
||||||
|
|
||||||
@@ -343,7 +335,7 @@ export const AddDownloadsModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = async (action: AddDownloadAction) => {
|
const handleAction = async (action: AddDownloadAction) => {
|
||||||
if (isSubmitting || parsedItems.length === 0 || parsedItems.some(item => item.status !== 'Ready')) {
|
if (isSubmitting || !canSubmitMetadataRows(parsedItems)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
|
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
|
||||||
@@ -397,7 +389,7 @@ export const AddDownloadsModal = () => {
|
|||||||
? finalLocation
|
? finalLocation
|
||||||
: destinationOverrides[i] || await categoryLocationForFile(finalFile);
|
: 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) {
|
if (isUrlDupe) {
|
||||||
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
|
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
|
||||||
} else {
|
} else {
|
||||||
@@ -452,7 +444,7 @@ export const AddDownloadsModal = () => {
|
|||||||
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
|
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
|
||||||
destinationOverrides: Record<number, string> = {}
|
destinationOverrides: Record<number, string> = {}
|
||||||
) => {
|
) => {
|
||||||
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
|
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...parsedItems];
|
||||||
|
|
||||||
if (resolutions) {
|
if (resolutions) {
|
||||||
for (const res of resolutions) {
|
for (const res of resolutions) {
|
||||||
@@ -533,7 +525,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const destination = download.destination ||
|
const destination = download.destination ||
|
||||||
await resolveCategoryDestination(currentSettings, download.category);
|
await resolveCategoryDestination(currentSettings, download.category);
|
||||||
if (
|
if (
|
||||||
(download.url === item.url ||
|
(download.url === item.downloadUrl ||
|
||||||
(destination === itemLocation && download.fileName === finalFile)) &&
|
(destination === itemLocation && download.fileName === finalFile)) &&
|
||||||
download.status !== 'failed'
|
download.status !== 'failed'
|
||||||
) {
|
) {
|
||||||
@@ -562,11 +554,10 @@ export const AddDownloadsModal = () => {
|
|||||||
try {
|
try {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
let finalFile = canonicalizeDownloadFileName(item.file);
|
let finalFile = canonicalizeDownloadFileName(item.file);
|
||||||
let formatSelector = undefined;
|
let formatSelector = mediaFormatSelectorForRow(item);
|
||||||
|
|
||||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||||
const selectedFormat = item.formats[item.selectedFormat];
|
const selectedFormat = item.formats[item.selectedFormat];
|
||||||
formatSelector = selectedFormat.selector;
|
|
||||||
if (!finalFile.endsWith(`.${selectedFormat.ext}`)) {
|
if (!finalFile.endsWith(`.${selectedFormat.ext}`)) {
|
||||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
finalFile = `${baseName}.${selectedFormat.ext}`;
|
||||||
@@ -576,7 +567,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const category = categoryForFileName(finalFile);
|
const category = categoryForFileName(finalFile);
|
||||||
await addDownload({
|
await addDownload({
|
||||||
id,
|
id,
|
||||||
url: item.url,
|
url: item.downloadUrl,
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
category,
|
category,
|
||||||
dateAdded: new Date().toISOString(),
|
dateAdded: new Date().toISOString(),
|
||||||
@@ -635,17 +626,22 @@ export const AddDownloadsModal = () => {
|
|||||||
|
|
||||||
const selectMediaFormat = (index: number) => {
|
const selectMediaFormat = (index: number) => {
|
||||||
if (selectedItemIndex === null) return;
|
if (selectedItemIndex === null) return;
|
||||||
const newItems = [...parsedItems];
|
const selectedItem = parsedItems[selectedItemIndex];
|
||||||
const selectedItem = newItems[selectedItemIndex];
|
|
||||||
const format = selectedItem?.formats?.[index];
|
const format = selectedItem?.formats?.[index];
|
||||||
if (!selectedItem || !format) return;
|
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;
|
const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file;
|
||||||
selectedItem.file = canonicalizeDownloadFileName(`${baseName}.${format.ext}`);
|
setParsedItems(items => items.map((item, itemIndex) =>
|
||||||
setParsedItems(newItems);
|
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);
|
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 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
|
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
|
||||||
: 'Unknown';
|
: '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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -711,11 +708,12 @@ export const AddDownloadsModal = () => {
|
|||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center px-1">
|
<div className="flex justify-between items-center px-1">
|
||||||
<span className="text-[11px] text-text-muted font-medium">
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
|
||||||
>
|
>
|
||||||
<RefreshCw size={12} /> Refresh Metadata
|
<RefreshCw size={12} /> Refresh Metadata
|
||||||
@@ -749,7 +747,7 @@ export const AddDownloadsModal = () => {
|
|||||||
) : (
|
) : (
|
||||||
parsedItems.map((item, i) => (
|
parsedItems.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={item.id}
|
||||||
onClick={() => setSelectedItemIndex(i)}
|
onClick={() => setSelectedItemIndex(i)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -768,14 +766,18 @@ export const AddDownloadsModal = () => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center w-full">
|
<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-[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 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'}`}>
|
<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' ? (
|
{item.status === 'loading' ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<RefreshCw size={12} className="animate-spin" /> Fetching...
|
<RefreshCw size={12} className="animate-spin" /> Fetching...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
item.status || 'Ready'
|
item.status === 'metadata-error'
|
||||||
|
? 'Fallback'
|
||||||
|
: item.status === 'invalid'
|
||||||
|
? 'Invalid'
|
||||||
|
: 'Ready'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -803,7 +805,7 @@ export const AddDownloadsModal = () => {
|
|||||||
<Video size={16} className="text-purple-500" /> Media Format
|
<Video size={16} className="text-purple-500" /> Media Format
|
||||||
</div>
|
</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">
|
<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" />
|
<RefreshCw size={24} className="animate-spin text-purple-500" />
|
||||||
<span className="text-xs text-text-muted font-medium animate-pulse">Fetching media streams...</span>
|
<span className="text-xs text-text-muted font-medium animate-pulse">Fetching media streams...</span>
|
||||||
@@ -849,7 +851,7 @@ export const AddDownloadsModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-4 relative z-10">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -984,11 +986,7 @@ export const AddDownloadsModal = () => {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="add-download-footer p-4 flex items-center shrink-0">
|
<div className="add-download-footer p-4 flex items-center shrink-0">
|
||||||
<div className="text-[11px] text-text-muted font-medium flex-1">
|
<div className="text-[11px] text-text-muted font-medium flex-1">
|
||||||
{parsedItems.length === 0
|
{metadataSummaryMessage(parsedItems)}
|
||||||
? 'Paste one or more links.'
|
|
||||||
: canSubmit
|
|
||||||
? `Ready to add ${parsedItems.length} download(s).`
|
|
||||||
: 'Wait for metadata or remove links that failed validation.'}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
|
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
|
||||||
|
|||||||
@@ -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 () => {
|
it('starts and pauses all items regardless of legacy missing queue ids', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
|
|||||||
@@ -444,9 +444,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaFormatSelector = targetItem.mediaFormatSelector?.trim();
|
const mediaFormatSelector = targetItem.mediaFormatSelector?.trim();
|
||||||
if (targetItem.isMedia && !mediaFormatSelector) {
|
|
||||||
throw new Error('Cannot redownload: selected media format is missing.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
const destPath = targetItem.destination ||
|
const destPath = targetItem.destination ||
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
@@ -8,6 +8,14 @@ const host = process.env.TAURI_DEV_HOST;
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react(), tailwindcss()],
|
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`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user