From ec3905670ceeeb336a94e74f8a72bb7e01e32377 Mon Sep 17 00:00:00 2001 From: houssemko Date: Thu, 17 Sep 2026 12:19:33 +0100 Subject: [PATCH] Stop-and-keep live HLS captures instead of cancelling --- po/grab.pot | 10 +++- src/download.rs | 78 +++++++++++++++++++----- src/download_tests.rs | 46 +++++++++----- src/video.rs | 135 ++++++++++++++++++++++++++++++++++++++---- src/video_tests.rs | 77 ++++++++++++++++++++++++ src/window.rs | 36 +++++++++-- 6 files changed, 336 insertions(+), 46 deletions(-) diff --git a/po/grab.pot b/po/grab.pot index 61b39bef..b88f2ee7 100644 --- a/po/grab.pot +++ b/po/grab.pot @@ -1000,4 +1000,12 @@ msgstr "" #: src/video.rs:333 msgid "video-only" -msgstr "" \ No newline at end of file +msgstr "" + +#: src/window.rs:104 +msgid "Stop recording" +msgstr "" + +#: src/window.rs:101 +msgid "Stop" +msgstr "" diff --git a/src/download.rs b/src/download.rs index 687d8aad..899a6e4a 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1704,6 +1704,11 @@ pub struct DownloadManager { /// dropped) from pause/park/cancel paths so the worker stops its /// extractor streams promptly; the pump tail also drops them. video_abort: RefCell>>, + /// Rows currently capturing a live stream. Pause/cancel/park only + /// signal these (no task abort, no status preset): the worker + /// finalizes the partial and its message drives the row to Done. + /// Set per attempt in `spawn_video`; dropped on terminal messages. + live_rows: RefCell>, /// Set by shutdown(): stale engine futures must not re-persist or /// re-mark rows once the authoritative shutdown persist has run. draining: Cell, @@ -1743,6 +1748,7 @@ impl DownloadManager { server_mtime: RefCell::new(HashMap::new()), video_sources: RefCell::new(HashMap::new()), video_abort: RefCell::new(HashMap::new()), + live_rows: RefCell::new(std::collections::HashSet::new()), draining: Cell::new(false), }); // Live preferences: raising the download limit must wake queued @@ -1844,6 +1850,12 @@ impl DownloadManager { self.video_sources.borrow().get(&id).cloned() } + /// Whether the row is currently capturing a live stream (stop-and-keep + /// applies: pausing/cancelling finalizes instead of discarding). + pub fn is_live_video(&self, id: u64) -> bool { + self.live_rows.borrow().contains(&id) + } + /// Validate, dedupe and queue a download, starting it when a slot is free. /// /// # Errors @@ -1967,9 +1979,7 @@ impl DownloadManager { page_url: &str, dest_dir: Option<&str>, filename: Option<&str>, - quality: &str, - audio_only: bool, - video_format_id: Option<&str>, + choices: crate::video::VideoChoices, ) -> Result { let url = normalize_url(page_url)?; if !crate::video::is_video_page(&url) { @@ -1992,7 +2002,7 @@ impl DownloadManager { }) }); let item = DownloadItem::new(self.alloc_id(), &url, &name, &dir); - item.set_detail(if audio_only { + item.set_detail(if choices.audio_only { gettext("Waiting to resolve audio…") } else { gettext("Waiting to resolve media…") @@ -2003,9 +2013,10 @@ impl DownloadManager { page_url: url, media_url: None, expires_at: None, - quality: quality.to_string(), - audio_only, - video_format_id: video_format_id.map(|s| s.to_string()), + quality: choices.quality, + audio_only: choices.audio_only, + is_live: choices.is_live, + video_format_id: choices.video_format_id, }, ); Ok(self.insert(item)) @@ -2182,11 +2193,19 @@ impl DownloadManager { page_url, quality, audio_only, + is_live, video_format_id, .. }) = self.video_source(item.id()) { - return self.spawn_video(item, page_url, quality, audio_only, video_format_id); + return self.spawn_video( + item, + page_url, + quality, + audio_only, + video_format_id, + is_live, + ); } let connections = (opts.connections.max(1) as usize).min(16); let timeout = Duration::from_secs(opts.timeout.max(1) as u64); @@ -2580,6 +2599,7 @@ impl DownloadManager { } this.running.borrow_mut().remove(&id); this.video_abort.borrow_mut().remove(&id); + this.live_rows.borrow_mut().remove(&id); if this.draining.get() { return; } @@ -2673,6 +2693,7 @@ impl DownloadManager { quality: String, audio_only: bool, video_format_id: Option, + is_live: bool, ) { let id = item.id(); let generation = self.epoch.borrow().get(&id).cloned().unwrap_or(0) + 1; @@ -2681,6 +2702,13 @@ impl DownloadManager { let (abort_tx, abort_rx) = tokio::sync::oneshot::channel(); // Overwrite any stale sender: its task is dead or guarded stale. self.video_abort.borrow_mut().insert(id, abort_tx); + // Live rows finalize in the worker on stop: track them so + // pause/cancel/park signal without aborting or presetting. + if is_live { + self.live_rows.borrow_mut().insert(id); + } else { + self.live_rows.borrow_mut().remove(&id); + } let opts = DownloadOptions::from_settings(&self.settings); let job = crate::video::VideoJob { item_id: id, @@ -2692,6 +2720,7 @@ impl DownloadManager { timeout_secs: opts.timeout.max(1) as u64, user_agent: opts.user_agent.clone(), video_format_id, + is_live, cookies_browser: self.settings.cookies_browser(), }; let handle = tokio_rt().spawn(async move { @@ -2751,6 +2780,14 @@ impl DownloadManager { /// Pause a running download, freeing its slot for the next queued item. pub fn pause(self: &Rc, id: u64) { + if self.live_rows.borrow().contains(&id) { + // Live captures finalize in the worker: signal it and let + // its Finished flip the row to Done. Aborting the task or + // presetting Paused would drop the recording or strand it. + // The pump tail frees the slot on completion. + self.stop_video_worker(id); + return; + } self.stop_video_worker(id); if let Some(handle) = self.running.borrow().get(&id) { handle.abort(); @@ -2864,8 +2901,13 @@ impl DownloadManager { /// Stop the engine for `id`, keeping file, bitmap and progress, and /// mark it queued. Unlike pause the row yields its slot; unlike cancel - /// nothing is deleted and progress is kept. + /// nothing is deleted and progress is kept. Live rows only signal: + /// the worker finalizes and its message completes the row. fn park(&self, id: u64) { + if self.live_rows.borrow().contains(&id) { + self.stop_video_worker(id); + return; + } self.stop_video_worker(id); if let Some(handle) = self.running.borrow().get(&id) { handle.abort(); @@ -2927,11 +2969,17 @@ impl DownloadManager { } fn cancel_inner(&self, id: u64, keep_partial: bool) { + // Live rows keep what's recorded (Stop, not Cancel): signal the + // worker and skip the task abort plus the Cancelled preset, so + // its Finished still lands. Discard via explicit row removal. + let live = self.live_rows.borrow().contains(&id); self.stop_video_worker(id); - if let Some(handle) = self.running.borrow().get(&id) { - handle.abort(); + if !live { + if let Some(handle) = self.running.borrow().get(&id) { + handle.abort(); + } + self.running.borrow_mut().remove(&id); } - self.running.borrow_mut().remove(&id); self.pending_names.borrow_mut().remove(&id); let had_segments = self.segment_state.borrow_mut().remove(&id).is_some(); self.torrent_pieces.borrow_mut().remove(&id); @@ -2948,8 +2996,10 @@ impl DownloadManager { if crate::torrent::is_torrent(&item.url()) && item.status() != DownloadStatus::Done { crate::torrent::forget_download(id); } - item.set_status(DownloadStatus::Cancelled); - item.set_detail(item.status().label()); + if !live { + item.set_status(DownloadStatus::Cancelled); + item.set_detail(item.status().label()); + } } } diff --git a/src/download_tests.rs b/src/download_tests.rs index 816fafea..09d2d543 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -640,9 +640,12 @@ fn enqueue_video_spawns_and_fails_without_tools() { "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest), Some("My Video.mp4"), - "1080p", - false, - None, + crate::video::VideoChoices { + quality: "1080p".to_string(), + audio_only: false, + video_format_id: None, + is_live: false, + }, ) .expect("video enqueue"); let id = item.id(); @@ -676,9 +679,12 @@ fn enqueue_video_rejects_direct_url() { "https://example.com/f.iso", None, None, - "1080p", - false, - None + crate::video::VideoChoices { + quality: "1080p".to_string(), + audio_only: false, + video_format_id: None, + is_live: false, + }, ) .is_err() ); @@ -701,9 +707,12 @@ fn video_source_survives_restore_and_retry() { "https://vimeo.com/123456", Some(&dest), Some("Clip.mp4"), - "720p", - true, - None, + crate::video::VideoChoices { + quality: "720p".to_string(), + audio_only: true, + video_format_id: None, + is_live: false, + }, ) .expect("video enqueue"); // Persisted with the Page marker (not silently dropped). @@ -752,6 +761,7 @@ fn mismatched_video_source_dropped_on_restore() { expires_at: None, quality: "1080p".into(), audio_only: false, + is_live: false, video_format_id: None, }), }], @@ -787,9 +797,12 @@ fn unremove_restores_video_source() { "https://vimeo.com/123456", Some(&dest), Some("Clip.mp4"), - "720p", - false, - None, + crate::video::VideoChoices { + quality: "720p".to_string(), + audio_only: false, + video_format_id: None, + is_live: false, + }, ) .expect("video enqueue"); let id = item.id(); @@ -835,9 +848,12 @@ fn queue_file_never_carries_cookies() { "https://vimeo.com/123456", Some("/tmp/dl"), Some("Clip.mp4"), - "1080p", - false, - None, + crate::video::VideoChoices { + quality: "1080p".to_string(), + audio_only: false, + video_format_id: None, + is_live: false, + }, ) .expect("video enqueue"); // Drain the spawned worker (fails fast without tools) so no woken pump diff --git a/src/video.rs b/src/video.rs index 2ee06bdf..0392d838 100644 --- a/src/video.rs +++ b/src/video.rs @@ -139,6 +139,17 @@ const VIDEO_DOMAINS: &[&str] = &[ /// back to audio-only on its own when no usable video format selects. const AUDIO_FIRST_DOMAINS: &[&str] = &["soundcloud.com", "bandcamp.com"]; +/// Per-row video choices from the New Download dialog: quality +/// preset, mode, format pin and liveness. Bundled so intake entry +/// points stay under the argument-count lint. +#[derive(Debug, Clone)] +pub struct VideoChoices { + pub quality: String, + pub audio_only: bool, + pub video_format_id: Option, + pub is_live: bool, +} + /// Where a to-be-downloaded resource comes from. Serialized into the queue /// file, so it stays stable across releases. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -158,6 +169,11 @@ pub enum VideoSource { quality: String, #[serde(default)] audio_only: bool, + /// Whether the page is a live stream. Persisted so rows restored + /// across launches keep their live behavior (stop-and-keep + /// instead of pause/cancel); refreshed on every resolve. + #[serde(default)] + is_live: bool, /// Pinned video format id chosen in the dialog (`None` = the /// quality preset decides at attempt time). Falls back to the /// preset when the id vanishes from fresh metadata. @@ -182,6 +198,7 @@ pub fn classify(url: &str) -> VideoSource { expires_at: None, quality: default_video_quality(), audio_only: false, + is_live: false, video_format_id: None, } } else { @@ -384,6 +401,9 @@ pub struct VideoInfo { /// Pinnable video-only formats, tallest first (empty when the page /// carries none). Computed once at resolve; the dialog lists these. pub formats: Vec, + /// Whether the page is currently live. Decides stop-and-keep + /// behavior for HLS captures; refreshed on every resolve. + pub is_live: bool, } impl VideoInfo { @@ -408,6 +428,7 @@ impl VideoInfo { page_url, expires_at, formats: video_format_options(v), + is_live: v.is_live.unwrap_or(false), } } } @@ -1597,6 +1618,9 @@ pub struct VideoJob { /// Dialog-pinned video format id, if the user picked an exact format. /// `None` means the quality preset decides at attempt time. pub video_format_id: Option, + /// Whether the page is currently live. A live HLS capture finalizes + /// and keeps its partial on stop instead of discarding it. + pub is_live: bool, /// Raw browser-auth setting (`none` when off). Resolved to a /// `--cookies-from-browser` spec inside the worker. pub cookies_browser: String, @@ -1614,7 +1638,7 @@ const PROGRESS_GRANULARITY: u64 = 65536; /// Returns a display-ready [`VideoError`]; the caller reports it as Failed. pub async fn run_video_download( job: VideoJob, - abort: oneshot::Receiver<()>, + mut abort: oneshot::Receiver<()>, tx: tokio::sync::mpsc::UnboundedSender, ) -> Result, VideoError> { use crate::download::EngineMsg; @@ -1756,6 +1780,13 @@ pub async fn run_video_download( .or_else(|| select_hls_format(&video.formats, quality_height(&job.quality))) }; if let Some(hls) = hls_sel { + // An abort that fired during resolve means stop-before-start: + // for live rows there is deliberately no pauser preset waiting + // on a message, so report instead of going quiet (the pump tail + // would fail the row either way — this names the cause). + if job.is_live && abort.try_recv().is_ok() { + return Err(VideoError::interrupted()); + } tracing::info!( item_id = job.item_id, page_host = %page_host(&job.page_url), @@ -2015,6 +2046,71 @@ async fn finish_merge( Ok(final_bytes.unwrap_or(0)) } +/// Ask ffmpeg to stop gracefully: SIGTERM finalizes the container so +/// the partial plays, unlike SIGKILL. Escalates to SIGKILL after +/// `grace`, then waits out the exit either way. Returns whether the +/// process exited on its own accord — only then is the file adoptable. +async fn terminate_ffmpeg(child: &mut tokio::process::Child, grace: Duration) -> bool { + if let Some(pid) = child.id() { + // SAFETY: constant signal number; ESRCH (already dead) is harmless. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + } + if tokio::time::timeout(grace, child.wait()).await.is_ok() { + return true; + } + child.kill().await.ok(); + let _ = child.wait().await; + false +} + +/// Move a finished HLS capture into place. Captures that never got +/// data (stopped before the first segment) fail instead of stranding +/// an empty Done row. +async fn adopt_hls_output( + out: &Path, + dest: &Path, + staging: &Path, +) -> Result, VideoError> { + if file_len(out).unwrap_or(0) == 0 { + let _ = tokio::fs::remove_dir_all(staging).await; + return Err(VideoError::part_failed("nothing recorded")); + } + // Atomic claim into place (EXDEV-safe, no clobber). + match crate::download::rename_noreplace(out, dest) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(VideoError::exists()); + } + Err(e) => return Err(VideoError::combine(&e)), + } + let _ = tokio::fs::remove_dir_all(staging).await; + Ok(file_len(dest)) +} + +/// Stop a live HLS capture and keep what's recorded: terminate +/// gracefully, then adopt the partial if it finalized. Used for both +/// user stops and timeouts on live rows — a stalled or stopped live +/// capture still yields playable media instead of nothing. +async fn stop_hls_capture( + child: &mut tokio::process::Child, + progress: tokio::task::JoinHandle, + logs: tokio::task::JoinHandle, + out_path: PathBuf, + dest: PathBuf, + staging: PathBuf, +) -> Result, VideoError> { + let exited_clean = terminate_ffmpeg(child, Duration::from_secs(10)).await; + progress.abort(); + let _ = logs.await; + if !exited_clean { + let _ = tokio::fs::remove_dir_all(&staging).await; + return Err(VideoError::part_failed("nothing recorded")); + } + adopt_hls_output(&out_path, &dest, &staging).await +} + /// Download one HLS variant with ffmpeg (`-c copy`: the variant /// playlist, segment requests and any playlist keys are ffmpeg's /// business). No resume — every attempt wipes staging and restarts — @@ -2108,9 +2204,23 @@ async fn run_hls_download( } String::from_utf8_lossy(&tail).into_owned() }); + let live = job.is_live; let status = tokio::select! { biased; _ = abort => { + if live { + // Live captures keep what's recorded (see + // stop_hls_capture); VOD attempts just stop. + return stop_hls_capture( + &mut child, + progress, + logs, + out_path, + job.dest.clone(), + staging.to_path_buf(), + ) + .await; + } child.kill().await.ok(); let _ = child.wait().await; progress.abort(); @@ -2127,6 +2237,18 @@ async fn run_hls_download( return Err(VideoError::runtime(&e)); } Err(_) => { + // A stalled live capture still yields what it got. + if live { + return stop_hls_capture( + &mut child, + progress, + logs, + out_path, + job.dest.clone(), + staging.to_path_buf(), + ) + .await; + } child.kill().await.ok(); progress.abort(); logs.abort(); @@ -2146,16 +2268,7 @@ async fn run_hls_download( .to_string(); return Err(VideoError::part_failed(detail)); } - // Atomic claim into place (EXDEV-safe, no clobber). - match crate::download::rename_noreplace(&out_path, &job.dest) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - return Err(VideoError::exists()); - } - Err(e) => return Err(VideoError::combine(&e)), - } - let _ = tokio::fs::remove_dir_all(staging).await; - Ok(file_len(&job.dest)) + adopt_hls_output(&out_path, &job.dest, staging).await } #[cfg(test)] diff --git a/src/video_tests.rs b/src/video_tests.rs index 7d280299..6e1cf3dd 100644 --- a/src/video_tests.rs +++ b/src/video_tests.rs @@ -300,6 +300,7 @@ fn serde_page_round_trip() { expires_at: Some(1_700_000_000), quality: "720p".into(), audio_only: true, + is_live: false, video_format_id: Some("137".into()), }; let json = serde_json::to_string(&src).unwrap(); @@ -321,6 +322,7 @@ fn serde_page_old_json_gets_defaults() { expires_at: None, quality: "1080p".into(), audio_only: false, + is_live: false, video_format_id: None, } ); @@ -686,6 +688,7 @@ fn pipeline_reports_missing_tools() { timeout_secs: 5, user_agent: "test".into(), video_format_id: None, + is_live: false, cookies_browser: "none".into(), }; let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); @@ -711,6 +714,7 @@ fn test_video_info(page_url: &str) -> VideoInfo { page_url: page_url.into(), expires_at: None, formats: vec![], + is_live: false, } } @@ -1131,6 +1135,78 @@ fn picker_lists_hls_gap_heights() { assert_eq!(opts[0].label, "1080p · HLS"); } +#[test] +fn video_info_carries_live_flag() { + let mut video = test_video(serde_json::json!([])); + assert!(!VideoInfo::from(&video, "https://x.com/u/status/1").is_live); + video.is_live = Some(true); + assert!(VideoInfo::from(&video, "https://x.com/u/status/1").is_live); +} + +#[cfg(unix)] +#[test] +fn terminate_ffmpeg_graceful_then_forced() { + crate::download::tokio_rt().block_on(async { + // A plain sleep dies on SIGTERM inside the grace period. + let mut child = tokio::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + assert!(terminate_ffmpeg(&mut child, Duration::from_millis(500)).await); + // A TERM-ignoring sleep survives grace: SIGKILL fallback still + // reaps it, reporting not-graceful. Clean env (no BASH_ENV slow + // startup racing the signal) and a READY handshake (trap + // installed before we signal) keep this deterministic; exec + // carries the ignored disposition into sleep itself. + let mut child = tokio::process::Command::new("sh") + .args(["-c", "trap '' TERM; echo READY; exec sleep 30"]) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + { + use tokio::io::AsyncBufReadExt as _; + let mut line = String::new(); + tokio::io::BufReader::new(child.stdout.as_mut().unwrap()) + .read_line(&mut line) + .await + .unwrap(); + assert_eq!(line.trim(), "READY"); + } + assert!(!terminate_ffmpeg(&mut child, Duration::from_millis(200)).await); + }); +} + +#[test] +fn adopt_hls_output_moves_or_rejects_empty() { + crate::download::tokio_rt().block_on(async { + let dir = std::env::temp_dir().join(format!("grab-adopt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let staging = dir.join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + // Empty capture fails instead of stranding an empty Done row. + let empty = staging.join("hls.mp4"); + std::fs::write(&empty, b"").unwrap(); + let dest = dir.join("out.mp4"); + assert!(adopt_hls_output(&empty, &dest, &staging).await.is_err()); + assert!(!staging.exists()); + assert!(!dest.exists()); + // Real capture moves with content; source gone. + std::fs::create_dir_all(&staging).unwrap(); + let full = staging.join("hls.mp4"); + std::fs::write(&full, b"0123456789").unwrap(); + let size = adopt_hls_output(&full, &dest, &staging) + .await + .expect("adopt"); + assert_eq!(size, Some(10)); + assert!(!full.exists()); + assert_eq!(std::fs::read(&dest).unwrap(), b"0123456789"); + let _ = std::fs::remove_dir_all(&dir); + }); +} + #[test] fn codec_rank_orders_newest_first() { assert!(codec_rank("av01.0.08M.08") < codec_rank("vp9")); @@ -1193,6 +1269,7 @@ fn video_source_page_carries_format_pin() { expires_at: None, quality: "1080p".into(), audio_only: false, + is_live: false, video_format_id: Some("137".into()), }; let json = serde_json::to_string(&src).unwrap(); diff --git a/src/window.rs b/src/window.rs index edf82dcd..db8baa77 100644 --- a/src/window.rs +++ b/src/window.rs @@ -75,7 +75,12 @@ fn another_queued(manager: &DownloadManager, item: &crate::download::DownloadIte n > 1 || (n == 1 && item.status() != DownloadStatus::Queued) } -fn refresh_row(item: &crate::download::DownloadItem, w: &RowWidgets, defer_available: bool) { +fn refresh_row( + item: &crate::download::DownloadItem, + w: &RowWidgets, + defer_available: bool, + is_live: bool, +) { let frac = item.progress().clamp(0.0, 1.0); w.progress.set_fraction(frac); let active = item.status() == DownloadStatus::Downloading; @@ -86,12 +91,28 @@ fn refresh_row(item: &crate::download::DownloadItem, w: &RowWidgets, defer_avail item.status(), DownloadStatus::Queued | DownloadStatus::Downloading | DownloadStatus::Paused ); - w.toggle_btn.set_visible(running); + // Live captures can't pause or defer mid-flight (resuming a moved-on + // stream is meaningless): the toggle and queue buttons hide, and Stop + // keeps what's recorded instead of discarding it. + let live_capturing = active && is_live; + w.toggle_btn.set_visible(running && !live_capturing); w.stop_btn.set_visible(running); + if live_capturing { + w.stop_btn.set_tooltip_text(Some(&gettext("Stop"))); + w.stop_btn + .update_property(&[gtk4::accessible::Property::Label(&gettext( + "Stop recording", + ))]); + } else { + w.stop_btn.set_tooltip_text(Some(&gettext("Cancel"))); + w.stop_btn + .update_property(&[gtk4::accessible::Property::Label(&gettext("Cancel"))]); + } // Deferring only makes sense while holding a slot that someone else // is waiting for; queued rows are already waiting. w.queue_btn.set_visible( defer_available + && !live_capturing && matches!( item.status(), DownloadStatus::Downloading | DownloadStatus::Paused @@ -426,6 +447,7 @@ fn build_row( expanded: Rc::clone(&exp_sync), }, another_queued(&m_sync, it), + m_sync.is_live_video(it.id()), ); } }; @@ -454,6 +476,7 @@ fn build_row( expanded: Rc::clone(&expanded), }, another_queued(manager, item), + manager.is_live_video(item.id()), ); { @@ -2040,9 +2063,12 @@ pub fn show_add_dialog(manager: Rc) { &v.page_url, Some(&dd.borrow()), name, - &quality, - audio_only, - format_id.as_deref(), + crate::video::VideoChoices { + quality, + audio_only, + video_format_id: format_id, + is_live: v.is_live, + }, ) { Ok(_) => close(), Err(e) => {