mirror of
https://github.com/nimbold/Firelink.git
synced 2026-10-25 21:19:49 +00:00
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:
+313
-1
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®istry, "media-opaque"));
|
||||
let registry = new_media_handoffs();
|
||||
assert_eq!(
|
||||
register_media_handoff(®istry, "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(®istry, &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(®istry, &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(®istry, "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(®istry, &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(®istry, &id),
|
||||
MediaHandoffRegistration::Tracked
|
||||
);
|
||||
let update = normalized_discovery_update(
|
||||
&id,
|
||||
&format!("https://cdn.example/{index}.m3u8"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(matches!(
|
||||
admit_media_discovery_update(®istry, &update, format!("attempt-{index}")),
|
||||
MediaDiscoveryAdmission::Fresh
|
||||
));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
register_media_handoff(®istry, "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(®istry, &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(®istry, &id),
|
||||
MediaHandoffRegistration::Tracked
|
||||
);
|
||||
let update = normalized_discovery_update(
|
||||
&id,
|
||||
&format!("https://cdn.example/{index}.m3u8"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(matches!(
|
||||
admit_media_discovery_update(®istry, &update, format!("attempt-{index}")),
|
||||
MediaDiscoveryAdmission::Fresh
|
||||
));
|
||||
assert!(finish_media_discovery_attempt(
|
||||
®istry,
|
||||
&id,
|
||||
&format!("attempt-{index}"),
|
||||
&update,
|
||||
MediaDiscoveryDeliveryResult::Acknowledged,
|
||||
));
|
||||
}
|
||||
|
||||
for index in 0..MAX_ACTIVE_MEDIA_HANDOFFS {
|
||||
assert_eq!(
|
||||
register_media_handoff(®istry, &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(®istry, &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(®istry, &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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+40
-17
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }>>,
|
||||
|
||||
Reference in New Issue
Block a user