mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +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,
|
||||
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
@@ -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]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 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`
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user