From 6f65b7f6f7ee5e7e578e887eb451355a68dd4687 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 00:59:26 +0200 Subject: [PATCH] feat: hand YouTube videos to the Terebi player --- Cargo.toml | 2 +- src/main.rs | 55 +++++++++++++++++------------- src/youtube.rs | 92 ++++++++++++++++---------------------------------- 3 files changed, 61 insertions(+), 88 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 92ffea3..0c05c5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "tubular" version = "0.1.0" edition = "2024" -description = "A terminal YouTube browser that streams videos to your chosen player" +description = "A terminal YouTube browser that opens selected videos in Terebi" [dependencies] clap = { version = "4.6.7", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index a3ab80a..3db1c19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ -//! A Ratatui YouTube browser backed by yt-dlp and FFmpeg, with no API key. +//! A Ratatui YouTube browser backed by yt-dlp, with playback handed to Terebi. //! -//! Run `cargo run -- "rust programming"`; macOS defaults to IINA, other platforms to mpv. +//! Run `cargo run -- "rust programming"`; default to the built sibling Terebi CLI, then terebi on PATH. //! Always start in normal mode; a CLI query searches automatically. Press i to edit the query. //! Insert: Enter submits a changed query and returns to normal; Esc returns without searching. //! Search and new-tag text: Option/Alt+Backspace or Ctrl-W deletes the previous whitespace word. @@ -8,7 +8,7 @@ //! The terminal must transmit modified Backspace; otherwise map it to Ctrl-W or Ctrl-U. //! Active text fields show a steady block cursor at the scrolled end; other views hide it. //! The terminal's configured default cursor shape is restored when the app exits. -//! Normal: j/k selects, Enter streams, and i returns to insert mode to edit the query. +//! Normal: j/k selects, Enter opens the video in Terebi, and i edits the query. //! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages. //! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells. //! Esc closes the popup; j/k loads the selected video's description and i returns to editing. @@ -38,9 +38,10 @@ //! Deep pages may slow down because yt-dlp offsets re-traverse earlier continuation pages. //! Video tables show Published as YYYY-MM-DD; ~ marks estimated UTC dates, Unknown missing metadata. //! Prefer exact upload dates and cache date labels during extraction, not on every redraw. -//! IINA receives `--stdin`; other players must read standard input when passed `-`. -//! Audio and video are streamed through a pipe, never saved to a video file. -//! See `--help` for controls; replacing playback or quitting cancels its processes. +//! Terebi receives `--url` and the canonical YouTube URL; it owns extraction and playback. +//! `--player` overrides its CLI path; build the sibling with `make -C ../terebi` if needed. +//! The CLI exits after handoff, not playback completion. Later playback errors appear in Terebi. +//! Quitting cancels pending metadata/launch processes, not the separately running player. mod config; mod thumbnail; @@ -67,23 +68,19 @@ use ratatui::{ text::Line, widgets::{Block, Clear, List, ListState, Paragraph, Row, Table, TableState, Tabs, Wrap}, }; -use youtube::{Job, PAGE_SIZE, Page, Playback, Video}; +use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[derive(Parser)] #[command( version, - about = "Search YouTube and stream videos to your chosen player", - after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c edits channel tags; q quits.\nTab/Shift-Tab switch Search, Feed, then tag views; r reloads config and refreshes the current view from its first page.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, and saved registrations are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS: existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\"" + about = "Browse YouTube and open selected videos in Terebi", + after_help = "Requires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to the built sibling ../terebi/.build/terebi when available, otherwise terebi on PATH; --player overrides the CLI path.\nBuild the sibling with make -C ../terebi. Keep its CLI and terebi.app together.\nTerebi receives --url followed by the canonical YouTube video URL and handles extraction, decoding, and playback itself; Tubular no longer pipes media to a player.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; d toggles description; t toggles thumbnail; c edits channel tags; q quits.\nTab/Shift-Tab switch Search, Feed, then tag views; r reloads config and refreshes the current view from its first page.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, and saved registrations are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS: existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nSelecting another video reuses Terebi's window. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular --player ../terebi/.build/terebi \"rust programming\"" )] struct Options { - /// Player executable (IINA or a player supporting '-' for standard input). - #[arg(long, default_value = if cfg!(target_os = "macos") { "iina" } else { "mpv" })] + /// Terebi CLI executable; accepts --url followed by a YouTube video URL. + #[arg(long, default_value_os_t = default_player())] player: OsString, - /// Argument passed to the player before its standard-input flag; repeat as needed. - #[arg(long, allow_hyphen_values = true)] - player_arg: Vec, - /// Optional initial YouTube search. query: Option, } @@ -221,7 +218,7 @@ struct App { description_max_scroll: u16, description_viewport: Option<(u16, u16)>, job: Option, - playback: Option, + player_launch: Option, status: String, } @@ -251,7 +248,7 @@ impl App { description_max_scroll: 0, description_viewport: None, job: None, - playback: None, + player_launch: None, status: "Press i to enter a query, then Enter to search.".into(), } } @@ -658,14 +655,15 @@ impl App { else { return; }; - match Playback::start(&video.id, &self.options.player, &self.options.player_arg) { - Ok(playback) => { + self.player_launch = None; + match PlayerLaunch::start(&video.id, &self.options.player) { + Ok(launch) => { self.status = format!( - "Streaming {} in {}...", + "Opening {} in {}...", video.title, self.options.player.to_string_lossy() ); - self.playback = Some(playback); + self.player_launch = Some(launch); } Err(error) => self.status = error.to_string(), } @@ -692,10 +690,10 @@ impl App { } self.description_viewport = None; } - if let Some(result) = self.playback.as_mut().and_then(Playback::poll) { - self.playback = None; + if let Some(result) = self.player_launch.as_mut().and_then(PlayerLaunch::poll) { + self.player_launch = None; self.status = match result { - Ok(()) => "Playback finished.".into(), + Ok(()) => "Video handed to player; playback continues in its window.".into(), Err(error) => error, }; } @@ -1124,6 +1122,15 @@ impl App { } } +fn default_player() -> OsString { + let sibling = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../terebi/.build/terebi"); + if sibling.is_file() { + sibling.into_os_string() + } else { + "terebi".into() + } +} + fn loading_title<'a>( title: &'a str, controls: &'static str, diff --git a/src/youtube.rs b/src/youtube.rs index 7b3e9f7..dac2898 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -1,5 +1,5 @@ use std::{ - ffi::{OsStr, OsString}, + ffi::OsStr, io::{self, Read}, process::{Child, Command, Stdio}, sync::mpsc::{self, Receiver, TryRecvError}, @@ -417,7 +417,7 @@ fn extractor() -> Command { fn stop(child: &mut Child) { if matches!(child.try_wait(), Ok(None)) { // SAFETY: isolated_command gives this still-running child its own process group. - // Cancel the whole group, including FFmpeg, without signaling the TUI. + // Cancel the whole process group without signaling the TUI. #[cfg(unix)] unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); @@ -449,82 +449,39 @@ fn failure(errors: &Receiver>, fallback: String) -> Option>, player_errors: Receiver>, } -impl Playback { - pub fn start(id: &str, player: &OsStr, args: &[OsString]) -> io::Result { - let mut extractor = extractor() - .args([ - "--quiet", - "--no-progress", - "--no-playlist", - "--no-cache-dir", - "--no-part", - "--format", - "b/bv+ba", - "--downloader", - "ffmpeg", - "--downloader-args", - "ffmpeg:-loglevel error", - "--output", - "-", - ]) - .arg(format!("https://www.youtube.com/watch?v={id}")) +impl PlayerLaunch { + pub fn start(id: &str, player: &OsStr) -> io::Result { + if !valid_video_id(id) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid YouTube video ID", + )); + } + let mut player = isolated_command(player) + .args(["--url", &format!("https://www.youtube.com/watch?v={id}")]) .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| { - io::Error::new(error.kind(), format!("Cannot start yt-dlp: {error}")) - })?; - let stream = extractor.stdout.take().expect("stdout is piped"); - let player = match isolated_command(player) - .args(args) - .arg(match std::path::Path::new(player).file_name() { - Some(name) if name == "iina" || name == "iina-cli" => "--stdin", - _ => "-", - }) - .stdin(Stdio::from(stream)) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() - { - Ok(player) => player, - Err(error) => { - stop(&mut extractor); - return Err(io::Error::new( + .map_err(|error| { + io::Error::new( error.kind(), - format!("Cannot start player: {error}"), - )); - } - }; - let mut player = player; - let extractor_errors = read_errors(extractor.stderr.take().expect("stderr is piped")); + format!("Cannot start Terebi launcher: {error}"), + ) + })?; let player_errors = read_errors(player.stderr.take().expect("stderr is piped")); Ok(Self { - extractor, player, - extractor_errors, player_errors, }) } pub fn poll(&mut self) -> Option> { - match self.extractor.try_wait() { - Ok(Some(status)) if !status.success() => { - return failure( - &self.extractor_errors, - format!("yt-dlp exited with {status}"), - ); - } - Err(error) => return Some(Err(format!("Cannot wait for yt-dlp: {error}"))), - _ => {} - } match self.player.try_wait() { Ok(Some(status)) if status.success() => Some(Ok(())), Ok(Some(status)) => { @@ -536,9 +493,8 @@ impl Playback { } } -impl Drop for Playback { +impl Drop for PlayerLaunch { fn drop(&mut self) { - stop(&mut self.extractor); stop(&mut self.player); } } @@ -547,6 +503,16 @@ impl Drop for Playback { mod tests { use super::*; + #[test] + fn player_launch_rejects_unsafe_video_ids_before_spawning() { + for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] { + assert!(matches!( + PlayerLaunch::start(id, OsStr::new("terebi")), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } + } + #[test] fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() { for parse in [search_results, feed_results] {