mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(downloads): support YouTube playlists
This commit is contained in:
+424
-37
@@ -278,6 +278,134 @@ pub struct MediaMetadata {
|
||||
pub formats: Vec<MediaFormat>,
|
||||
}
|
||||
|
||||
#[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<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, serde::Deserialize, Clone, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct MediaPlaylistMetadata {
|
||||
pub title: String,
|
||||
pub playlist_id: Option<String>,
|
||||
#[ts(type = "number | null")]
|
||||
pub entry_count: Option<u64>,
|
||||
#[ts(type = "number")]
|
||||
pub skipped_entries: u64,
|
||||
pub truncated: bool,
|
||||
pub entries: Vec<MediaPlaylistEntry>,
|
||||
}
|
||||
|
||||
const MEDIA_PLAYLIST_MAX_ENTRIES: usize = 1000;
|
||||
|
||||
fn parse_media_playlist_metadata(
|
||||
value: &serde_json::Value,
|
||||
max_entries: usize,
|
||||
) -> Result<MediaPlaylistMetadata, String> {
|
||||
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<i32>,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn shell_command_output_with_timeout(
|
||||
command: tauri_plugin_shell::process::Command,
|
||||
timeout: Duration,
|
||||
operation: &str,
|
||||
) -> Result<ShellCommandOutput, String> {
|
||||
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<tokio::sync::Mutex<HashMap<u64, (Instant, MediaMetadata)>>> =
|
||||
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<String, String> {
|
||||
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<String>,
|
||||
user_agent: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
headers: Option<String>,
|
||||
cookies: Option<String>,
|
||||
proxy: Option<String>,
|
||||
) -> Result<MediaPlaylistMetadata, String> {
|
||||
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. Do not edit this file manually.
|
||||
|
||||
export type MediaPlaylistEntry = { id: string, url: string, title: string, playlist_index: number | null, };
|
||||
@@ -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<MediaPlaylistEntry>, };
|
||||
@@ -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<number | null>(null);
|
||||
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
|
||||
const metadataRequestsRef = useRef(new Set<string>());
|
||||
const playlistRequestsRef = useRef(new Set<string>());
|
||||
const latestPlaylistRequestRef = useRef(new Map<string, string>());
|
||||
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
|
||||
|
||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
|
||||
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<number, string> = {}
|
||||
) => {
|
||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...parsedItems];
|
||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = 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 = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<p key={sourceUrl} className="px-1 text-[11px] text-purple-500 dark:text-purple-400">
|
||||
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` : ''}.
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
<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, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
|
||||
{selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -969,14 +1088,22 @@ export const AddDownloadsModal = () => {
|
||||
>
|
||||
<RefreshCw size={12} /> Refresh Metadata
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllRows}
|
||||
disabled={parsedItems.length === 0}
|
||||
className="add-download-link-button ml-3 text-[11px] font-medium"
|
||||
>
|
||||
{allRowsSelected ? 'Clear selection' : 'Select all'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<SummaryBox title="Files" value={parsedItems.length} icon={FileText} color="text-blue-500" />
|
||||
<SummaryBox title="Files" value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
|
||||
<SummaryBox title="Required" value={requiredStr} icon={Database} color="text-orange-500" />
|
||||
<SummaryBox title="Free" value={freeSpace} icon={HardDrive} color="text-green-500" />
|
||||
<SummaryBox title="Unknown" value={parsedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
|
||||
<SummaryBox title="Unknown" value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
|
||||
@@ -1014,18 +1141,26 @@ export const AddDownloadsModal = () => {
|
||||
? 'is-selected border'
|
||||
: 'border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.selected !== false}
|
||||
onChange={() => toggleRowSelection(i)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
aria-label={`Select ${item.file}`}
|
||||
className="mr-2 shrink-0 accent-purple-500"
|
||||
/>
|
||||
<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 === '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...
|
||||
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? 'Fetching playlist...' : 'Fetching...'}
|
||||
</div>
|
||||
) : (
|
||||
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 = () => {
|
||||
</div>
|
||||
<div className="add-download-section-title flex items-center gap-2 mb-3 relative z-10">
|
||||
<Video size={16} className="text-purple-500" /> Media Format
|
||||
{parsedItems[selectedItemIndex].playlistSourceUrl && (
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
Playlist item {parsedItems[selectedItemIndex].playlistIndex || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsedItems[selectedItemIndex].status === 'loading' ? (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Record<string, MediaPlaylistMetadata>>;
|
||||
|
||||
const parseInputLines = (
|
||||
rawText: string,
|
||||
playlistExpansions: PlaylistExpansions,
|
||||
requestContextVersions: Readonly<Record<string, number>>,
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>>
|
||||
): ParsedInput[] => {
|
||||
const seen = new Set<string>();
|
||||
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<string> = new Set(),
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
requestFilenames: Readonly<Record<string, string>> = {},
|
||||
requestContextVersions: Readonly<Record<string, number>> = {}
|
||||
requestContextVersions: Readonly<Record<string, number>> = {},
|
||||
playlistExpansions: PlaylistExpansions = {},
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>> = {}
|
||||
): 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.`;
|
||||
}
|
||||
|
||||
@@ -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<string, Promise<MediaMetadata>>();
|
||||
const inFlightMediaPlaylists = new Map<string, Promise<MediaPlaylistMetadata>>();
|
||||
|
||||
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<MediaPlaylistMetadata> => {
|
||||
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user