fix: address regressions since v1.4.2

- Encode Linux autostart Exec paths and preserve Add row selection across discovery.

- Queue media discovery updates during busy flows and bound session registries.

- Journal terminal completions across shutdown and restore Aria2 dispatch state safely.

- Add accessible Add row controls and lifecycle regression coverage.

Refs #44
This commit is contained in:
NimBold
2026-10-11 21:56:29 +03:30
parent fbf01fabeb
commit d91728bebb
15 changed files with 3974 additions and 404 deletions
+313 -1
View File
@@ -7,7 +7,7 @@ use std::sync::Mutex;
const DATABASE_NAME: &str = "firelink.sqlite";
const LEGACY_STORE_NAME: &str = "store.bin";
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
const CURRENT_SCHEMA_VERSION: i64 = 4;
const CURRENT_SCHEMA_VERSION: i64 = 5;
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
// Development builds are a different executable identity from the packaged
@@ -106,6 +106,7 @@ fn init_at_path_internal(
import_legacy_data(&mut connection, app_data_dir, portable)?;
recover_downloads_from_migration_backup(&mut connection, app_data_dir, portable)?;
recover_shutdown_terminal_journal(&mut connection)?;
if portable {
sanitize_persisted_downloads(&mut connection)?;
}
@@ -238,6 +239,19 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
").map_err(|error| format!("failed to migrate removal jobs: {error}"))?;
}
if from_version < 5 {
transaction
.execute_batch(
"
CREATE TABLE IF NOT EXISTS shutdown_terminal_journal (
id TEXT PRIMARY KEY,
lifecycle_generation TEXT NOT NULL
);
",
)
.map_err(|error| format!("failed to create shutdown terminal journal: {error}"))?;
}
transaction
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
.map_err(|error| format!("failed to update database schema version: {error}"))?;
@@ -1422,6 +1436,167 @@ pub fn load_downloads(connection: &Connection) -> Result<Vec<String>, String> {
query_string_column(connection, "SELECT data FROM downloads ORDER BY rowid")
}
/// Confirm that both the indexed status and the canonical download document
/// durably describe a terminal completion. This is used only when the
/// shutdown journal could not commit and a final frontend flush is the
/// recovery path.
pub fn is_download_durably_completed(connection: &Connection, id: &str) -> Result<bool, String> {
let row = connection
.query_row(
"SELECT status, data FROM downloads WHERE id = ?1",
params![id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional()
.map_err(|error| format!("failed to verify completed download '{id}': {error}"))?;
let Some((indexed_status, data)) = row else {
return Ok(false);
};
let document: Value = serde_json::from_str(&data)
.map_err(|error| format!("persisted download '{id}' is malformed: {error}"))?;
let document_id = required_string(&document, "id")?;
let document_status = required_string(&document, "status")?;
Ok(document_id == id && indexed_status == "completed" && document_status == "completed")
}
/// Persist a native completion observed while Firelink is shutting down.
///
/// The frontend owns full download snapshots, so this journal is deliberately
/// separate: a late or failed frontend flush cannot overwrite the terminal
/// fact. Startup applies it to the matching durable row before interrupted
/// active rows are normalized for requeue.
pub fn record_shutdown_terminal(
connection: &mut Connection,
id: &str,
lifecycle_generation: u64,
) -> Result<(), String> {
if id.trim().is_empty() {
return Err("shutdown completion journal requires a download id".to_string());
}
let transaction = connection
.transaction()
.map_err(|error| format!("failed to begin shutdown completion journal: {error}"))?;
let previous = transaction
.query_row(
"SELECT lifecycle_generation FROM shutdown_terminal_journal WHERE id = ?1",
params![id],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| format!("failed to read shutdown completion journal: {error}"))?;
if let Some(previous) = previous {
let previous_generation = previous.parse::<u64>().map_err(|_| {
"shutdown completion journal contains an invalid lifecycle generation".to_string()
})?;
if previous_generation > lifecycle_generation {
transaction
.rollback()
.map_err(|error| format!("failed to keep newer shutdown completion: {error}"))?;
return Ok(());
}
}
transaction
.execute(
"INSERT INTO shutdown_terminal_journal (id, lifecycle_generation)
VALUES (?1, ?2)
ON CONFLICT(id) DO UPDATE SET lifecycle_generation = excluded.lifecycle_generation",
params![id, lifecycle_generation.to_string()],
)
.map_err(|error| format!("failed to record shutdown completion: {error}"))?;
transaction
.commit()
.map_err(|error| format!("failed to commit shutdown completion journal: {error}"))
}
pub fn clear_shutdown_terminal(
connection: &mut Connection,
id: &str,
) -> Result<(), String> {
let transaction = connection
.transaction()
.map_err(|error| format!("failed to begin shutdown completion invalidation: {error}"))?;
transaction
.execute("DELETE FROM shutdown_terminal_journal WHERE id = ?1", params![id])
.map_err(|error| format!("failed to invalidate shutdown completion for '{id}': {error}"))?;
transaction
.commit()
.map_err(|error| format!("failed to commit shutdown completion invalidation: {error}"))
}
fn recover_shutdown_terminal_journal(connection: &mut Connection) -> Result<usize, String> {
let transaction = connection
.transaction()
.map_err(|error| format!("failed to begin shutdown completion recovery: {error}"))?;
let entries = {
let mut statement = transaction
.prepare("SELECT id, lifecycle_generation FROM shutdown_terminal_journal ORDER BY id")
.map_err(|error| format!("failed to read shutdown completion journal: {error}"))?;
let rows = statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|error| format!("failed to query shutdown completion journal: {error}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("failed to collect shutdown completion journal: {error}"))?
};
let mut recovered = 0;
for (id, generation) in entries {
generation.parse::<u64>().map_err(|_| {
format!("shutdown completion journal for '{id}' has an invalid lifecycle generation")
})?;
let persisted = transaction
.query_row(
"SELECT status, data FROM downloads WHERE id = ?1",
params![id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional()
.map_err(|error| format!("failed to load shutdown-completed download '{id}': {error}"))?;
let Some((indexed_status, data)) = persisted else {
// A later user removal is authoritative. The journal is still
// consumed atomically so a deleted row cannot be resurrected.
continue;
};
let mut value: Value = serde_json::from_str(&data)
.map_err(|error| format!("persisted download '{id}' is malformed: {error}"))?;
if required_string(&value, "id")? != id {
return Err(format!("persisted download '{id}' has mismatched document id"));
}
let document_status = required_string(&value, "status")?;
if document_status != indexed_status {
return Err(format!("persisted download '{id}' has mismatched indexed status"));
}
if document_status == "completed" {
continue;
}
let object = value
.as_object_mut()
.ok_or_else(|| format!("persisted download '{id}' is not an object"))?;
object.insert("status".to_string(), Value::String("completed".to_string()));
let data = serde_json::to_string(&value)
.map_err(|error| format!("failed to encode recovered completion '{id}': {error}"))?;
let changed = transaction
.execute(
"UPDATE downloads SET status = 'completed', data = ?1 WHERE id = ?2",
params![data, id],
)
.map_err(|error| format!("failed to recover shutdown completion '{id}': {error}"))?;
if changed != 1 {
return Err(format!("persisted download '{id}' changed during recovery"));
}
recovered += 1;
}
transaction
.execute("DELETE FROM shutdown_terminal_journal", [])
.map_err(|error| format!("failed to clear recovered shutdown completions: {error}"))?;
transaction
.commit()
.map_err(|error| format!("failed to commit shutdown completion recovery: {error}"))?;
Ok(recovered)
}
pub fn replace_downloads(
connection: &mut Connection,
data: &str,
@@ -3529,6 +3704,143 @@ mod tests {
assert!(load_queues(&connection).unwrap()[0].contains("new-queue"));
}
#[test]
fn shutdown_terminal_journal_prevents_requeue_after_final_frontend_flush_failure() {
let root = TempDir::new().unwrap();
let database = init_at_path(root.path()).unwrap();
{
let mut connection = database.lock().unwrap();
replace_downloads(
&mut connection,
&json!([{
"id": "completed-during-shutdown",
"status": "downloading",
"queueId": "main",
"downloadedBytes": 900,
"totalBytes": 1000
}])
.to_string(),
false,
)
.unwrap();
// The native lifecycle observed completion, but the simulated
// final frontend flush times out and leaves the earlier active
// snapshot untouched.
record_shutdown_terminal(&mut connection, "completed-during-shutdown", 42).unwrap();
let journal_generation: String = connection
.query_row(
"SELECT lifecycle_generation FROM shutdown_terminal_journal WHERE id = ?1",
params!["completed-during-shutdown"],
|row| row.get(0),
)
.unwrap();
assert_eq!(journal_generation, "42");
}
drop(database);
let restarted = init_at_path(root.path()).unwrap();
let connection = restarted.lock().unwrap();
let rows = load_downloads(&connection).unwrap();
let row: Value = serde_json::from_str(&rows[0]).unwrap();
assert_eq!(row["status"], "completed");
assert_eq!(row["downloadedBytes"], 900);
let journal_entries: i64 = connection
.query_row("SELECT COUNT(*) FROM shutdown_terminal_journal", [], |row| row.get(0))
.unwrap();
assert_eq!(journal_entries, 0);
}
#[test]
fn failed_shutdown_terminal_journal_commit_is_not_reported_as_success() {
let root = TempDir::new().unwrap();
let database = init_at_path(root.path()).unwrap();
let mut connection = database.lock().unwrap();
connection
.execute_batch(
"
CREATE TRIGGER reject_shutdown_terminal_journal
BEFORE INSERT ON shutdown_terminal_journal
BEGIN
SELECT RAISE(ABORT, 'simulated journal failure');
END;
",
)
.unwrap();
assert!(record_shutdown_terminal(&mut connection, "download-1", 7).is_err());
let journal_entries: i64 = connection
.query_row("SELECT COUNT(*) FROM shutdown_terminal_journal", [], |row| row.get(0))
.unwrap();
assert_eq!(journal_entries, 0);
}
#[test]
fn failed_journal_fallback_requires_the_index_and_document_to_be_completed() {
let root = TempDir::new().unwrap();
let database = init_at_path(root.path()).unwrap();
let mut connection = database.lock().unwrap();
replace_downloads(
&mut connection,
&json!([
{ "id": "completed", "status": "completed", "queueId": "main" },
{ "id": "active", "status": "downloading", "queueId": "main" },
{ "id": "mismatched", "status": "completed", "queueId": "main" }
])
.to_string(),
false,
)
.unwrap();
connection
.execute(
"UPDATE downloads SET data = ?1 WHERE id = ?2",
params![json!({ "id": "mismatched", "status": "downloading" }).to_string(), "mismatched"],
)
.unwrap();
assert!(is_download_durably_completed(&connection, "completed").unwrap());
assert!(!is_download_durably_completed(&connection, "active").unwrap());
assert!(!is_download_durably_completed(&connection, "mismatched").unwrap());
assert!(!is_download_durably_completed(&connection, "missing").unwrap());
}
#[test]
fn new_download_lifecycle_clears_a_prior_shutdown_completion_record() {
let root = TempDir::new().unwrap();
let database = init_at_path(root.path()).unwrap();
let mut connection = database.lock().unwrap();
record_shutdown_terminal(&mut connection, "redownload", 5).unwrap();
clear_shutdown_terminal(&mut connection, "redownload").unwrap();
let journal_entries: i64 = connection
.query_row("SELECT COUNT(*) FROM shutdown_terminal_journal", [], |row| row.get(0))
.unwrap();
assert_eq!(journal_entries, 0);
}
#[test]
fn shutdown_terminal_recovery_retains_journal_if_row_recovery_fails() {
let root = TempDir::new().unwrap();
let database = init_at_path(root.path()).unwrap();
let mut connection = database.lock().unwrap();
connection
.execute(
"INSERT INTO downloads (id, status, queue_id, data) VALUES ('broken', 'downloading', 'main', 'not-json')",
[],
)
.unwrap();
record_shutdown_terminal(&mut connection, "broken", 1).unwrap();
assert!(recover_shutdown_terminal_journal(&mut connection).is_err());
let journal_entries: i64 = connection
.query_row("SELECT COUNT(*) FROM shutdown_terminal_journal", [], |row| row.get(0))
.unwrap();
assert_eq!(journal_entries, 1);
let status: String = connection
.query_row("SELECT status FROM downloads WHERE id = 'broken'", [], |row| row.get(0))
.unwrap();
assert_eq!(status, "downloading");
}
#[test]
fn native_download_mutation_keeps_object_records_and_unrelated_rows_unchanged() {
let temp = TempDir::new().unwrap();
+184 -2
View File
@@ -17,6 +17,13 @@ pub enum DownloadEvent {
pub struct DownloadCoordinator {
tx: mpsc::Sender<DownloadCmd>,
media_tx: mpsc::Sender<MediaCmd>,
media_registration: std::sync::Arc<tokio::sync::Mutex<MediaRegistrationState>>,
}
#[derive(Default)]
struct MediaRegistrationState {
closed: bool,
rejected_during_shutdown: HashSet<(String, u64)>,
}
impl DownloadCoordinator {
@@ -36,7 +43,13 @@ impl DownloadCoordinator {
let (tx, rx) = mpsc::channel(128);
let (media_tx, media_rx) = mpsc::channel(32);
tauri::async_runtime::spawn(run_coordinator(events, rx, media_rx));
Self { tx, media_tx }
Self {
tx,
media_tx,
media_registration: std::sync::Arc::new(tokio::sync::Mutex::new(
MediaRegistrationState::default(),
)),
}
}
pub async fn send(&self, command: DownloadCmd) -> Result<(), String> {
@@ -51,6 +64,14 @@ impl DownloadCoordinator {
id: String,
lifecycle_generation: u64,
) -> Result<watch::Receiver<bool>, String> {
let mut registration = self.media_registration.lock().await;
if registration.closed {
registration
.rejected_during_shutdown
.insert((id, lifecycle_generation));
let (_cancel_tx, cancel_rx) = watch::channel(true);
return Ok(cancel_rx);
}
let (cancel_tx, cancel_rx) = watch::channel(false);
self.media_tx
.send(MediaCmd::Register {
@@ -60,9 +81,42 @@ impl DownloadCoordinator {
})
.await
.map_err(|_| "download coordinator is unavailable".to_string())?;
drop(registration);
Ok(cancel_rx)
}
/// Close media registration, cancel each active producer, and wait until
/// every accepted media runner has completed its terminal path.
pub async fn cancel_active_media_for_shutdown(&self) -> Result<Vec<(String, u64)>, String> {
let mut registration = self.media_registration.lock().await;
registration.closed = true;
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
if self
.media_tx
.send(MediaCmd::CancelAllForShutdown { ack: ack_tx })
.await
.is_err()
{
registration.closed = false;
return Err("download coordinator is unavailable".to_string());
}
drop(registration);
ack_rx
.await
.map_err(|_| "media runners stopped before shutdown cancellation completed".to_string())
}
pub async fn reopen_media_registration(&self) -> Vec<(String, u64)> {
let mut registration = self.media_registration.lock().await;
registration.closed = false;
let mut rejected = registration
.rejected_during_shutdown
.drain()
.collect::<Vec<_>>();
rejected.sort();
rejected
}
pub async fn pause_media(&self, id: String, lifecycle_generation: u64) -> Result<(), String> {
self.media_tx
.send(MediaCmd::Pause {
@@ -90,11 +144,22 @@ impl DownloadCoordinator {
}
pub async fn finish_media(&self, id: String, lifecycle_generation: u64) {
self.finish_media_with_shutdown_outcome(id, lifecycle_generation, false)
.await;
}
pub async fn finish_media_with_shutdown_outcome(
&self,
id: String,
lifecycle_generation: u64,
shutdown_interrupted: bool,
) {
let _ = self
.media_tx
.send(MediaCmd::Finished {
id,
lifecycle_generation,
shutdown_interrupted,
})
.await;
}
@@ -133,7 +198,17 @@ enum MediaCmd {
Finished {
id: String,
lifecycle_generation: u64,
shutdown_interrupted: bool,
},
CancelAllForShutdown {
ack: tokio::sync::oneshot::Sender<Vec<(String, u64)>>,
},
}
struct PendingMediaShutdown {
remaining: HashSet<(String, u64)>,
interrupted: Vec<(String, u64)>,
ack: tokio::sync::oneshot::Sender<Vec<(String, u64)>>,
}
async fn run_coordinator(
@@ -145,6 +220,7 @@ async fn run_coordinator(
let mut cancelled_media_generations = HashMap::<String, u64>::new();
let mut pending_media_acks =
HashMap::<(String, u64), tokio::sync::oneshot::Sender<bool>>::new();
let mut pending_shutdown: Option<PendingMediaShutdown> = None;
let mut pending_captured_urls = Vec::<String>::new();
let mut frontend_ready = false;
let mut command_open = true;
@@ -234,7 +310,7 @@ async fn run_coordinator(
let _ = ack.send(false);
}
}
MediaCmd::Finished { id, lifecycle_generation } => {
MediaCmd::Finished { id, lifecycle_generation, shutdown_interrupted } => {
if active_media.get(&id).is_some_and(|(generation, _)| *generation == lifecycle_generation) {
active_media.remove(&id);
}
@@ -251,6 +327,43 @@ async fn run_coordinator(
{
cancelled_media_generations.remove(&id);
}
if let Some(waiting) = pending_shutdown.as_mut() {
waiting
.remaining
.remove(&(id.clone(), lifecycle_generation));
if shutdown_interrupted {
waiting
.interrupted
.push((id.clone(), lifecycle_generation));
}
if waiting.remaining.is_empty() {
if let Some(completed) = pending_shutdown.take() {
let mut interrupted = completed.interrupted;
interrupted.sort();
let _ = completed.ack.send(interrupted);
}
}
}
}
MediaCmd::CancelAllForShutdown { ack } => {
let mut candidates = active_media
.iter()
.map(|(id, (generation, _))| (id.clone(), *generation))
.collect::<Vec<_>>();
candidates.sort();
let remaining = candidates.iter().cloned().collect::<HashSet<_>>();
for (_, cancel_tx) in active_media.values() {
let _ = cancel_tx.send(true);
}
if remaining.is_empty() {
let _ = ack.send(Vec::new());
} else {
pending_shutdown = Some(PendingMediaShutdown {
interrupted: Vec::new(),
remaining,
ack,
});
}
}
},
None => media_open = false,
@@ -498,4 +611,73 @@ mod tests {
.unwrap()
.unwrap();
}
#[tokio::test]
async fn shutdown_cancels_and_drains_active_media_before_reopening_registration() {
let (coordinator, _events) = DownloadCoordinator::spawn_headless();
let mut cancel_rx = coordinator
.register_media("shutdown-media".to_string(), 17)
.await
.unwrap();
let mut completed_cancel_rx = coordinator
.register_media("completed-media".to_string(), 20)
.await
.unwrap();
let shutdown_coordinator = coordinator.clone();
let shutdown = tokio::spawn(async move {
shutdown_coordinator
.cancel_active_media_for_shutdown()
.await
});
tokio::time::timeout(Duration::from_secs(1), async {
while !*cancel_rx.borrow_and_update() {
cancel_rx.changed().await.unwrap();
}
})
.await
.expect("shutdown did not cancel the active media runner");
tokio::time::timeout(Duration::from_secs(1), async {
while !*completed_cancel_rx.borrow_and_update() {
completed_cancel_rx.changed().await.unwrap();
}
})
.await
.expect("shutdown did not notify the finishing media runner");
assert!(!shutdown.is_finished(), "shutdown must wait for runner cleanup");
coordinator
.finish_media_with_shutdown_outcome("completed-media".to_string(), 20, false)
.await;
coordinator
.finish_media_with_shutdown_outcome("shutdown-media".to_string(), 17, true)
.await;
assert_eq!(
tokio::time::timeout(Duration::from_secs(1), shutdown)
.await
.unwrap()
.unwrap()
.unwrap(),
vec![("shutdown-media".to_string(), 17)]
);
let late_registration = coordinator
.register_media("late-media".to_string(), 18)
.await
.unwrap();
assert!(*late_registration.borrow());
assert_eq!(
coordinator.reopen_media_registration().await,
vec![("late-media".to_string(), 18)]
);
let reopened_registration = coordinator
.register_media("reopened-media".to_string(), 19)
.await
.unwrap();
assert!(!*reopened_registration.borrow());
coordinator
.finish_media("reopened-media".to_string(), 19)
.await;
}
}
+268 -41
View File
@@ -52,6 +52,7 @@ const PROTOCOL_VERSION: &str = "7";
const MEDIA_HANDOFF_PROTOCOL_VERSION: u16 = 7;
const MAX_MEDIA_HANDOFF_ID_BYTES: usize = 128;
const MAX_ACTIVE_MEDIA_HANDOFFS: usize = 64;
const MAX_MEDIA_DISCOVERY_REPLAY_RECORDS: usize = 128;
const MEDIA_HANDOFF_TTL: Duration = Duration::from_secs(30);
const MEDIA_DISCOVERY_ATTEMPT_TTL: Duration = Duration::from_secs(10);
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
@@ -62,7 +63,7 @@ pub type SharedExtensionToken = Arc<RwLock<String>>;
pub type SharedFrontendReady = Arc<AtomicBool>;
pub type SharedServerPort = Arc<RwLock<Option<u16>>>;
pub type SharedExtensionAcks = Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>;
pub type SharedMediaHandoffs = Arc<Mutex<HashMap<String, MediaHandoffState>>>;
pub type SharedMediaHandoffs = Arc<Mutex<MediaHandoffRegistry>>;
type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize, TS)]
@@ -94,9 +95,6 @@ enum MediaDiscoveryState {
completion: watch::Sender<Option<MediaDiscoveryDeliveryResult>>,
attempt_expires_at: Instant,
},
Acknowledged {
content: MediaDiscoveryContent,
},
}
#[derive(Clone, Debug)]
@@ -105,6 +103,22 @@ pub struct MediaHandoffState {
expires_at: Instant,
}
#[derive(Clone, Debug)]
struct MediaDiscoveryReplay {
content: MediaDiscoveryContent,
expires_at: Instant,
}
#[derive(Default)]
pub struct MediaHandoffRegistry {
active: HashMap<String, MediaHandoffState>,
replays: HashMap<String, MediaDiscoveryReplay>,
}
pub fn new_media_handoffs() -> SharedMediaHandoffs {
Arc::new(Mutex::new(MediaHandoffRegistry::default()))
}
#[derive(Clone)]
pub struct ServerState {
pub app_handle: AppHandle,
@@ -503,11 +517,21 @@ async fn download_handler(
}
if let Some(handoff_id) = media_handoff_id.as_deref() {
if !register_media_handoff(&state.media_handoffs, handoff_id) {
if cached_torrent {
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
match register_media_handoff(&state.media_handoffs, handoff_id) {
MediaHandoffRegistration::Tracked
| MediaHandoffRegistration::TrackedAfterEviction => {}
MediaHandoffRegistration::UntrackedCapacity => {
log::warn!("media discovery capacity reached; preserving the initial page handoff");
}
MediaHandoffRegistration::RegistryUnavailable => {
log::warn!("media discovery registry unavailable; preserving the initial page handoff");
}
MediaHandoffRegistration::Duplicate => {
if cached_torrent {
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
}
return Err(StatusCode::CONFLICT);
}
return Err(StatusCode::CONFLICT);
}
}
@@ -622,7 +646,12 @@ async fn deliver_media_discovery_update(
let handoff_id = update.handoff_id.clone();
let request_id = uuid::Uuid::new_v4().simple().to_string();
match admit_media_discovery_update(&state.media_handoffs, &update, request_id.clone()) {
MediaDiscoveryAdmission::Missing => return Err(StatusCode::NOT_FOUND),
MediaDiscoveryAdmission::Missing => {
// The bounded active registry may evict an idle session under
// pressure. Its initial page handoff was already delivered, so a
// later optional manifest update is a successful no-op.
return signed_server_response(timestamp_str, nonce, state);
}
MediaDiscoveryAdmission::Conflict => return Err(StatusCode::CONFLICT),
MediaDiscoveryAdmission::RegistryUnavailable => {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
@@ -890,9 +919,9 @@ fn media_discovery_content(update: &ExtensionMediaDiscoveryUpdate) -> MediaDisco
}
}
fn prune_media_handoffs(registry: &mut HashMap<String, MediaHandoffState>) {
fn prune_media_handoffs(registry: &mut MediaHandoffRegistry) {
let now = Instant::now();
for state in registry.values_mut() {
for state in registry.active.values_mut() {
let attempt_expired = matches!(
state.discovery.as_ref(),
Some(MediaDiscoveryState::InFlight {
@@ -906,30 +935,65 @@ fn prune_media_handoffs(registry: &mut HashMap<String, MediaHandoffState>) {
}
}
}
registry.retain(|_, state| state.expires_at > now);
registry.active.retain(|_, state| state.expires_at > now);
registry.replays.retain(|_, replay| replay.expires_at > now);
}
fn register_media_handoff(registry: &SharedMediaHandoffs, handoff_id: &str) -> bool {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MediaHandoffRegistration {
Tracked,
TrackedAfterEviction,
UntrackedCapacity,
Duplicate,
RegistryUnavailable,
}
fn register_media_handoff(
registry: &SharedMediaHandoffs,
handoff_id: &str,
) -> MediaHandoffRegistration {
let Ok(mut registry) = registry.lock() else {
return false;
return MediaHandoffRegistration::RegistryUnavailable;
};
prune_media_handoffs(&mut registry);
if registry.contains_key(handoff_id) || registry.len() >= MAX_ACTIVE_MEDIA_HANDOFFS {
return false;
if registry.active.contains_key(handoff_id) || registry.replays.contains_key(handoff_id) {
return MediaHandoffRegistration::Duplicate;
}
registry.insert(
let registration = if registry.active.len() >= MAX_ACTIVE_MEDIA_HANDOFFS {
// An idle page session is less valuable than the new user's base
// handoff. Never evict a discovery request already being delivered.
let oldest_idle = registry
.active
.iter()
.filter(|(_, state)| state.discovery.is_none())
.min_by_key(|(_, state)| state.expires_at)
.map(|(id, _)| id.clone());
if let Some(id) = oldest_idle {
registry.active.remove(&id);
MediaHandoffRegistration::TrackedAfterEviction
} else {
// The initial page capture remains available. Its optional late
// manifest update will be acknowledged as a safe stale no-op.
return MediaHandoffRegistration::UntrackedCapacity;
}
} else {
MediaHandoffRegistration::Tracked
};
registry.active.insert(
handoff_id.to_string(),
MediaHandoffState {
discovery: None,
expires_at: Instant::now() + MEDIA_HANDOFF_TTL,
},
);
true
registration
}
fn remove_media_handoff(registry: &SharedMediaHandoffs, handoff_id: &str) {
if let Ok(mut registry) = registry.lock() {
registry.remove(handoff_id);
registry.active.remove(handoff_id);
registry.replays.remove(handoff_id);
}
}
@@ -954,10 +1018,17 @@ fn admit_media_discovery_update(
return MediaDiscoveryAdmission::RegistryUnavailable;
};
prune_media_handoffs(&mut registry);
let Some(state) = registry.get_mut(&update.handoff_id) else {
let content = media_discovery_content(update);
if let Some(replay) = registry.replays.get(&update.handoff_id) {
return if replay.content == content {
MediaDiscoveryAdmission::Duplicate
} else {
MediaDiscoveryAdmission::Conflict
};
}
let Some(state) = registry.active.get_mut(&update.handoff_id) else {
return MediaDiscoveryAdmission::Missing;
};
let content = media_discovery_content(update);
match state.discovery.take() {
None => {
let (completion, _waiter) = watch::channel(None);
@@ -991,15 +1062,6 @@ fn admit_media_discovery_update(
MediaDiscoveryAdmission::Conflict
}
}
Some(MediaDiscoveryState::Acknowledged { content: previous }) => {
let same_content = previous == content;
state.discovery = Some(MediaDiscoveryState::Acknowledged { content: previous });
if same_content {
MediaDiscoveryAdmission::Duplicate
} else {
MediaDiscoveryAdmission::Conflict
}
}
}
}
@@ -1014,7 +1076,7 @@ fn finish_media_discovery_attempt(
let Ok(mut registry) = registry.lock() else {
return false;
};
let Some(state) = registry.get_mut(handoff_id) else {
let Some(mut state) = registry.active.remove(handoff_id) else {
return false;
};
let Some(MediaDiscoveryState::InFlight {
@@ -1024,6 +1086,7 @@ fn finish_media_discovery_attempt(
attempt_expires_at,
}) = state.discovery.take()
else {
registry.active.insert(handoff_id.to_string(), state);
return false;
};
if current_attempt_id != attempt_id || content != media_discovery_content(update) {
@@ -1033,11 +1096,33 @@ fn finish_media_discovery_attempt(
completion,
attempt_expires_at,
});
registry.active.insert(handoff_id.to_string(), state);
return false;
}
if result == MediaDiscoveryDeliveryResult::Acknowledged {
state.discovery = Some(MediaDiscoveryState::Acknowledged { content });
let now = Instant::now();
registry.replays.insert(
handoff_id.to_string(),
MediaDiscoveryReplay {
content,
expires_at: now + MEDIA_HANDOFF_TTL,
},
);
if registry.replays.len() > MAX_MEDIA_DISCOVERY_REPLAY_RECORDS {
if let Some(oldest_id) = registry
.replays
.iter()
.min_by_key(|(_, replay)| replay.expires_at)
.map(|(id, _)| id.clone())
{
registry.replays.remove(&oldest_id);
}
}
} else {
state.discovery = None;
state.expires_at = Instant::now() + MEDIA_HANDOFF_TTL;
registry.active.insert(handoff_id.to_string(), state);
}
completion
};
@@ -1049,7 +1134,7 @@ fn finish_media_discovery_attempt(
#[cfg(test)]
fn expire_media_handoff(registry: &SharedMediaHandoffs, handoff_id: &str) {
if let Ok(mut registry) = registry.lock() {
if let Some(state) = registry.get_mut(handoff_id) {
if let Some(state) = registry.active.get_mut(handoff_id) {
state.expires_at = Instant::now() - Duration::from_secs(1);
}
}
@@ -1591,7 +1676,8 @@ mod tests {
is_valid_client_nonce, is_valid_media_handoff_id, normalize_download,
normalize_headers, normalize_media_discovery_update, normalize_media_handoff_metadata,
normalize_media_url, normalize_referer, normalize_url,
expire_media_handoff, finish_media_discovery_attempt, register_media_handoff,
expire_media_handoff, finish_media_discovery_attempt, new_media_handoffs,
register_media_handoff,
reject_extension_download, wait_for_extension_acknowledgement,
require_frontend_ready, required_client_nonce, same_origin_url, sanitize_filename,
server_session_for_request, session_binding_requested,
@@ -1599,7 +1685,9 @@ mod tests {
wait_for_media_discovery_completion,
ExtensionCookieScope, ExtensionMediaDiscoveryRequest, ExtensionRequest,
MediaDiscoveryAdmission, MediaDiscoveryDeliveryResult, MediaHandoffPhase,
MAX_MEDIA_HEADER_VALUE_BYTES, MAX_MEDIA_HANDOFF_ID_BYTES, MAX_MEDIA_REFERER_BYTES,
MediaHandoffRegistration, MAX_ACTIVE_MEDIA_HANDOFFS,
MAX_MEDIA_DISCOVERY_REPLAY_RECORDS, MAX_MEDIA_HEADER_VALUE_BYTES,
MAX_MEDIA_HANDOFF_ID_BYTES, MAX_MEDIA_REFERER_BYTES,
MAX_URL_COUNT,
MEDIA_HANDOFF_PROTOCOL_VERSION,
PROTOCOL_VERSION_HEADER, SERVER_HEADER, SERVER_SESSION_HEADER,
@@ -1617,7 +1705,7 @@ mod tests {
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use std::time::{Duration, Instant};
fn normalized_discovery_update(
handoff_id: &str,
@@ -1808,8 +1896,11 @@ mod tests {
#[tokio::test]
async fn discovery_updates_require_ack_before_deduplication_and_roll_back_on_failure() {
let registry = Arc::new(Mutex::new(HashMap::new()));
assert!(register_media_handoff(&registry, "media-opaque"));
let registry = new_media_handoffs();
assert_eq!(
register_media_handoff(&registry, "media-opaque"),
MediaHandoffRegistration::Tracked
);
let update = normalized_discovery_update(
"media-opaque",
@@ -1866,6 +1957,8 @@ mod tests {
success_waiter.await.unwrap(),
Some(MediaDiscoveryDeliveryResult::Acknowledged)
);
assert_eq!(registry.lock().unwrap().active.len(), 0);
assert_eq!(registry.lock().unwrap().replays.len(), 1);
assert!(matches!(
admit_media_discovery_update(&registry, &update, "attempt-5".to_string()),
MediaDiscoveryAdmission::Duplicate
@@ -1887,7 +1980,7 @@ mod tests {
#[test]
fn missing_and_expired_discovery_handoffs_are_not_successful_duplicates() {
let missing_registry = Arc::new(Mutex::new(HashMap::new()));
let missing_registry = new_media_handoffs();
let update = normalized_discovery_update(
"missing-handoff",
"https://cdn.example/stream.m3u8",
@@ -1899,8 +1992,11 @@ mod tests {
MediaDiscoveryAdmission::Missing
));
let expired_registry = Arc::new(Mutex::new(HashMap::new()));
assert!(register_media_handoff(&expired_registry, "expired-handoff"));
let expired_registry = new_media_handoffs();
assert_eq!(
register_media_handoff(&expired_registry, "expired-handoff"),
MediaHandoffRegistration::Tracked
);
expire_media_handoff(&expired_registry, "expired-handoff");
let expired = normalized_discovery_update(
"expired-handoff",
@@ -1914,6 +2010,137 @@ mod tests {
));
}
#[test]
fn active_capacity_evicts_old_idle_sessions_and_keeps_initial_handoff_admissible() {
let registry = new_media_handoffs();
for index in 0..MAX_ACTIVE_MEDIA_HANDOFFS {
assert_eq!(
register_media_handoff(&registry, &format!("idle-{index}")),
MediaHandoffRegistration::Tracked
);
}
{
let mut registry = registry.lock().unwrap();
registry.active.get_mut("idle-0").unwrap().expires_at =
Instant::now() + Duration::from_secs(10);
}
assert_eq!(
register_media_handoff(&registry, "new-page-handoff"),
MediaHandoffRegistration::TrackedAfterEviction
);
assert_eq!(registry.lock().unwrap().active.len(), MAX_ACTIVE_MEDIA_HANDOFFS);
let evicted_update = normalized_discovery_update(
"idle-0",
"https://cdn.example/late.m3u8",
None,
None,
);
assert!(matches!(
admit_media_discovery_update(&registry, &evicted_update, "late-attempt".to_string()),
MediaDiscoveryAdmission::Missing
));
}
#[test]
fn full_inflight_registry_preserves_base_handoff_without_evicting_delivery() {
let registry = new_media_handoffs();
for index in 0..MAX_ACTIVE_MEDIA_HANDOFFS {
let id = format!("inflight-{index}");
assert_eq!(
register_media_handoff(&registry, &id),
MediaHandoffRegistration::Tracked
);
let update = normalized_discovery_update(
&id,
&format!("https://cdn.example/{index}.m3u8"),
None,
None,
);
assert!(matches!(
admit_media_discovery_update(&registry, &update, format!("attempt-{index}")),
MediaDiscoveryAdmission::Fresh
));
}
assert_eq!(
register_media_handoff(&registry, "untracked-page-handoff"),
MediaHandoffRegistration::UntrackedCapacity
);
assert_eq!(registry.lock().unwrap().active.len(), MAX_ACTIVE_MEDIA_HANDOFFS);
let late_update = normalized_discovery_update(
"untracked-page-handoff",
"https://cdn.example/untracked.m3u8",
None,
None,
);
assert!(matches!(
admit_media_discovery_update(&registry, &late_update, "late-attempt".to_string()),
MediaDiscoveryAdmission::Missing
));
}
#[tokio::test]
async fn replay_records_are_separate_from_active_capacity_and_remain_bounded() {
let registry = new_media_handoffs();
for index in 0..(MAX_MEDIA_DISCOVERY_REPLAY_RECORDS + 1) {
let id = format!("replay-{index}");
assert_eq!(
register_media_handoff(&registry, &id),
MediaHandoffRegistration::Tracked
);
let update = normalized_discovery_update(
&id,
&format!("https://cdn.example/{index}.m3u8"),
None,
None,
);
assert!(matches!(
admit_media_discovery_update(&registry, &update, format!("attempt-{index}")),
MediaDiscoveryAdmission::Fresh
));
assert!(finish_media_discovery_attempt(
&registry,
&id,
&format!("attempt-{index}"),
&update,
MediaDiscoveryDeliveryResult::Acknowledged,
));
}
for index in 0..MAX_ACTIVE_MEDIA_HANDOFFS {
assert_eq!(
register_media_handoff(&registry, &format!("active-{index}")),
MediaHandoffRegistration::Tracked
);
}
{
let state = registry.lock().unwrap();
assert_eq!(state.active.len(), MAX_ACTIVE_MEDIA_HANDOFFS);
assert_eq!(state.replays.len(), MAX_MEDIA_DISCOVERY_REPLAY_RECORDS);
}
let retained_replay = normalized_discovery_update(
"replay-128",
"https://cdn.example/128.m3u8",
None,
None,
);
assert!(matches!(
admit_media_discovery_update(&registry, &retained_replay, "replay-check".to_string()),
MediaDiscoveryAdmission::Duplicate
));
let evicted_replay = normalized_discovery_update(
"replay-0",
"https://cdn.example/0.m3u8",
None,
None,
);
assert!(matches!(
admit_media_discovery_update(&registry, &evicted_replay, "evicted-check".to_string()),
MediaDiscoveryAdmission::Missing
));
}
#[test]
fn discovery_update_payload_has_a_stable_ipc_shape() {
let update = normalized_discovery_update(
+1322 -187
View File
File diff suppressed because it is too large Load Diff
+73 -6
View File
@@ -216,11 +216,55 @@ fn launch_path(path: String) -> String {
format!("\"{}\"", path.replace('"', "\\\""))
}
#[cfg(any(target_os = "linux", test))]
fn encode_linux_exec_path(path: &str) -> Result<String, String> {
if path.chars().any(char::is_control) {
return Err(
"Linux executable path contains an unsupported control character".to_string(),
);
}
if path.contains('=') {
return Err(
"Linux executable path contains '=' which Desktop Entry Exec does not support"
.to_string(),
);
}
// First encode the argument according to the Exec quoting rules. Percent
// signs are doubled so the launcher does not treat them as field codes.
let mut quoted_argument = String::with_capacity(path.len() + 2);
quoted_argument.push('"');
for character in path.chars() {
match character {
'\\' | '"' | '`' | '$' => {
quoted_argument.push('\\');
quoted_argument.push(character);
}
'%' => quoted_argument.push_str("%%"),
_ => quoted_argument.push(character),
}
}
quoted_argument.push('"');
// Exec is also a desktop-entry string value. Escape its backslashes and
// supported control characters so the parser can apply the Exec quoting
// rules to the intended argument above.
let mut encoded = String::with_capacity(quoted_argument.len() + 2);
for character in quoted_argument.chars() {
match character {
'\\' => encoded.push_str("\\\\"),
'\t' => encoded.push_str("\\t"),
'\n' => encoded.push_str("\\n"),
'\r' => encoded.push_str("\\r"),
_ => encoded.push(character),
}
}
Ok(encoded)
}
#[cfg(target_os = "linux")]
fn launch_path(path: String) -> String {
// XDG Exec values use double-quoted arguments rather than shell parsing.
let escaped = path.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
fn launch_path(path: String) -> Result<String, String> {
encode_linux_exec_path(&path)
}
#[cfg(target_os = "macos")]
@@ -248,7 +292,7 @@ fn build_auto_launch() -> Result<auto_launch::AutoLaunch, String> {
fn build_auto_launch() -> Result<auto_launch::AutoLaunch, String> {
use auto_launch::{AutoLaunchBuilder, LinuxLaunchMode};
let executable = launch_path(linux_executable_path()?);
let executable = launch_path(linux_executable_path()?)?;
let mut builder = AutoLaunchBuilder::new();
builder
.set_app_name("Firelink")
@@ -481,7 +525,7 @@ pub fn open_login_items_settings() -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::{has_autostart_argument, AUTOSTART_ARGUMENT};
use super::{encode_linux_exec_path, has_autostart_argument, AUTOSTART_ARGUMENT};
#[test]
fn accepts_only_the_exact_autostart_argument() {
@@ -495,4 +539,27 @@ mod tests {
assert!(!has_autostart_argument(["--firelink-autostart-extra"]));
assert!(!has_autostart_argument(["firelink-autostart"]));
}
#[test]
fn encodes_linux_exec_path_special_characters() {
let path = r#"/opt/Firelink app/quoted"/back\slash/$cost/`tick`/100%"#;
assert_eq!(
encode_linux_exec_path(path).unwrap(),
r#""/opt/Firelink app/quoted\\"/back\\\\slash/\\$cost/\\`tick\\`/100%%""#
);
}
#[test]
fn rejects_control_characters_in_linux_executable_paths() {
for character in ['\0', '\t', '\n', '\r', '\u{1}', '\u{7f}', '\u{85}'] {
let path = format!("/opt/Firelink{character}path");
assert!(encode_linux_exec_path(&path).is_err());
}
}
#[test]
fn rejects_equals_in_linux_executable_path() {
assert!(encode_linux_exec_path("/opt/Fire=link").is_err());
}
}
+1412 -68
View File
File diff suppressed because it is too large Load Diff
+40 -17
View File
@@ -299,6 +299,7 @@ function App() {
const pendingScheduledStopIds = useRef(new Set<string>());
const startupInputReady = useRef(false);
const extensionProcessing = useRef(createSerialTaskQueue());
const mediaDiscoveryProcessing = useRef(createSerialTaskQueue());
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
const pendingStartupInputs = useRef<Array<
| { type: 'extension'; payload: ExtensionDownloadRequest }
@@ -394,19 +395,19 @@ function App() {
}, []);
const processExtensionMediaDiscovery = useCallback(async (payload: ExtensionMediaDiscoveryUpdate) => {
let handled = false;
let disposition = 'retryable';
try {
await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate(payload);
handled = true;
} finally {
// Only a completed projection is a success ACK. On failure, close the
// native waiter as retryable so the extension can safely replay the
// bounded discovery update against the native admission state.
if (handled) {
await acknowledgeExtensionMediaDiscovery(payload.request_id);
} else {
await failExtensionMediaDiscovery(payload.request_id);
}
disposition = await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate(payload);
} catch (error) {
console.error('Could not apply browser media discovery:', error);
}
// A queued update is safe to acknowledge because the store now owns it;
// stale updates are intentional no-ops. Only a rejected update stays
// retryable at the native handoff boundary.
if (disposition === 'retryable') {
await failExtensionMediaDiscovery(payload.request_id);
} else {
await acknowledgeExtensionMediaDiscovery(payload.request_id);
}
}, [acknowledgeExtensionMediaDiscovery, failExtensionMediaDiscovery]);
@@ -414,6 +415,10 @@ function App() {
return extensionProcessing.current(task);
}, []);
const enqueueMediaDiscoveryInput = useCallback((task: () => void | Promise<void>) => {
return mediaDiscoveryProcessing.current(task);
}, []);
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
clearPendingPostActionTimer();
@@ -686,7 +691,26 @@ function App() {
}
});
const exitCancelledListener = listen('app-exit-cancelled', event => {
if (activeExitRequestId !== event.payload.requestId) return;
restoreExitAttempt(event.payload.requestId, true);
const interruptedMediaIds = Array.isArray(event.payload.interruptedMediaIds)
? event.payload.interruptedMediaIds.filter((id): id is string => typeof id === 'string')
: [];
if (interruptedMediaIds.length > 0) {
void Promise.allSettled(interruptedMediaIds.map(async id => {
const item = useDownloadStore.getState().downloads.find(download => download.id === id);
if (
!item
|| !['downloading', 'processing', 'verifying', 'retrying'].includes(item.status)
) {
return;
}
const resumed = await useDownloadStore.getState().resumeDownload(id);
if (!resumed) {
console.error(`Could not resume media download ${id} after exit was cancelled`);
}
}));
}
});
const exitFinalFlushListener = listen('app-exit-final-flush-requested', async event => {
if (activeExitRequestId !== event.payload.requestId || !exiting) {
@@ -717,7 +741,6 @@ function App() {
requestId: event.payload.requestId,
succeeded
});
activeExitRequestId = null;
} catch (error) {
console.error('Failed to acknowledge the final exit flush:', error);
}
@@ -951,7 +974,7 @@ function App() {
pendingStartupInputs.current.push({ type: 'extension-media-discovery', payload: event.payload });
return;
}
enqueueAddInput(() => processExtensionMediaDiscovery(event.payload)).catch(error => {
enqueueMediaDiscoveryInput(() => processExtensionMediaDiscovery(event.payload)).catch(error => {
console.error('Failed to handle browser media discovery:', error);
});
});
@@ -1044,7 +1067,7 @@ function App() {
disposePersistence?.();
disposePersistence = null;
};
}, [addToast, enqueueAddInput, processExtensionDownload, processExtensionMediaDiscovery, queueFrontendReadyUpdate]);
}, [addToast, enqueueAddInput, enqueueMediaDiscoveryInput, processExtensionDownload, processExtensionMediaDiscovery, queueFrontendReadyUpdate]);
useEffect(() => {
if (!coreReady || exitPreparing) return;
@@ -1070,14 +1093,14 @@ function App() {
console.error('Failed to handle queued browser extension download:', error);
});
} else if (input.type === 'extension-media-discovery') {
enqueueAddInput(() => processExtensionMediaDiscovery(input.payload)).catch(error => {
enqueueMediaDiscoveryInput(() => processExtensionMediaDiscovery(input.payload)).catch(error => {
console.error('Failed to handle queued browser media discovery:', error);
});
} else {
enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(input.payload));
}
}
}, [coreReady, enqueueAddInput, exitPreparing, processExtensionDownload, processExtensionMediaDiscovery, showKeychainModal]);
}, [coreReady, enqueueAddInput, enqueueMediaDiscoveryInput, exitPreparing, processExtensionDownload, processExtensionMediaDiscovery, showKeychainModal]);
useEffect(() => {
if (!coreReady || showKeychainModal || startupResumeStarted.current || exitPreparing || frontendExitPreparing) return;
@@ -0,0 +1,38 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { AddDownloadPreviewRow } from './AddDownloadPreviewRow';
describe('AddDownloadPreviewRow accessibility', () => {
it('keeps details, download selection, and media mode as separate native controls', () => {
const markup = renderToStaticMarkup(
<div role="list" aria-label="Download preview">
<AddDownloadPreviewRow
file="episode.mkv"
selected={false}
detailsSelected
selectionLabel="Select episode.mkv"
onToggleSelection={() => undefined}
onSelectDetails={() => undefined}
details={<span>episode.mkv 1080p ready</span>}
mediaControl={(
<button type="button" aria-label="Treat as media" aria-pressed="false">
Media
</button>
)}
/>
</div>
);
expect(markup).toContain('role="listitem"');
expect(markup).not.toMatch(/role="(?:listbox|option)"/);
expect(markup).toContain('<input type="checkbox"');
expect(markup).toContain('<button type="button" aria-pressed="true"');
expect(markup).toContain('aria-label="Select episode.mkv"');
expect(markup).toContain('aria-label="Treat as media"');
const listItem = markup.slice(markup.indexOf('role="listitem"'));
expect(listItem.indexOf('<input')).toBeLessThan(listItem.indexOf('<button'));
expect(listItem.indexOf('</button>')).toBeLessThan(listItem.lastIndexOf('<button'));
expect(listItem).not.toMatch(/<button\b[^>]*>[^]*?<div\b/);
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react';
type AddDownloadPreviewRowProps = {
file: string;
selected: boolean;
detailsSelected: boolean;
selectionLabel: string;
onToggleSelection: () => void;
onSelectDetails: () => void;
details: ReactNode;
mediaControl?: ReactNode;
};
export const AddDownloadPreviewRow = ({
file,
selected,
detailsSelected,
selectionLabel,
onToggleSelection,
onSelectDetails,
details,
mediaControl
}: AddDownloadPreviewRowProps) => (
<div
role="listitem"
className={`add-download-preview-row flex flex-col text-xs px-2 py-2 rounded-md group ${
detailsSelected ? 'is-selected border' : 'border border-transparent'
}`}
>
<div className="flex items-center w-full">
<input
type="checkbox"
checked={selected}
onChange={onToggleSelection}
aria-label={selectionLabel}
className="me-2 shrink-0 accent-purple-500"
/>
<button
type="button"
onClick={onSelectDetails}
aria-pressed={detailsSelected}
className="flex-1 min-w-0 flex items-center text-start bg-transparent focus-visible:outline focus-visible:outline-2 focus-visible:outline-purple-500"
title={file}
>
{details}
</button>
{mediaControl}
</div>
</div>
);
+43 -63
View File
@@ -15,6 +15,7 @@ import { open } from '@tauri-apps/plugin-dialog';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { AddDownloadPreviewRow } from './AddDownloadPreviewRow';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isDirectMediaManifestUrl, isHttpMediaRouteUrl, isMediaUrl, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type MediaMode, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
@@ -37,6 +38,7 @@ import {
canSubmitMetadataRows,
applyMediaModeToRow,
appendRequestUrlsAfterVersion,
buildSelectedBySourceUrl,
commonMediaFormatsForRows,
commonMediaQualitiesForRows,
durableDownloadUrl,
@@ -424,9 +426,15 @@ export const AddDownloadsModal = () => {
// Run these after the busy flag is cleared. The first queued handoff opens
// a fresh modal after a completed submission, while later handoffs append
// to that same review session.
for (const request of queued) {
for (const request of queued.handoffs) {
void useDownloadStore.getState().handleExtensionDownload(request);
}
// Initial handoffs establish their row contexts synchronously. Apply
// accepted discovery updates afterward so a discovery that arrived while
// its initial capture was queued can still upgrade the correct row.
for (const update of queued.mediaDiscoveryUpdates) {
void useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate(update);
}
}, [releasePendingAddHandoffs, toggleAddModal]);
const closeModalFromDismissAction = useCallback(() => {
@@ -676,14 +684,7 @@ export const AddDownloadsModal = () => {
mediaModesBySourceUrl[normalizeComparableUrl(row.sourceUrl)] = row.mediaMode;
}
}
const selectedBySourceUrl = Object.fromEntries(
current.map(row => [row.sourceUrl, row.selected !== false])
);
for (const row of current) {
if (row.playlistSourceUrl && !(row.playlistSourceUrl in selectedBySourceUrl)) {
selectedBySourceUrl[row.playlistSourceUrl] = row.selected !== false;
}
}
const selectedBySourceUrl = buildSelectedBySourceUrl(current, pendingAddRequestContexts);
return reconcileDownloadRows(
urls,
current,
@@ -2535,54 +2536,37 @@ export const AddDownloadsModal = () => {
<div className="flex-1">{t($ => $.addDownloads.size)}</div>
<div className="flex-[1.5]">{t($ => $.addDownloads.status)}</div>
</div>
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label={t($ => $.addDownloads.downloadPreview)}>
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="list" aria-label={t($ => $.addDownloads.downloadPreview)}>
{parsedItems.length === 0 ? (
<div className="add-download-empty h-full flex items-center justify-center text-text-muted text-xs">
{t($ => $.addDownloads.noLinks)}
</div>
) : (
parsedItems.map((item, i) => (
<div
<AddDownloadPreviewRow
key={item.id}
onClick={() => setSelectedItemIndex(i)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedItemIndex(i);
}
}}
role="option"
aria-selected={selectedItemIndex === i}
tabIndex={0}
className={`add-download-preview-row flex flex-col text-xs px-2 py-2 cursor-pointer rounded-md group ${
selectedItemIndex === i
? 'is-selected border'
: 'border border-transparent'
}`}
>
<div className="flex items-center w-full">
<input
type="checkbox"
checked={item.selected !== false}
onChange={() => toggleRowSelection(i)}
onClick={event => event.stopPropagation()}
aria-label={t($ => $.addDownloads.selectItem, { file: item.file })}
className="me-2 shrink-0 accent-purple-500"
/>
<div className="flex-[2] min-w-0 flex items-center gap-2 text-text-primary font-medium truncate pe-2" title={item.file}>
file={item.file}
selected={item.selected !== false}
detailsSelected={selectedItemIndex === i}
selectionLabel={t($ => $.addDownloads.selectItem, { file: item.file })}
onToggleSelection={() => toggleRowSelection(i)}
onSelectDetails={() => setSelectedItemIndex(i)}
details={(
<span className="flex items-center w-full">
<span className="flex-[2] min-w-0 flex items-center gap-2 text-text-primary font-medium truncate pe-2" title={item.file}>
<span className="truncate">{item.file}</span>
{item.isMedia && item.status === 'ready' && mediaQualityForRow(item) ? (
<span className="add-download-quality-chip shrink-0" title={t($ => $.addDownloads.quality)}>
{mediaQualityForRow(item)}
</span>
) : null}
</div>
<div className={`flex-1 font-mono ${isAddDownloadMetadataLoading(item) ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</div>
<div className={`flex-[1.5] font-medium ${((isAddDownloadMetadataError(item) && !isYtDlpDefaultMediaFormatSelected(item)) || item.status === 'invalid') ? 'text-red-500' : isAddDownloadMetadataLoading(item) ? 'text-orange-400' : 'text-blue-500'}`}>
</span>
<span className={`flex-1 font-mono ${isAddDownloadMetadataLoading(item) ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</span>
<span className={`flex-[1.5] font-medium ${((isAddDownloadMetadataError(item) && !isYtDlpDefaultMediaFormatSelected(item)) || item.status === 'invalid') ? 'text-red-500' : isAddDownloadMetadataLoading(item) ? 'text-orange-400' : 'text-blue-500'}`}>
{isAddDownloadMetadataLoading(item) ? (
<div className="flex items-center gap-1.5">
<span className="flex items-center gap-1.5">
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? t($ => $.addDownloads.fetchingPlaylist) : t($ => $.addDownloads.fetching)}
</div>
</span>
) : (
isYtDlpDefaultMediaFormatSelected(item)
? t($ => $.addDownloads.defaultMediaFormat)
@@ -2602,27 +2586,23 @@ export const AddDownloadsModal = () => {
? t($ => $.addDownloads.invalid)
: t($ => $.addDownloads.ready)
)}
</div>
{!item.isTorrent && (
<button
type="button"
className={`app-icon-button h-6 w-6 shrink-0 ${item.mediaMode === 'media' ? 'text-blue-500' : ''}`}
aria-label={t($ => $.addDownloads.treatAsMedia)}
aria-pressed={item.mediaMode === 'media'}
title={t($ => $.addDownloads.treatAsMedia)}
disabled={!isHttpMediaRouteUrl(item.sourceUrl) || item.status === 'invalid'}
onClick={event => {
event.preventDefault();
event.stopPropagation();
updateMediaModeForRow(i, 'media');
}}
onKeyDown={event => event.stopPropagation()}
>
<Video size={13} aria-hidden="true" />
</button>
)}
</div>
</div>
</span>
</span>
)}
mediaControl={!item.isTorrent ? (
<button
type="button"
className={`app-icon-button h-6 w-6 shrink-0 ${item.mediaMode === 'media' ? 'text-blue-500' : ''}`}
aria-label={t($ => $.addDownloads.treatAsMedia)}
aria-pressed={item.mediaMode === 'media'}
title={t($ => $.addDownloads.treatAsMedia)}
disabled={!isHttpMediaRouteUrl(item.sourceUrl) || item.status === 'invalid'}
onClick={() => updateMediaModeForRow(i, 'media')}
>
<Video size={13} aria-hidden="true" />
</button>
) : undefined}
/>
))
)}
</div>
+5 -1
View File
@@ -268,7 +268,11 @@ type EventMap = {
'tray-action-available': null;
'tray-action-result': TrayActionResult;
'app-exit-requested': { requestId: string };
'app-exit-cancelled': { requestId: string; reason: 'rejected' | 'timeout' };
'app-exit-cancelled': {
requestId: string;
reason: 'rejected' | 'timeout' | 'signal-failed' | 'media-shutdown-failed' | 'terminal-journal-failed';
interruptedMediaIds: string[];
};
'app-exit-final-flush-requested': { requestId: string };
};
+108 -6
View File
@@ -111,6 +111,7 @@ describe('useDownloadStore', () => {
pendingAddRequestContexts: {},
pendingAddRequestVersion: 0,
pendingAddHandoffs: [],
pendingAddMediaDiscoveryUpdates: [],
pendingAddModalBusy: false,
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
});
@@ -3652,6 +3653,38 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-destination-access')).toBe(false);
});
it('does not requeue a completion recovered from the shutdown journal', async () => {
const id = 'shutdown-recovered-completion';
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'list_download_removals') return [];
if (command === 'db_get_all_queues') return [];
if (command === 'db_get_all_downloads') {
return [JSON.stringify({
id,
url: 'https://example.com/completed.mp4',
fileName: 'completed.mp4',
status: 'completed',
category: 'Movies',
dateAdded: '',
queueId: MAIN_QUEUE_ID,
isMedia: true,
hasBeenDispatched: true
})];
}
return undefined;
});
await useDownloadStore.getState().initDB();
expect(useDownloadStore.getState().downloads.find(download => download.id === id)?.status)
.toBe('completed');
await useDownloadStore.getState().resumePendingDownloads();
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
expect(useDownloadStore.getState().downloads.find(download => download.id === id)?.status)
.toBe('completed');
});
it('does not show allocation for a startup Torrent batch while it is merely queued', async () => {
let releaseEnqueue!: (value: Array<{ id: string; success: boolean; filename: string }>) => void;
const enqueue = new Promise<Array<{ id: string; success: boolean; filename: string }>>(resolve => {
@@ -5114,7 +5147,7 @@ describe('useDownloadStore', () => {
referer: pageUrl,
headers: 'Origin: https://video.example'
};
await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate(update);
expect(await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate(update)).toBe('applied');
const state = useDownloadStore.getState();
expect(state.pendingAddUrls).toBe(manifestUrl);
@@ -5177,19 +5210,87 @@ describe('useDownloadStore', () => {
});
it('ignores a discovered manifest when its initial handoff is no longer reviewable', async () => {
await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate({
expect(await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate({
request_id: 'late-discovery',
handoff_id: 'm-missing',
phase: 'discovered',
urls: ['https://cdn.example/late.m3u8'],
referer: null,
headers: null
});
})).toBe('stale');
expect(useDownloadStore.getState().pendingAddUrls).toBe('');
expect(useDownloadStore.getState().pendingAddRequestContexts).toEqual({});
});
it('queues a discovery update during a busy Add flow and applies it after release', async () => {
const handoffId = 'm-busy-discovery';
const pageUrl = 'https://video.example/watch?id=busy';
const manifestUrl = 'https://cdn.example/busy.m3u8';
useDownloadStore.getState().openAddModalWithUrls(
pageUrl,
pageUrl,
null,
null,
null,
true,
undefined,
false,
undefined,
false,
undefined,
undefined,
handoffId
);
useDownloadStore.getState().setPendingAddModalBusy(true);
const update: ExtensionMediaDiscoveryUpdate = {
request_id: 'busy-discovery-request',
handoff_id: handoffId,
phase: 'discovered',
urls: [manifestUrl],
referer: pageUrl,
headers: null
};
expect(await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate(update)).toBe('queued');
expect(useDownloadStore.getState().pendingAddUrls).toBe(pageUrl);
expect(useDownloadStore.getState().pendingAddMediaDiscoveryUpdates).toEqual([update]);
const queued = useDownloadStore.getState().releasePendingAddHandoffs();
expect(queued).toEqual({ handoffs: [], mediaDiscoveryUpdates: [update] });
expect(await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate(queued.mediaDiscoveryUpdates[0])).toBe('applied');
expect(useDownloadStore.getState().pendingAddUrls).toBe(manifestUrl);
expect(useDownloadStore.getState().pendingAddRequestContexts[manifestUrl]).toMatchObject({
handoffId,
replacesUrl: pageUrl
});
});
it('returns retryable instead of acknowledging when a busy discovery queue is full', async () => {
useDownloadStore.getState().setPendingAddModalBusy(true);
const queue = Array.from({ length: 64 }, (_, index): ExtensionMediaDiscoveryUpdate => ({
request_id: `queued-${index}`,
handoff_id: `handoff-${index}`,
phase: 'discovered',
urls: [`https://cdn.example/${index}.m3u8`],
referer: null,
headers: null
}));
useDownloadStore.setState({ pendingAddMediaDiscoveryUpdates: queue });
const disposition = await useDownloadStore.getState().handleExtensionMediaDiscoveryUpdate({
request_id: 'queue-overflow',
handoff_id: 'handoff-overflow',
phase: 'discovered',
urls: ['https://cdn.example/overflow.m3u8'],
referer: null,
headers: null
});
expect(disposition).toBe('retryable');
expect(useDownloadStore.getState().pendingAddMediaDiscoveryUpdates).toHaveLength(64);
});
it('routes direct manifests through media header and cookie suppression without forcing the mode', () => {
const url = 'https://cdn.example/live/stream.m3u8?token=abc';
useDownloadStore.getState().openAddModalWithUrls(
@@ -5285,12 +5386,13 @@ describe('useDownloadStore', () => {
expect(duplicateResolved).toBe(false);
const queued = useDownloadStore.getState().releasePendingAddHandoffs();
expect(queued).toHaveLength(1);
expect(queued[0]).toMatchObject({ request_id: 'busy-handoff-1' });
expect(queued.handoffs).toHaveLength(1);
expect(queued.handoffs[0]).toMatchObject({ request_id: 'busy-handoff-1' });
expect(queued.mediaDiscoveryUpdates).toEqual([]);
expect(useDownloadStore.getState().pendingAddHandoffs).toEqual([]);
expect(useDownloadStore.getState().pendingAddModalBusy).toBe(false);
await useDownloadStore.getState().handleExtensionDownload(queued[0]);
await useDownloadStore.getState().handleExtensionDownload(queued.handoffs[0]);
await Promise.all([firstDelivery, duplicateDelivery]);
expect(firstResolved).toBe(true);
expect(duplicateResolved).toBe(true);
+63 -12
View File
@@ -1201,6 +1201,10 @@ export type PendingAddRequestContext = {
torrentCacheId?: string;
};
export type MediaDiscoveryDisposition = 'applied' | 'queued' | 'stale' | 'retryable';
const MAX_PENDING_ADD_MEDIA_DISCOVERY_UPDATES = 64;
export type DeleteModalState = {
isOpen: boolean;
downloadIds?: string[];
@@ -1246,11 +1250,16 @@ interface DownloadState {
pendingAddRequestVersion: number;
/** Extension handoffs received while the Add window is mutating its rows. */
pendingAddHandoffs: ExtensionDownloadRequest[];
/** Accepted media-discovery updates waiting for the current Add mutation. */
pendingAddMediaDiscoveryUpdates: ExtensionMediaDiscoveryUpdate[];
pendingAddModalBusy: boolean;
selectedPropertiesDownloadId: string | null;
toggleAddModal: (isOpen: boolean) => void;
setPendingAddModalBusy: (busy: boolean) => void;
releasePendingAddHandoffs: () => ExtensionDownloadRequest[];
releasePendingAddHandoffs: () => {
handoffs: ExtensionDownloadRequest[];
mediaDiscoveryUpdates: ExtensionMediaDiscoveryUpdate[];
};
openAddModalWithUrls: (
urls: string,
referer?: string | null,
@@ -1267,7 +1276,9 @@ interface DownloadState {
handoffId?: string
) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
handleExtensionMediaDiscoveryUpdate: (update: ExtensionMediaDiscoveryUpdate) => Promise<void>;
handleExtensionMediaDiscoveryUpdate: (
update: ExtensionMediaDiscoveryUpdate
) => Promise<MediaDiscoveryDisposition>;
deleteModalState: DeleteModalState;
openDeleteModal: (downloadIds?: string | string[]) => void;
closeDeleteModal: () => void;
@@ -2010,6 +2021,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
pendingAddRequestContexts: {},
pendingAddRequestVersion: 0,
pendingAddHandoffs: [],
pendingAddMediaDiscoveryUpdates: [],
pendingAddModalBusy: false,
selectedPropertiesDownloadId: null,
deleteModalState: { isOpen: false },
@@ -2032,6 +2044,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
pendingAddBatch: false,
pendingAddBatchName: '',
pendingAddRequestContexts: {},
pendingAddMediaDiscoveryUpdates: [],
// Invalidate any in-flight Add-modal handoff even when the modal is
// opened or closed without URLs.
pendingAddRequestVersion: state.pendingAddRequestVersion + 1
@@ -2039,14 +2052,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
setPendingAddModalBusy: (busy) => set({ pendingAddModalBusy: busy }),
releasePendingAddHandoffs: () => {
let handoffs: ExtensionDownloadRequest[] = [];
let mediaDiscoveryUpdates: ExtensionMediaDiscoveryUpdate[] = [];
set(state => {
handoffs = state.pendingAddHandoffs;
mediaDiscoveryUpdates = state.pendingAddMediaDiscoveryUpdates;
return {
pendingAddHandoffs: [],
pendingAddMediaDiscoveryUpdates: [],
pendingAddModalBusy: false
};
});
return handoffs;
return { handoffs, mediaDiscoveryUpdates };
},
openAddModalWithUrls: (
urls,
@@ -2212,26 +2228,59 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
handleExtensionMediaDiscoveryUpdate: async (update) => {
const handoffId = update.handoff_id?.trim();
const discoveredUrl = update.urls?.length === 1 ? update.urls[0]?.trim() : '';
if (!handoffId || update.phase !== 'discovered' || !discoveredUrl) return;
if (!handoffId || update.phase !== 'discovered' || !discoveredUrl) return 'retryable';
const comparable = (url: string) => {
try { return new URL(url).href; } catch { return url; }
};
const sameQueuedUpdate = (queued: ExtensionMediaDiscoveryUpdate) =>
queued.handoff_id?.trim() === handoffId
&& queued.phase === update.phase
&& queued.urls?.length === 1
&& comparable(queued.urls[0]?.trim() || '') === comparable(discoveredUrl)
&& (queued.referer?.trim() || '') === (update.referer?.trim() || '')
&& (stripSensitiveMediaHeaders(queued.headers) || '')
=== (stripSensitiveMediaHeaders(update.headers) || '');
let disposition: MediaDiscoveryDisposition = 'stale';
set(state => {
if (!state.isAddModalOpen || state.pendingAddModalBusy) return state;
if (state.pendingAddModalBusy) {
const queuedForHandoff = state.pendingAddMediaDiscoveryUpdates.find(
candidate => candidate.handoff_id?.trim() === handoffId
);
if (queuedForHandoff) {
disposition = sameQueuedUpdate(queuedForHandoff) ? 'queued' : 'retryable';
return state;
}
if (state.pendingAddMediaDiscoveryUpdates.length >= MAX_PENDING_ADD_MEDIA_DISCOVERY_UPDATES) {
disposition = 'retryable';
return state;
}
disposition = 'queued';
return {
pendingAddMediaDiscoveryUpdates: [
...state.pendingAddMediaDiscoveryUpdates,
update
]
};
}
if (!state.isAddModalOpen) return state;
const matchingEntry = Object.entries(state.pendingAddRequestContexts)
.find(([, context]) => context.handoffId === handoffId && context.media === true);
if (!matchingEntry) return state;
const [currentUrl, currentContext] = matchingEntry;
const nextUrl = discoveredUrl;
const currentComparable = (() => {
try { return new URL(currentUrl).href; } catch { return currentUrl; }
})();
const nextComparable = (() => {
try { return new URL(nextUrl).href; } catch { return nextUrl; }
})();
const currentComparable = comparable(currentUrl);
const nextComparable = comparable(nextUrl);
// Backend retries are idempotent, so a duplicate delivery of the same
// discovered target must not create a new request version or restart
// metadata work for an already-upgraded row.
if (currentComparable === nextComparable) return state;
if (currentComparable === nextComparable) {
disposition = 'applied';
return state;
}
const existingDifferentContext = Object.entries(state.pendingAddRequestContexts)
.some(([url, context]) => url !== currentUrl
&& context.handoffId !== handoffId
@@ -2272,6 +2321,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const pendingAddMediaUrls = Object.entries(nextContexts)
.filter(([, context]) => context.mediaMode === 'media')
.map(([url]) => url);
disposition = 'applied';
return {
pendingAddUrls,
pendingAddReferer: nextContexts[nextComparable]?.referer || state.pendingAddReferer,
@@ -2282,6 +2332,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
pendingAddRequestVersion: nextVersion
};
});
return disposition;
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
addDownload: async (item, action) => {
+25
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
appendRequestUrlsAfterVersion,
applyMediaModeToRow,
buildSelectedBySourceUrl,
classifyMediaMetadataError,
commonMediaFormatsForRows,
canSubmitMetadataRows,
@@ -79,6 +80,30 @@ describe('add download metadata workflow', () => {
});
});
it('keeps an unchecked row unchecked when discovery replaces its page URL', () => {
const pageUrl = 'https://video.example/watch?id=1';
const manifestUrl = 'https://cdn.example/master.m3u8';
const uncheckedPage = row({ sourceUrl: pageUrl, downloadUrl: pageUrl, selected: false });
const selectedBySourceUrl = buildSelectedBySourceUrl([uncheckedPage], {
[manifestUrl]: { replacesUrl: pageUrl }
});
const [replacement] = reconcileDownloadRows(
manifestUrl,
[uncheckedPage],
undefined,
new Set(),
undefined,
{},
{},
{},
selectedBySourceUrl
);
expect(replacement.sourceUrl).toBe(manifestUrl);
expect(replacement.selected).toBe(false);
});
it('deduplicates normalized URLs and marks malformed or unsupported URLs invalid', () => {
let nextId = 0;
const rows = reconcileDownloadRows(
+30
View File
@@ -558,6 +558,36 @@ const comparableUrl = (rawUrl: string): string => {
}
};
export const buildSelectedBySourceUrl = (
rows: readonly Pick<AddDownloadDraftRow, 'sourceUrl' | 'playlistSourceUrl' | 'selected'>[],
requestContexts: Readonly<Record<string, { replacesUrl?: string }>>
): Record<string, boolean> => {
const selectedBySourceUrl: Record<string, boolean> = {};
for (const row of rows) {
const selected = row.selected !== false;
selectedBySourceUrl[comparableUrl(row.sourceUrl)] = selected;
if (row.playlistSourceUrl) {
const playlistUrl = comparableUrl(row.playlistSourceUrl);
if (!(playlistUrl in selectedBySourceUrl)) {
selectedBySourceUrl[playlistUrl] = selected;
}
}
}
// Discovery changes the input URL while retaining the original row as its
// logical identity. Carry an explicit unchecked state across that alias so
// reconciliation cannot default the replacement manifest back to selected.
for (const [url, context] of Object.entries(requestContexts)) {
if (!context.replacesUrl) continue;
const originalUrl = comparableUrl(context.replacesUrl);
const replacementUrl = comparableUrl(url);
if (originalUrl in selectedBySourceUrl && !(replacementUrl in selectedBySourceUrl)) {
selectedBySourceUrl[replacementUrl] = selectedBySourceUrl[originalUrl];
}
}
return selectedBySourceUrl;
};
export const appendRequestUrlsAfterVersion = (
rawText: string,
requestContexts: Readonly<Record<string, { version: number; replacesUrl?: string }>>,