mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-29 03:57:11 +00:00
fix: address post-audit regressions across queue, db, and ui
- Preserved extension-captured cookies through the Add modal, with a clean fallback when captured cookies break metadata fetching. - Prevented batched extension captures from losing URLs or reusing stale cookie/header contexts. - Fixed pause/resume and enqueue generation races, including cancellation during queue reservation and replay after task removal. - Made startup database initialization safe under React StrictMode. - Serialized keyring operations and corrected Linux legacy migration/deletion behavior. - Restored `Downloading` state after yt-dlp retries. - Replaced hardcoded media heights with dynamically detected formats, including nonstandard qualities such as 576p and 2880p.
This commit is contained in:
+76
-6
@@ -12,6 +12,7 @@ const CURRENT_SCHEMA_VERSION: i64 = 1;
|
|||||||
const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
|
const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
|
||||||
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
||||||
const KEYCHAIN_SERVICE: &str = "com.firelink.app";
|
const KEYCHAIN_SERVICE: &str = "com.firelink.app";
|
||||||
|
static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
pub struct DbState {
|
pub struct DbState {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
@@ -888,16 +889,16 @@ fn ensure_keyring_store() -> Result<(), String> {
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let store = windows_native_keyring_store::Store::new()
|
let store =
|
||||||
.map_err(|error| error.to_string())?;
|
windows_native_keyring_store::Store::new().map_err(|error| error.to_string())?;
|
||||||
keyring_core::set_default_store(store);
|
keyring_core::set_default_store(store);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
let store = zbus_secret_service_keyring_store::Store::new()
|
let store =
|
||||||
.map_err(|error| error.to_string())?;
|
zbus_secret_service_keyring_store::Store::new().map_err(|error| error.to_string())?;
|
||||||
keyring_core::set_default_store(store);
|
keyring_core::set_default_store(store);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -934,29 +935,98 @@ fn keychain_entry(id: &str) -> Result<keyring_core::Entry, String> {
|
|||||||
keychain_entry_with_target(id, None)
|
keychain_entry_with_target(id, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lock_keyring_operations() -> Result<std::sync::MutexGuard<'static, ()>, String> {
|
||||||
|
KEYRING_OPERATION_LOCK
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "keyring operation lock is unavailable".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn legacy_linux_keychain_entries(id: &str) -> Result<Vec<keyring_core::Entry>, String> {
|
||||||
|
ensure_keyring_store()?;
|
||||||
|
let entries = keyring_core::Entry::search(&std::collections::HashMap::from([
|
||||||
|
("service", KEYCHAIN_SERVICE),
|
||||||
|
("username", id),
|
||||||
|
]))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let mut legacy = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
let attributes = entry.get_attributes().map_err(|error| error.to_string())?;
|
||||||
|
if !attributes.contains_key("target") {
|
||||||
|
legacy.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn unique_legacy_linux_keychain_entry(id: &str) -> Result<Option<keyring_core::Entry>, String> {
|
||||||
|
let mut entries = legacy_linux_keychain_entries(id)?;
|
||||||
|
match entries.len() {
|
||||||
|
0 => Ok(None),
|
||||||
|
1 => Ok(entries.pop()),
|
||||||
|
count => Err(format!(
|
||||||
|
"Entry is matched by {count} legacy Linux credentials"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
|
pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
|
||||||
|
let _guard = lock_keyring_operations()?;
|
||||||
let entry = keychain_entry(id)?;
|
let entry = keychain_entry(id)?;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Err(error) = entry.get_credential() {
|
||||||
|
match error {
|
||||||
|
keyring_core::Error::NoEntry => {
|
||||||
|
if let Some(legacy) = unique_legacy_linux_keychain_entry(id)? {
|
||||||
|
return legacy
|
||||||
|
.set_password(password)
|
||||||
|
.map_err(|error| error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error => return Err(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entry
|
entry
|
||||||
.set_password(password)
|
.set_password(password)
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_keychain_password(id: &str) -> Result<String, String> {
|
pub fn get_keychain_password(id: &str) -> Result<String, String> {
|
||||||
|
let _guard = lock_keyring_operations()?;
|
||||||
let entry = keychain_entry(id)?;
|
let entry = keychain_entry(id)?;
|
||||||
match entry.get_password() {
|
match entry.get_password() {
|
||||||
Ok(password) => Ok(password),
|
Ok(password) => Ok(password),
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Err(_) => keychain_entry_with_target(id, None)?
|
Err(keyring_core::Error::NoEntry) => unique_legacy_linux_keychain_entry(id)?
|
||||||
|
.ok_or_else(|| keyring_core::Error::NoEntry.to_string())?
|
||||||
.get_password()
|
.get_password()
|
||||||
.map_err(|error| error.to_string()),
|
.map_err(|error| error.to_string()),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
Err(error) => Err(error.to_string()),
|
Err(error) => Err(error.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_keychain_password(id: &str) -> Result<(), String> {
|
pub fn delete_keychain_password(id: &str) -> Result<(), String> {
|
||||||
|
let _guard = lock_keyring_operations()?;
|
||||||
let entry = keychain_entry(id)?;
|
let entry = keychain_entry(id)?;
|
||||||
let _ = entry.delete_credential();
|
match entry.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||||
|
Err(error) => return Err(error.to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
for legacy in legacy_linux_keychain_entries(id)? {
|
||||||
|
match legacy.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||||
|
Err(error) => return Err(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -315,13 +315,10 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
|||||||
silent: payload.silent,
|
silent: payload.silent,
|
||||||
filename,
|
filename,
|
||||||
headers: payload.headers.filter(|value| !value.trim().is_empty()),
|
headers: payload.headers.filter(|value| !value.trim().is_empty()),
|
||||||
// A full browser Cookie header can exceed upstream request-header
|
// Keep the exact tab/container cookie context. The Add modal may retry
|
||||||
// limits. Media uses yt-dlp's configured browser-cookie source
|
// public media without this header when an upstream rejects its size,
|
||||||
// instead; regular captured downloads retain their exact cookies.
|
// but authenticated and container-scoped media must not lose it here.
|
||||||
cookies: (!payload.media)
|
cookies: payload.cookies.filter(|value| !value.trim().is_empty()),
|
||||||
.then_some(payload.cookies)
|
|
||||||
.flatten()
|
|
||||||
.filter(|value| !value.trim().is_empty()),
|
|
||||||
media: payload.media,
|
media: payload.media,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -499,7 +496,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_media_drops_the_extension_cookie_header() {
|
fn explicit_media_preserves_the_extension_cookie_header() {
|
||||||
let download = normalize_download(ExtensionRequest {
|
let download = normalize_download(ExtensionRequest {
|
||||||
urls: vec!["https://www.youtube.com/watch?v=example".to_string()],
|
urls: vec!["https://www.youtube.com/watch?v=example".to_string()],
|
||||||
referer: None,
|
referer: None,
|
||||||
@@ -512,7 +509,10 @@ mod tests {
|
|||||||
.expect("valid media handoff");
|
.expect("valid media handoff");
|
||||||
|
|
||||||
assert!(download.media);
|
assert!(download.media);
|
||||||
assert!(download.cookies.is_none());
|
assert_eq!(
|
||||||
|
download.cookies.as_deref(),
|
||||||
|
Some("large=browser-cookie-header")
|
||||||
|
);
|
||||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+165
-58
@@ -3,7 +3,7 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
@@ -312,26 +312,43 @@ fn is_excluded_yt_dlp_format(value: &serde_json::Value) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn format_height(value: &serde_json::Value) -> Option<u64> {
|
fn format_height(value: &serde_json::Value) -> Option<u64> {
|
||||||
if let Some(height) = json_u64(value, "height") {
|
if let Some(height) = json_u64(value, "height").filter(|height| *height > 0) {
|
||||||
return Some(height);
|
return Some(height);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(resolution) = json_str(value, "resolution") {
|
if let Some(resolution) = json_str(value, "resolution") {
|
||||||
if let Some((_, height)) = resolution.split_once('x') {
|
if let Some((_, height)) = resolution
|
||||||
if let Ok(parsed) = height.parse::<u64>() {
|
.split_once('x')
|
||||||
return Some(parsed);
|
.or_else(|| resolution.split_once('X'))
|
||||||
|
{
|
||||||
|
if let Ok(parsed) = height.trim().parse::<u64>() {
|
||||||
|
if parsed > 0 {
|
||||||
|
return Some(parsed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let note = json_lower(value, "format_note");
|
let note = json_lower(value, "format_note");
|
||||||
for height in [4320_u64, 2160, 1440, 1080, 720, 480, 360, 240, 144] {
|
let bytes = note.as_bytes();
|
||||||
if note.contains(&format!("{height}p")) {
|
let mut heights = Vec::new();
|
||||||
return Some(height);
|
for (index, byte) in bytes.iter().enumerate() {
|
||||||
|
if *byte != b'p' || index == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut start = index;
|
||||||
|
while start > 0 && bytes[start - 1].is_ascii_digit() {
|
||||||
|
start -= 1;
|
||||||
|
}
|
||||||
|
if start < index {
|
||||||
|
if let Ok(height) = note[start..index].parse::<u64>() {
|
||||||
|
if height > 0 {
|
||||||
|
heights.push(height);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
heights.into_iter().max()
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matches_media_height(value: &serde_json::Value, target: u64) -> bool {
|
fn matches_media_height(value: &serde_json::Value, target: u64) -> bool {
|
||||||
@@ -339,11 +356,6 @@ fn matches_media_height(value: &serde_json::Value, target: u64) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let note = json_lower(value, "format_note");
|
|
||||||
if note.contains(&format!("{target}p")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
format_height(value) == Some(target)
|
format_height(value) == Some(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,13 +548,13 @@ fn build_media_format_options(
|
|||||||
let mut options = Vec::new();
|
let mut options = Vec::new();
|
||||||
|
|
||||||
if has_video {
|
if has_video {
|
||||||
let available_heights: Vec<u64> = [4320_u64, 2160, 1440, 1080, 720, 480, 360, 240, 144]
|
let available_heights: Vec<u64> = clean_formats
|
||||||
|
.iter()
|
||||||
|
.filter(|format| has_video_stream(format))
|
||||||
|
.filter_map(|format| format_height(format))
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|height| {
|
.rev()
|
||||||
clean_formats
|
|
||||||
.iter()
|
|
||||||
.any(|format| matches_media_height(format, *height))
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for height in available_heights {
|
for height in available_heights {
|
||||||
@@ -2882,11 +2894,11 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
|
|
||||||
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
|
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
|
||||||
let mut strike = 0_usize;
|
let mut strike = 0_usize;
|
||||||
let mut processing_started = false;
|
|
||||||
let mut effective_cookie_source = cookie_source.clone();
|
let mut effective_cookie_source = cookie_source.clone();
|
||||||
let mut browser_cookie_fallback_used = false;
|
let mut browser_cookie_fallback_used = false;
|
||||||
|
|
||||||
while strike <= max_retries {
|
while strike <= max_retries {
|
||||||
|
let mut processing_started = false;
|
||||||
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
|
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
|
||||||
let mut cmd = app_handle.shell().command(&ytdlp_path);
|
let mut cmd = app_handle.shell().command(&ytdlp_path);
|
||||||
for arg in media_progress_args() {
|
for arg in media_progress_args() {
|
||||||
@@ -2986,6 +2998,16 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
let (mut rx, child) = cmd
|
let (mut rx, child) = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?;
|
.map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?;
|
||||||
|
if strike > 0 {
|
||||||
|
// The backoff path emits `Retrying`. Restore the live transfer
|
||||||
|
// state when the replacement process actually starts so React
|
||||||
|
// accepts progress from this attempt instead of staying stuck.
|
||||||
|
progress_state.speed_sampler.reset();
|
||||||
|
let _ = app_handle.emit(
|
||||||
|
"download-state",
|
||||||
|
DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Downloading),
|
||||||
|
);
|
||||||
|
}
|
||||||
log::info!("yt-dlp spawned for id: {} (strike {})", id, strike);
|
log::info!("yt-dlp spawned for id: {} (strike {})", id, strike);
|
||||||
|
|
||||||
let mut stderr_tail = String::new();
|
let mut stderr_tail = String::new();
|
||||||
@@ -3101,7 +3123,6 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !processing_started && is_media_processing_line(&line) {
|
if !processing_started && is_media_processing_line(&line) {
|
||||||
processing_started = true;
|
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
DownloadStateEvent::new(
|
DownloadStateEvent::new(
|
||||||
@@ -3946,6 +3967,18 @@ async fn get_pending_order(
|
|||||||
Ok(state.queue_manager.pending_order(queue_id.as_deref()).await)
|
Ok(state.queue_manager.pending_order(queue_id.as_deref()).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn enqueue_lifecycle_generation(item: &queue::EnqueueItem) -> Result<u64, String> {
|
||||||
|
item.lifecycle_generation
|
||||||
|
.as_deref()
|
||||||
|
.map(|generation| {
|
||||||
|
generation
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| "Invalid enqueue lifecycle generation".to_string())
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.map(|generation| generation.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn enqueue_download(
|
async fn enqueue_download(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -3955,30 +3988,35 @@ async fn enqueue_download(
|
|||||||
let id = item.id.clone();
|
let id = item.id.clone();
|
||||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||||
let accepted_filename = item.filename.clone();
|
let accepted_filename = item.filename.clone();
|
||||||
crate::download_ownership::register_expected(
|
let lifecycle_generation = enqueue_lifecycle_generation(&item).map_err(AppError::Internal)?;
|
||||||
|
let previous_generation = state
|
||||||
|
.queue_manager
|
||||||
|
.reserve_enqueue_generation(&id, lifecycle_generation)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
if let Err(error) = crate::download_ownership::register_expected(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&item.id,
|
&item.id,
|
||||||
&item.destination,
|
&item.destination,
|
||||||
&item.filename,
|
&item.filename,
|
||||||
)?;
|
) {
|
||||||
let lifecycle_generation = item
|
state
|
||||||
.lifecycle_generation
|
.queue_manager
|
||||||
.as_deref()
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
.map(|generation| {
|
.await;
|
||||||
generation
|
return Err(AppError::Internal(error));
|
||||||
.parse::<u64>()
|
}
|
||||||
.map_err(|_| AppError::Internal("Invalid enqueue lifecycle generation".to_string()))
|
if let Err(error) = state
|
||||||
})
|
|
||||||
.transpose()?
|
|
||||||
.unwrap_or_default();
|
|
||||||
if let Err(e) = state
|
|
||||||
.queue_manager
|
.queue_manager
|
||||||
.push_with_generation(item.into_task(), lifecycle_generation)
|
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let _ = crate::download_ownership::remove(&app_handle, &id);
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
state.queue_manager.release_registered_id(&id).await;
|
state
|
||||||
return Err(AppError::Internal(e));
|
.queue_manager
|
||||||
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
Ok(crate::ipc::EnqueueAccepted {
|
Ok(crate::ipc::EnqueueAccepted {
|
||||||
id,
|
id,
|
||||||
@@ -4006,30 +4044,83 @@ async fn cancel_enqueue_generation(
|
|||||||
async fn enqueue_many(
|
async fn enqueue_many(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
mut items: Vec<queue::EnqueueItem>,
|
items: Vec<queue::EnqueueItem>,
|
||||||
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
|
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
|
||||||
for item in &mut items {
|
let mut results = Vec::with_capacity(items.len());
|
||||||
|
for mut item in items {
|
||||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||||
}
|
let id = item.id.clone();
|
||||||
for item in &items {
|
let filename = item.filename.clone();
|
||||||
crate::download_ownership::register_expected(
|
let lifecycle_generation = match enqueue_lifecycle_generation(&item) {
|
||||||
|
Ok(generation) => generation,
|
||||||
|
Err(error) => {
|
||||||
|
results.push(crate::ipc::EnqueueResult {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
filename: None,
|
||||||
|
error: Some(error),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let previous_generation = match state
|
||||||
|
.queue_manager
|
||||||
|
.reserve_enqueue_generation(&id, lifecycle_generation)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(previous) => previous,
|
||||||
|
Err(error) => {
|
||||||
|
results.push(crate::ipc::EnqueueResult {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
filename: None,
|
||||||
|
error: Some(error),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = crate::download_ownership::register_expected(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&item.id,
|
&item.id,
|
||||||
&item.destination,
|
&item.destination,
|
||||||
&item.filename,
|
&item.filename,
|
||||||
)?;
|
) {
|
||||||
}
|
state
|
||||||
let tasks = items
|
.queue_manager
|
||||||
.into_iter()
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
.map(queue::EnqueueItem::into_task)
|
.await;
|
||||||
.collect();
|
results.push(crate::ipc::EnqueueResult {
|
||||||
let results = state.queue_manager.enqueue_many(tasks).await;
|
id,
|
||||||
|
success: false,
|
||||||
for result in &results {
|
filename: None,
|
||||||
if !result.success {
|
error: Some(error),
|
||||||
let _ = crate::download_ownership::remove(&app_handle, &result.id);
|
});
|
||||||
state.queue_manager.release_registered_id(&result.id).await;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Err(error) = state
|
||||||
|
.queue_manager
|
||||||
|
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
results.push(crate::ipc::EnqueueResult {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
filename: None,
|
||||||
|
error: Some(error),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push(crate::ipc::EnqueueResult {
|
||||||
|
id,
|
||||||
|
success: true,
|
||||||
|
filename: Some(filename),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
@@ -5048,7 +5139,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keeps_low_and_ultra_high_available_video_qualities() {
|
fn keeps_every_available_video_height_including_nonstandard_qualities() {
|
||||||
let formats = vec![
|
let formats = vec![
|
||||||
json!({
|
json!({
|
||||||
"format_id": "401",
|
"format_id": "401",
|
||||||
@@ -5063,12 +5154,28 @@ mod tests {
|
|||||||
"height": 144,
|
"height": 144,
|
||||||
"vcodec": "avc1.42E01E",
|
"vcodec": "avc1.42E01E",
|
||||||
"acodec": "mp4a.40.2"
|
"acodec": "mp4a.40.2"
|
||||||
})
|
}),
|
||||||
|
json!({
|
||||||
|
"format_id": "five-k",
|
||||||
|
"ext": "webm",
|
||||||
|
"resolution": "5120X2880",
|
||||||
|
"vcodec": "av01.0.17M.08",
|
||||||
|
"acodec": "none"
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"format_id": "pal",
|
||||||
|
"ext": "mp4",
|
||||||
|
"format_note": "Premium 576p",
|
||||||
|
"vcodec": "avc1.4d401f",
|
||||||
|
"acodec": "none"
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
let options = build_media_format_options(&formats, Some(60.0));
|
let options = build_media_format_options(&formats, Some(60.0));
|
||||||
|
|
||||||
assert!(options.iter().any(|format| format.resolution == "4320p"));
|
assert!(options.iter().any(|format| format.resolution == "4320p"));
|
||||||
|
assert!(options.iter().any(|format| format.resolution == "2880p"));
|
||||||
|
assert!(options.iter().any(|format| format.resolution == "576p"));
|
||||||
assert!(options.iter().any(|format| format.resolution == "144p"));
|
assert!(options.iter().any(|format| format.resolution == "144p"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+81
-62
@@ -85,6 +85,7 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
|||||||
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||||
registered_ids: Mutex<HashSet<String>>,
|
registered_ids: Mutex<HashSet<String>>,
|
||||||
enqueue_cancellations: Mutex<HashMap<String, u64>>,
|
enqueue_cancellations: Mutex<HashMap<String, u64>>,
|
||||||
|
enqueue_generations: Mutex<HashMap<String, u64>>,
|
||||||
pending: Mutex<VecDeque<QueuedTask>>,
|
pending: Mutex<VecDeque<QueuedTask>>,
|
||||||
semaphore: Arc<Semaphore>,
|
semaphore: Arc<Semaphore>,
|
||||||
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
|
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
|
||||||
@@ -136,6 +137,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
Self {
|
Self {
|
||||||
registered_ids: Mutex::new(HashSet::new()),
|
registered_ids: Mutex::new(HashSet::new()),
|
||||||
enqueue_cancellations: Mutex::new(HashMap::new()),
|
enqueue_cancellations: Mutex::new(HashMap::new()),
|
||||||
|
enqueue_generations: Mutex::new(HashMap::new()),
|
||||||
pending: Mutex::new(VecDeque::new()),
|
pending: Mutex::new(VecDeque::new()),
|
||||||
semaphore: Arc::new(Semaphore::new(capacity)),
|
semaphore: Arc::new(Semaphore::new(capacity)),
|
||||||
active_permits: Mutex::new(HashMap::new()),
|
active_permits: Mutex::new(HashMap::new()),
|
||||||
@@ -184,32 +186,99 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.or_insert(generation);
|
.or_insert(generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically checks the cancellation watermark before registering a task.
|
/// Atomically reserve an ID after rejecting cancelled or replayed generations.
|
||||||
pub async fn push_with_generation(
|
/// The returned watermark must be passed to `rollback_enqueue_reservation`
|
||||||
|
/// if ownership registration fails before the task is committed.
|
||||||
|
pub async fn reserve_enqueue_generation(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<Option<u64>, String> {
|
||||||
|
let cancellations = self.enqueue_cancellations.lock().await;
|
||||||
|
if cancellations
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|cancelled| *cancelled >= generation)
|
||||||
|
{
|
||||||
|
return Err("Download enqueue was superseded by a newer user action".to_string());
|
||||||
|
}
|
||||||
|
let mut generations = self.enqueue_generations.lock().await;
|
||||||
|
let previous_generation = generations.get(id).copied();
|
||||||
|
if previous_generation.is_some_and(|seen| seen >= generation) {
|
||||||
|
return Err("Download enqueue was superseded by a newer user action".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut registered = self.registered_ids.lock().await;
|
||||||
|
if registered.contains(id) {
|
||||||
|
return Err("Duplicate task".to_string());
|
||||||
|
}
|
||||||
|
registered.insert(id.to_string());
|
||||||
|
generations.insert(id.to_string(), generation);
|
||||||
|
Ok(previous_generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rollback_enqueue_reservation(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
generation: u64,
|
||||||
|
previous_generation: Option<u64>,
|
||||||
|
) {
|
||||||
|
let mut generations = self.enqueue_generations.lock().await;
|
||||||
|
let mut registered = self.registered_ids.lock().await;
|
||||||
|
if generations.get(id).copied() != Some(generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registered.remove(id);
|
||||||
|
match previous_generation {
|
||||||
|
Some(previous) => {
|
||||||
|
generations.insert(id.to_string(), previous);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
generations.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit_reserved_enqueue(
|
||||||
&self,
|
&self,
|
||||||
task: QueuedTask,
|
task: QueuedTask,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let id = task.id.clone();
|
let id = task.id.clone();
|
||||||
let cancellations = self.enqueue_cancellations.lock().await;
|
let cancellations = self.enqueue_cancellations.lock().await;
|
||||||
if cancellations.get(&id).is_some_and(|cancelled| *cancelled >= generation) {
|
if cancellations
|
||||||
|
.get(&id)
|
||||||
|
.is_some_and(|cancelled| *cancelled >= generation)
|
||||||
|
{
|
||||||
return Err("Download enqueue was superseded by a newer user action".to_string());
|
return Err("Download enqueue was superseded by a newer user action".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut registered = self.registered_ids.lock().await;
|
|
||||||
if registered.contains(&id) {
|
|
||||||
return Err("Duplicate task".to_string());
|
|
||||||
}
|
|
||||||
registered.insert(id.clone());
|
|
||||||
drop(registered);
|
|
||||||
drop(cancellations);
|
|
||||||
|
|
||||||
self.pending.lock().await.push_back(task);
|
self.pending.lock().await.push_back(task);
|
||||||
self.emit_state(id, DownloadStatus::Queued);
|
self.emit_state(id, DownloadStatus::Queued);
|
||||||
self.notify.notify_one();
|
self.notify.notify_one();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically checks the generation watermark before registering a task.
|
||||||
|
pub async fn push_with_generation(
|
||||||
|
&self,
|
||||||
|
task: QueuedTask,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let id = task.id.clone();
|
||||||
|
let previous_generation = self.reserve_enqueue_generation(&id, generation).await?;
|
||||||
|
if let Err(error) = self.commit_reserved_enqueue(task, generation).await {
|
||||||
|
self.rollback_enqueue_reservation(&id, generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueue a task without a frontend lifecycle token. This is retained for
|
||||||
|
/// internal/test callers and still gets replay protection at generation 0.
|
||||||
|
pub async fn push(&self, task: QueuedTask) -> Result<(), String> {
|
||||||
|
self.push_with_generation(task, 0).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn next_aria2_control_epoch(&self, id: &str) -> u64 {
|
pub async fn next_aria2_control_epoch(&self, id: &str) -> u64 {
|
||||||
let mut epochs = self.aria2_control_epochs.lock().await;
|
let mut epochs = self.aria2_control_epochs.lock().await;
|
||||||
let epoch = epochs.get(id).copied().unwrap_or_default().wrapping_add(1);
|
let epoch = epochs.get(id).copied().unwrap_or_default().wrapping_add(1);
|
||||||
@@ -235,22 +304,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
self.aria2_retry_strikes.lock().await.contains_key(id)
|
self.aria2_retry_strikes.lock().await.contains_key(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueue a task. Checks the centralized `registered_ids` for deduplication.
|
|
||||||
pub async fn push(&self, task: QueuedTask) -> Result<(), String> {
|
|
||||||
let id = task.id.clone();
|
|
||||||
let mut registered = self.registered_ids.lock().await;
|
|
||||||
if registered.contains(&id) {
|
|
||||||
return Err("Duplicate task".to_string());
|
|
||||||
}
|
|
||||||
registered.insert(id.clone());
|
|
||||||
drop(registered);
|
|
||||||
|
|
||||||
self.pending.lock().await.push_back(task);
|
|
||||||
self.emit_state(id, DownloadStatus::Queued);
|
|
||||||
self.notify.notify_one();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pop the next task, or None if empty.
|
/// Pop the next task, or None if empty.
|
||||||
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
||||||
self.pending.lock().await.pop_front()
|
self.pending.lock().await.pop_front()
|
||||||
@@ -878,40 +931,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}
|
}
|
||||||
removed
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bulk enqueue by appending tasks. Used by startup and start-all.
|
|
||||||
pub async fn enqueue_many(&self, tasks: Vec<QueuedTask>) -> Vec<crate::ipc::EnqueueResult> {
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let mut registered = self.registered_ids.lock().await;
|
|
||||||
let mut pending = self.pending.lock().await;
|
|
||||||
|
|
||||||
for task in tasks {
|
|
||||||
let id = task.id.clone();
|
|
||||||
let filename = task.payload.filename.clone();
|
|
||||||
if registered.contains(&id) {
|
|
||||||
results.push(crate::ipc::EnqueueResult {
|
|
||||||
id: id.clone(),
|
|
||||||
success: false,
|
|
||||||
filename: None,
|
|
||||||
error: Some("Duplicate task".to_string()),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
registered.insert(id.clone());
|
|
||||||
pending.push_back(task);
|
|
||||||
self.emit_state(id.clone(), DownloadStatus::Queued);
|
|
||||||
results.push(crate::ipc::EnqueueResult {
|
|
||||||
id,
|
|
||||||
success: true,
|
|
||||||
filename: Some(filename),
|
|
||||||
error: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
drop(pending);
|
|
||||||
drop(registered);
|
|
||||||
self.notify.notify_one();
|
|
||||||
results
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ async fn cancelled_enqueue_generation_cannot_register_after_a_newer_user_action(
|
|||||||
mgr.cancel_enqueue_generation("a", 4).await;
|
mgr.cancel_enqueue_generation("a", 4).await;
|
||||||
|
|
||||||
let stale = mgr.push_with_generation(sample_task("a"), 4).await;
|
let stale = mgr.push_with_generation(sample_task("a"), 4).await;
|
||||||
assert!(stale.is_err(), "cancelled generation must not enter the queue");
|
assert!(
|
||||||
|
stale.is_err(),
|
||||||
|
"cancelled generation must not enter the queue"
|
||||||
|
);
|
||||||
assert!(!mgr.is_registered("a").await);
|
assert!(!mgr.is_registered("a").await);
|
||||||
|
|
||||||
mgr.push_with_generation(sample_task("a"), 5)
|
mgr.push_with_generation(sample_task("a"), 5)
|
||||||
@@ -116,6 +119,42 @@ async fn cancelled_enqueue_generation_cannot_register_after_a_newer_user_action(
|
|||||||
assert_eq!(mgr.pending_order(None).await, vec!["a".to_string()]);
|
assert_eq!(mgr.pending_order(None).await, vec!["a".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancellation_between_reservation_and_commit_cannot_start_the_task() {
|
||||||
|
let (mgr, _spawner) = make_manager(2);
|
||||||
|
let previous = mgr
|
||||||
|
.reserve_enqueue_generation("a", 7)
|
||||||
|
.await
|
||||||
|
.expect("reservation should succeed");
|
||||||
|
mgr.cancel_enqueue_generation("a", 7).await;
|
||||||
|
|
||||||
|
let committed = mgr.commit_reserved_enqueue(sample_task("a"), 7).await;
|
||||||
|
assert!(committed.is_err(), "cancelled reservation must not commit");
|
||||||
|
mgr.rollback_enqueue_reservation("a", 7, previous).await;
|
||||||
|
|
||||||
|
assert!(!mgr.is_registered("a").await);
|
||||||
|
assert!(mgr.pending_order(None).await.is_empty());
|
||||||
|
assert!(mgr.push_with_generation(sample_task("a"), 7).await.is_err());
|
||||||
|
mgr.push_with_generation(sample_task("a"), 8)
|
||||||
|
.await
|
||||||
|
.expect("a newer generation should remain startable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn accepted_generation_cannot_be_replayed_after_registry_release() {
|
||||||
|
let (mgr, _spawner) = make_manager(2);
|
||||||
|
mgr.push_with_generation(sample_task("a"), 3)
|
||||||
|
.await
|
||||||
|
.expect("first enqueue should succeed");
|
||||||
|
assert!(mgr.remove_from_pending("a").await);
|
||||||
|
mgr.release_registered_id("a").await;
|
||||||
|
|
||||||
|
assert!(mgr.push_with_generation(sample_task("a"), 3).await.is_err());
|
||||||
|
mgr.push_with_generation(sample_task("a"), 4)
|
||||||
|
.await
|
||||||
|
.expect("only a newer lifecycle may reuse the id");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn release_permit_is_idempotent() {
|
async fn release_permit_is_idempotent() {
|
||||||
let (mgr, _spawner) = make_manager(2);
|
let (mgr, _spawner) = make_manager(2);
|
||||||
|
|||||||
+18
-3
@@ -37,6 +37,20 @@ const waitForSettingsHydration = (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let downloadStateInitialization: Promise<void> | null = null;
|
||||||
|
const initializeDownloadState = (): Promise<void> => {
|
||||||
|
if (!downloadStateInitialization) {
|
||||||
|
downloadStateInitialization = (async () => {
|
||||||
|
await waitForSettingsHydration();
|
||||||
|
await useDownloadStore.getState().initDB();
|
||||||
|
})().catch(error => {
|
||||||
|
downloadStateInitialization = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return downloadStateInitialization;
|
||||||
|
};
|
||||||
|
|
||||||
const getScheduledQueueIds = () => {
|
const getScheduledQueueIds = () => {
|
||||||
const downloadState = useDownloadStore.getState();
|
const downloadState = useDownloadStore.getState();
|
||||||
const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id));
|
const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id));
|
||||||
@@ -227,10 +241,11 @@ function App() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
try {
|
try {
|
||||||
await waitForSettingsHydration();
|
await initializeDownloadState();
|
||||||
await useDownloadStore.getState().initDB();
|
if (!active) return;
|
||||||
if (active) setCoreReady(true);
|
setCoreReady(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!active) return;
|
||||||
console.error('Failed to initialize Firelink state:', error);
|
console.error('Failed to initialize Firelink state:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not initialize saved downloads: ${String(error)}`,
|
message: `Could not initialize saved downloads: ${String(error)}`,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { isTransferLocked } from '../utils/downloadActions';
|
|||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import {
|
import {
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
|
appendRequestUrlsAfterVersion,
|
||||||
mediaFileNameForSelectedFormat,
|
mediaFileNameForSelectedFormat,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
metadataSummaryMessage,
|
metadataSummaryMessage,
|
||||||
@@ -65,7 +66,6 @@ export const AddDownloadsModal = () => {
|
|||||||
pendingAddMediaUrls,
|
pendingAddMediaUrls,
|
||||||
pendingAddRequestContexts,
|
pendingAddRequestContexts,
|
||||||
pendingAddRequestVersion,
|
pendingAddRequestVersion,
|
||||||
pendingAddLatestUrls,
|
|
||||||
toggleAddModal,
|
toggleAddModal,
|
||||||
addDownload,
|
addDownload,
|
||||||
queues
|
queues
|
||||||
@@ -120,8 +120,9 @@ export const AddDownloadsModal = () => {
|
|||||||
if (context) return extensionHeaders(context).trim();
|
if (context) return extensionHeaders(context).trim();
|
||||||
return hasExtensionRequestContext ? '' : headers.trim();
|
return hasExtensionRequestContext ? '' : headers.trim();
|
||||||
};
|
};
|
||||||
const cookiesForRow = (sourceUrl: string) => {
|
const cookiesForRow = (sourceUrl: string, omitRequestCookies = false) => {
|
||||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||||
|
if (omitRequestCookies) return '';
|
||||||
const context = requestContextForUrl(sourceUrl);
|
const context = requestContextForUrl(sourceUrl);
|
||||||
if (context) return context.cookies.trim();
|
if (context) return context.cookies.trim();
|
||||||
return hasExtensionRequestContext ? '' : cookies.trim();
|
return hasExtensionRequestContext ? '' : cookies.trim();
|
||||||
@@ -189,14 +190,14 @@ export const AddDownloadsModal = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAddModalOpen || !modalSessionRef.current
|
if (!isAddModalOpen || !modalSessionRef.current
|
||||||
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
|
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
|
||||||
|
const observedVersion = observedRequestVersionRef.current;
|
||||||
observedRequestVersionRef.current = pendingAddRequestVersion;
|
observedRequestVersionRef.current = pendingAddRequestVersion;
|
||||||
const additions = pendingAddLatestUrls
|
setUrls(current => appendRequestUrlsAfterVersion(
|
||||||
.split('\n')
|
current,
|
||||||
.map(url => url.trim())
|
pendingAddRequestContexts,
|
||||||
.filter(Boolean);
|
observedVersion
|
||||||
if (additions.length === 0) return;
|
));
|
||||||
setUrls(current => current.trim() ? `${current.trim()}\n${additions.join('\n')}` : additions.join('\n'));
|
}, [isAddModalOpen, pendingAddRequestContexts, pendingAddRequestVersion]);
|
||||||
}, [isAddModalOpen, pendingAddRequestVersion, pendingAddLatestUrls]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQueueMenuOpen) return;
|
if (!isQueueMenuOpen) return;
|
||||||
@@ -302,7 +303,25 @@ export const AddDownloadsModal = () => {
|
|||||||
cookies: rowCookies || null,
|
cookies: rowCookies || null,
|
||||||
proxy
|
proxy
|
||||||
};
|
};
|
||||||
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
let requestCookiesOmitted = false;
|
||||||
|
let mediaData;
|
||||||
|
try {
|
||||||
|
mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||||
|
} catch (error) {
|
||||||
|
const capturedCookies = requestContextForUrl(row.sourceUrl)?.cookies.trim();
|
||||||
|
if (!rowCookies || !capturedCookies || cookiesManuallyEditedRef.current) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
'Media metadata rejected the captured Cookie header; retrying without request cookies',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
mediaData = await fetchMediaMetadataDeduped({
|
||||||
|
...mediaMetadataArgs,
|
||||||
|
cookies: null
|
||||||
|
});
|
||||||
|
requestCookiesOmitted = true;
|
||||||
|
}
|
||||||
if (mediaData && mediaData.formats.length > 0) {
|
if (mediaData && mediaData.formats.length > 0) {
|
||||||
const mappedFormats = mediaData.formats.map(f => {
|
const mappedFormats = mediaData.formats.map(f => {
|
||||||
const quality = f.resolution || 'Video';
|
const quality = f.resolution || 'Video';
|
||||||
@@ -334,6 +353,7 @@ export const AddDownloadsModal = () => {
|
|||||||
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
|
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
|
||||||
sizeBytes: mappedFormats[0].bytes || undefined,
|
sizeBytes: mappedFormats[0].bytes || undefined,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
|
requestCookiesOmitted,
|
||||||
formats: mappedFormats,
|
formats: mappedFormats,
|
||||||
selectedFormat: 0
|
selectedFormat: 0
|
||||||
})
|
})
|
||||||
@@ -709,7 +729,7 @@ export const AddDownloadsModal = () => {
|
|||||||
checksum: checksumEnabled && checksumValue.trim()
|
checksum: checksumEnabled && checksumValue.trim()
|
||||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||||
: undefined,
|
: undefined,
|
||||||
cookies: cookiesForRow(item.sourceUrl) || undefined,
|
cookies: cookiesForRow(item.sourceUrl, item.requestCookiesOmitted) || undefined,
|
||||||
mirrors: mirrors.trim() || undefined,
|
mirrors: mirrors.trim() || undefined,
|
||||||
destination: useSharedDestination
|
destination: useSharedDestination
|
||||||
? finalLocation
|
? finalLocation
|
||||||
@@ -1128,6 +1148,11 @@ export const AddDownloadsModal = () => {
|
|||||||
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
||||||
aria-label="Cookies"
|
aria-label="Cookies"
|
||||||
/>
|
/>
|
||||||
|
{!cookiesManuallyEditedRef.current && parsedItems.some(item => item.requestCookiesOmitted) && (
|
||||||
|
<p className="mt-1 text-[10px] text-amber-400">
|
||||||
|
Media metadata only worked without the captured cookies, so they will be omitted for affected rows. Edit this field to force a manual value.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ describe('useDownloadStore', () => {
|
|||||||
pendingAddMediaUrls: [],
|
pendingAddMediaUrls: [],
|
||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {},
|
||||||
pendingAddRequestVersion: 0,
|
pendingAddRequestVersion: 0,
|
||||||
pendingAddLatestUrls: '',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,6 +314,15 @@ describe('useDownloadStore', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const pause = useDownloadStore.getState().pauseDownload('paused');
|
const pause = useDownloadStore.getState().pauseDownload('paused');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||||
|
'cancel_enqueue_generation',
|
||||||
|
expect.objectContaining({ id: 'paused' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'pause_download')
|
||||||
|
).toBe(false);
|
||||||
resolveEnqueue({ id: 'paused', filename: 'paused.bin' });
|
resolveEnqueue({ id: 'paused', filename: 'paused.bin' });
|
||||||
|
|
||||||
await expect(pause).resolves.toBeUndefined();
|
await expect(pause).resolves.toBeUndefined();
|
||||||
@@ -784,21 +792,22 @@ describe('useDownloadStore', () => {
|
|||||||
'https://first.example/file.zip\nhttps://second.example/file.zip'
|
'https://first.example/file.zip\nhttps://second.example/file.zip'
|
||||||
);
|
);
|
||||||
expect(state.pendingAddRequestVersion).toBe(2);
|
expect(state.pendingAddRequestVersion).toBe(2);
|
||||||
expect(state.pendingAddLatestUrls).toBe('https://second.example/file.zip');
|
|
||||||
expect(state.pendingAddRequestContexts).toEqual({
|
expect(state.pendingAddRequestContexts).toEqual({
|
||||||
'https://first.example/file.zip': {
|
'https://first.example/file.zip': {
|
||||||
version: 1,
|
version: 1,
|
||||||
referer: 'https://first.example/page',
|
referer: 'https://first.example/page',
|
||||||
filename: 'first.zip',
|
filename: 'first.zip',
|
||||||
headers: 'User-Agent: First Browser',
|
headers: 'User-Agent: First Browser',
|
||||||
cookies: 'first=session'
|
cookies: 'first=session',
|
||||||
|
media: false
|
||||||
},
|
},
|
||||||
'https://second.example/file.zip': {
|
'https://second.example/file.zip': {
|
||||||
version: 2,
|
version: 2,
|
||||||
referer: 'https://second.example/page',
|
referer: 'https://second.example/page',
|
||||||
filename: 'second.zip',
|
filename: 'second.zip',
|
||||||
headers: 'User-Agent: Second Browser',
|
headers: 'User-Agent: Second Browser',
|
||||||
cookies: 'second=session'
|
cookies: 'second=session',
|
||||||
|
media: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -818,7 +827,38 @@ describe('useDownloadStore', () => {
|
|||||||
expect(state.isAddModalOpen).toBe(true);
|
expect(state.isAddModalOpen).toBe(true);
|
||||||
expect(state.pendingAddUrls).toBe('https://adult.example/watch/123');
|
expect(state.pendingAddUrls).toBe('https://adult.example/watch/123');
|
||||||
expect(state.pendingAddMediaUrls).toEqual(['https://adult.example/watch/123']);
|
expect(state.pendingAddMediaUrls).toEqual(['https://adult.example/watch/123']);
|
||||||
expect(state.pendingAddCookies).toBe('');
|
expect(state.pendingAddCookies).toBe('session=secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears stale request context when the same URL is captured without it later', async () => {
|
||||||
|
const url = 'https://example.com/file.zip';
|
||||||
|
await useDownloadStore.getState().handleExtensionDownload({
|
||||||
|
urls: [url],
|
||||||
|
referer: 'https://example.com/private',
|
||||||
|
silent: true,
|
||||||
|
filename: 'private.zip',
|
||||||
|
headers: 'Authorization: secret',
|
||||||
|
cookies: 'session=secret',
|
||||||
|
media: false
|
||||||
|
});
|
||||||
|
await useDownloadStore.getState().handleExtensionDownload({
|
||||||
|
urls: [url],
|
||||||
|
referer: null,
|
||||||
|
silent: true,
|
||||||
|
filename: null,
|
||||||
|
headers: null,
|
||||||
|
cookies: null,
|
||||||
|
media: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().pendingAddRequestContexts[url]).toEqual({
|
||||||
|
version: 2,
|
||||||
|
referer: '',
|
||||||
|
filename: '',
|
||||||
|
headers: '',
|
||||||
|
cookies: '',
|
||||||
|
media: false
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates forced media URLs and drops stale media intent when opening fresh', async () => {
|
it('deduplicates forced media URLs and drops stale media intent when opening fresh', async () => {
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export type PendingAddRequestContext = {
|
|||||||
filename: string;
|
filename: string;
|
||||||
headers: string;
|
headers: string;
|
||||||
cookies: string;
|
cookies: string;
|
||||||
|
media: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeleteModalState = {
|
export type DeleteModalState = {
|
||||||
@@ -338,7 +339,6 @@ interface DownloadState {
|
|||||||
pendingAddMediaUrls: string[];
|
pendingAddMediaUrls: string[];
|
||||||
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
||||||
pendingAddRequestVersion: number;
|
pendingAddRequestVersion: number;
|
||||||
pendingAddLatestUrls: string;
|
|
||||||
selectedPropertiesDownloadId: string | null;
|
selectedPropertiesDownloadId: string | null;
|
||||||
toggleAddModal: (isOpen: boolean) => void;
|
toggleAddModal: (isOpen: boolean) => void;
|
||||||
openAddModalWithUrls: (
|
openAddModalWithUrls: (
|
||||||
@@ -475,7 +475,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
pendingAddMediaUrls: [],
|
pendingAddMediaUrls: [],
|
||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {},
|
||||||
pendingAddRequestVersion: 0,
|
pendingAddRequestVersion: 0,
|
||||||
pendingAddLatestUrls: '',
|
|
||||||
selectedPropertiesDownloadId: null,
|
selectedPropertiesDownloadId: null,
|
||||||
deleteModalState: { isOpen: false },
|
deleteModalState: { isOpen: false },
|
||||||
openDeleteModal: (downloadIds) => set({
|
openDeleteModal: (downloadIds) => set({
|
||||||
@@ -493,8 +492,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
pendingAddHeaders: '',
|
pendingAddHeaders: '',
|
||||||
pendingAddCookies: '',
|
pendingAddCookies: '',
|
||||||
pendingAddMediaUrls: [],
|
pendingAddMediaUrls: [],
|
||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {}
|
||||||
pendingAddLatestUrls: ''
|
|
||||||
}),
|
}),
|
||||||
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
|
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
|
||||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||||
@@ -512,28 +510,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
const cleanHeaders = headers?.trim() || '';
|
const cleanHeaders = headers?.trim() || '';
|
||||||
const cleanCookies = cookies?.trim() || '';
|
const cleanCookies = cookies?.trim() || '';
|
||||||
const requestVersion = state.pendingAddRequestVersion + 1;
|
const requestVersion = state.pendingAddRequestVersion + 1;
|
||||||
const hasRequestContext = Boolean(cleanReferer || cleanFilename || cleanHeaders || cleanCookies);
|
|
||||||
const pendingAddRequestContexts = isAppending
|
const pendingAddRequestContexts = isAppending
|
||||||
? { ...state.pendingAddRequestContexts }
|
? { ...state.pendingAddRequestContexts }
|
||||||
: {};
|
: {};
|
||||||
if (hasRequestContext) {
|
// Every handoff gets a versioned row context, including an intentionally
|
||||||
for (const rawUrl of urls.split('\n')) {
|
// empty one. Otherwise a later request for the same URL cannot clear stale
|
||||||
const trimmedUrl = rawUrl.trim();
|
// cookies/headers from an earlier capture, and batched React renders can
|
||||||
if (!trimmedUrl) continue;
|
// lose all but the most recent appended URL.
|
||||||
let key = trimmedUrl;
|
for (const rawUrl of urls.split('\n')) {
|
||||||
try {
|
const trimmedUrl = rawUrl.trim();
|
||||||
key = new URL(trimmedUrl).href;
|
if (!trimmedUrl) continue;
|
||||||
} catch {
|
let key = trimmedUrl;
|
||||||
// The Add modal will mark malformed input invalid; retain its original key here.
|
try {
|
||||||
}
|
key = new URL(trimmedUrl).href;
|
||||||
pendingAddRequestContexts[key] = {
|
} catch {
|
||||||
version: requestVersion,
|
// The Add modal will mark malformed input invalid; retain its original key here.
|
||||||
referer: cleanReferer,
|
|
||||||
filename: cleanFilename,
|
|
||||||
headers: cleanHeaders,
|
|
||||||
cookies: cleanCookies
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
pendingAddRequestContexts[key] = {
|
||||||
|
version: requestVersion,
|
||||||
|
referer: cleanReferer,
|
||||||
|
filename: cleanFilename,
|
||||||
|
headers: cleanHeaders,
|
||||||
|
cookies: cleanCookies,
|
||||||
|
media
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
isAddModalOpen: true,
|
isAddModalOpen: true,
|
||||||
@@ -544,25 +544,19 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
pendingAddCookies: cleanCookies,
|
pendingAddCookies: cleanCookies,
|
||||||
pendingAddMediaUrls,
|
pendingAddMediaUrls,
|
||||||
pendingAddRequestContexts,
|
pendingAddRequestContexts,
|
||||||
pendingAddRequestVersion: requestVersion,
|
pendingAddRequestVersion: requestVersion
|
||||||
pendingAddLatestUrls: urls
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
handleExtensionDownload: async (request) => {
|
handleExtensionDownload: async (request) => {
|
||||||
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
||||||
if (urls.length === 0) return;
|
if (urls.length === 0) return;
|
||||||
|
|
||||||
// Explicit media uses yt-dlp and its configured browser-cookie source.
|
|
||||||
// Passing Firefox's complete page Cookie header can exceed YouTube's
|
|
||||||
// request-header limit; ordinary captured file downloads keep it.
|
|
||||||
const cookies = request.media === true ? null : request.cookies;
|
|
||||||
|
|
||||||
get().openAddModalWithUrls(
|
get().openAddModalWithUrls(
|
||||||
urls.join('\n'),
|
urls.join('\n'),
|
||||||
request.referer,
|
request.referer,
|
||||||
urls.length === 1 ? request.filename : null,
|
urls.length === 1 ? request.filename : null,
|
||||||
request.headers,
|
request.headers,
|
||||||
cookies,
|
request.cookies,
|
||||||
request.media === true
|
request.media === true
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -687,7 +681,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
syncSystemIntegrations();
|
syncSystemIntegrations();
|
||||||
},
|
},
|
||||||
pauseDownload: async (id) => {
|
pauseDownload: async (id) => {
|
||||||
const { generation } = await invalidateDispatch(id);
|
const { generation, pendingDispatch } = await invalidateDispatch(id);
|
||||||
|
if (pendingDispatch) {
|
||||||
|
await pendingDispatch;
|
||||||
|
}
|
||||||
|
|
||||||
await invoke('pause_download', { id });
|
await invoke('pause_download', { id });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
appendRequestUrlsAfterVersion,
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
mediaFileNameForSelectedFormat,
|
mediaFileNameForSelectedFormat,
|
||||||
@@ -93,6 +94,7 @@ describe('add download metadata workflow', () => {
|
|||||||
status: 'ready',
|
status: 'ready',
|
||||||
generation: 4,
|
generation: 4,
|
||||||
requestContextVersion: 1,
|
requestContextVersion: 1,
|
||||||
|
requestCookiesOmitted: true,
|
||||||
formats: [{
|
formats: [{
|
||||||
name: '1080p MP4',
|
name: '1080p MP4',
|
||||||
selector: '137+140',
|
selector: '137+140',
|
||||||
@@ -119,11 +121,30 @@ describe('add download metadata workflow', () => {
|
|||||||
status: 'loading',
|
status: 'loading',
|
||||||
generation: 5,
|
generation: 5,
|
||||||
requestContextVersion: 2,
|
requestContextVersion: 2,
|
||||||
|
requestCookiesOmitted: false,
|
||||||
formats: undefined,
|
formats: undefined,
|
||||||
selectedFormat: undefined
|
selectedFormat: undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('appends every unseen handoff after the observed version', () => {
|
||||||
|
const merged = appendRequestUrlsAfterVersion(
|
||||||
|
'https://existing.example/file.zip',
|
||||||
|
{
|
||||||
|
'https://first.example/file.zip': { version: 2 },
|
||||||
|
'https://second.example/file.zip': { version: 3 },
|
||||||
|
'https://existing.example/file.zip': { version: 4 }
|
||||||
|
},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged).toBe(
|
||||||
|
'https://existing.example/file.zip\n' +
|
||||||
|
'https://first.example/file.zip\n' +
|
||||||
|
'https://second.example/file.zip'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('upgrades an existing normal row when the user explicitly fetches it as media', () => {
|
it('upgrades an existing normal row when the user explicitly fetches it as media', () => {
|
||||||
const existing = row({
|
const existing = row({
|
||||||
sourceUrl: 'https://adult.example/watch/123',
|
sourceUrl: 'https://adult.example/watch/123',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface AddDownloadDraftRow {
|
|||||||
status: MetadataStatus;
|
status: MetadataStatus;
|
||||||
generation: number;
|
generation: number;
|
||||||
requestContextVersion?: number;
|
requestContextVersion?: number;
|
||||||
|
requestCookiesOmitted?: boolean;
|
||||||
isMedia: boolean;
|
isMedia: boolean;
|
||||||
resumable?: boolean;
|
resumable?: boolean;
|
||||||
formats?: AddMediaFormat[];
|
formats?: AddMediaFormat[];
|
||||||
@@ -93,6 +94,7 @@ export const reconcileDownloadRows = (
|
|||||||
status: 'loading',
|
status: 'loading',
|
||||||
generation: preserved.generation + 1,
|
generation: preserved.generation + 1,
|
||||||
requestContextVersion,
|
requestContextVersion,
|
||||||
|
requestCookiesOmitted: false,
|
||||||
isMedia: preserved.isMedia || forcedMedia,
|
isMedia: preserved.isMedia || forcedMedia,
|
||||||
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
||||||
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
||||||
@@ -120,6 +122,35 @@ export const reconcileDownloadRows = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const comparableUrl = (rawUrl: string): string => {
|
||||||
|
try {
|
||||||
|
return new URL(rawUrl).href;
|
||||||
|
} catch {
|
||||||
|
return rawUrl.trim();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appendRequestUrlsAfterVersion = (
|
||||||
|
rawText: string,
|
||||||
|
requestContexts: Readonly<Record<string, { version: number }>>,
|
||||||
|
observedVersion: number
|
||||||
|
): string => {
|
||||||
|
const lines = rawText.split('\n').map(line => line.trim()).filter(Boolean);
|
||||||
|
const seen = new Set(lines.map(comparableUrl));
|
||||||
|
const additions = Object.entries(requestContexts)
|
||||||
|
.filter(([, context]) => context.version > observedVersion)
|
||||||
|
.sort(([, left], [, right]) => left.version - right.version);
|
||||||
|
|
||||||
|
for (const [url] of additions) {
|
||||||
|
const identity = comparableUrl(url);
|
||||||
|
if (seen.has(identity)) continue;
|
||||||
|
seen.add(identity);
|
||||||
|
lines.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
export const updateRowIfCurrent = (
|
export const updateRowIfCurrent = (
|
||||||
rows: AddDownloadDraftRow[],
|
rows: AddDownloadDraftRow[],
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user