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:
NimBold
2026-07-10 12:07:25 +03:30
parent 3fbd0742be
commit 4f4c655de6
11 changed files with 550 additions and 186 deletions
+76 -6
View File
@@ -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(())
} }
+9 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+40 -1
View File
@@ -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
View File
@@ -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)}`,
+36 -11
View File
@@ -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>
+45 -5
View File
@@ -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 () => {
+28 -31
View File
@@ -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 });
+21
View File
@@ -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',
+31
View File
@@ -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,