From 8973a72be4f41a77806cfcdc50484f94958f68af Mon Sep 17 00:00:00 2001 From: hjoncour Date: Sun, 3 May 2026 15:44:39 -0400 Subject: [PATCH 1/2] fix(download): add an issues backlog --- TODO.MD | 1 + src-tauri/src/commands/downloader.rs | 23 ++ src-tauri/src/commands/files.rs | 5 +- src-tauri/src/commands/import.rs | 11 +- src-tauri/src/commands/tools.rs | 6 +- src-tauri/src/database.rs | 238 +++++++++++----- src-tauri/src/download/manager.rs | 150 +++++++++- src-tauri/src/download/pipeline.rs | 189 +++++++++++-- src-tauri/src/download/video.rs | 168 +++++++++-- src-tauri/src/lib.rs | 2 + src/app.rs | 408 ++++++++++++++++++++++----- src/pages/downloads.rs | 131 ++++++++- src/styles/downloads.css | 59 ++++ src/types.rs | 2 + 14 files changed, 1171 insertions(+), 222 deletions(-) diff --git a/TODO.MD b/TODO.MD index 2285238..4201584 100644 --- a/TODO.MD +++ b/TODO.MD @@ -35,3 +35,4 @@ fix color buttons on windows number of parallel downloads: I update it to 10, goes back to 3 +cookie_db_path on windows diff --git a/src-tauri/src/commands/downloader.rs b/src-tauri/src/commands/downloader.rs index 285ee0b..75d5e77 100644 --- a/src-tauri/src/commands/downloader.rs +++ b/src-tauri/src/commands/downloader.rs @@ -102,6 +102,28 @@ pub async fn refresh_download_settings(manager: State<'_, DownloadManager>) -> R .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn reconcile_downloads(manager: State<'_, DownloadManager>) -> Result<(), String> { + manager + .send(DownloadCommand::ReconcileState) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn refresh_downloads_snapshot( + manager: State<'_, DownloadManager>, +) -> Result, String> { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + manager + .send(DownloadCommand::RefreshSnapshot { reply: reply_tx }) + .await + .map_err(|e| e.to_string())?; + reply_rx + .await + .map_err(|e| format!("snapshot channel closed: {e}"))? +} + fn sanitize_url(raw: &str) -> String { raw.trim() .replace("#__audio_only__", "") @@ -134,6 +156,7 @@ fn ensure_row_for_url(url: &str, force_audio: Option) -> Result<(i64, bool status: DownloadStatus::Queued, path: "unknown_path".into(), image_set_id: None, + last_error: None, date_added: Utc::now(), date_downloaded: None, }; diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 92ec51e..b3fed4b 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -52,7 +52,10 @@ pub async fn read_csv_from_path(app: tauri::AppHandle, path: String) -> Result { - println!("[BACKEND] [files] imported {n} rows (drag-drop) from {}", path); + println!( + "[BACKEND] [files] imported {n} rows (drag-drop) from {}", + path + ); let _ = app.emit("import_completed", n); } Err(e) => { diff --git a/src-tauri/src/commands/import.rs b/src-tauri/src/commands/import.rs index d3e56b5..15445d2 100644 --- a/src-tauri/src/commands/import.rs +++ b/src-tauri/src/commands/import.rs @@ -11,7 +11,10 @@ /// Returns the number of successfully imported rows. #[tauri::command] -pub async fn import_csv_to_db(csv_text: Option, csvText: Option) -> Result { +pub async fn import_csv_to_db( + csv_text: Option, + csvText: Option, +) -> Result { // Accept both snake_case and camelCase keys from JS. let csv_text = csv_text .or(csvText) @@ -104,7 +107,10 @@ pub async fn import_csv_text(csv_text: String) -> Result { let platform_token = format!("{:?}", platform).to_lowercase(); let origin_token = format!("{:?}", origin).to_lowercase(); - if db.link_exists_in_collection(&link, &platform_token, &handle, &origin_token).unwrap_or(false) { + if db + .link_exists_in_collection(&link, &platform_token, &handle, &origin_token) + .unwrap_or(false) + { continue; } @@ -120,6 +126,7 @@ pub async fn import_csv_text(csv_text: String) -> Result { status: crate::database::DownloadStatus::Backlog, path: String::new(), image_set_id: None, + last_error: None, date_added: chrono::Utc::now(), date_downloaded: None, }; diff --git a/src-tauri/src/commands/tools.rs b/src-tauri/src/commands/tools.rs index a30e3ee..a0acedf 100644 --- a/src-tauri/src/commands/tools.rs +++ b/src-tauri/src/commands/tools.rs @@ -57,5 +57,9 @@ pub async fn check_sidecar_tools(app: tauri::AppHandle) -> Result Result<()> { // Legacy builds persisted queued rows as "queue". Normalize them so // frontend and backend codepaths do not disagree about visibility/mutations. - conn.execute("UPDATE downloads SET status='queued' WHERE status='queue'", [])?; + conn.execute( + "UPDATE downloads SET status='queued' WHERE status='queue'", + [], + )?; + ensure_last_error_column(conn)?; + Ok(()) +} + +fn ensure_last_error_column(conn: &Connection) -> Result<()> { + let mut stmt = conn.prepare("PRAGMA table_info(downloads)")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let column_name: String = row.get(1)?; + if column_name == "last_error" { + return Ok(()); + } + } + conn.execute("ALTER TABLE downloads ADD COLUMN last_error TEXT", [])?; Ok(()) } @@ -63,7 +80,7 @@ pub fn open_connection() -> Result { pub fn find_download_by_id_conn(conn: &Connection, id: i64) -> Result> { let mut stmt = conn.prepare( - "SELECT id, platform, media, user_handle, origin, link, output_format, status, path, name + "SELECT id, platform, media, user_handle, origin, link, output_format, status, path, name, last_error FROM downloads WHERE id=?1 LIMIT 1", @@ -82,6 +99,7 @@ pub fn find_download_by_id_conn(conn: &Connection, id: i64) -> Result?1", [token, &id.to_string()], // unchanged rows (same status) will not be updated @@ -100,6 +119,17 @@ pub fn set_status_by_id_conn(conn: &Connection, id: i64, status: DownloadStatus) Ok(updated) } +pub fn set_last_error_by_id_conn( + conn: &Connection, + id: i64, + last_error: Option<&str>, +) -> Result { + conn.execute( + "UPDATE downloads SET last_error=?1 WHERE id=?2", + params![last_error, id], + ) +} + /// Reset any rows that were left in 'downloading' (e.g. after a crash) back to 'queued'. /// Returns the number of rows updated. pub fn reset_stale_downloading_to_queued_conn(conn: &Connection) -> Result { @@ -125,6 +155,21 @@ pub fn list_queued_ids_conn(conn: &Connection) -> Result> { Ok(out) } +pub fn list_downloading_ids_conn(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id + FROM downloads + WHERE status = 'downloading' + ORDER BY id", + )?; + let rows = stmt.query_map([], |row| row.get(0))?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + pub fn mark_id_done_conn(conn: &Connection, id: i64, path: &str) -> Result { let path_value = if path.is_empty() { "unknown_path".to_string() @@ -133,12 +178,78 @@ pub fn mark_id_done_conn(conn: &Connection, id: i64, path: &str) -> Result Result> { + let mut stmt = conn.prepare( + "SELECT id, status, platform, user_handle, origin, media, link, name, output_format, last_error + FROM downloads + ORDER BY CASE status + WHEN 'downloading' THEN 0 + WHEN 'queued' THEN 1 + WHEN 'queue' THEN 1 + WHEN 'backlog' THEN 2 + WHEN 'error' THEN 3 + WHEN 'done' THEN 4 + WHEN 'canceled' THEN 5 + ELSE 6 + END, + id", + )?; + + let rows = stmt.query_map([], |row| { + let id: i64 = row.get(0)?; + let status_raw: String = row.get(1)?; + let platform: String = row.get(2)?; + let handle: String = row.get(3)?; + let origin: String = row.get(4)?; + let media: String = row.get(5)?; + let link: String = row.get(6)?; + let _name: String = row.get(7)?; + let output_format: String = row.get(8).unwrap_or_else(|_| "default".to_string()); + let last_error: Option = row.get(9).ok(); + + let content_type = match origin.as_str() { + "recommendation" | "playlist" | "profile" | "bookmarks" | "liked" | "reposts" => { + origin.clone() + } + _ => "recommendation".to_string(), + }; + let media_token = if media == "image" || media == "images" { + "pictures".to_string() + } else { + "video".to_string() + }; + + Ok(UiBacklogRow { + id, + platform, + content_type, + handle, + media: media_token, + link, + output_format, + status: DownloadStatus::from_db(status_raw), + last_error, + }) + })?; + + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + /* ----------------------------- enums & models ----------------------------- */ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Platform { @@ -221,6 +332,7 @@ pub struct Download { pub status: DownloadStatus, pub path: String, pub image_set_id: Option, + pub last_error: Option, pub date_added: DateTime, pub date_downloaded: Option>, } @@ -248,6 +360,8 @@ pub struct UiBacklogRow { pub output_format: String, #[serde(default)] pub status: DownloadStatus, + #[serde(default)] + pub last_error: Option, } /// Lightweight info for deciding the destination collection directory. @@ -270,6 +384,7 @@ pub struct DbDownloadRow { pub status: DownloadStatus, pub path: String, pub name: String, + pub last_error: Option, } /* ------------------------------ conversions ------------------------------ */ @@ -595,21 +710,39 @@ impl Database { }; self.conn.execute( - "INSERT INTO downloads (platform, name, media, user_handle, origin, link, output_format, status, path, image_set_id, date_added, date_downloaded) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - [ - &format!("{:?}", download.platform).to_lowercase(), - &download.name, - &format!("{:?}", download.media).to_lowercase(), - &download.user, - &format!("{:?}", download.origin).to_lowercase(), - &download.link, - &format!("{:?}", download.output_format).to_lowercase(), - &format!("{:?}", download.status).to_lowercase(), - &path_value, - &download.image_set_id.clone().unwrap_or_default(), - &download.date_added.to_rfc3339(), - &download.date_downloaded.as_ref().map(|dt| dt.to_rfc3339()).unwrap_or_default(), + "INSERT INTO downloads ( + platform, + name, + media, + user_handle, + origin, + link, + output_format, + status, + path, + image_set_id, + last_error, + date_added, + date_downloaded + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + format!("{:?}", download.platform).to_lowercase(), + download.name.clone(), + format!("{:?}", download.media).to_lowercase(), + download.user.clone(), + format!("{:?}", download.origin).to_lowercase(), + download.link.clone(), + format!("{:?}", download.output_format).to_lowercase(), + format!("{:?}", download.status).to_lowercase(), + path_value, + download.image_set_id.clone().unwrap_or_default(), + download.last_error.clone(), + download.date_added.to_rfc3339(), + download + .date_downloaded + .as_ref() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(), ], )?; Ok(self.conn.last_insert_rowid()) @@ -767,7 +900,7 @@ impl Database { /// Ordered by platform → handle → type → name (case-insensitive). pub fn list_backlog_ui(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, status, platform, user_handle, origin, media, link, name, output_format + "SELECT id, status, platform, user_handle, origin, media, link, name, output_format, last_error FROM downloads WHERE status = 'backlog' ORDER BY platform COLLATE NOCASE, @@ -786,6 +919,7 @@ impl Database { let link: String = row.get(6)?; let _name: String = row.get(7)?; let output_format: String = row.get(8).unwrap_or_else(|_| "default".to_string()); + let last_error: Option = row.get(9).ok(); let content_type = origin.clone(); let media_token = if media == "image" || media == "images" { @@ -803,6 +937,7 @@ impl Database { link, output_format, status: DownloadStatus::from_db(status_raw), + last_error, }) })?; @@ -816,7 +951,7 @@ impl Database { /// Fetch rows with `status = 'queued'`, normalized for the UI. pub fn list_queue_ui(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, status, platform, user_handle, origin, media, link, name, output_format + "SELECT id, status, platform, user_handle, origin, media, link, name, output_format, last_error FROM downloads WHERE status IN ('queued', 'queue') ORDER BY platform COLLATE NOCASE, @@ -835,6 +970,7 @@ impl Database { let link: String = row.get(6)?; let _name: String = row.get(7)?; let output_format: String = row.get(8).unwrap_or_else(|_| "default".to_string()); + let last_error: Option = row.get(9).ok(); let content_type = match origin.as_str() { "recommendation" | "playlist" | "profile" | "bookmarks" | "liked" | "reposts" => { @@ -857,6 +993,7 @@ impl Database { link, output_format, status: DownloadStatus::from_db(status_raw), + last_error, }) })?; @@ -869,7 +1006,7 @@ impl Database { pub fn list_done_ui(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, status, platform, user_handle, origin, media, link, name, output_format + "SELECT id, status, platform, user_handle, origin, media, link, name, output_format, last_error FROM downloads WHERE status = 'done' ORDER BY platform COLLATE NOCASE, @@ -888,6 +1025,7 @@ impl Database { let link: String = row.get(6)?; let _name: String = row.get(7)?; let output_format: String = row.get(8).unwrap_or_else(|_| "default".to_string()); + let last_error: Option = row.get(9).ok(); let content_type = match origin.as_str() { "recommendation" | "playlist" | "profile" | "bookmarks" | "liked" | "reposts" => { @@ -910,6 +1048,7 @@ impl Database { link, output_format, status: DownloadStatus::from_db(status_raw), + last_error, }) })?; @@ -922,62 +1061,7 @@ impl Database { /// Fetch all rows regardless of status for the UI. pub fn list_all_ui(&self) -> Result> { - let mut stmt = self.conn.prepare( - "SELECT id, status, platform, user_handle, origin, media, link, name, output_format - FROM downloads - ORDER BY CASE status - WHEN 'downloading' THEN 0 - WHEN 'queued' THEN 1 - WHEN 'queue' THEN 1 - WHEN 'backlog' THEN 2 - WHEN 'done' THEN 3 - WHEN 'error' THEN 4 - WHEN 'canceled' THEN 5 - ELSE 6 - END, - id", - )?; - - let rows = stmt.query_map([], |row| { - let id: i64 = row.get(0)?; - let status_raw: String = row.get(1)?; - let platform: String = row.get(2)?; - let handle: String = row.get(3)?; - let origin: String = row.get(4)?; - let media: String = row.get(5)?; - let link: String = row.get(6)?; - let _name: String = row.get(7)?; - let output_format: String = row.get(8).unwrap_or_else(|_| "default".to_string()); - - let content_type = match origin.as_str() { - "recommendation" | "playlist" | "profile" | "bookmarks" | "liked" | "reposts" => { - origin.clone() - } - _ => "recommendation".to_string(), - }; - let media_token = if media == "image" || media == "images" { - "pictures".to_string() - } else { - "video".to_string() - }; - - Ok(UiBacklogRow { - id, - platform, - content_type, - handle, - media: media_token, - link, - output_format, - status: DownloadStatus::from_db(status_raw), - }) - })?; - - let mut out = Vec::new(); - for r in rows { - out.push(r?); - } - Ok(out) + list_all_ui_conn(&self.conn) } /* -------------------- status transitions (→ Queue) -------------------- */ diff --git a/src-tauri/src/download/manager.rs b/src-tauri/src/download/manager.rs index 5d9fca1..7c69ada 100644 --- a/src-tauri/src/download/manager.rs +++ b/src-tauri/src/download/manager.rs @@ -2,13 +2,14 @@ use std::collections::{HashMap, VecDeque}; use std::fmt; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tauri::{AppHandle, Emitter}; use crate::database::{ - find_download_by_id_conn, list_queued_ids_conn, mark_id_done_conn, - reset_stale_downloading_to_queued_conn, set_status_by_id_conn, DownloadStatus, + find_download_by_id_conn, list_all_ui_conn, list_downloading_ids_conn, list_queued_ids_conn, + mark_id_done_conn, reset_stale_downloading_to_queued_conn, set_last_error_by_id_conn, + set_status_by_id_conn, DownloadStatus, UiBacklogRow, }; use crate::download::pipeline; use crate::settings; @@ -50,6 +51,10 @@ pub enum DownloadCommand { }, RefreshSettings, SetPaused(bool), + ReconcileState, + RefreshSnapshot { + reply: oneshot::Sender, String>>, + }, TaskFinished { id: i64, }, @@ -89,7 +94,12 @@ struct ActiveTask { handle: tauri::async_runtime::JoinHandle<()>, } -pub async fn run_download_manager(app: AppHandle, db: Arc>, mut cmd_rx: mpsc::Receiver, cmd_tx: mpsc::Sender) { +pub async fn run_download_manager( + app: AppHandle, + db: Arc>, + mut cmd_rx: mpsc::Receiver, + cmd_tx: mpsc::Sender, +) { let mut queue: VecDeque = VecDeque::new(); let mut active: HashMap = HashMap::new(); let mut overrides: HashMap = HashMap::new(); @@ -121,21 +131,53 @@ pub async fn run_download_manager(app: AppHandle, db: Arc { - enqueue_ids(&app, db.clone(), &ids, &mut queue, &active, DownloadStatus::Queued) + enqueue_ids( + &app, + db.clone(), + &ids, + &mut queue, + &active, + DownloadStatus::Queued, + ) .await; } DownloadCommand::MoveToBacklog { ids } => { - move_to_backlog(&app, db.clone(), &ids, &mut queue, &mut active, &mut overrides).await; + move_to_backlog( + &app, + db.clone(), + &ids, + &mut queue, + &mut active, + &mut overrides, + ) + .await; } DownloadCommand::Cancel { id } => { - cancel_active(&app, db.clone(), id, &mut queue, &mut active, &mut overrides) + cancel_active( + &app, + db.clone(), + id, + &mut queue, + &mut active, + &mut overrides, + ) .await; } DownloadCommand::StartNow { id, overrides: ov } => { @@ -160,16 +202,88 @@ pub async fn run_download_manager(app: AppHandle, db: Arc { paused = next; } + DownloadCommand::ReconcileState => { + reconcile_state(&app, db.clone(), &mut queue, &active).await; + } + DownloadCommand::RefreshSnapshot { reply } => { + reconcile_state(&app, db.clone(), &mut queue, &active).await; + let _ = reply.send(snapshot_downloads(db.clone()).await); + } DownloadCommand::TaskFinished { id } => { active.remove(&id); } } - maybe_start_next(&app, db.clone(), &mut queue, &mut active, &mut overrides, paused, max_parallel, &cmd_tx, force_start) + maybe_start_next( + &app, + db.clone(), + &mut queue, + &mut active, + &mut overrides, + paused, + max_parallel, + &cmd_tx, + force_start, + ) .await; } } +async fn reconcile_state( + app: &AppHandle, + db: Arc>, + queue: &mut VecDeque, + active: &HashMap, +) { + let db_clone = db.clone(); + let (queued_ids, downloading_ids) = tauri::async_runtime::spawn_blocking(move || { + let conn = db_clone.blocking_lock(); + let queued = list_queued_ids_conn(&*conn).unwrap_or_default(); + let downloading = list_downloading_ids_conn(&*conn).unwrap_or_default(); + (queued, downloading) + }) + .await + .unwrap_or_default(); + + for id in downloading_ids { + if active.contains_key(&id) { + continue; + } + if let Ok(changed) = set_status(db.clone(), id, DownloadStatus::Queued).await { + if changed { + emit_event( + app, + DownloadEvent::StatusChanged { + id, + status: DownloadStatus::Queued, + }, + ); + } + } + if !queue.contains(&id) { + queue.push_back(id); + } + } + + for id in queued_ids { + if active.contains_key(&id) || queue.contains(&id) { + continue; + } + queue.push_back(id); + } +} + +async fn snapshot_downloads( + db: Arc>, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let conn = db.blocking_lock(); + list_all_ui_conn(&*conn).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("Join error: {e}"))? +} + async fn enqueue_ids( app: &AppHandle, db: Arc>, @@ -335,6 +449,7 @@ async fn maybe_start_next( match run_download_with_progress(&app_clone, db_clone.clone(), id, opts).await { Ok(path) => { let _ = set_status(db_clone.clone(), id, DownloadStatus::Done).await; + let _ = set_last_error(db_clone.clone(), id, None).await; let final_path = path.unwrap_or_default(); let _ = mark_download_done(db_clone.clone(), id, &final_path).await; emit_event( @@ -346,6 +461,7 @@ async fn maybe_start_next( ); } Err(err_msg) => { + let _ = set_last_error(db_clone.clone(), id, Some(err_msg.clone())).await; let _ = set_status(db_clone.clone(), id, DownloadStatus::Error).await; emit_event( &app_clone, @@ -384,6 +500,22 @@ async fn set_status( Ok(changed > 0) } +async fn set_last_error( + db: Arc>, + id: i64, + last_error: Option, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let conn = db.blocking_lock(); + set_last_error_by_id_conn(&*conn, id, last_error.as_deref()) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("Join error: {e}"))??; + Ok(()) +} + fn emit_event(app: &AppHandle, event: DownloadEvent) { if let Err(err) = app.emit("download_event", &event) { eprintln!("emit_event failed: {err}"); diff --git a/src-tauri/src/download/pipeline.rs b/src-tauri/src/download/pipeline.rs index 1359168..01c1814 100644 --- a/src-tauri/src/download/pipeline.rs +++ b/src-tauri/src/download/pipeline.rs @@ -22,7 +22,12 @@ fn ensure_parent_dir(p: &Path) { } } -fn move_with_policy(src: &Path, dest_dir: &Path, file_name: &str, on_duplicate: &OnDuplicate) -> std::io::Result<(Option, &'static str)> { +fn move_with_policy( + src: &Path, + dest_dir: &Path, + file_name: &str, + on_duplicate: &OnDuplicate, +) -> std::io::Result<(Option, &'static str)> { let (stem, ext) = match file_name.rsplit_once('.') { Some((s, e)) if !s.is_empty() && !e.is_empty() => (s.to_string(), e.to_string()), _ => (file_name.to_string(), String::from("bin")), @@ -69,7 +74,12 @@ fn move_with_policy(src: &Path, dest_dir: &Path, file_name: &str, on_duplicate: } } -fn move_tmp_into_site_dir(tmp: &Path, dest_dir: &Path, on_duplicate: &OnDuplicate, mut notify: impl FnMut(String)) -> std::io::Result<(bool, Vec)> { +fn move_tmp_into_site_dir( + tmp: &Path, + dest_dir: &Path, + on_duplicate: &OnDuplicate, + mut notify: impl FnMut(String), +) -> std::io::Result<(bool, Vec)> { let mut moved_any = false; let mut finals = Vec::new(); fs::create_dir_all(dest_dir).ok(); @@ -79,15 +89,25 @@ fn move_tmp_into_site_dir(tmp: &Path, dest_dir: &Path, on_duplicate: &OnDuplicat continue; } let src = entry.path(); - let file_name = src.file_name().and_then(|s| s.to_str()).unwrap_or("image.bin"); + let file_name = src + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("image.bin"); match move_with_policy(src, dest_dir, file_name, on_duplicate) { Ok((Some(fp), action)) => { moved_any = true; notify(format!("{action}: {fp}")); finals.push(fp); } - Ok((None, _)) => notify(format!("Skipped (exists): {}", dest_dir.join(file_name).display())), - Err(e) => notify(format!("Failed to move {} → {}: {e}", src.display(), dest_dir.join(file_name).display())) + Ok((None, _)) => notify(format!( + "Skipped (exists): {}", + dest_dir.join(file_name).display() + )), + Err(e) => notify(format!( + "Failed to move {} → {}: {e}", + src.display(), + dest_dir.join(file_name).display() + )), } } Ok((moved_any, finals)) @@ -95,13 +115,20 @@ fn move_tmp_into_site_dir(tmp: &Path, dest_dir: &Path, on_duplicate: &OnDuplicat fn friendly_browser_error(browser: &str, output: &str) -> Option { let lower = output.to_lowercase(); - if lower.contains("find-generic-password failed") || lower.contains("cannot decrypt v10 cookies") { + if lower.contains("find-generic-password failed") + || lower.contains("cannot decrypt v10 cookies") + { return Some(format!("Could not decrypt {browser} cookies. macOS blocked access to Chromium's cookie key, so the download could not authenticate to the site.")); } None } -pub async fn execute_download_job(app: AppHandle, row: DbDownloadRow, overrides: Option, emitter: Arc) -> Result, String> { +pub async fn execute_download_job( + app: AppHandle, + row: DbDownloadRow, + overrides: Option, + emitter: Arc, +) -> Result, String> { let settings = settings::load_settings(); let download_root = PathBuf::from(settings.download_directory.clone()); if let Err(e) = fs::create_dir_all(&download_root) { @@ -160,7 +187,10 @@ pub async fn execute_download_job(app: AppHandle, row: DbDownloadRow, overrides: let mut last_error: Option = None; let mut specific_cookie_error: Option = None; for (browser, cookie_arg) in &browsers { - (emitter)(DownloadEvent::Message {id: row.id, message: format!("Trying {} cookies; dest={}", browser, dest_dir.display())}); + (emitter)(DownloadEvent::Message { + id: row.id, + message: format!("Trying {} cookies; dest={}", browser, dest_dir.display()), + }); if is_instagram { let effective_url = if want_audio_only { @@ -168,7 +198,18 @@ pub async fn execute_download_job(app: AppHandle, row: DbDownloadRow, overrides: } else { cleaned_url.clone() }; - match video::run_yt_dlp_with_progress(&app, &dest_dir, cookie_arg, &effective_url, false, &settings.on_duplicate, row.id, emitter.clone()).await { + match video::run_yt_dlp_with_progress( + &app, + &dest_dir, + cookie_arg, + &effective_url, + false, + &settings.on_duplicate, + row.id, + emitter.clone(), + ) + .await + { Ok((true, output)) => { (emitter)(DownloadEvent::Message { id: row.id, @@ -178,32 +219,72 @@ pub async fn execute_download_job(app: AppHandle, row: DbDownloadRow, overrides: "Saved (video)".into() }, }); - let files = parse_multiple_filenames_from_output(&output, &cleaned_url, Some(&dest_dir)); + let files = parse_multiple_filenames_from_output( + &output, + &cleaned_url, + Some(&dest_dir), + ); return Ok(files.get(0).map(|t| t.2.clone())); } Ok((false, _)) | Err(_) => { if is_ig_post_p { - match image::run_gallery_dl_to_temp(&app, &download_root, &cleaned_url, cookie_arg, row.id, emitter.clone()).await { + (emitter)(DownloadEvent::Message { + id: row.id, + message: "Video fetch failed, trying image fallback".into(), + }); + match image::run_gallery_dl_to_temp( + &app, + &download_root, + &cleaned_url, + cookie_arg, + row.id, + emitter.clone(), + ) + .await + { Ok((ok, _out, tmp_dir)) if ok => { - let (moved_any, finals) = move_tmp_into_site_dir(&tmp_dir, &dest_dir, &settings.on_duplicate, + let (moved_any, finals) = move_tmp_into_site_dir( + &tmp_dir, + &dest_dir, + &settings.on_duplicate, |line| { - (emitter)(DownloadEvent::Message {id: row.id, message: line}); - }).unwrap_or((false, vec![])); + (emitter)(DownloadEvent::Message { + id: row.id, + message: line, + }); + }, + ) + .unwrap_or((false, vec![])); let _ = fs::remove_dir_all(&tmp_dir); if moved_any { - (emitter)(DownloadEvent::Message {id: row.id, message: "Saved images".into()}); + (emitter)(DownloadEvent::Message { + id: row.id, + message: "Saved images".into(), + }); return Ok(finals.get(0).cloned()); } else { - last_error = Some(format!("No files moved from {}", tmp_dir.display())); + last_error = + Some(format!("No files moved from {}", tmp_dir.display())); } } Ok((_ok, output, tmp_dir)) => { - let msg = friendly_browser_error(browser, &output).unwrap_or_else(|| {format!("gallery-dl failed (IG fallback) tmp={}\n{}", tmp_dir.display(), output)}); + let msg = + friendly_browser_error(browser, &output).unwrap_or_else(|| { + format!( + "gallery-dl failed (IG fallback) tmp={}\n{}", + tmp_dir.display(), + output + ) + }); if specific_cookie_error.is_none() { - specific_cookie_error = friendly_browser_error(browser, &output); + specific_cookie_error = + friendly_browser_error(browser, &output); } last_error = Some(msg.clone()); - (emitter)(DownloadEvent::Message {id: row.id, message: msg}); + (emitter)(DownloadEvent::Message { + id: row.id, + message: msg, + }); let _ = fs::remove_dir_all(&tmp_dir); } Err(e) => { @@ -217,27 +298,56 @@ pub async fn execute_download_job(app: AppHandle, row: DbDownloadRow, overrides: } if site == "pinterest" || is_tt_photo { - match image::run_gallery_dl_to_temp(&app, &download_root, &cleaned_url, cookie_arg, row.id, emitter.clone()).await { + (emitter)(DownloadEvent::Message { + id: row.id, + message: "Preparing image download".into(), + }); + match image::run_gallery_dl_to_temp( + &app, + &download_root, + &cleaned_url, + cookie_arg, + row.id, + emitter.clone(), + ) + .await + { Ok((ok, _output, tmp_dir)) if ok => { - let (moved_any, finals) = move_tmp_into_site_dir(&tmp_dir, &dest_dir, &settings.on_duplicate, + let (moved_any, finals) = move_tmp_into_site_dir( + &tmp_dir, + &dest_dir, + &settings.on_duplicate, |line| { - (emitter)(DownloadEvent::Message {id: row.id, message: line}); - }).unwrap_or((false, vec![])); + (emitter)(DownloadEvent::Message { + id: row.id, + message: line, + }); + }, + ) + .unwrap_or((false, vec![])); let _ = fs::remove_dir_all(&tmp_dir); if moved_any { - (emitter)(DownloadEvent::Message {id: row.id, message: "Saved images".into()}); + (emitter)(DownloadEvent::Message { + id: row.id, + message: "Saved images".into(), + }); return Ok(finals.get(0).cloned()); } else { last_error = Some(format!("No files moved from {}", tmp_dir.display())); } } Ok((_ok, output, tmp_dir)) => { - let msg = friendly_browser_error(browser, &output).unwrap_or_else(|| {format!("gallery-dl failed tmp={}\n{}", tmp_dir.display(), output)}); + let msg = friendly_browser_error(browser, &output).unwrap_or_else(|| { + format!("gallery-dl failed tmp={}\n{}", tmp_dir.display(), output) + }); if specific_cookie_error.is_none() { specific_cookie_error = friendly_browser_error(browser, &output); } last_error = Some(msg.clone()); - (emitter)(DownloadEvent::Message {id: row.id, message: msg}); + (emitter)(DownloadEvent::Message { + id: row.id, + message: msg, + }); let _ = fs::remove_dir_all(&tmp_dir); } Err(e) => { @@ -252,7 +362,18 @@ pub async fn execute_download_job(app: AppHandle, row: DbDownloadRow, overrides: } else { cleaned_url.clone() }; - match video::run_yt_dlp_with_progress(&app, &dest_dir, cookie_arg, &effective_url, false, &settings.on_duplicate, row.id, emitter.clone()).await { + match video::run_yt_dlp_with_progress( + &app, + &dest_dir, + cookie_arg, + &effective_url, + false, + &settings.on_duplicate, + row.id, + emitter.clone(), + ) + .await + { Ok((true, output)) => { (emitter)(DownloadEvent::Message { id: row.id, @@ -260,18 +381,24 @@ pub async fn execute_download_job(app: AppHandle, row: DbDownloadRow, overrides: "Saved (audio)".into() } else { "Saved (video)".into() - } + }, }); - let files = parse_multiple_filenames_from_output(&output, &cleaned_url, Some(&dest_dir)); + let files = + parse_multiple_filenames_from_output(&output, &cleaned_url, Some(&dest_dir)); return Ok(files.get(0).map(|t| t.2.clone())); } Ok((false, output)) => { - let msg = friendly_browser_error(browser, &output).unwrap_or_else(|| {format!("yt-dlp failed with browser: {browser}\noutput:\n{output}")}); + let msg = friendly_browser_error(browser, &output).unwrap_or_else(|| { + format!("yt-dlp failed with browser: {browser}\noutput:\n{output}") + }); if specific_cookie_error.is_none() { specific_cookie_error = friendly_browser_error(browser, &output); } last_error = Some(msg.clone()); - (emitter)(DownloadEvent::Message {id: row.id, message: msg}); + (emitter)(DownloadEvent::Message { + id: row.id, + message: msg, + }); } Err(e) => { last_error = Some(e.to_string()); diff --git a/src-tauri/src/download/video.rs b/src-tauri/src/download/video.rs index 0ce3d42..424491f 100644 --- a/src-tauri/src/download/video.rs +++ b/src-tauri/src/download/video.rs @@ -130,6 +130,41 @@ fn sanitize>(s: S) -> String { t.split_whitespace().collect::>().join(" ") } +fn stage_message_from_output_line(line: &str) -> Option<&'static str> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.contains("Extracting URL") { + Some("Extracting media info") + } else if trimmed.contains("Downloading webpage") { + Some("Fetching webpage") + } else if trimmed.contains("Downloading JSON metadata") + || trimmed.contains("Downloading API JSON") + { + Some("Fetching metadata") + } else if trimmed.contains("Downloading m3u8 information") + || trimmed.contains("Downloading MPD manifest") + { + Some("Fetching stream manifest") + } else if trimmed.contains("[download] Destination:") + || trimmed.contains("[download] Resuming download at") + { + Some("Downloading media") + } else if trimmed.contains("Merging formats into") { + Some("Merging audio and video") + } else if trimmed.contains("Deleting original file") { + Some("Cleaning up temp files") + } else if trimmed.contains("ExtractAudio") { + Some("Extracting audio") + } else if trimmed.contains("has already been downloaded") { + Some("Already downloaded") + } else { + None + } +} + // Probe uploader using yt-dlp sidecar (simulate + print) async fn probe_uploader( app: &tauri::AppHandle, @@ -207,18 +242,21 @@ async fn choose_output_template( ) -> io::Result { let rest_id = sanitize(rest_token_from_url(processed_url)); - let mut author_real = probe_uploader(app, cookie_arg, processed_url, is_ig_images) - .await - .or_else(|| { - if processed_url.contains("instagram.com/") { - ig_handle_from_url(processed_url) - } else if processed_url.contains("tiktok.com/") { - tiktok_username_from_url(processed_url) - } else { - None - } - }) - .unwrap_or_else(|| "unknown".into()); + let url_author = if processed_url.contains("instagram.com/") { + ig_handle_from_url(processed_url) + } else if processed_url.contains("tiktok.com/") { + tiktok_username_from_url(processed_url) + } else { + None + }; + + let mut author_real = if let Some(author) = url_author { + author + } else { + probe_uploader(app, cookie_arg, processed_url, is_ig_images) + .await + .unwrap_or_else(|| "unknown".into()) + }; author_real = sanitize(author_real); let base_stem = format!("{author_real} [{rest_id}]"); @@ -249,7 +287,16 @@ async fn choose_output_template( /* ---------- runner ---------- */ -pub async fn run_yt_dlp_with_progress(app: &tauri::AppHandle, out_dir: &Path, cookie_arg: &str, processed_url: &str, is_ig_images: bool, on_duplicate: &OnDuplicate, id: i64, emitter: Arc) -> io::Result<(bool, String)> { +pub async fn run_yt_dlp_with_progress( + app: &tauri::AppHandle, + out_dir: &Path, + cookie_arg: &str, + processed_url: &str, + is_ig_images: bool, + on_duplicate: &OnDuplicate, + id: i64, + emitter: Arc, +) -> io::Result<(bool, String)> { let audio_only = processed_url.ends_with("#__audio_only__"); let real_url = if audio_only { &processed_url[..processed_url.len() - "#__audio_only__".len()] @@ -279,23 +326,51 @@ pub async fn run_yt_dlp_with_progress(app: &tauri::AppHandle, out_dir: &Path, co // Determine resource dir for bundled ffmpeg (when not using system binaries) use tauri::path::BaseDirectory; - let res_dir = app.path().resolve("", BaseDirectory::Resource).unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| ".".into())); + let res_dir = app + .path() + .resolve("", BaseDirectory::Resource) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| ".".into())); // Only force ffmpeg location when using bundled sidecar tools if !settings.use_system_binaries { args.push("--ffmpeg-location".into()); args.push(res_dir.to_string_lossy().to_string()); } + (emitter)(DownloadEvent::Message { + id, + message: "Inspecting media metadata".into(), + }); + // Output template with uniqueness policy - let output_template = choose_output_template(app, out_dir, cookie_arg, real_url, is_ig_images, audio_only, on_duplicate).await?; + let output_template = choose_output_template( + app, + out_dir, + cookie_arg, + real_url, + is_ig_images, + audio_only, + on_duplicate, + ) + .await?; args.push("-o".into()); args.push(output_template.clone()); // URL last args.push(real_url.to_string()); - let planned_path = out_dir.join(output_template.replace("%(ext)s", if audio_only { "mp3" } else { "mp4" })); - println!("[YT-DLP][sidecar] policy={:?} dir='{}'\nurl='{}'\nout='{}'", on_duplicate, out_dir.display(), real_url, planned_path.display()); + let planned_path = + out_dir.join(output_template.replace("%(ext)s", if audio_only { "mp3" } else { "mp4" })); + println!( + "[YT-DLP][sidecar] policy={:?} dir='{}'\nurl='{}'\nout='{}'", + on_duplicate, + out_dir.display(), + real_url, + planned_path.display() + ); + (emitter)(DownloadEvent::Message { + id, + message: format!("Prepared output file {}", planned_path.display()), + }); let cmd = if settings.use_system_binaries { app.shell().command("yt-dlp") @@ -310,7 +385,12 @@ pub async fn run_yt_dlp_with_progress(app: &tauri::AppHandle, out_dir: &Path, co std::env::var("PATH").unwrap_or_default() } else { // Prepend bundled resources so yt-dlp can find ffmpeg from the app - format!("{}{}{}", res_dir.to_string_lossy(), path_sep(), std::env::var("PATH").unwrap_or_default()) + format!( + "{}{}{}", + res_dir.to_string_lossy(), + path_sep(), + std::env::var("PATH").unwrap_or_default() + ) }; let (mut rx, child) = @@ -318,12 +398,18 @@ pub async fn run_yt_dlp_with_progress(app: &tauri::AppHandle, out_dir: &Path, co io::Error::new(io::ErrorKind::Other, format!("spawn yt-dlp failed: {e}")) })?; + (emitter)(DownloadEvent::Message { + id, + message: "Launching downloader".into(), + }); + let _guard = KillGuard(Some(child)); let mut all_output = String::new(); let mut already_downloaded = false; let mut file_skipped = false; let mut ok = false; + let mut last_stage: Option<&'static str> = None; loop { // Yield to allow other tasks (like event emission) to run @@ -346,17 +432,41 @@ pub async fn run_yt_dlp_with_progress(app: &tauri::AppHandle, out_dir: &Path, co all_output.push_str(l); all_output.push('\n'); + if let Some(stage) = stage_message_from_output_line(l) { + if last_stage != Some(stage) { + last_stage = Some(stage); + (emitter)(DownloadEvent::Message { + id, + message: stage.into(), + }); + } + } + if l.contains("has already been downloaded") { already_downloaded = true; } - if l.contains("has already been recorded in the archive") || l.starts_with("[download] Skipping") { + if l.contains("has already been recorded in the archive") + || l.starts_with("[download] Skipping") + { file_skipped = true; } if let Some(progress) = parse_progress_percentage(l) { - (emitter)(DownloadEvent::Progress {id, progress, downloaded_bytes: 0, total_bytes: None}); - } else if (l.contains("[download]") || l.contains("[info]")) && !l.contains("Starting download for") && !l.contains("Sleeping") && !l.starts_with("[info] Downloading") { - (emitter)(DownloadEvent::Message {id, message: l.to_string()}); + (emitter)(DownloadEvent::Progress { + id, + progress, + downloaded_bytes: 0, + total_bytes: None, + }); + } else if (l.contains("[download]") || l.contains("[info]")) + && !l.contains("Starting download for") + && !l.contains("Sleeping") + && !l.starts_with("[info] Downloading") + { + (emitter)(DownloadEvent::Message { + id, + message: l.to_string(), + }); } } } @@ -367,7 +477,19 @@ pub async fn run_yt_dlp_with_progress(app: &tauri::AppHandle, out_dir: &Path, co if !l.is_empty() { all_output.push_str(l); all_output.push('\n'); - (emitter)(DownloadEvent::Message {id, message: l.to_string()}); + if let Some(stage) = stage_message_from_output_line(l) { + if last_stage != Some(stage) { + last_stage = Some(stage); + (emitter)(DownloadEvent::Message { + id, + message: stage.into(), + }); + } + } + (emitter)(DownloadEvent::Message { + id, + message: l.to_string(), + }); } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1bcca71..ba4e7dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,6 +50,8 @@ pub fn run() { commands::downloader::move_downloads_to_backlog, commands::downloader::set_download_paused, commands::downloader::refresh_download_settings, + commands::downloader::reconcile_downloads, + commands::downloader::refresh_downloads_snapshot, // TOOLS / SYSTEM commands::tools::check_sidecar_tools, // FILES / IMPORT diff --git a/src/app.rs b/src/app.rs index 5f10f8f..72de171 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,8 +4,9 @@ use crate::pages; use crate::pages::downloads::ActiveDownload; use crate::pages::settings::Settings; use crate::types::{ClipRow, ContentType, DownloadStatus, Platform}; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; +use std::rc::Rc; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::spawn_local; use yew::prelude::*; @@ -40,6 +41,8 @@ struct DownloadEntry { progress: f32, downloaded_bytes: u64, total_bytes: Option, + stage_text: String, + last_message: Option, } fn log_download_snapshot(rows: &[ClipRow]) { @@ -62,38 +65,157 @@ fn log_download_snapshot(rows: &[ClipRow]) { web_sys::console::log_1(&format!("[UI] list_downloads loaded: backlog={} queue={} downloading={} done={} error={} canceled={}",cnt_backlog, cnt_queue, cnt_down, cnt_done, cnt_err, cnt_cancel).into()); } -fn build_download_entries(rows: Vec) -> HashMap { +fn default_stage_text(row: &ClipRow) -> String { + match row.status { + DownloadStatus::Backlog => "Backlog".into(), + DownloadStatus::Queued => "Queued".into(), + DownloadStatus::Downloading => "Preparing download".into(), + DownloadStatus::Done => "Done".into(), + DownloadStatus::Error => row.last_error.clone().unwrap_or_else(|| "Failed".into()), + DownloadStatus::Canceled => "Canceled".into(), + } +} + +fn merge_download_entries( + previous: &HashMap, + rows: Vec, +) -> HashMap { let mut map = HashMap::new(); + for row in rows { - if matches!(row.status, DownloadStatus::Done | DownloadStatus::Error | DownloadStatus::Canceled) { + if matches!(row.status, DownloadStatus::Done | DownloadStatus::Canceled) { continue; } - map.insert(row.id, DownloadEntry {row, progress: 0.0, downloaded_bytes: 0, total_bytes: None}); + + let status = row.status; + let persisted_error = row.last_error.clone(); + let mut entry = previous.get(&row.id).cloned().unwrap_or(DownloadEntry { + row: row.clone(), + progress: 0.0, + downloaded_bytes: 0, + total_bytes: None, + stage_text: default_stage_text(&row), + last_message: persisted_error.clone(), + }); + + entry.row = row; + if let Some(err) = persisted_error.clone() { + entry.last_message = Some(err); + } + + match status { + DownloadStatus::Backlog | DownloadStatus::Queued => { + entry.progress = 0.0; + entry.downloaded_bytes = 0; + entry.total_bytes = None; + entry.row.last_error = None; + entry.last_message = None; + entry.stage_text = default_stage_text(&entry.row); + } + DownloadStatus::Downloading => { + entry.row.last_error = None; + if entry.stage_text.is_empty() || entry.stage_text == "Queued" { + entry.stage_text = "Preparing download".into(); + } + } + DownloadStatus::Error => { + entry.progress = 0.0; + entry.downloaded_bytes = 0; + entry.total_bytes = None; + if entry.row.last_error.is_none() { + entry.row.last_error = entry.last_message.clone(); + } + entry.stage_text = entry + .row + .last_error + .clone() + .unwrap_or_else(|| "Failed".into()); + } + DownloadStatus::Done | DownloadStatus::Canceled => {} + } + + map.insert(entry.row.id, entry); } + map } -fn spawn_reload_downloads( +fn summarize_download_message(message: &str) -> String { + let trimmed = message.trim(); + if trimmed.is_empty() { + return "Working...".into(); + } + + if let Some(rest) = trimmed.strip_prefix("Trying ") { + if let Some((browser, _)) = rest.split_once(" cookies") { + return format!("Loading {browser} cookies"); + } + } + + if trimmed.starts_with("Saved ") { + return "Finalizing download".into(); + } + + if trimmed.starts_with("Prepared output file ") { + return "Prepared output path".into(); + } + + trimmed.to_string() +} + +fn commit_download_map( + downloads: &UseStateHandle>, + downloads_ref: &Rc>>, + next: HashMap, +) { + *downloads_ref.borrow_mut() = next.clone(); + downloads.set(next); +} + +fn spawn_refresh_downloads( downloads: UseStateHandle>, + downloads_ref: Rc>>, ready: UseStateHandle, ) { spawn_local(async move { - match invoke("list_downloads", JsValue::NULL).await { + match invoke("refresh_downloads_snapshot", JsValue::NULL).await { Ok(js) => match serde_wasm_bindgen::from_value::>(js) { Ok(rows) => { log_download_snapshot(&rows); - downloads.set(build_download_entries(rows)); + let previous = downloads_ref.borrow().clone(); + let next = merge_download_entries(&previous, rows); + commit_download_map(&downloads, &downloads_ref, next); } Err(err) => { - web_sys::console::error_1(&format!("deserialize(list_downloads) failed: {err}").into()); + web_sys::console::error_1( + &format!("deserialize(refresh_downloads_snapshot) failed: {err}").into(), + ); } }, - Err(e) => log_invoke_err("list_downloads", e), + Err(e) => log_invoke_err("refresh_downloads_snapshot", e), } ready.set(true); }); } +fn schedule_download_refresh( + refresh_pending: Rc>, + downloads: UseStateHandle>, + downloads_ref: Rc>>, + ready: UseStateHandle, +) { + if refresh_pending.get() { + return; + } + + refresh_pending.set(true); + spawn_local(async move { + gloo_timers::future::TimeoutFuture::new(250).await; + spawn_refresh_downloads(downloads.clone(), downloads_ref.clone(), ready.clone()); + refresh_pending.set(false); + }); +} + thread_local! { static LAST_DROP: RefCell<(String, f64)> = RefCell::new(("".to_string(), 0.0)); } @@ -227,6 +349,7 @@ pub fn app() -> Html { let settings = use_state(Settings::default); let downloads = use_state(HashMap::::new); + let downloads_ref = use_mut_ref(HashMap::::new); let downloads_ready = use_state(|| false); let paused = use_state(|| false); @@ -236,14 +359,12 @@ pub fn app() -> Html { spawn_local(async move { if let Ok(loaded) = invoke("load_settings", JsValue::NULL).await { if let Ok(s) = serde_wasm_bindgen::from_value::(loaded) { - // Update local state settings.set(s.clone()); - // Ensure backend DownloadManager pause state matches settings on boot let paused = !s.download_automatically; let args = - serde_wasm_bindgen::to_value(&serde_json::json!({ "paused": paused })).unwrap(); + serde_wasm_bindgen::to_value(&serde_json::json!({ "paused": paused })) + .unwrap(); let _ = invoke("set_download_paused", args).await; - // Also refresh runtime parameters (e.g., parallel downloads) let _ = invoke("refresh_download_settings", JsValue::NULL).await; } } @@ -262,19 +383,29 @@ pub fn app() -> Html { { let downloads = downloads.clone(); + let downloads_ref = downloads_ref.clone(); let downloads_ready = downloads_ready.clone(); use_effect_with((), move |_| { - spawn_reload_downloads(downloads.clone(), downloads_ready.clone()); + spawn_refresh_downloads( + downloads.clone(), + downloads_ref.clone(), + downloads_ready.clone(), + ); || () }); } { let downloads = downloads.clone(); + let downloads_ref = downloads_ref.clone(); let downloads_ready = downloads_ready.clone(); use_effect_with(*page, move |p| { if *p == Page::Downloads { - spawn_reload_downloads(downloads.clone(), downloads_ready.clone()); + spawn_refresh_downloads( + downloads.clone(), + downloads_ref.clone(), + downloads_ready.clone(), + ); } || () }); @@ -282,11 +413,10 @@ pub fn app() -> Html { { let downloads = downloads.clone(); + let downloads_ref = downloads_ref.clone(); let downloads_ready = downloads_ready.clone(); use_effect_with((), move |_| { - // Track whether a debounced refresh is already scheduled so we - // don't fire hundreds of concurrent list_downloads calls. - let refresh_pending = std::rc::Rc::new(std::cell::Cell::new(false)); + let refresh_pending = Rc::new(Cell::new(false)); spawn_local(async move { #[derive(serde::Deserialize, Debug)] @@ -307,56 +437,122 @@ pub fn app() -> Html { message: String, }, } + let handler = Closure::::new(move |event: JsValue| { let payload = js_sys::Reflect::get(&event, &JsValue::from_str("payload")) .unwrap_or(event.clone()); + if let Ok(evt) = serde_wasm_bindgen::from_value::(payload) { - let mut map = (*downloads).clone(); + let mut map = downloads_ref.borrow().clone(); + let mut commit = false; + let mut should_refresh = false; + match evt { - DownloadEventPayload::StatusChanged { id, status } => { - if matches!( - status, - DownloadStatus::Done - | DownloadStatus::Error - | DownloadStatus::Canceled - ) { - map.remove(&id); - downloads.set(map); - } else if let Some(entry) = map.get_mut(&id) { - entry.row.status = status; - downloads.set(map); - } else { - // Unknown ID — schedule a single debounced refresh - if !refresh_pending.get() { - refresh_pending.set(true); - let downloads_ref = downloads.clone(); - let ready_ref = downloads_ready.clone(); - let flag = refresh_pending.clone(); - wasm_bindgen_futures::spawn_local(async move { - // Small delay so multiple unknown-id events coalesce - gloo_timers::future::TimeoutFuture::new(500).await; - spawn_reload_downloads(downloads_ref.clone(), ready_ref.clone()); - flag.set(false); - }); + DownloadEventPayload::StatusChanged { id, status } => match status { + DownloadStatus::Done | DownloadStatus::Canceled => { + if map.remove(&id).is_none() { + should_refresh = true; + } else { + commit = true; } + should_refresh = true; } - } - DownloadEventPayload::Progress {id, progress, downloaded_bytes, total_bytes} => { + DownloadStatus::Error => { + if let Some(entry) = map.get_mut(&id) { + entry.row.status = DownloadStatus::Error; + if entry.row.last_error.is_none() { + entry.row.last_error = entry.last_message.clone(); + } + entry.progress = 0.0; + entry.downloaded_bytes = 0; + entry.total_bytes = None; + entry.stage_text = entry + .row + .last_error + .clone() + .unwrap_or_else(|| "Failed".into()); + commit = true; + } else { + should_refresh = true; + } + should_refresh = true; + } + DownloadStatus::Backlog | DownloadStatus::Queued => { + if let Some(entry) = map.get_mut(&id) { + entry.row.status = status; + entry.row.last_error = None; + entry.progress = 0.0; + entry.downloaded_bytes = 0; + entry.total_bytes = None; + entry.stage_text = default_stage_text(&entry.row); + entry.last_message = None; + commit = true; + } else { + should_refresh = true; + } + } + DownloadStatus::Downloading => { + if let Some(entry) = map.get_mut(&id) { + entry.row.status = DownloadStatus::Downloading; + entry.row.last_error = None; + entry.stage_text = "Preparing download".into(); + entry.last_message = None; + commit = true; + } else { + should_refresh = true; + } + } + }, + DownloadEventPayload::Progress { + id, + progress, + downloaded_bytes, + total_bytes, + } => { if let Some(entry) = map.get_mut(&id) { + entry.row.status = DownloadStatus::Downloading; entry.progress = progress; entry.downloaded_bytes = downloaded_bytes; entry.total_bytes = total_bytes; - downloads.set(map); + if progress > 0.0 { + entry.stage_text = "Downloading".into(); + } + commit = true; } } DownloadEventPayload::Message { id, message } => { - log::info("download_event_message", serde_json::json!({ "id": id, "message": message.clone() })); - let _ = (id, message); // suppress unused warnings + log::info( + "download_event_message", + serde_json::json!({ "id": id, "message": message.clone() }), + ); + if let Some(entry) = map.get_mut(&id) { + entry.last_message = Some(message.clone()); + if entry.row.status == DownloadStatus::Error { + entry.row.last_error = Some(message.clone()); + entry.stage_text = message; + } else { + entry.stage_text = summarize_download_message(&message); + } + commit = true; + } } } + + if commit { + commit_download_map(&downloads, &downloads_ref, map); + } + if should_refresh { + schedule_download_refresh( + refresh_pending.clone(), + downloads.clone(), + downloads_ref.clone(), + downloads_ready.clone(), + ); + } } }); + let _ = listen("download_event", &handler).await; handler.forget(); }); @@ -366,12 +562,19 @@ pub fn app() -> Html { { let downloads = downloads.clone(); + let downloads_ref = downloads_ref.clone(); let downloads_ready = downloads_ready.clone(); use_effect_with((), move |_| { spawn_local(async move { let handler = Closure::::new(move |_event: JsValue| { - web_sys::console::log_1(&"[UI] import_completed event received, reloading downloads".into()); - spawn_reload_downloads(downloads.clone(), downloads_ready.clone()); + web_sys::console::log_1( + &"[UI] import_completed event received, reloading downloads".into(), + ); + spawn_refresh_downloads( + downloads.clone(), + downloads_ref.clone(), + downloads_ready.clone(), + ); }); let _ = listen("import_completed", &handler).await; handler.forget(); @@ -387,7 +590,8 @@ pub fn app() -> Html { paused_state.set(next); log::info("queue_toggle", serde_json::json!({ "paused": next })); spawn_local(async move { - let args = serde_wasm_bindgen::to_value(&serde_json::json!({ "paused": next })).unwrap(); + let args = + serde_wasm_bindgen::to_value(&serde_json::json!({ "paused": next })).unwrap(); if let Err(e) = invoke("set_download_paused", args).await { log_invoke_err("set_download_paused", e); } @@ -397,24 +601,32 @@ pub fn app() -> Html { let on_delete = { let downloads = downloads.clone(); + let downloads_ref = downloads_ref.clone(); Callback::from(move |item: DeleteItem| { - downloads.set({ - let mut map = (*downloads).clone(); - map.retain(|_, entry| !matches_delete_item(&entry.row, &item)); - map - }); + let mut map = downloads_ref.borrow().clone(); + map.retain(|_, entry| !matches_delete_item(&entry.row, &item)); + commit_download_map(&downloads, &downloads_ref, map); }) }; let on_move_to_queue = { - let downloads = downloads.clone(); + let downloads_ref = downloads_ref.clone(); Callback::from(move |item: crate::app::MoveItem| { - let ids: Vec = (*downloads).values().filter(|entry| {entry.row.status == DownloadStatus::Backlog && matches_move_item(&entry.row, &item)}).map(|entry| entry.row.id).collect(); + let ids: Vec = downloads_ref + .borrow() + .values() + .filter(|entry| { + entry.row.status == DownloadStatus::Backlog + && matches_move_item(&entry.row, &item) + }) + .map(|entry| entry.row.id) + .collect(); if ids.is_empty() { return; } spawn_local(async move { - let args = serde_wasm_bindgen::to_value(&serde_json::json!({ "ids": ids })).unwrap(); + let args = + serde_wasm_bindgen::to_value(&serde_json::json!({ "ids": ids })).unwrap(); if let Err(e) = invoke("enqueue_downloads", args).await { log_invoke_err("enqueue_downloads", e); } @@ -423,14 +635,28 @@ pub fn app() -> Html { }; let on_move_to_backlog = { - let downloads = downloads.clone(); + let downloads_ref = downloads_ref.clone(); Callback::from(move |item: crate::app::MoveBackItem| { - let ids: Vec = (*downloads).values().filter(|entry| {matches_move_back_item(&entry.row, &item) && matches!(entry.row.status, DownloadStatus::Queued | DownloadStatus::Downloading)}).map(|entry| entry.row.id).collect(); + let ids: Vec = downloads_ref + .borrow() + .values() + .filter(|entry| { + matches_move_back_item(&entry.row, &item) + && matches!( + entry.row.status, + DownloadStatus::Queued + | DownloadStatus::Downloading + | DownloadStatus::Error + ) + }) + .map(|entry| entry.row.id) + .collect(); if ids.is_empty() { return; } spawn_local(async move { - let args = serde_wasm_bindgen::to_value(&serde_json::json!({ "ids": ids })).unwrap(); + let args = + serde_wasm_bindgen::to_value(&serde_json::json!({ "ids": ids })).unwrap(); if let Err(e) = invoke("move_downloads_to_backlog", args).await { log_invoke_err("move_downloads_to_backlog", e); } @@ -438,6 +664,16 @@ pub fn app() -> Html { }) }; + let on_retry_issue = Callback::from(move |id: i64| { + spawn_local(async move { + let args = + serde_wasm_bindgen::to_value(&serde_json::json!({ "ids": vec![id] })).unwrap(); + if let Err(e) = invoke("enqueue_downloads", args).await { + log_invoke_err("enqueue_downloads", e); + } + }); + }); + let on_csv_load = Callback::from(move |_csv_text: String| {}); let on_open_file = Callback::from(move |_: ()| { spawn_local(async move { @@ -465,12 +701,28 @@ pub fn app() -> Html { .filter(|entry| entry.row.status == DownloadStatus::Queued) .map(|entry| entry.row.clone()) .collect(); + let issue_rows_vec: Vec = (*downloads) + .values() + .filter(|entry| entry.row.status == DownloadStatus::Error) + .map(|entry| { + let mut row = entry.row.clone(); + if row.last_error.is_none() { + row.last_error = entry.last_message.clone(); + } + row + }) + .collect(); let active_downloads_vec: Vec = (*downloads) .values() .filter(|entry| entry.row.status == DownloadStatus::Downloading) .map(|entry| ActiveDownload { row: entry.row.clone(), - progress: format!("{:.0}%", entry.progress * 100.0), + progress: if entry.progress > 0.0 { + Some(format!("{:.0}%", entry.progress * 100.0)) + } else { + None + }, + stage: entry.stage_text.clone(), }) .collect(); @@ -478,11 +730,27 @@ pub fn app() -> Html { Page::Home => { html! { } } - Page::Downloads => html! {}, - Page::Library => html! { }, - Page::Settings => html! { }, + Page::Downloads => { + html! { + + } + } + Page::Library => html! { }, + Page::Settings => html! { }, Page::Extension => html! { }, - Page::Sponsor => html! { }, + Page::Sponsor => html! { }, }; html! { <>{ body } } @@ -491,7 +759,9 @@ pub fn app() -> Html { fn matches_delete_item(row: &ClipRow, item: &DeleteItem) -> bool { match item { DeleteItem::Platform(p) => row.platform == *p, - DeleteItem::Collection(p, handle, ctype) => row.platform == *p && row.handle == *handle && row.content_type == *ctype, + DeleteItem::Collection(p, handle, ctype) => { + row.platform == *p && row.handle == *handle && row.content_type == *ctype + } DeleteItem::Row(link) => row.link == *link, } } diff --git a/src/pages/downloads.rs b/src/pages/downloads.rs index bbfc313..24928d5 100644 --- a/src/pages/downloads.rs +++ b/src/pages/downloads.rs @@ -27,6 +27,7 @@ fn toggle_icon_for_row(row: &ClipRow) -> IconId { pub struct Props { pub backlog: Vec, pub queue: Vec, + pub issues: Vec, pub active: Vec, pub loading: bool, pub paused: bool, @@ -34,12 +35,14 @@ pub struct Props { pub on_delete: Callback, pub on_move_to_queue: Callback, pub on_move_to_backlog: Callback, + pub on_retry_issue: Callback, } #[derive(Clone, PartialEq)] pub struct ActiveDownload { pub row: ClipRow, - pub progress: String, // ignored in UI per requirements + pub progress: Option, + pub stage: String, } /* ───────────────────────── label helpers ───────────────────────── */ @@ -152,8 +155,10 @@ fn platform_icon_src(p: &str) -> &'static str { /* ───────────────────────── component ───────────────────────── */ #[function_component(DownloadsPage)] pub fn downloads_page(props: &Props) -> Html { - let has_any_rows = - !props.active.is_empty() || !props.queue.is_empty() || !props.backlog.is_empty(); + let has_any_rows = !props.active.is_empty() + || !props.queue.is_empty() + || !props.backlog.is_empty() + || !props.issues.is_empty(); let expanded_platforms = use_state(|| std::collections::HashSet::::new()); let expanded_collections = use_state(|| std::collections::HashSet::::new()); // Local overrides so icon flips instantly on click (DB persists separately) @@ -182,7 +187,10 @@ pub fn downloads_page(props: &Props) -> Html { let section_id = title.to_lowercase(); // "backlog" or "queue" // platform -> (handle, type, Platform, ContentType) -> rows - let mut map: BTreeMap>> = BTreeMap::new(); + let mut map: BTreeMap< + String, + BTreeMap<(String, String, Platform, ContentType), Vec>, + > = BTreeMap::new(); // De-dupe by (platform, handle, type, link) within this section let mut seen = HashSet::::new(); @@ -194,12 +202,22 @@ pub fn downloads_page(props: &Props) -> Html { let plat = platform_str(&r.platform).to_string(); let typ = content_type_str(&r.content_type).to_string(); - let dedup_key = format!("{}|{}|{}|{}", plat, r.handle.to_lowercase().trim(), typ, r.link.trim()); + let dedup_key = format!( + "{}|{}|{}|{}", + plat, + r.handle.to_lowercase().trim(), + typ, + r.link.trim() + ); if !seen.insert(dedup_key) { continue; } - map.entry(plat).or_default().entry((r.handle.clone(), typ, r.platform, r.content_type)).or_default().push(r); + map.entry(plat) + .or_default() + .entry((r.handle.clone(), typ, r.platform, r.content_type)) + .or_default() + .push(r); } html! { @@ -606,8 +624,15 @@ pub fn downloads_page(props: &Props) -> Html { { collection_title(&active.row) } {" - "}{ item_label_for_row(&active.row) } -
- { &active.progress } +
+ { &active.stage } + { + if let Some(progress) = &active.progress { + html! { { progress } } + } else { + html! {} + } + }
} @@ -632,7 +657,95 @@ pub fn downloads_page(props: &Props) -> Html { } } - { render_section(props.backlog.clone(), "Backlog", true) } + { + if !props.backlog.is_empty() { + html! { render_section(props.backlog.clone(), "Backlog", true) } + } else { + html! {} + } + } + + { + if !props.issues.is_empty() { + html! { + <> +

{"Issues"}

+
+
+
    + { + for props.issues.iter().map(|row| { + let plat_label = platform_str(&row.platform).to_string(); + let row_for_delete = row.clone(); + let row_for_backlog = row.clone(); + let issue_id = row.id; + let on_delete = { + let on_delete = props.on_delete.clone(); + let link = row_for_delete.link.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + let link_for_backend = link.clone(); + wasm_bindgen_futures::spawn_local(async move { + let args = serde_wasm_bindgen::to_value(&serde_json::json!({ "link": link_for_backend })).unwrap(); + let _ = invoke("delete_rows_by_link", args).await; + }); + on_delete.emit(DeleteItem::Row(link.clone())); + }) + }; + let on_backlog = { + let on_move_back = props.on_move_to_backlog.clone(); + let link = row_for_backlog.link.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + on_move_back.emit(crate::app::MoveBackItem::Row(link.clone())); + }) + }; + let on_retry = { + let on_retry_issue = props.on_retry_issue.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + on_retry_issue.emit(issue_id); + }) + }; + html! { +
  • +
    +
    + + { collection_title(row) } + {" - "}{ item_label_for_row(row) } +
    +
    + { row.last_error.clone().unwrap_or_else(|| "Download failed".into()) } +
    +
    +
    + + + +
    +
  • + } + }) + } +
+
+
+ + } + } else { + html! {} + } + } } } diff --git a/src/styles/downloads.css b/src/styles/downloads.css index deb5dba..7c12d5c 100644 --- a/src/styles/downloads.css +++ b/src/styles/downloads.css @@ -147,6 +147,65 @@ height: 36px; } +.row-actions.active-status { + visibility: visible; + opacity: 1; + position: static; + transform: none; + margin-left: auto; + background: transparent; + padding: 0; + height: auto; +} + +.stage-text { + max-width: 220px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + opacity: 0.88; + font-size: 0.92rem; +} + +.progress-text { + font-variant-numeric: tabular-nums; + min-width: 40px; + text-align: right; +} + +.issue-line { + align-items: flex-start; + min-height: 70px; +} + +.issue-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding-right: 172px; +} + +.issue-title { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.issue-reason { + color: #f0b4b4; + font-size: 0.9rem; + line-height: 1.35; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.row-actions.issue-actions { + background: transparent; +} + /* Shared icon button */ .icon-btn { padding: 0; diff --git a/src/types.rs b/src/types.rs index ce52d5f..16d543d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -54,6 +54,8 @@ pub struct ClipRow { pub output_format: Option, #[serde(default)] pub status: DownloadStatus, + #[serde(default)] + pub last_error: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] From c94dd31cb25f3b02b6ab30917ae3565397dfa103 Mon Sep 17 00:00:00 2001 From: hjoncour Date: Tue, 5 May 2026 00:00:44 -0400 Subject: [PATCH 2/2] fix(backlog): issues section --- src/pages/downloads.rs | 312 +++++++++++++++++++++++++++++++---------- src/pages/settings.rs | 4 +- 2 files changed, 239 insertions(+), 77 deletions(-) diff --git a/src/pages/downloads.rs b/src/pages/downloads.rs index 24928d5..3774b5c 100644 --- a/src/pages/downloads.rs +++ b/src/pages/downloads.rs @@ -583,6 +583,242 @@ pub fn downloads_page(props: &Props) -> Html { } }; + let render_issues = { + let expanded_platforms = expanded_platforms.clone(); + let expanded_collections = expanded_collections.clone(); + let on_delete_prop = props.on_delete.clone(); + let on_move_back_prop = props.on_move_to_backlog.clone(); + let on_retry_prop = props.on_retry_issue.clone(); + + move |rows_in: Vec| -> Html { + use std::collections::BTreeMap; + + let mut map: BTreeMap>> = BTreeMap::new(); + for mut r in rows_in { + if r.handle.trim().is_empty() { r.handle = "Unknown".into(); } + let plat = platform_str(&r.platform).to_string(); + let typ = content_type_str(&r.content_type).to_string(); + map.entry(plat).or_default().entry((r.handle.clone(), typ)).or_default().push(r); + } + + html! { + <> +

{"Issues"}

+
+ { + for map.into_iter().map(|(plat_label, col_map)| { + let total: usize = col_map.values().map(|v| v.len()).sum(); + let col_count = col_map.len(); + let platform_key = format!("issues::{}", plat_label); + let is_open = expanded_platforms.contains(&platform_key); + + let on_platform_click = { + let expanded_platforms = expanded_platforms.clone(); + let k = platform_key.clone(); + Callback::from(move |_| { + let mut set = (*expanded_platforms).clone(); + if !set.insert(k.clone()) { set.remove(&k); } + expanded_platforms.set(set); + }) + }; + + let on_delete_platform = { + let on_delete = on_delete_prop.clone(); + let platform = match plat_label.as_str() { + "instagram" => Platform::Instagram, + "tiktok" => Platform::Tiktok, + "youtube" => Platform::Youtube, + "pinterest" => Platform::Pinterest, + _ => Platform::Tiktok, + }; + let plat_s = plat_label.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + let plat_s = plat_s.clone(); + wasm_bindgen_futures::spawn_local(async move { + let args = serde_wasm_bindgen::to_value(&serde_json::json!({ "platform": plat_s })).unwrap(); + let _ = invoke("delete_rows_by_platform", args).await; + }); + on_delete.emit(DeleteItem::Platform(platform)); + }) + }; + + let platform_rows = if is_open { + html! { +
+ { + for col_map.into_iter().map(|((handle, typ_str), rows)| { + let col_key = format!("issues::{}::{}::{}", plat_label, handle, typ_str); + let col_open = expanded_collections.contains(&col_key); + + let on_col_click = { + let expanded_collections = expanded_collections.clone(); + let k = col_key.clone(); + Callback::from(move |_| { + let mut set = (*expanded_collections).clone(); + if !set.insert(k.clone()) { set.remove(&k); } + expanded_collections.set(set); + }) + }; + + let on_delete_col = { + let on_delete = on_delete_prop.clone(); + let plat_s = plat_label.clone(); + let handle_s = handle.clone(); + let typ_s = typ_str.clone(); + let platform = match plat_label.as_str() { + "instagram" => Platform::Instagram, + "tiktok" => Platform::Tiktok, + "youtube" => Platform::Youtube, + "pinterest" => Platform::Pinterest, + _ => Platform::Tiktok, + }; + let ctype = match typ_str.as_str() { + "liked" => ContentType::Liked, + "reposts" => ContentType::Reposts, + "profile" => ContentType::Profile, + "bookmarks" => ContentType::Bookmarks, + "playlist" => ContentType::Playlist, + "recommendation" => ContentType::Recommendation, + "manual" => ContentType::Manual, + "pinboard" => ContentType::Pinboard, + _ => ContentType::Other, + }; + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + let plat_s = plat_s.clone(); + let handle_s = handle_s.clone(); + let handle_b = handle_s.clone(); + let typ_s = typ_s.clone(); + wasm_bindgen_futures::spawn_local(async move { + let args = serde_wasm_bindgen::to_value(&serde_json::json!({ + "platform": plat_s, + "handle": handle_b, + "origin": typ_s, + })).unwrap(); + let _ = invoke("delete_rows_by_collection", args).await; + }); + on_delete.emit(DeleteItem::Collection(platform, handle_s, ctype)); + }) + }; + + html! { +
+
+
+ { format!("{} | {}", handle, typ_str) } +
+
+ { format!("{} items", rows.len()) } + +
+
+ { + if col_open { + html! { +
+
    + { + for rows.into_iter().map(|row| { + let issue_id = row.id; + let on_delete_row = { + let on_delete = on_delete_prop.clone(); + let link = row.link.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + let link_b = link.clone(); + wasm_bindgen_futures::spawn_local(async move { + let args = serde_wasm_bindgen::to_value(&serde_json::json!({ "link": link_b })).unwrap(); + let _ = invoke("delete_rows_by_link", args).await; + }); + on_delete.emit(DeleteItem::Row(link.clone())); + }) + }; + let on_backlog_row = { + let on_move_back = on_move_back_prop.clone(); + let link = row.link.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + on_move_back.emit(crate::app::MoveBackItem::Row(link.clone())); + }) + }; + let on_retry_row = { + let on_retry = on_retry_prop.clone(); + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + e.stop_propagation(); + on_retry.emit(issue_id); + }) + }; + html! { +
  • +
    +
    + { item_label_for_row(&row) } +
    +
    + { row.last_error.clone().unwrap_or_else(|| "Download failed".into()) } +
    +
    +
    + + + +
    +
  • + } + }) + } +
+
+ } + } else { html!{} } + } +
+ } + }) + } +
+ } + } else { html!{} }; + + html! { +
+
+
+ + { plat_label.clone() } +
+
+ { format!("{} collections | {} items", col_count, total) } + +
+
+ { platform_rows } +
+ } + }) + } +
+ + } + } + }; + html! {
@@ -667,81 +903,7 @@ pub fn downloads_page(props: &Props) -> Html { { if !props.issues.is_empty() { - html! { - <> -

{"Issues"}

-
-
-
    - { - for props.issues.iter().map(|row| { - let plat_label = platform_str(&row.platform).to_string(); - let row_for_delete = row.clone(); - let row_for_backlog = row.clone(); - let issue_id = row.id; - let on_delete = { - let on_delete = props.on_delete.clone(); - let link = row_for_delete.link.clone(); - Callback::from(move |e: MouseEvent| { - e.prevent_default(); - e.stop_propagation(); - let link_for_backend = link.clone(); - wasm_bindgen_futures::spawn_local(async move { - let args = serde_wasm_bindgen::to_value(&serde_json::json!({ "link": link_for_backend })).unwrap(); - let _ = invoke("delete_rows_by_link", args).await; - }); - on_delete.emit(DeleteItem::Row(link.clone())); - }) - }; - let on_backlog = { - let on_move_back = props.on_move_to_backlog.clone(); - let link = row_for_backlog.link.clone(); - Callback::from(move |e: MouseEvent| { - e.prevent_default(); - e.stop_propagation(); - on_move_back.emit(crate::app::MoveBackItem::Row(link.clone())); - }) - }; - let on_retry = { - let on_retry_issue = props.on_retry_issue.clone(); - Callback::from(move |e: MouseEvent| { - e.prevent_default(); - e.stop_propagation(); - on_retry_issue.emit(issue_id); - }) - }; - html! { -
  • -
    -
    - - { collection_title(row) } - {" - "}{ item_label_for_row(row) } -
    -
    - { row.last_error.clone().unwrap_or_else(|| "Download failed".into()) } -
    -
    -
    - - - -
    -
  • - } - }) - } -
-
-
- - } + render_issues(props.issues.clone()) } else { html! {} } diff --git a/src/pages/settings.rs b/src/pages/settings.rs index 3f32ff4..68d88ef 100644 --- a/src/pages/settings.rs +++ b/src/pages/settings.rs @@ -168,7 +168,7 @@ pub fn settings_page() -> Html { let on_parallel_downloads_change = { let settings = settings.clone(); - Callback::from(move |e: Event| { + Callback::from(move |e: web_sys::InputEvent| { let value = e .target_unchecked_into::() .value_as_number() as u8; @@ -323,7 +323,7 @@ pub fn settings_page() -> Html {
- +