feat: copy selected video URL

This commit is contained in:
2026-10-22 15:38:59 +02:00
parent 01275e20b8
commit b12bba9125
9 changed files with 80 additions and 14 deletions
Generated
+10 -2
View File
@@ -79,6 +79,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.23.1" version = "0.23.1"
@@ -260,6 +266,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [ dependencies = [
"base64 0.22.1",
"bitflags", "bitflags",
"crossterm_winapi", "crossterm_winapi",
"derive_more", "derive_more",
@@ -1445,6 +1452,7 @@ name = "tubular"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"crossterm",
"image", "image",
"libc", "libc",
"ratatui", "ratatui",
@@ -1508,7 +1516,7 @@ version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [ dependencies = [
"base64", "base64 0.23.1",
"log", "log",
"percent-encoding", "percent-encoding",
"rustls", "rustls",
@@ -1524,7 +1532,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [ dependencies = [
"base64", "base64 0.23.1",
"http", "http",
"httparse", "httparse",
"log", "log",
+1
View File
@@ -6,6 +6,7 @@ description = "A terminal YouTube and Miruro browser that opens selected videos
[dependencies] [dependencies]
clap = { version = "4.6.7", features = ["derive"] } clap = { version = "4.6.7", features = ["derive"] }
crossterm = { version = "0.29.0", default-features = false, features = ["osc52"] }
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] } image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] } ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
+4
View File
@@ -84,6 +84,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode | | `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode |
| Space / Esc | Check/uncheck the hovered episode / discard episode checks | | Space / Esc | Check/uncheck the hovered episode / discard episode checks |
| Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) | | Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) |
| `y` | Copy the hovered episode's page URL (also in previews and offline collections; `video.copy_url`) |
| `-` / `+` | Back / forward through cached screens, preserving query and selection | | `-` / `+` | Back / forward through cached screens, preserving query and selection |
| `w` / `v` | Save the selected episode to watch later / saved episodes | | `w` / `v` | Save the selected episode to watch later / saved episodes |
| `s` | Edit the selected show's category tags; Enter saves the show, including with no tags | | `s` | Edit the selected show's category tags; Enter saves the show, including with no tags |
@@ -121,6 +122,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| Enter | Play checked videos in displayed order, or the hovered video when none are checked | | Enter | Play checked videos in displayed order, or the hovered video when none are checked |
| `p` | Reopen the provider chooser | | `p` | Reopen the provider chooser |
| `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) | | `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) |
| `y` | Copy the hovered video's page URL (also in previews and offline collections; `video.copy_url`) |
| `g` / Home, `G` / End | Select the first / last loaded video | | `g` / Home, `G` / End | Select the first / last loaded video |
| `i` | Edit the current category/combined feed search; other views use general YouTube Search | | `i` | Edit the current category/combined feed search; other views use general YouTube Search |
| `/` | Go directly to general YouTube Search | | `/` | Go directly to general YouTube Search |
@@ -154,6 +156,8 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
**Ctrl-C quits from any mode or popup.** **Ctrl-C quits from any mode or popup.**
URL copying always uses the highlighted row, not the playback checks, and copies the same canonical page URL used for playback—not an expiring stream URL. It uses the terminal's OSC 52 clipboard support without external clipboard tools. Clipboard writes must be supported and enabled by your terminal and any multiplexer; Tubular can report sending the URL, but cannot confirm that the terminal accepted it. Text inputs and selectors keep `y` as ordinary input or ignore it.
The bottom bar shows essential actions for the current context. Press `?` in normal mode—including selectors and previews—for the full reference. Scroll help with `j` / `k`, arrows, Ctrl-J / Ctrl-K, or PageDown / PageUp; `?` or Esc closes it without changing the underlying view or dialog. In text fields, `?` remains ordinary input. The bottom bar shows essential actions for the current context. Press `?` in normal mode—including selectors and previews—for the full reference. Scroll help with `j` / `k`, arrows, Ctrl-J / Ctrl-K, or PageDown / PageUp; `?` or Esc closes it without changing the underlying view or dialog. In text fields, `?` remains ordinary input.
The overview groups shortcut/action rows under bold section headers such as `VIEWS AND VIDEOS`, with an empty line beneath each header. Shortcuts and action descriptions remain normal weight; the rows reflect configured bindings rather than showing internal action paths. The overview groups shortcut/action rows under bold section headers such as `VIEWS AND VIDEOS`, with an empty line beneath each header. Shortcuts and action descriptions remain normal weight; the rows reflect configured bindings rather than showing internal action paths.
+2
View File
@@ -121,6 +121,7 @@ actions! {
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"]; ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"]; VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"];
VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"]; VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"];
VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"];
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"]; 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"]; VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"];
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"]; VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
@@ -495,6 +496,7 @@ impl Bindings {
&[VideoOpen], &[VideoOpen],
"Open the selected YouTube video in the default browser", "Open the selected YouTube video in the default browser",
), ),
(&[VideoCopyUrl], "Copy the selected video's URL"),
( (
&[WatchStatusWatching], &[WatchStatusWatching],
"Mark selected as watching (clear watched)", "Mark selected as watching (clear watched)",
+10
View File
@@ -2937,6 +2937,16 @@ impl App {
Some(Action::VideoToggle) => self.views[self.view_index].toggle_checked(), Some(Action::VideoToggle) => self.views[self.view_index].toggle_checked(),
Some(Action::VideoClear) => self.views[self.view_index].checked.clear(), Some(Action::VideoClear) => self.views[self.view_index].checked.clear(),
Some(Action::VideoOpen) => self.open_video_in_browser(), Some(Action::VideoOpen) => self.open_video_in_browser(),
Some(Action::VideoCopyUrl) => {
let view = &self.views[self.view_index];
if let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
{
self.status = ui::copy_video_url(&video.id);
}
}
Some(Action::VideoNext) => { Some(Action::VideoNext) => {
self.views[self.view_index].prefetch_requested = true; self.views[self.view_index].prefetch_requested = true;
self.views[self.view_index].selection.select_next(); self.views[self.view_index].selection.select_next();
+6
View File
@@ -1212,6 +1212,11 @@ impl Browser {
Some(Action::VideoPlay) => self.open(), Some(Action::VideoPlay) => self.open(),
Some(Action::VideoToggle) => self.screen_mut().toggle_checked(), Some(Action::VideoToggle) => self.screen_mut().toggle_checked(),
Some(Action::VideoClear) => self.screen_mut().checked.clear(), Some(Action::VideoClear) => self.screen_mut().checked.clear(),
Some(Action::VideoCopyUrl) if self.screen().playable() => {
if let Some(id) = self.screen().selected_key() {
self.status = ui::copy_video_url(id);
}
}
Some( Some(
action @ (Action::VideoNext action @ (Action::VideoNext
| Action::VideoPrevious | Action::VideoPrevious
@@ -1372,6 +1377,7 @@ impl Browser {
bindings.extend([ bindings.extend([
(Action::VideoToggle, "Toggle checked episode"), (Action::VideoToggle, "Toggle checked episode"),
(Action::VideoClear, "Clear checked episodes"), (Action::VideoClear, "Clear checked episodes"),
(Action::VideoCopyUrl, "Copy selected episode URL"),
(Action::WatchLaterSave, "Save episode to watch later"), (Action::WatchLaterSave, "Save episode to watch later"),
(Action::SavedVideoSave, "Save episode"), (Action::SavedVideoSave, "Save episode"),
(Action::WatchStatusWatching, "Mark episode as watching"), (Action::WatchStatusWatching, "Mark episode as watching"),
+10
View File
@@ -2,6 +2,7 @@ use std::time::Duration;
use ratatui::{ use ratatui::{
Frame, Frame,
crossterm::{clipboard::CopyToClipboard, execute},
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
style::{Color, Style}, style::{Color, Style},
text::{Line, Span}, text::{Line, Span},
@@ -12,6 +13,15 @@ pub const ACCENT: Style = Style::new().fg(Color::Cyan);
pub const SELECTED: Style = Style::new().fg(Color::Black).bg(Color::Cyan); pub const SELECTED: Style = Style::new().fg(Color::Black).bg(Color::Cyan);
pub const SELECTION_MARKER: &str = "> "; pub const SELECTION_MARKER: &str = "> ";
pub fn copy_video_url(id: &str) -> String {
let result = crate::youtube::video_url(id)
.and_then(|url| execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from(url)));
match result {
Ok(()) => "Video URL sent to terminal clipboard.".into(),
Err(error) => format!("Cannot copy video URL: {error}"),
}
}
pub fn browser_layout(area: Rect, header_height: u16) -> [Rect; 5] { pub fn browser_layout(area: Rect, header_height: u16) -> [Rect; 5] {
Layout::vertical([ Layout::vertical([
Constraint::Length(header_height), Constraint::Length(header_height),
+13 -12
View File
@@ -863,6 +863,16 @@ fn failure(errors: &Receiver<io::Result<String>>, fallback: String) -> Option<Re
} }
} }
pub fn video_url(id: &str) -> io::Result<String> {
if valid_video_id(id) {
Ok(format!("https://www.youtube.com/watch?v={id}"))
} else {
tubular::miruro::Identity::from_key(id)
.and_then(|identity| identity.watch_url())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
}
}
pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> { pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> {
if !valid_video_id(id) { if !valid_video_id(id) {
return Err(io::Error::new( return Err(io::Error::new(
@@ -876,7 +886,7 @@ pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> {
"xdg-open" "xdg-open"
}; };
let child = isolated_command(OsStr::new(opener)) let child = isolated_command(OsStr::new(opener))
.arg(format!("https://www.youtube.com/watch?v={id}")) .arg(video_url(id)?)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
@@ -926,19 +936,10 @@ impl PlayerLaunch {
"No videos selected", "No videos selected",
)); ));
} }
let playback_url = |id: &str| {
if valid_video_id(id) {
Ok(format!("https://www.youtube.com/watch?v={id}"))
} else {
tubular::miruro::Identity::from_key(id)
.and_then(|identity| identity.watch_url())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
}
};
let mut command = isolated_command(player); let mut command = isolated_command(player);
command.arg("--new-instance"); command.arg("--new-instance");
if let [video] = videos { if let [video] = videos {
let url = playback_url(&video.id)?; let url = video_url(&video.id)?;
let report = state.progress_file(video)?; let report = state.progress_file(video)?;
crate::video_state::register_identity(&report, &video.id)?; crate::video_state::register_identity(&report, &video.id)?;
command command
@@ -957,7 +958,7 @@ impl PlayerLaunch {
"Duplicate queue identity", "Duplicate queue identity",
)); ));
} }
playback_url(&video.id) video_url(&video.id)
}) })
.collect::<io::Result<Vec<_>>>()?; .collect::<io::Result<Vec<_>>>()?;
let reports = state.progress_files(videos)?; let reports = state.progress_files(videos)?;
+24
View File
@@ -532,6 +532,30 @@ mod subprocesses {
assert_reaped(pid); assert_reaped(pid);
} }
} }
#[test]
fn video_urls_preserve_provider_identity_and_reject_invalid_keys() {
assert_eq!(
video_url("jNQXAC9IVRw").unwrap(),
"https://www.youtube.com/watch?v=jNQXAC9IVRw"
);
assert_eq!(
video_url("miruro:opaque_ID-7:3").unwrap(),
"https://barelystarted.miruro.tv/watch/opaque_ID-7/episode?ep=3"
);
for id in [
"",
"--help",
"jNQXAC9IVRw&list=other",
"miruro:id:0",
"miruro:id:03",
"miruro:../id:3",
] {
assert_eq!(
video_url(id).unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
}
}
#[test] #[test]
fn browser_open_rejects_unsafe_video_ids_before_spawning() { fn browser_open_rejects_unsafe_video_ids_before_spawning() {