feat: hand YouTube videos to the Terebi player
This commit is contained in:
+1
-1
@@ -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"] }
|
||||
|
||||
+31
-24
File diff suppressed because one or more lines are too long
+29
-63
@@ -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<io::Result<String>>, fallback: String) -> Option<Re
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Playback {
|
||||
extractor: Child,
|
||||
pub struct PlayerLaunch {
|
||||
player: Child,
|
||||
extractor_errors: Receiver<io::Result<String>>,
|
||||
player_errors: Receiver<io::Result<String>>,
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
pub fn start(id: &str, player: &OsStr, args: &[OsString]) -> io::Result<Self> {
|
||||
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<Self> {
|
||||
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<Result<(), String>> {
|
||||
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] {
|
||||
|
||||
Reference in New Issue
Block a user