From a136fa832c6bfa2448c11e7c9a8b884ece05f03a Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 16 Jul 2026 16:07:00 +0330 Subject: [PATCH] feat(downloads): support YouTube playlists --- src-tauri/src/lib.rs | 461 +++++++++++++++++++++++--- src/bindings/MediaPlaylistEntry.ts | 3 + src/bindings/MediaPlaylistMetadata.ts | 4 + src/components/AddDownloadsModal.tsx | 212 ++++++++++-- src/ipc.ts | 5 + src/utils/addDownloadMetadata.test.ts | 183 ++++++++++ src/utils/addDownloadMetadata.ts | 153 ++++++++- src/utils/mediaMetadata.ts | 17 + 8 files changed, 947 insertions(+), 91 deletions(-) create mode 100644 src/bindings/MediaPlaylistEntry.ts create mode 100644 src/bindings/MediaPlaylistMetadata.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e0e829..67450d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -278,6 +278,134 @@ pub struct MediaMetadata { pub formats: Vec, } +#[derive(Debug, Serialize, serde::Deserialize, Clone, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct MediaPlaylistEntry { + pub id: String, + pub url: String, + pub title: String, + #[ts(type = "number | null")] + pub playlist_index: Option, +} + +#[derive(Debug, Serialize, serde::Deserialize, Clone, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct MediaPlaylistMetadata { + pub title: String, + pub playlist_id: Option, + #[ts(type = "number | null")] + pub entry_count: Option, + #[ts(type = "number")] + pub skipped_entries: u64, + pub truncated: bool, + pub entries: Vec, +} + +const MEDIA_PLAYLIST_MAX_ENTRIES: usize = 1000; + +fn parse_media_playlist_metadata( + value: &serde_json::Value, + max_entries: usize, +) -> Result { + let raw_entries = value + .get("entries") + .and_then(|entries| entries.as_array()) + .ok_or_else(|| "yt-dlp did not return playlist entries".to_string())?; + let entry_count = value.get("playlist_count").and_then(|count| count.as_u64()); + let playlist_title = value + .get("title") + .and_then(|title| title.as_str()) + .filter(|title| !title.trim().is_empty()) + .unwrap_or("YouTube playlist") + .trim() + .to_string(); + let playlist_id = value + .get("id") + .and_then(|id| id.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned); + + let mut seen_urls = HashSet::new(); + let mut entries = Vec::new(); + let mut skipped_entries = 0_u64; + + for (position, entry) in raw_entries.iter().enumerate() { + let candidate_url = ["webpage_url", "url"].iter().find_map(|key| { + entry + .get(*key) + .and_then(|url| url.as_str()) + .map(str::trim) + .filter(|url| url.starts_with("https://") || url.starts_with("http://")) + }); + let Some(url) = candidate_url else { + skipped_entries += 1; + continue; + }; + let url = url.to_string(); + if !seen_urls.insert(url.clone()) { + skipped_entries += 1; + continue; + } + if entries.len() >= max_entries { + continue; + } + + let id = entry + .get("id") + .and_then(|id| id.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .unwrap_or(&url) + .to_string(); + let title = entry + .get("title") + .and_then(|title| title.as_str()) + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or("Untitled video") + .to_string(); + let playlist_index = entry + .get("playlist_index") + .and_then(|index| index.as_u64()) + .or(Some((position + 1) as u64)); + + entries.push(MediaPlaylistEntry { + id, + url, + title, + playlist_index, + }); + } + + let truncated = raw_entries.len() > max_entries + || entry_count.is_some_and(|count| count > max_entries as u64); + if let Some(count) = entry_count { + skipped_entries = skipped_entries.max(count.saturating_sub(entries.len() as u64)); + } else { + skipped_entries = skipped_entries.max( + raw_entries + .len() + .saturating_sub(entries.len()) + .try_into() + .unwrap_or(u64::MAX), + ); + } + + if entries.is_empty() { + return Err("yt-dlp returned no usable entries for this playlist".to_string()); + } + + Ok(MediaPlaylistMetadata { + title: playlist_title, + playlist_id, + entry_count, + skipped_entries, + truncated, + entries, + }) +} + fn is_media_processing_line(line: &str) -> bool { let lower = line.to_lowercase(); lower.contains("[merger]") @@ -1687,6 +1815,72 @@ const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55); const MEDIA_METADATA_CACHE_MAX_ENTRIES: usize = 128; const FILE_METADATA_TIMEOUT: Duration = Duration::from_secs(20); +struct ShellCommandOutput { + status_code: Option, + stdout: Vec, + stderr: Vec, +} + +async fn shell_command_output_with_timeout( + command: tauri_plugin_shell::process::Command, + timeout: Duration, + operation: &str, +) -> Result { + let (mut events, child) = command + .spawn() + .map_err(|error| format!("failed to spawn command: {error}"))?; + + let collect_output = async move { + let mut output = ShellCommandOutput { + status_code: None, + stdout: Vec::new(), + stderr: Vec::new(), + }; + + while let Some(event) = events.recv().await { + match event { + tauri_plugin_shell::process::CommandEvent::Stdout(bytes) => { + output.stdout.extend(bytes); + output.stdout.push(b'\n'); + } + tauri_plugin_shell::process::CommandEvent::Stderr(bytes) => { + output.stderr.extend(bytes); + output.stderr.push(b'\n'); + } + tauri_plugin_shell::process::CommandEvent::Terminated(payload) => { + output.status_code = payload.code; + } + tauri_plugin_shell::process::CommandEvent::Error(_) => {} + _ => {} + } + } + + output + }; + + match tokio::time::timeout(timeout, collect_output).await { + Ok(output) if output.status_code.is_some() => Ok(output), + Ok(_) => match child.kill() { + Ok(()) => Err(format!( + "{operation} ended without a process exit status" + )), + Err(error) => Err(format!( + "{operation} ended without a process exit status and failed to terminate yt-dlp: {error}" + )), + }, + Err(_) => match child.kill() { + Ok(()) => Err(format!( + "{operation} timed out after {}s", + timeout.as_secs() + )), + Err(error) => Err(format!( + "{operation} timed out after {}s and failed to terminate yt-dlp: {error}", + timeout.as_secs() + )), + }, + } +} + static MEDIA_METADATA_CACHE: OnceLock>> = OnceLock::new(); static MEDIA_METADATA_LOCKS: OnceLock< @@ -1739,6 +1933,28 @@ fn resolve_metadata_ytdlp_path( .map_err(|e| format!("failed to find bundled yt-dlp: {e}")) } +#[allow(clippy::too_many_arguments)] +fn build_ytdlp_config_content( + username: Option<&str>, + password: Option<&str>, + headers: Option<&str>, + cookies: Option<&str>, +) -> Result { + let mut config_content = String::new(); + if let Some(user) = username { + if !user.is_empty() { + append_ytdlp_config_option(&mut config_content, "--username", user); + } + } + if let Some(pass) = password { + if !pass.is_empty() { + append_ytdlp_config_option(&mut config_content, "--password", pass); + } + } + append_ytdlp_http_headers(&mut config_content, headers, cookies)?; + Ok(config_content) +} + #[tauri::command] #[allow(clippy::too_many_arguments)] // Keep the generated TypeScript IPC contract flat and stable. async fn fetch_media_metadata( @@ -1846,6 +2062,121 @@ async fn fetch_media_metadata( result } +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn fetch_media_playlist_metadata( + app_handle: tauri::AppHandle, + url: String, + cookie_browser: Option, + user_agent: Option, + username: Option, + password: Option, + headers: Option, + cookies: Option, + proxy: Option, +) -> Result { + validate_url_ssrf(&url).await?; + + let deno_path = resolve_bundled_binary_path(&app_handle, "deno") + .map_err(|e| format!("failed to find bundled deno: {e}"))?; + let ffmpeg_path = resolve_bundled_binary_path(&app_handle, "ffmpeg") + .map_err(|e| format!("failed to find bundled ffmpeg: {e}"))?; + let deno_runtime = format!("deno:{}", deno_path.to_string_lossy()); + let trusted_path = crate::platform::trusted_system_path()?; + + use tauri_plugin_shell::ShellExt; + let (ytdlp_path, _) = resolve_metadata_ytdlp_path(&app_handle)?; + let mut cmd = app_handle + .shell() + .command(ytdlp_path.to_string_lossy().to_string()); + cmd = cmd + .env("PATH", trusted_path) + .arg("--ffmpeg-location") + .arg(&ffmpeg_path) + .arg("--js-runtimes") + .arg(&deno_runtime) + .arg("--no-warnings") + .arg("--ignore-errors") + .arg("--flat-playlist") + .arg("--playlist-end") + .arg(MEDIA_PLAYLIST_MAX_ENTRIES.to_string()) + .arg("--dump-single-json") + .arg("--socket-timeout") + .arg("20") + .arg("--retries") + .arg("3") + .arg("--extractor-retries") + .arg("3") + .arg("--compat-options") + .arg("no-youtube-unavailable-videos"); + + if let Some(browser) = cookie_browser.as_deref() { + if !browser.is_empty() { + cmd = cmd.arg("--cookies-from-browser").arg(browser); + } + } + + if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if proxy.eq_ignore_ascii_case("none") { + cmd = cmd.arg("--proxy").arg(""); + } else { + cmd = cmd.arg("--proxy").arg(proxy); + } + } + + if let Some(ua) = user_agent + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + cmd = cmd.arg("--user-agent").arg(ua); + } + + let mut config_file = tempfile::Builder::new() + .prefix("ytdlp-playlist-") + .suffix(".conf") + .tempfile() + .map_err(|e| e.to_string())?; + let config_content = build_ytdlp_config_content( + username.as_deref(), + password.as_deref(), + headers.as_deref(), + cookies.as_deref(), + )?; + use std::io::Write; + config_file + .write_all(config_content.as_bytes()) + .map_err(|e| e.to_string())?; + let config_path = config_file.into_temp_path(); + if !config_content.is_empty() { + cmd = cmd.arg("--config-location").arg(&config_path); + } + + cmd = cmd.arg("--").arg(&url); + let output = shell_command_output_with_timeout( + cmd, + MEDIA_METADATA_TIMEOUT, + "yt-dlp playlist metadata fetch", + ) + .await?; + + if output.status_code != Some(0) { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if err.is_empty() { + format!( + "yt-dlp failed while fetching playlist metadata (exit status: {:?})", + output.status_code + ) + } else { + format!("yt-dlp failed while fetching playlist metadata: {}", err) + }); + } + + let value: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse playlist JSON: {}", e))?; + parse_media_playlist_metadata(&value, MEDIA_PLAYLIST_MAX_ENTRIES) +} + #[allow(clippy::too_many_arguments)] // Mirrors the stable command contract for the fallback call. async fn fetch_media_metadata_uncached( app_handle: tauri::AppHandle, @@ -1906,7 +2237,11 @@ async fn fetch_media_metadata_uncached( } } - if let Some(ua) = user_agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if let Some(ua) = user_agent + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { cmd = cmd.arg("--user-agent").arg(ua); } @@ -1915,18 +2250,12 @@ async fn fetch_media_metadata_uncached( .suffix(".conf") .tempfile() .map_err(|e| e.to_string())?; - let mut config_content = String::new(); - if let Some(user) = username.as_deref() { - if !user.is_empty() { - append_ytdlp_config_option(&mut config_content, "--username", user); - } - } - if let Some(pass) = password.as_deref() { - if !pass.is_empty() { - append_ytdlp_config_option(&mut config_content, "--password", pass); - } - } - append_ytdlp_http_headers(&mut config_content, headers.as_deref(), cookies.as_deref())?; + let config_content = build_ytdlp_config_content( + username.as_deref(), + password.as_deref(), + headers.as_deref(), + cookies.as_deref(), + )?; use std::io::Write; config_file .write_all(config_content.as_bytes()) @@ -1938,16 +2267,13 @@ async fn fetch_media_metadata_uncached( cmd = cmd.arg("--").arg(&url); - let output = tokio::time::timeout(MEDIA_METADATA_TIMEOUT, cmd.output()) - .await - .map_err(|_| { - format!( - "yt-dlp timed out after {}s while fetching media metadata", - MEDIA_METADATA_TIMEOUT.as_secs() - ) - })? - .map_err(|e| format!("Failed to execute yt-dlp: {}", e))?; - if output.status.success() { + let output = shell_command_output_with_timeout( + cmd, + MEDIA_METADATA_TIMEOUT, + "yt-dlp media metadata fetch", + ) + .await?; + if output.status_code == Some(0) { let value: serde_json::Value = serde_json::from_slice(&output.stdout) .map_err(|e| format!("Failed to parse JSON: {}", e))?; @@ -1979,7 +2305,7 @@ async fn fetch_media_metadata_uncached( if err.is_empty() { Err(format!( "yt-dlp failed while fetching media metadata (exit status: {:?})", - output.status.code() + output.status_code )) } else { Err(format!( @@ -3141,18 +3467,12 @@ pub(crate) async fn start_media_download_internal( .suffix(".conf") .tempfile() .map_err(|e| e.to_string())?; - let mut config_content = String::new(); - if let Some(user) = username.as_deref() { - if !user.is_empty() { - append_ytdlp_config_option(&mut config_content, "--username", user); - } - } - if let Some(pass) = password.as_deref() { - if !pass.is_empty() { - append_ytdlp_config_option(&mut config_content, "--password", pass); - } - } - append_ytdlp_http_headers(&mut config_content, headers.as_deref(), cookies.as_deref())?; + let config_content = build_ytdlp_config_content( + username.as_deref(), + password.as_deref(), + headers.as_deref(), + cookies.as_deref(), + )?; use std::io::Write; config_file .write_all(config_content.as_bytes()) @@ -3216,6 +3536,10 @@ pub(crate) async fn start_media_download_internal( .arg("--concurrent-fragments") .arg("4") .arg("--no-warnings") + // Playlist expansion is owned by Firelink. Every persisted media + // row must remain a single-video lifecycle, even when yt-dlp's + // webpage URL still carries a playlist query parameter. + .arg("--no-playlist") .arg("--continue") .arg("--compat-options") .arg("no-youtube-unavailable-videos") @@ -5705,6 +6029,7 @@ mod tests { percent_decode_metadata_value, MediaProgress, MediaProgressEmitterState, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, observe_aria2_connections, Aria2ConnectionObservation, Aria2RecoveryReason, + parse_media_playlist_metadata, }; use serde_json::json; use std::time::{Duration, Instant}; @@ -5982,6 +6307,68 @@ mod tests { assert_eq!(template, destination.join("clip.mp4")); } + #[test] + fn parses_playlist_entries_and_skips_duplicates_or_unusable_entries() { + let metadata = parse_media_playlist_metadata( + &json!({ + "id": "playlist-1", + "title": "Example playlist", + "playlist_count": 5, + "entries": [ + {"id": "one", "title": "One", "url": "https://www.youtube.com/watch?v=one"}, + {"id": "one", "title": "Duplicate", "url": "https://www.youtube.com/watch?v=one"}, + {"id": "missing", "title": "Missing"}, + {"id": "two", "title": "Two", "url": "https://www.youtube.com/watch?v=wrong", "webpage_url": "https://www.youtube.com/watch?v=two"} + ] + }), + 10, + ) + .unwrap(); + + assert_eq!(metadata.playlist_id.as_deref(), Some("playlist-1")); + assert_eq!(metadata.title, "Example playlist"); + assert_eq!(metadata.entries.len(), 2); + assert_eq!(metadata.entries[0].url, "https://www.youtube.com/watch?v=one"); + assert_eq!(metadata.entries[1].url, "https://www.youtube.com/watch?v=two"); + assert_eq!(metadata.skipped_entries, 3); + assert!(!metadata.truncated); + } + + #[test] + fn bounds_playlist_discovery_without_silently_claiming_all_entries_loaded() { + let metadata = parse_media_playlist_metadata( + &json!({ + "id": "large-playlist", + "title": "Large playlist", + "playlist_count": 4, + "entries": [ + {"id": "one", "url": "https://www.youtube.com/watch?v=one"}, + {"id": "two", "url": "https://www.youtube.com/watch?v=two"}, + {"id": "three", "url": "https://www.youtube.com/watch?v=three"}, + {"id": "four", "url": "https://www.youtube.com/watch?v=four"} + ] + }), + 2, + ) + .unwrap(); + + assert_eq!(metadata.entries.len(), 2); + assert_eq!(metadata.entry_count, Some(4)); + assert_eq!(metadata.skipped_entries, 2); + assert!(metadata.truncated); + } + + #[test] + fn rejects_playlists_without_usable_entries() { + let error = parse_media_playlist_metadata( + &json!({"title": "Empty", "entries": [{"id": "missing"}]}), + 100, + ) + .expect_err("a playlist without downloadable URLs must fail clearly"); + + assert!(error.contains("no usable entries")); + } + #[test] fn metadata_rejects_final_http_errors_but_accepts_partial_content() { assert!(metadata_response_error(reqwest::StatusCode::PARTIAL_CONTENT).is_none()); @@ -7753,7 +8140,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status, get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, - pause_download, resume_download, fetch_metadata, fetch_media_metadata, + pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action, ack_schedule_trigger, check_automation_permission, request_automation_permission, open_automation_settings, diff --git a/src/bindings/MediaPlaylistEntry.ts b/src/bindings/MediaPlaylistEntry.ts new file mode 100644 index 0000000..947b4df --- /dev/null +++ b/src/bindings/MediaPlaylistEntry.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MediaPlaylistEntry = { id: string, url: string, title: string, playlist_index: number | null, }; diff --git a/src/bindings/MediaPlaylistMetadata.ts b/src/bindings/MediaPlaylistMetadata.ts new file mode 100644 index 0000000..03d4ae0 --- /dev/null +++ b/src/bindings/MediaPlaylistMetadata.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MediaPlaylistEntry } from "./MediaPlaylistEntry"; + +export type MediaPlaylistMetadata = { title: string, playlist_id: string | null, entry_count: number | null, skipped_entries: number, truncated: boolean, entries: Array, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 5ac547e..96aca81 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -7,12 +7,13 @@ import { type PendingAddRequestContext } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; +import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/downloads'; -import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata'; +import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { resolveCategoryDestination, resolveDownloadFilePath, @@ -27,6 +28,7 @@ import { mediaFileNameForSelectedFormat, mediaFormatSelectorForRow, metadataSummaryMessage, + playlistFilePrefix, reconcileDownloadRows, refreshFailedMetadataRows, updateRowIfCurrent, @@ -97,6 +99,9 @@ export const AddDownloadsModal = () => { const [selectedItemIndex, setSelectedItemIndex] = useState(null); const [parsedItems, setParsedItems] = useState([]); const metadataRequestsRef = useRef(new Set()); + const playlistRequestsRef = useRef(new Set()); + const latestPlaylistRequestRef = useRef(new Map()); + const [playlistExpansions, setPlaylistExpansions] = useState>({}); const [conflicts, setConflicts] = useState([]); const [showingDuplicates, setShowingDuplicates] = useState(false); @@ -159,6 +164,8 @@ export const AddDownloadsModal = () => { if (context?.filename) return context.filename; return hasExtensionRequestContext ? '' : pendingAddFilename; }; + const requestContextUrlForRow = (row: AddDownloadDraftRow) => + row.playlistSourceUrl || row.sourceUrl; const closeModalFromDismissAction = useCallback(() => { if (isSubmitting || isSubmittingRef.current) return; @@ -173,6 +180,9 @@ export const AddDownloadsModal = () => { if (!isAddModalOpen) { modalSessionRef.current = false; setUrls(''); + setPlaylistExpansions({}); + playlistRequestsRef.current.clear(); + latestPlaylistRequestRef.current.clear(); return; } @@ -189,6 +199,10 @@ export const AddDownloadsModal = () => { setIsSaveLocationManual(false); setUrls(initialUrls); setParsedItems([]); + setPlaylistExpansions({}); + metadataRequestsRef.current.clear(); + playlistRequestsRef.current.clear(); + latestPlaylistRequestRef.current.clear(); setSelectedItemIndex(null); setPendingUseSharedDestination(false); setPendingDestinationOverrides({}); @@ -230,6 +244,11 @@ export const AddDownloadsModal = () => { || observedRequestVersionRef.current === pendingAddRequestVersion) return; const observedVersion = observedRequestVersionRef.current; observedRequestVersionRef.current = pendingAddRequestVersion; + // Playlist membership and entry access can depend on the handoff's + // browser context. Re-discover playlists when a newer extension context + // arrives instead of reusing entries extracted under stale cookies. + setPlaylistExpansions({}); + latestPlaylistRequestRef.current.clear(); setUrls(current => appendRequestUrlsAfterVersion( current, pendingAddRequestContexts, @@ -282,6 +301,21 @@ export const AddDownloadsModal = () => { }, [saveLocation, isAddModalOpen]); useEffect(() => { + const activeUrls = new Set( + urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl) + ); + for (const sourceUrl of latestPlaylistRequestRef.current.keys()) { + if (!activeUrls.has(sourceUrl)) { + latestPlaylistRequestRef.current.delete(sourceUrl); + } + } + setPlaylistExpansions(current => { + const retained = Object.fromEntries( + Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl)) + ); + return Object.keys(retained).length === Object.keys(current).length ? current : retained; + }); + const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => { try { return new URL(url).href; @@ -298,37 +332,54 @@ export const AddDownloadsModal = () => { Object.entries(pendingAddRequestContexts) .map(([url, context]) => [url, context.version]) ); - setParsedItems(current => - reconcileDownloadRows( + setParsedItems(current => { + const selectedBySourceUrl = Object.fromEntries( + current.map(row => [row.sourceUrl, row.selected !== false]) + ); + for (const row of current) { + if (row.playlistSourceUrl && !(row.playlistSourceUrl in selectedBySourceUrl)) { + selectedBySourceUrl[row.playlistSourceUrl] = row.selected !== false; + } + } + return reconcileDownloadRows( urls, current, hasExtensionRequestContext ? undefined : pendingAddFilename || undefined, forcedMediaUrls, undefined, requestFilenames, - requestContextVersions - ) - ); + requestContextVersions, + playlistExpansions, + selectedBySourceUrl + ); + }); }, [ urls, pendingAddFilename, pendingAddMediaUrls, pendingAddRequestContexts, - hasExtensionRequestContext + hasExtensionRequestContext, + playlistExpansions ]); useEffect(() => { + const maxConcurrentMetadataRequests = 4; for (const row of parsedItems) { - if (row.status !== 'loading') continue; + if (row.status !== 'loading' || row.selected === false) continue; const requestKey = `${row.id}:${row.generation}`; - if (metadataRequestsRef.current.has(requestKey)) continue; - metadataRequestsRef.current.add(requestKey); + const requestSet = row.isPlaylist ? playlistRequestsRef.current : metadataRequestsRef.current; + if (requestSet.has(requestKey)) continue; + if (metadataRequestsRef.current.size + playlistRequestsRef.current.size >= maxConcurrentMetadataRequests) { + break; + } + requestSet.add(requestKey); void (async () => { try { const settingsStore = useSettingsStore.getState(); const proxy = await getProxyArgs(settingsStore); const login = getSiteLogin(row.sourceUrl, settingsStore); + const contextUrl = requestContextUrlForRow(row); if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) { settingsStore.setShowKeychainModal(true); return; @@ -345,8 +396,8 @@ export const AddDownloadsModal = () => { } } - const rowHeaders = headersForRow(row.sourceUrl); - const rowCookies = cookiesForRow(row.sourceUrl); + const rowHeaders = headersForRow(contextUrl); + const rowCookies = cookiesForRow(contextUrl, row.sourceUrl); const mediaMetadataArgs = { url: row.sourceUrl, cookieBrowser: browserArg, @@ -357,6 +408,25 @@ export const AddDownloadsModal = () => { cookies: rowCookies || null, proxy }; + + if (row.isPlaylist) { + if (playlistExpansions[row.sourceUrl]) return; + latestPlaylistRequestRef.current.set(row.sourceUrl, requestKey); + const playlistData = await fetchMediaPlaylistMetadataDeduped({ + ...mediaMetadataArgs, + url: contextUrl + }); + if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return; + if (!playlistData.entries.length) { + throw new Error('Playlist contains no downloadable entries'); + } + setPlaylistExpansions(current => ({ + ...current, + [row.sourceUrl]: playlistData + })); + return; + } + const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs); if (mediaData && mediaData.formats.length > 0) { const mappedFormats = mediaData.formats.map(f => { @@ -385,12 +455,15 @@ export const AddDownloadsModal = () => { currentRow => ({ ...currentRow, downloadUrl: row.sourceUrl, - file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`), + file: canonicalizeDownloadFileName( + `${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${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 + selectedFormat: 0, + playlistError: undefined }) )); } else { @@ -410,8 +483,8 @@ export const AddDownloadsModal = () => { userAgent: settingsStore.customUserAgent.trim() || null, username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword, - headers: headersForRow(row.sourceUrl) || null, - cookies: cookiesForRow(row.sourceUrl) || null, + headers: headersForRow(contextUrl) || null, + cookies: cookiesForRow(contextUrl, row.sourceUrl) || null, proxy, deferCookies: shouldDeferCookiesForRow(row.sourceUrl) }); @@ -425,8 +498,8 @@ export const AddDownloadsModal = () => { ...currentRow, downloadUrl: nextDownloadUrl || currentRow.downloadUrl, file: canonicalizeDownloadFileName( - current.length === 1 && suggestedFilenameForRow(row.sourceUrl) - ? suggestedFilenameForRow(row.sourceUrl) + current.length === 1 && suggestedFilenameForRow(contextUrl) + ? suggestedFilenameForRow(contextUrl) : meta.filename ), size: meta.size_bytes ? meta.size : undefined, @@ -450,15 +523,26 @@ export const AddDownloadsModal = () => { sizeBytes: undefined, status: 'metadata-error', formats: undefined, - selectedFormat: undefined + selectedFormat: undefined, + playlistError: row.isPlaylist + ? (e instanceof Error ? e.message : String(e)) + : undefined }) )); } finally { - metadataRequestsRef.current.delete(requestKey); + requestSet.delete(requestKey); } })(); } - }, [keychainAccessReady, keychainPromptDismissed, parsedItems, pendingAddFilename, pendingAddMediaUrls, useAuth]); + }, [ + keychainAccessReady, + keychainPromptDismissed, + parsedItems, + pendingAddFilename, + pendingAddMediaUrls, + playlistExpansions, + useAuth + ]); useEffect(() => { if (parsedItems.length === 0) { @@ -522,6 +606,7 @@ export const AddDownloadsModal = () => { const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' })); if (settings.askWhereToSaveEachFile && parsedItems.length > 0) { for (const [index, item] of parsedItems.entries()) { + if (item.selected === false) continue; try { const suggestedLocation = isSaveLocationManual ? finalLocation @@ -556,6 +641,7 @@ export const AddDownloadsModal = () => { for (let i = 0; i < parsedItems.length; i++) { const item = parsedItems[i]; + if (item.selected === false) continue; let finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); @@ -655,7 +741,9 @@ export const AddDownloadsModal = () => { resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[], destinationOverrides: Record = {} ) => { - let itemsToAdd: Array = [...parsedItems]; + let itemsToAdd: Array = parsedItems.map(item => + item.selected === false ? null : item + ); const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' })); if (resolutions) { @@ -792,6 +880,7 @@ export const AddDownloadsModal = () => { ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); let formatSelector = mediaFormatSelectorForRow(item); + const contextUrl = requestContextUrlForRow(item); const category = categoryForFileName(finalFile); const added = await addDownload({ @@ -804,11 +893,11 @@ export const AddDownloadsModal = () => { speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, username: useAuth ? username.trim() : undefined, password: useAuth ? password.trim() : undefined, - headers: headersForRow(item.sourceUrl) || undefined, + headers: headersForRow(contextUrl) || undefined, checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgo}=${checksumValue.trim()}` : undefined, - cookies: cookiesForRow(item.sourceUrl, item.downloadUrl) || undefined, + cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined, mirrors: mirrors.trim() || undefined, destination: useSharedDestination ? finalLocation @@ -857,6 +946,19 @@ export const AddDownloadsModal = () => { ); + const toggleRowSelection = (index: number) => { + setParsedItems(items => items.map((item, itemIndex) => + itemIndex === index ? { ...item, selected: item.selected === false } : item + )); + }; + + const toggleAllRows = () => { + setParsedItems(items => { + const shouldSelect = items.some(item => item.selected === false); + return items.map(item => ({ ...item, selected: shouldSelect })); + }); + }; + const selectMediaFormat = (index: number) => { if (selectedItemIndex === null) return; const selectedItem = parsedItems[selectedItemIndex]; @@ -879,8 +981,10 @@ export const AddDownloadsModal = () => { )); }; - const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); - const hasApproximateSize = parsedItems.some(item => + const selectedItems = parsedItems.filter(item => item.selected !== false); + const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length; + const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); + const hasApproximateSize = selectedItems.some(item => item.formats?.[item.selectedFormat ?? -1]?.isApproximate ); const requiredStr = requiredBytes > 0 @@ -889,11 +993,16 @@ export const AddDownloadsModal = () => { : `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}` : 'Unknown'; const canSubmit = canSubmitMetadataRows(parsedItems); - const failedMetadataCount = parsedItems.filter(item => item.status === 'metadata-error').length; - const failedMediaMetadataCount = parsedItems.filter( + const failedMetadataCount = selectedItems.filter(item => item.status === 'metadata-error').length; + const failedMediaMetadataCount = selectedItems.filter( item => item.status === 'metadata-error' && item.isMedia ).length; const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount; + const activePlaylistUrls = new Set( + urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl) + ); + const playlistSummaries = Object.entries(playlistExpansions) + .filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl)); return ( <> @@ -957,9 +1066,19 @@ export const AddDownloadsModal = () => { value={urls} onChange={(e) => setUrls(e.target.value)} /> + {playlistSummaries.map(([sourceUrl, playlist]) => { + const total = playlist.entry_count || playlist.entries.length; + return ( +

+ Playlist “{playlist.title}”: {playlist.entries.length}{total > playlist.entries.length ? ` of ${total}` : ''} entries loaded + {playlist.truncated ? ' (safe entry limit reached)' : ''} + {playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : ''}. +

+ ); + })}
- {parsedItems.filter(item => item.status === 'ready').length} ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry + {selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry -
+ +
- + - !i.sizeBytes).length} icon={FileText} color="text-purple-500" /> + !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
@@ -1014,18 +1141,26 @@ export const AddDownloadsModal = () => { ? 'is-selected border' : 'border border-transparent' }`} - > -
+ > +
+ toggleRowSelection(i)} + onClick={event => event.stopPropagation()} + aria-label={`Select ${item.file}`} + className="mr-2 shrink-0 accent-purple-500" + />
{item.file}
{item.size || 'Unknown'}
{item.status === 'loading' ? (
- Fetching... + {item.isPlaylist ? 'Fetching playlist...' : 'Fetching...'}
) : ( item.status === 'metadata-error' - ? item.isMedia ? 'Metadata failed' : 'Fallback' + ? item.isPlaylist ? 'Playlist failed' : item.isMedia ? 'Metadata failed' : 'Fallback' : item.status === 'invalid' ? 'Invalid' : 'Ready' @@ -1054,6 +1189,11 @@ export const AddDownloadsModal = () => {
{parsedItems[selectedItemIndex].status === 'loading' ? ( diff --git a/src/ipc.ts b/src/ipc.ts index a54af0b..024a13b 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -6,6 +6,7 @@ import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent'; import type { DownloadStateEvent } from './bindings/DownloadStateEvent'; import type { ExtensionDownload } from './bindings/ExtensionDownload'; import type { MediaMetadata } from './bindings/MediaMetadata'; +import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata'; import type { MetadataResponse } from './bindings/MetadataResponse'; import type { EngineStatusItem } from './bindings/EngineStatusItem'; import type { PostQueueAction } from './bindings/PostQueueAction'; @@ -24,6 +25,10 @@ type CommandMap = { args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; result: MediaMetadata; }; + fetch_media_playlist_metadata: { + args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; + result: MediaPlaylistMetadata; + }; get_aria2_engine_status: { args: undefined; result: EngineStatusItem }; get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem }; get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem }; diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index b6d015a..ef65bfe 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -5,6 +5,8 @@ import { mediaFormatSelectorForRow, mediaFileNameForSelectedFormat, metadataSummaryMessage, + isYouTubePlaylistUrl, + playlistFilePrefix, reconcileDownloadRows, refreshFailedMetadataRows, updateRowIfCurrent, @@ -57,6 +59,168 @@ describe('add download metadata workflow', () => { expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']); }); + it('recognizes pure YouTube playlist URLs without changing video-plus-playlist behavior', () => { + expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist?list=PL123')).toBe(true); + expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist/?list=PL123')).toBe(true); + expect(isYouTubePlaylistUrl('https://music.youtube.com/playlist?list=PL123')).toBe(true); + expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=video&list=PL123')).toBe(false); + expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false); + }); + + it('keeps a playlist as one loading row until discovery succeeds', () => { + const rows = reconcileDownloadRows( + 'https://www.youtube.com/playlist?list=PL123', + [] + ); + + expect(rows[0]).toMatchObject({ + isMedia: true, + isPlaylist: true, + status: 'loading' + }); + }); + + it('expands playlist entries into independently identifiable media rows', () => { + const playlistUrl = 'https://www.youtube.com/playlist?list=PL123'; + const rows = reconcileDownloadRows( + playlistUrl, + [], + undefined, + new Set(), + undefined, + {}, + { [playlistUrl]: 4 }, + { + [playlistUrl]: { + title: 'Example playlist', + playlist_id: 'PL123', + entry_count: 2, + skipped_entries: 1, + truncated: false, + entries: [ + { id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }, + { id: 'one-duplicate', url: 'https://www.youtube.com/watch?v=one', title: 'Duplicate', playlist_index: 2 }, + { id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 3 } + ] + } + } + ); + + expect(rows).toHaveLength(2); + expect(rows.map(item => item.sourceUrl)).toEqual([ + 'https://www.youtube.com/watch?v=one', + 'https://www.youtube.com/watch?v=two' + ]); + expect(rows[0]).toMatchObject({ + file: '001 - First', + isMedia: true, + playlistSourceUrl: playlistUrl, + playlistTitle: 'Example playlist', + playlistIndex: 1, + playlistCount: 2, + requestContextVersion: 4, + status: 'loading' + }); + expect(rows[1].file).toBe('003 - Second'); + expect(rows.every(item => !item.isPlaylist)).toBe(true); + }); + + it('uses a stable three-digit playlist prefix and widens it for four-digit lists', () => { + expect(playlistFilePrefix(1, 12)).toBe('001 - '); + expect(playlistFilePrefix(12, 12)).toBe('012 - '); + expect(playlistFilePrefix(1000, 1000)).toBe('1000 - '); + expect(playlistFilePrefix(undefined, 12)).toBe(''); + }); + + it('propagates a playlist selection to entries discovered after the user deselects it', () => { + const playlistUrl = 'https://www.youtube.com/playlist?list=PL123'; + const rows = reconcileDownloadRows( + playlistUrl, + [], + undefined, + new Set(), + undefined, + {}, + {}, + { + [playlistUrl]: { + title: 'Example playlist', + playlist_id: 'PL123', + entry_count: 1, + skipped_entries: 0, + truncated: false, + entries: [{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }] + } + }, + { [playlistUrl]: false } + ); + + expect(rows).toHaveLength(1); + expect(rows[0].selected).toBe(false); + }); + + it('preserves entry-level selection when expanded rows are recreated', () => { + const playlistUrl = 'https://www.youtube.com/playlist?list=PL123'; + const expansion = { + [playlistUrl]: { + title: 'Example playlist', + playlist_id: 'PL123', + entry_count: 2, + skipped_entries: 0, + truncated: false, + entries: [ + { id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }, + { id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 2 } + ] + } + }; + + const rows = reconcileDownloadRows( + playlistUrl, + [], + undefined, + new Set(), + undefined, + {}, + {}, + expansion, + { + 'https://www.youtube.com/watch?v=one': false, + 'https://www.youtube.com/watch?v=two': true + } + ); + + expect(rows.map(item => item.selected)).toEqual([false, true]); + }); + + it('does not leave a loading playlist row when every entry is already present', () => { + const videoUrl = 'https://www.youtube.com/watch?v=one'; + const playlistUrl = 'https://www.youtube.com/playlist?list=PL123'; + const rows = reconcileDownloadRows( + `${videoUrl}\n${playlistUrl}`, + [], + undefined, + new Set(), + undefined, + {}, + {}, + { + [playlistUrl]: { + title: 'Example playlist', + playlist_id: 'PL123', + entry_count: 1, + skipped_entries: 0, + truncated: false, + entries: [{ id: 'one', url: videoUrl, title: 'First', playlist_index: 1 }] + } + } + ); + + expect(rows).toHaveLength(1); + expect(rows[0].sourceUrl).toBe(videoUrl); + expect(rows.some(item => item.isPlaylist)).toBe(false); + }); + it('forces explicit extension media fetches through media metadata for any http page', () => { const rows = reconcileDownloadRows( 'https://adult.example/watch/123', @@ -244,6 +408,25 @@ describe('add download metadata workflow', () => { expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false); }); + it('validates only selected rows and requires at least one selection', () => { + expect(canSubmitMetadataRows([ + row({ status: 'loading' }), + row({ id: 'skipped', status: 'invalid', selected: false }) + ])).toBe(false); + expect(canSubmitMetadataRows([ + row({ status: 'ready' }), + row({ id: 'skipped', status: 'invalid', selected: false }) + ])).toBe(true); + expect(canSubmitMetadataRows([ + row({ selected: false }), + row({ id: 'skipped', selected: false }) + ])).toBe(false); + expect(metadataSummaryMessage([ + row({ status: 'metadata-error', selected: false }), + row({ id: 'ready', status: 'ready' }) + ])).toContain('Ready to add 1 download'); + }); + it('keeps failed media routing without a format selector', () => { const failedMedia = row({ status: 'metadata-error', diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index 9f0793b..1fdf626 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -3,6 +3,7 @@ import { fileNameFromUrl, isMediaUrl } from './downloads'; +import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid'; @@ -31,6 +32,14 @@ export interface AddDownloadDraftRow { resumable?: boolean; formats?: AddMediaFormat[]; selectedFormat?: number; + isPlaylist?: boolean; + playlistSourceUrl?: string; + playlistTitle?: string; + playlistIndex?: number; + playlistCount?: number; + playlistEntryTitle?: string; + playlistError?: string; + selected?: boolean; } const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']); @@ -39,9 +48,45 @@ type ParsedInput = { identity: string; sourceUrl: string; valid: boolean; + isPlaylist?: boolean; + playlistSourceUrl?: string; + playlistTitle?: string; + playlistIndex?: number; + playlistCount?: number; + playlistEntryTitle?: string; + requestContextVersion?: number; + selected?: boolean; }; -const parseInputLines = (rawText: string): ParsedInput[] => { +export const isYouTubePlaylistUrl = (rawUrl: string): boolean => { + try { + const url = new URL(rawUrl); + const hostname = url.hostname.toLowerCase(); + const isYouTube = hostname === 'youtube.com' || hostname.endsWith('.youtube.com'); + const pathname = url.pathname.replace(/\/+$/, '') || '/'; + return isYouTube && pathname === '/playlist' && Boolean(url.searchParams.get('list')); + } catch { + return false; + } +}; + +export const playlistFilePrefix = ( + playlistIndex: number | undefined, + playlistCount: number | undefined +): string => { + if (!playlistIndex || playlistIndex < 1) return ''; + const width = Math.max(3, String(playlistCount || playlistIndex).length); + return `${String(playlistIndex).padStart(width, '0')} - `; +}; + +type PlaylistExpansions = Readonly>; + +const parseInputLines = ( + rawText: string, + playlistExpansions: PlaylistExpansions, + requestContextVersions: Readonly>, + selectedBySourceUrl: Readonly> +): ParsedInput[] => { const seen = new Set(); const parsed: ParsedInput[] = []; @@ -62,7 +107,50 @@ const parseInputLines = (rawText: string): ParsedInput[] => { const identity = valid ? sourceUrl : `invalid:${line}`; if (seen.has(identity)) continue; seen.add(identity); - parsed.push({ identity, sourceUrl, valid }); + + if (valid && isYouTubePlaylistUrl(sourceUrl)) { + const expansion = playlistExpansions[sourceUrl]; + if (expansion) { + const playlistSelected = selectedBySourceUrl[sourceUrl] !== false; + for (const [position, entry] of expansion.entries.entries()) { + let entryUrl: string; + try { + const parsedEntryUrl = new URL(entry.url); + if (!ALLOWED_SCHEMES.has(parsedEntryUrl.protocol)) continue; + entryUrl = parsedEntryUrl.href; + } catch { + continue; + } + if (seen.has(entryUrl)) continue; + seen.add(entryUrl); + parsed.push({ + identity: entryUrl, + sourceUrl: entryUrl, + valid: true, + playlistSourceUrl: sourceUrl, + playlistTitle: expansion.title, + playlistIndex: entry.playlist_index || position + 1, + playlistCount: expansion.entry_count || expansion.entries.length, + playlistEntryTitle: entry.title, + requestContextVersion: requestContextVersions[sourceUrl], + selected: selectedBySourceUrl[entryUrl] ?? playlistSelected + }); + } + // The playlist has been successfully discovered even when every + // entry was already represented by another input row. Do not put the + // source playlist back into loading state in that case. + continue; + } + } + + parsed.push({ + identity, + sourceUrl, + valid, + isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl), + requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined, + selected: selectedBySourceUrl[sourceUrl] !== false + }); } return parsed; @@ -75,20 +163,29 @@ export const reconcileDownloadRows = ( forceMediaUrls: ReadonlySet = new Set(), createId: () => string = () => crypto.randomUUID(), requestFilenames: Readonly> = {}, - requestContextVersions: Readonly> = {} + requestContextVersions: Readonly> = {}, + playlistExpansions: PlaylistExpansions = {}, + selectedBySourceUrl: Readonly> = {} ): AddDownloadDraftRow[] => { - const inputs = parseInputLines(rawText); + const inputs = parseInputLines( + rawText, + playlistExpansions, + requestContextVersions, + selectedBySourceUrl + ); const existing = new Map(currentRows.map(row => [row.sourceUrl, row])); return inputs.map(input => { const preserved = existing.get(input.sourceUrl); if (preserved) { const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl); - const requestContextVersion = requestContextVersions[input.sourceUrl]; + const requestContextVersion = input.requestContextVersion; const contextChanged = requestContextVersion !== undefined && requestContextVersion !== preserved.requestContextVersion; if ((forcedMedia && !preserved.isMedia) || contextChanged) { - const requestedFilename = requestFilenames[input.sourceUrl]; + const requestedFilename = input.playlistSourceUrl + ? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}` + : requestFilenames[input.sourceUrl]; return { ...preserved, file: contextChanged @@ -99,13 +196,16 @@ export const reconcileDownloadRows = ( requestContextVersion, isMedia: preserved.isMedia || forcedMedia, formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats, - selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat + selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat, + playlistError: undefined }; } return preserved; } - const requestedFilename = requestFilenames[input.sourceUrl] + const requestedFilename = input.playlistSourceUrl + ? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}` + : requestFilenames[input.sourceUrl] || (inputs.length === 1 ? pendingFilename : undefined); const fallback = canonicalizeDownloadFileName( requestedFilename || fileNameFromUrl(input.sourceUrl) @@ -118,8 +218,20 @@ export const reconcileDownloadRows = ( file: fallback, status: input.valid ? 'loading' : 'invalid', generation: input.valid ? 1 : 0, - requestContextVersion: requestContextVersions[input.sourceUrl], - isMedia: input.valid && (forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl)) + requestContextVersion: input.requestContextVersion, + isMedia: input.valid && ( + Boolean(input.isPlaylist) + || Boolean(input.playlistSourceUrl) + || forceMediaUrls.has(input.sourceUrl) + || isMediaUrl(input.sourceUrl) + ), + isPlaylist: input.isPlaylist, + playlistSourceUrl: input.playlistSourceUrl, + playlistTitle: input.playlistTitle, + playlistIndex: input.playlistIndex, + playlistCount: input.playlistCount, + playlistEntryTitle: input.playlistEntryTitle, + selected: input.selected !== false }; }); }; @@ -177,12 +289,14 @@ export const refreshFailedMetadataRows = ( : row ); -export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => - rows.length > 0 - && rows.every(row => +export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => { + const selectedRows = rows.filter(row => row.selected !== false); + return selectedRows.length > 0 + && selectedRows.every(row => row.status === 'ready' || (!row.isMedia && row.status === 'metadata-error') ); +}; export const mediaFormatSelectorForRow = ( row: AddDownloadDraftRow @@ -228,19 +342,22 @@ export const mediaFileNameForSelectedFormat = ( 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; + const selectedRows = rows.filter(row => row.selected !== false); + if (selectedRows.length === 0) return 'Select at least one download.'; + + const invalid = selectedRows.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; + const loading = selectedRows.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 failedMedia = rows.filter(row => row.status === 'metadata-error' && row.isMedia).length; - const ready = rows.filter(row => row.status === 'ready').length; + const failed = selectedRows.filter(row => row.status === 'metadata-error').length; + const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length; + const ready = selectedRows.filter(row => row.status === 'ready').length; if (failedMedia > 0) { return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`; } diff --git a/src/utils/mediaMetadata.ts b/src/utils/mediaMetadata.ts index 50985f2..e5e13d2 100644 --- a/src/utils/mediaMetadata.ts +++ b/src/utils/mediaMetadata.ts @@ -1,5 +1,6 @@ import { invokeCommand as invoke } from '../ipc'; import type { MediaMetadata } from '../bindings/MediaMetadata'; +import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; type FetchMediaMetadataArgs = { url: string; @@ -13,6 +14,7 @@ type FetchMediaMetadataArgs = { }; const inFlightMediaMetadata = new Map>(); +const inFlightMediaPlaylists = new Map>(); const metadataKey = (args: FetchMediaMetadataArgs) => JSON.stringify([ @@ -38,3 +40,18 @@ export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise inFlightMediaMetadata.set(key, request); return request; }; + +export const fetchMediaPlaylistMetadataDeduped = ( + args: FetchMediaMetadataArgs +): Promise => { + const key = metadataKey(args); + const existing = inFlightMediaPlaylists.get(key); + if (existing) return existing; + + const request = invoke('fetch_media_playlist_metadata', args) + .finally(() => { + inFlightMediaPlaylists.delete(key); + }); + inFlightMediaPlaylists.set(key, request); + return request; +};