From a1290e69e6a4b3c54c467972bc958f8dd06bad1e Mon Sep 17 00:00:00 2001 From: houssemko Date: Wed, 16 Sep 2026 10:07:38 +0100 Subject: [PATCH 1/2] Fix video lookup stalling Add; show tool versions and install progress (2.7.0) - Dialog submit uses the same preview-freshness round-trip key as the resolve skip, so canonicalized page URLs no longer trap Add in a re-resolve loop (youtu.be vs youtube.com/watch) - Metadata extraction times out after 60s with Retry instead of spinning forever - Video preferences probe both tool versions off-thread (Checking, then Ready with versions) and show a spinner during installation - Version 2.7.0 metadata and translation template --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/io.github.houssemko.Grab.metainfo.xml.in | 5 + po/grab.pot | 92 ++++++------ src/preferences.rs | 134 ++++++++++++++++-- src/video.rs | 31 +++- src/video_tests.rs | 119 +++++----------- src/window.rs | 20 ++- 8 files changed, 256 insertions(+), 149 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e21c16546..1bec66c76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1222,7 +1222,7 @@ dependencies = [ [[package]] name = "grab" -version = "2.6.0" +version = "2.7.0" dependencies = [ "futures-util", "gettext-rs", diff --git a/Cargo.toml b/Cargo.toml index 17e01ee46..4fa022ac4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "grab" -version = "2.6.0" +version = "2.7.0" edition = "2024" rust-version = "1.92" description = "A GNOME download manager (GTK 4 / libadwaita, reqwest backend)" diff --git a/data/io.github.houssemko.Grab.metainfo.xml.in b/data/io.github.houssemko.Grab.metainfo.xml.in index 32839bd6f..d5ab91b7f 100644 --- a/data/io.github.houssemko.Grab.metainfo.xml.in +++ b/data/io.github.houssemko.Grab.metainfo.xml.in @@ -46,6 +46,11 @@ + + +

Fix: video-page preview no longer stalls Add on canonicalized links, and metadata lookups time out with Retry instead of spinning forever. Video preferences show tool versions with install progress. More video sites supported.

+
+

New: video-page downloads (YouTube, Vimeo and more) with quality picker up to 4K, audio-only mode, and automatic merging. The app is now licensed under GPL-3.0-only.

diff --git a/po/grab.pot b/po/grab.pot index b0f7f518d..f432153ba 100644 --- a/po/grab.pot +++ b/po/grab.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: grab\n" "Report-Msgid-Bugs-To: https://github.com/houssemko/Grab/issues\n" -"POT-Creation-Date: 2026-09-16 08:27+0100\n" +"POT-Creation-Date: 2026-09-16 10:07+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -#: src/application.rs:114 src/window.rs:1660 +#: src/application.rs:114 src/window.rs:1671 msgid "Could not read that .torrent file" msgstr "" @@ -133,7 +133,7 @@ msgid "File changed on server" msgstr "" #: src/download.rs:807 src/download.rs:813 src/download.rs:819 -#: src/download.rs:2536 src/download.rs:2537 src/video.rs:233 +#: src/download.rs:2536 src/download.rs:2537 src/video.rs:289 msgid "Download interrupted" msgstr "" @@ -236,7 +236,7 @@ msgstr "" #. Resolve (with retries): the extractor cache already drops expired #. format URLs, so a cached hit is safe to download from. -#: src/download.rs:2658 src/video.rs:620 +#: src/download.rs:2658 src/video.rs:707 msgid "Resolving video…" msgstr "" @@ -451,55 +451,63 @@ msgid "" "engine first starts." msgstr "" -#: src/preferences.rs:344 +#: src/preferences.rs:374 src/preferences.rs:489 +msgid "Ready • {yt} • {ff}" +msgstr "" + +#: src/preferences.rs:378 src/preferences.rs:493 msgid "Update" msgstr "" -#: src/preferences.rs:347 +#: src/preferences.rs:381 src/preferences.rs:496 msgid "Not installed" msgstr "" -#: src/preferences.rs:348 src/window.rs:1355 +#: src/preferences.rs:382 src/preferences.rs:497 src/window.rs:1355 msgid "Install" msgstr "" -#: src/preferences.rs:353 +#: src/preferences.rs:387 msgid "Video" msgstr "" -#: src/preferences.rs:357 src/window.rs:1340 +#: src/preferences.rs:391 src/window.rs:1340 msgid "Quality" msgstr "" -#: src/preferences.rs:362 +#: src/preferences.rs:396 msgid "Preferred quality" msgstr "" -#: src/preferences.rs:363 +#: src/preferences.rs:397 msgid "Used for new video downloads" msgstr "" -#: src/preferences.rs:390 src/window.rs:1346 +#: src/preferences.rs:424 src/window.rs:1346 msgid "Audio only" msgstr "" -#: src/preferences.rs:391 +#: src/preferences.rs:425 msgid "New video downloads skip the video track" msgstr "" -#: src/preferences.rs:408 src/window.rs:1352 +#: src/preferences.rs:442 src/window.rs:1352 msgid "Support tools" msgstr "" -#: src/preferences.rs:409 +#: src/preferences.rs:443 msgid "yt-dlp and ffmpeg resolve video pages" msgstr "" -#: src/preferences.rs:412 +#: src/preferences.rs:446 msgid "Video support tools" msgstr "" -#: src/preferences.rs:423 src/window.rs:1591 +#: src/preferences.rs:464 +msgid "Checking…" +msgstr "" + +#: src/preferences.rs:514 src/window.rs:1602 msgid "Installing support tools…" msgstr "" @@ -551,35 +559,39 @@ msgstr "" msgid "480p" msgstr "" -#: src/video.rs:198 +#: src/video.rs:254 msgid "Video downloads need the yt-dlp support tools" msgstr "" -#: src/video.rs:202 +#: src/video.rs:258 msgid "Couldn't read the video page: {detail}" msgstr "" -#: src/video.rs:207 +#: src/video.rs:263 msgid "Couldn't install the video support tools: {detail}" msgstr "" -#: src/video.rs:213 +#: src/video.rs:269 msgid "Couldn't prepare video staging: {detail}" msgstr "" -#: src/video.rs:220 +#: src/video.rs:276 msgid "No suitable formats found for this video" msgstr "" -#: src/video.rs:224 +#: src/video.rs:280 msgid "Media download failed: {detail}" msgstr "" -#: src/video.rs:229 +#: src/video.rs:285 msgid "Couldn't merge video and audio: {detail}" msgstr "" -#: src/video.rs:797 +#: src/video.rs:450 +msgid "the lookup timed out" +msgstr "" + +#: src/video.rs:901 msgid "Merging…" msgstr "" @@ -667,7 +679,7 @@ msgstr "" msgid "Download Manager" msgstr "" -#: src/window.rs:741 src/window.rs:1869 +#: src/window.rs:741 src/window.rs:1892 msgid "Add Multiple URLs" msgstr "" @@ -743,7 +755,7 @@ msgstr "" msgid "File name" msgstr "" -#: src/window.rs:1198 src/window.rs:1697 src/window.rs:1904 src/window.rs:2021 +#: src/window.rs:1198 src/window.rs:1708 src/window.rs:1927 src/window.rs:2044 msgid "_Cancel" msgstr "" @@ -787,56 +799,56 @@ msgstr "" msgid "Save to" msgstr "" -#: src/window.rs:1635 +#: src/window.rs:1646 msgid "Torrent files" msgstr "" -#: src/window.rs:1641 +#: src/window.rs:1652 msgid "Choose torrent file" msgstr "" -#: src/window.rs:1642 +#: src/window.rs:1653 msgid "Add Torrent" msgstr "" -#: src/window.rs:1701 +#: src/window.rs:1712 msgid "_Add Download" msgstr "" -#: src/window.rs:1802 +#: src/window.rs:1821 msgid "Still looking up the video — wait for the preview, then add." msgstr "" -#: src/window.rs:1875 +#: src/window.rs:1898 msgid "One URL per line" msgstr "" -#: src/window.rs:1908 +#: src/window.rs:1931 msgid "_Add Downloads" msgstr "" -#: src/window.rs:1943 +#: src/window.rs:1966 msgid "Enter at least one URL" msgstr "" -#: src/window.rs:1961 +#: src/window.rs:1984 msgid "Added {added} of {total} URLs ({skipped} invalid lines skipped)" msgstr "" -#: src/window.rs:1990 +#: src/window.rs:2013 msgid "Files" msgstr "" -#: src/window.rs:1992 +#: src/window.rs:2015 msgid "{} file" msgid_plural "{} files" msgstr[0] "" msgstr[1] "" -#: src/window.rs:2025 +#: src/window.rs:2048 msgid "_Add Files" msgstr "" -#: src/window.rs:2054 +#: src/window.rs:2077 msgid "Select at least one file" msgstr "" diff --git a/src/preferences.rs b/src/preferences.rs index 21524f9c8..04e27d6f4 100644 --- a/src/preferences.rs +++ b/src/preferences.rs @@ -331,19 +331,53 @@ pub fn show( torrent_page.add(&torrent_net_group); dialog.add(&torrent_page); - // Video pages resolve through the yt-dlp support tools (P1); this page + // Video pages resolve through the yt-dlp support tools; this page // holds the defaults new video downloads start from, plus tool setup. - fn refresh_video_tools(row: &adw::ActionRow, btn: >k4::Button) { - match crate::video::resolve_libraries() { - Ok(libs) => { - row.set_subtitle(&format!( - "{} + {}", - libs.youtube.display(), - libs.ffmpeg.display() - )); + // Runs `binary --version` and reports `None` when it fails. + fn tool_version(binary: &std::path::Path) -> Option { + let out = std::process::Command::new(binary).arg("--version").output().ok()?; + if !out.status.success() { + return None; + } + let first = String::from_utf8(out.stdout) + .ok()? + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if first.is_empty() { + return None; + } + // ffmpeg prints a whole sentence ("ffmpeg version n9.0.1 ..."): + // keep the version token so the row stays readable. + if binary.file_name().is_some_and(|n| n.to_string_lossy() == "ffmpeg") + && let Some(token) = first.split_whitespace().nth(2) + { + return Some(format!("ffmpeg {token}")); + } + Some(first) + } + fn refresh_video_tools(row: &adw::ActionRow, btn: >k4::Button, spin: >k4::Spinner) { + spin.stop(); + spin.set_visible(false); + let probed = crate::video::resolve_libraries().ok().map(|libs| { + let yt = tool_version(&libs.youtube) + .unwrap_or_else(|| libs.youtube.display().to_string()); + let ff = tool_version(&libs.ffmpeg) + .unwrap_or_else(|| libs.ffmpeg.display().to_string()); + (yt, ff) + }); + match probed { + Some((yt, ff)) => { + row.set_subtitle( + &gettext("Ready • {yt} • {ff}") + .replace("{yt}", &yt) + .replace("{ff}", &ff), + ); btn.set_label(&gettext("Update")); } - Err(_) => { + None => { row.set_subtitle(&gettext("Not installed")); btn.set_label(&gettext("Install")); } @@ -414,18 +448,88 @@ pub fn show( let video_tools_btn = gtk4::Button::builder().valign(gtk4::Align::Center).build(); video_tools_row.set_activatable_widget(Some(&video_tools_btn)); video_tools_row.add_suffix(&video_tools_btn); + let video_tools_spin = gtk4::Spinner::new(); + video_tools_spin.set_visible(false); + video_tools_row.add_suffix(&video_tools_spin); video_tools_group.add(&video_tools_row); - refresh_video_tools(&video_tools_row, &video_tools_btn); + // Probe off the main thread: spawning cold binaries can jank startup. + // The row shows Checking until versions (or absence) resolve. + { + let (row, btn, spin) = ( + video_tools_row.clone(), + video_tools_btn.clone(), + video_tools_spin.clone(), + ); + let dialog_weak = dialog.downgrade(); + video_tools_row.set_subtitle(&gettext("Checking…")); + video_tools_btn.set_sensitive(false); + video_tools_spin.set_visible(true); + video_tools_spin.start(); + gtk4::glib::spawn_future_local(async move { + let probed = gio::spawn_blocking(|| { + crate::video::resolve_libraries().ok().map(|libs| { + let yt = tool_version(&libs.youtube) + .unwrap_or_else(|| libs.youtube.display().to_string()); + let ff = tool_version(&libs.ffmpeg) + .unwrap_or_else(|| libs.ffmpeg.display().to_string()); + (yt, ff) + }) + }) + .await + .ok() + .flatten(); + if dialog_weak.upgrade().is_none() { + return; + } + spin.stop(); + spin.set_visible(false); + match probed { + Some((yt, ff)) => { + row.set_subtitle( + &gettext("Ready • {yt} • {ff}") + .replace("{yt}", &yt) + .replace("{ff}", &ff), + ); + btn.set_label(&gettext("Update")); + } + None => { + row.set_subtitle(&gettext("Not installed")); + btn.set_label(&gettext("Install")); + } + } + btn.set_sensitive(true); + }); + } { - let (row, btn) = (video_tools_row.clone(), video_tools_btn.clone()); + let (row, btn, spin) = ( + video_tools_row.clone(), + video_tools_btn.clone(), + video_tools_spin.clone(), + ); + let dialog_weak = dialog.downgrade(); video_tools_btn.connect_clicked(move |_| { btn.set_sensitive(false); + spin.set_visible(true); + spin.start(); row.set_subtitle(&gettext("Installing support tools…")); - let (row_b, btn_b) = (row.clone(), btn.clone()); + let (row_b, btn_b, spin_b) = (row.clone(), btn.clone(), spin.clone()); + let dialog_b = dialog_weak.clone(); gtk4::glib::spawn_future_local(async move { match crate::video::install_libraries().await { - Ok(_) => refresh_video_tools(&row_b, &btn_b), - Err(e) => row_b.set_subtitle(&e.to_string()), + Ok(_) => { + if dialog_b.upgrade().is_none() { + return; + } + refresh_video_tools(&row_b, &btn_b, &spin_b); + } + Err(e) => { + if dialog_b.upgrade().is_none() { + return; + } + spin_b.stop(); + spin_b.set_visible(false); + row_b.set_subtitle(&e.to_string()); + } } btn_b.set_sensitive(true); }); diff --git a/src/video.rs b/src/video.rs index 13b7b9e81..1e5c33917 100644 --- a/src/video.rs +++ b/src/video.rs @@ -180,6 +180,16 @@ pub fn is_video_page(url: &str) -> bool { matches!(classify(url), VideoSource::Page { .. }) } +/// Whether a resolved preview still matches the dialog's current text. +/// The dialog kick and the submit gate must agree on this: the extractor +/// canonicalizes page URLs (youtu.be → youtube.com/watch), so comparing +/// the stored canonical URL against the typed text would reject every +/// canonicalized preview and trap Add in a re-resolve loop. The +/// round-trip key (which exact text was resolved) is the stable one. +pub fn preview_fresh(info: &Option, last_ok: &str, url: &str) -> bool { + !url.is_empty() && last_ok == url && info.is_some() +} + fn video_domain(host: &str) -> bool { let host = host.trim_end_matches('.').to_ascii_lowercase(); VIDEO_DOMAINS @@ -412,6 +422,11 @@ pub fn clean_staging(dir: &Path) { } } +/// How long one metadata extraction may take before it counts as failed. +/// Without a ceiling a throttled host parks the dialog on its spinner +/// forever; the error path (with Retry) is strictly more useful. +const FETCH_TIMEOUT_SECS: u64 = 60; + /// Extract metadata for one video page. The media URLs inside the returned /// [`VideoInfo`] are only passed on to the download step; the *page URL* is /// what survives restarts. @@ -423,10 +438,18 @@ pub async fn fetch_video_infos(libs: Libraries, url: String) -> Result video, + Ok(Err(e)) => return Err(VideoError::fetch(&e)), + Err(_) => { + return Err(VideoError::fetch(gettext("the lookup timed out"))); + } + }; Ok::<_, VideoError>(VideoInfo::from(&video, &url)) }); match handle.await { diff --git a/src/video_tests.rs b/src/video_tests.rs index a235c0e1c..f0e644438 100644 --- a/src/video_tests.rs +++ b/src/video_tests.rs @@ -620,91 +620,46 @@ fn pipeline_reports_missing_tools() { assert!(!staging_dir(item_id).exists()); } -// ── TEMPORARY live checks for new domains (deleted before merge) ───── -// Ignored: network + real tools. XDG_DATA_HOME=/tmp/opencode/livecheck. -// Run SOLO: cargo test -- --ignored --test-threads=1 live_new_domain --nocapture -#[test] -#[ignore] -fn live_new_domain_archive_video() { - unsafe { - std::env::set_var("XDG_DATA_HOME", "/tmp/opencode/livecheck"); +// ── preview freshness (dialog kick/submit gate) ────────────────────── + +fn test_video_info(page_url: &str) -> VideoInfo { + VideoInfo { + id: "x".into(), + title: "T".into(), + thumbnail: None, + duration: None, + duration_string: None, + page_url: page_url.into(), + expires_at: None, } - assert!(is_video_page( - "https://archive.org/details/M1_20241029_164500_Agenda" - )); - let item_id = 720_000 + std::process::id() as u64; - let dest = std::path::PathBuf::from(format!( - "/tmp/opencode/livecheck/out/live-new-{item_id}.mp4" - )); - let _ = std::fs::remove_file(&dest); - let job = VideoJob { - item_id, - page_url: "https://archive.org/details/M1_20241029_164500_Agenda".into(), - quality: "480p".into(), - audio_only: false, - dest: dest.clone(), - tries: 2, - timeout_secs: 120, - user_agent: "test".into(), - }; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let (_abort_tx, abort_rx) = tokio::sync::oneshot::channel(); - let pump = std::thread::spawn(move || { - let mut ticks = 0u32; - while rx.blocking_recv().is_some() { - ticks += 1; - } - ticks - }); - let res = crate::download::tokio_rt().block_on(run_video_download(job, abort_rx, tx)); - let ticks = pump.join().unwrap(); - eprintln!("LIVE-NEW-VIDEO result: {res:?} ticks={ticks}"); - assert!(ticks >= 1); - assert!(matches!(res, Ok(Some(n)) if n > 500_000)); - assert!(dest.exists()); - assert!(!staging_dir(item_id).exists()); } #[test] -#[ignore] -fn live_new_domain_archive_audio_fallback() { - unsafe { - std::env::set_var("XDG_DATA_HOME", "/tmp/opencode/livecheck"); - } - let url = "https://archive.org/details/t3-podcast-7"; - assert!(is_video_page(url)); - // archive.org is NOT in the audio-first preset list: reaching audio - // means the worker fell back on its own from missing video formats. - assert!(!is_audio_first(url)); - let item_id = 730_000 + std::process::id() as u64; - let dest = std::path::PathBuf::from(format!( - "/tmp/opencode/livecheck/out/live-new-{item_id}.mp4" +fn preview_fresh_matches_round_trip() { + let info = Some(test_video_info("https://www.youtube.com/watch?v=x")); + assert!(preview_fresh( + &info, + "https://www.youtube.com/watch?v=x", + "https://www.youtube.com/watch?v=x", )); - let _ = std::fs::remove_file(&dest); - let job = VideoJob { - item_id, - page_url: url.into(), - quality: "480p".into(), - audio_only: false, // deliberately NOT audio-only: worker must fall back - dest: dest.clone(), - tries: 2, - timeout_secs: 120, - user_agent: "test".into(), - }; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let (_abort_tx, abort_rx) = tokio::sync::oneshot::channel(); - let pump = std::thread::spawn(move || { - let mut ticks = 0u32; - while rx.blocking_recv().is_some() { - ticks += 1; - } - ticks - }); - let res = crate::download::tokio_rt().block_on(run_video_download(job, abort_rx, tx)); - let ticks = pump.join().unwrap(); - eprintln!("LIVE-NEW-AUDIO result: {res:?} ticks={ticks}"); - assert!(ticks >= 1); - assert!(matches!(res, Ok(Some(n)) if n > 100_000)); - assert!(dest.exists()); - assert!(!staging_dir(item_id).exists()); +} + +#[test] +fn preview_fresh_accepts_canonical_drift() { + // Extractor canonicalized youtu.be → youtube.com/watch: the stored + // page URL differs from the typed text, but the round-trip key + // matches, so Add must proceed instead of re-resolving forever. + let info = Some(test_video_info("https://www.youtube.com/watch?v=x")); + assert!(preview_fresh(&info, "https://youtu.be/x", "https://youtu.be/x")); +} + +#[test] +fn preview_fresh_rejects_stale_and_empty() { + let info = Some(test_video_info("https://vimeo.com/1")); + // User edited the URL after resolving: not fresh. + assert!(!preview_fresh(&info, "https://vimeo.com/1", "https://vimeo.com/2")); + // Nothing resolved yet. + assert!(!preview_fresh(&None, "https://vimeo.com/1", "https://vimeo.com/1")); + // Empty text never matches, even with a coincidental empty key. + assert!(!preview_fresh(&info, "", "")); } diff --git a/src/window.rs b/src/window.rs index 9048afd1e..e23c9a20e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1500,7 +1500,7 @@ pub fn show_add_dialog(manager: Rc) { info_b.borrow_mut().take(); return; } - if *last_b.borrow() == url && info_b.borrow().is_some() { + if crate::video::preview_fresh(&info_b.borrow(), last_b.borrow().as_str(), &url) { show_video_ready(&step_b); return; } @@ -1741,6 +1741,7 @@ pub fn show_add_dialog(manager: Rc) { let error_label = error_label.clone(); let dialog_weak = dialog.downgrade(); let info = video_info.clone(); + let last_ok = video_last_ok.clone(); let step2 = step.clone(); let kick = kick_video.clone(); let quiet = video_quiet.clone(); @@ -1769,11 +1770,18 @@ pub fn show_add_dialog(manager: Rc) { }; let url = url_row.text().trim().to_string(); if crate::video::is_video_page(&url) { - let ready = info - .borrow() - .as_ref() - .filter(|v| v.page_url == url) - .cloned(); + // Same freshness gate as the kick skip above: the stored + // page URL is canonicalized, so only the round-trip key + // (which text was resolved) decides. + let ready = if crate::video::preview_fresh( + &info.borrow(), + last_ok.borrow().as_str(), + &url, + ) { + info.borrow().clone() + } else { + None + }; match ready { Some(v) => { let typed = file_row.text().trim().to_string(); From 8ae3c1b1225e6e4ff384621688e49a33e3840213 Mon Sep 17 00:00:00 2001 From: houssemko Date: Wed, 16 Sep 2026 10:12:37 +0100 Subject: [PATCH 2/2] Fix formatting per CI --- src/preferences.rs | 17 +++++++++++------ src/video_tests.rs | 18 +++++++++++++++--- src/window.rs | 16 +++++++--------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/preferences.rs b/src/preferences.rs index 04e27d6f4..2ce0ebf60 100644 --- a/src/preferences.rs +++ b/src/preferences.rs @@ -335,7 +335,10 @@ pub fn show( // holds the defaults new video downloads start from, plus tool setup. // Runs `binary --version` and reports `None` when it fails. fn tool_version(binary: &std::path::Path) -> Option { - let out = std::process::Command::new(binary).arg("--version").output().ok()?; + let out = std::process::Command::new(binary) + .arg("--version") + .output() + .ok()?; if !out.status.success() { return None; } @@ -351,7 +354,9 @@ pub fn show( } // ffmpeg prints a whole sentence ("ffmpeg version n9.0.1 ..."): // keep the version token so the row stays readable. - if binary.file_name().is_some_and(|n| n.to_string_lossy() == "ffmpeg") + if binary + .file_name() + .is_some_and(|n| n.to_string_lossy() == "ffmpeg") && let Some(token) = first.split_whitespace().nth(2) { return Some(format!("ffmpeg {token}")); @@ -362,10 +367,10 @@ pub fn show( spin.stop(); spin.set_visible(false); let probed = crate::video::resolve_libraries().ok().map(|libs| { - let yt = tool_version(&libs.youtube) - .unwrap_or_else(|| libs.youtube.display().to_string()); - let ff = tool_version(&libs.ffmpeg) - .unwrap_or_else(|| libs.ffmpeg.display().to_string()); + let yt = + tool_version(&libs.youtube).unwrap_or_else(|| libs.youtube.display().to_string()); + let ff = + tool_version(&libs.ffmpeg).unwrap_or_else(|| libs.ffmpeg.display().to_string()); (yt, ff) }); match probed { diff --git a/src/video_tests.rs b/src/video_tests.rs index f0e644438..0aee70a33 100644 --- a/src/video_tests.rs +++ b/src/video_tests.rs @@ -650,16 +650,28 @@ fn preview_fresh_accepts_canonical_drift() { // page URL differs from the typed text, but the round-trip key // matches, so Add must proceed instead of re-resolving forever. let info = Some(test_video_info("https://www.youtube.com/watch?v=x")); - assert!(preview_fresh(&info, "https://youtu.be/x", "https://youtu.be/x")); + assert!(preview_fresh( + &info, + "https://youtu.be/x", + "https://youtu.be/x" + )); } #[test] fn preview_fresh_rejects_stale_and_empty() { let info = Some(test_video_info("https://vimeo.com/1")); // User edited the URL after resolving: not fresh. - assert!(!preview_fresh(&info, "https://vimeo.com/1", "https://vimeo.com/2")); + assert!(!preview_fresh( + &info, + "https://vimeo.com/1", + "https://vimeo.com/2" + )); // Nothing resolved yet. - assert!(!preview_fresh(&None, "https://vimeo.com/1", "https://vimeo.com/1")); + assert!(!preview_fresh( + &None, + "https://vimeo.com/1", + "https://vimeo.com/1" + )); // Empty text never matches, even with a coincidental empty key. assert!(!preview_fresh(&info, "", "")); } diff --git a/src/window.rs b/src/window.rs index e23c9a20e..eb3c9a6c8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1773,15 +1773,13 @@ pub fn show_add_dialog(manager: Rc) { // Same freshness gate as the kick skip above: the stored // page URL is canonicalized, so only the round-trip key // (which text was resolved) decides. - let ready = if crate::video::preview_fresh( - &info.borrow(), - last_ok.borrow().as_str(), - &url, - ) { - info.borrow().clone() - } else { - None - }; + let ready = + if crate::video::preview_fresh(&info.borrow(), last_ok.borrow().as_str(), &url) + { + info.borrow().clone() + } else { + None + }; match ready { Some(v) => { let typed = file_row.text().trim().to_string();