diff --git a/README.md b/README.md index a257445..6e4fdfe 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | --- | --- | | `j` / `k`, ↓ / ↑ | Select the next / previous video | | Enter | Launch the selected video in Terebi | +| `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) | | `g` / Home, `G` / End | Select the first / last loaded video | | `i` | Edit the current category/combined feed search; other views use general YouTube Search | | `/` | Go directly to general YouTube Search | diff --git a/src/keys.rs b/src/keys.rs index df44ecb..f5dbe9c 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -119,6 +119,7 @@ actions! { ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"]; ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"]; VideoPlay, "video.play", "Play the selected video", VIEW, ["enter"]; + VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"]; VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"]; VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"]; VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"]; @@ -479,6 +480,10 @@ impl Bindings { &[ (&[VideoNext, VideoPrevious], "Select next / previous video"), (&[VideoPlay], "Play the selected video in Terebi"), + ( + &[VideoOpen], + "Open the selected YouTube video in the default browser", + ), ( &[WatchStatusWatching], "Mark selected as watching (clear watched)", diff --git a/src/main.rs b/src/main.rs index 84387ba..0961d00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -948,6 +948,7 @@ struct App { description_viewport: Option<(u16, u16)>, job: Option, player_launch: Option, + browser_launch: Option>>, status: String, } @@ -1023,6 +1024,7 @@ impl App { description_viewport: None, job: None, player_launch: None, + browser_launch: None, status: "Enter a query to search YouTube.".into(), } } @@ -2553,6 +2555,24 @@ impl App { } } + fn open_video_in_browser(&mut self) { + let view = &self.views[self.view_index]; + let Some(video) = view + .selection + .selected() + .and_then(|index| view.videos.get(index)) + else { + return; + }; + match youtube::open_in_browser(&video.id) { + Ok(launch) => { + self.browser_launch = Some(launch); + self.status = format!("Opening {} in the default browser...", video.title); + } + Err(error) => self.status = error.to_string(), + } + } + fn finish_description(&mut self, id: String, result: Result) { match result.and_then(|output| youtube::full_description(&output, &id)) { Ok(description) => self.remember_description(id, description), @@ -2601,6 +2621,17 @@ impl App { Err(error) => error, }; } + if let Some(result) = self + .browser_launch + .as_ref() + .and_then(|launch| launch.try_recv().ok()) + { + self.browser_launch = None; + self.status = match result { + Ok(()) => "Video handed to the default browser.".into(), + Err(error) => error, + }; + } match self.video_state.poll_progress() { Ok(true) => { self.rebuild_collections(); @@ -2869,6 +2900,7 @@ impl App { self.browse_category_collection(CollectionKind::Saved) } Some(Action::VideoPlay) => self.play(), + Some(Action::VideoOpen) => self.open_video_in_browser(), Some(Action::VideoNext) => { self.views[self.view_index].prefetch_requested = true; self.views[self.view_index].selection.select_next(); diff --git a/src/youtube.rs b/src/youtube.rs index 9e04f05..1e69b0d 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -805,6 +805,52 @@ fn failure(errors: &Receiver>, fallback: String) -> Option io::Result>> { + if !valid_video_id(id) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid YouTube video ID", + )); + } + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + let child = isolated_command(OsStr::new(opener)) + .arg(format!("https://www.youtube.com/watch?v={id}")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + io::Error::new( + error.kind(), + format!("Cannot start browser opener: {error}"), + ) + })?; + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result = child + .wait_with_output() + .map_err(|error| format!("Cannot wait for browser opener: {error}")) + .and_then(|output| { + if output.status.success() { + Ok(()) + } else { + let error = String::from_utf8_lossy(&output.stderr); + Err(format!( + "Browser opener exited with {}: {}", + output.status, + error.trim() + )) + } + }); + let _ = sender.send(result); + }); + Ok(receiver) +} + pub struct PlayerLaunch { player: Child, player_errors: Receiver>, @@ -1465,6 +1511,16 @@ mod tests { } } + #[test] + fn browser_open_rejects_unsafe_video_ids_before_spawning() { + for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] { + assert!(matches!( + open_in_browser(id), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } + } + #[test] fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() { for feed in [false, true] {