feat: hand YouTube videos to the Terebi player
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
name = "tubular"
|
name = "tubular"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
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]
|
[dependencies]
|
||||||
clap = { version = "4.6.7", features = ["derive"] }
|
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::{
|
use std::{
|
||||||
ffi::{OsStr, OsString},
|
ffi::OsStr,
|
||||||
io::{self, Read},
|
io::{self, Read},
|
||||||
process::{Child, Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
sync::mpsc::{self, Receiver, TryRecvError},
|
sync::mpsc::{self, Receiver, TryRecvError},
|
||||||
@@ -417,7 +417,7 @@ fn extractor() -> Command {
|
|||||||
fn stop(child: &mut Child) {
|
fn stop(child: &mut Child) {
|
||||||
if matches!(child.try_wait(), Ok(None)) {
|
if matches!(child.try_wait(), Ok(None)) {
|
||||||
// SAFETY: isolated_command gives this still-running child its own process group.
|
// 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)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(-(child.id() as i32), libc::SIGKILL);
|
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 {
|
pub struct PlayerLaunch {
|
||||||
extractor: Child,
|
|
||||||
player: Child,
|
player: Child,
|
||||||
extractor_errors: Receiver<io::Result<String>>,
|
|
||||||
player_errors: Receiver<io::Result<String>>,
|
player_errors: Receiver<io::Result<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Playback {
|
impl PlayerLaunch {
|
||||||
pub fn start(id: &str, player: &OsStr, args: &[OsString]) -> io::Result<Self> {
|
pub fn start(id: &str, player: &OsStr) -> io::Result<Self> {
|
||||||
let mut extractor = extractor()
|
if !valid_video_id(id) {
|
||||||
.args([
|
return Err(io::Error::new(
|
||||||
"--quiet",
|
io::ErrorKind::InvalidInput,
|
||||||
"--no-progress",
|
"Invalid YouTube video ID",
|
||||||
"--no-playlist",
|
));
|
||||||
"--no-cache-dir",
|
}
|
||||||
"--no-part",
|
let mut player = isolated_command(player)
|
||||||
"--format",
|
.args(["--url", &format!("https://www.youtube.com/watch?v={id}")])
|
||||||
"b/bv+ba",
|
|
||||||
"--downloader",
|
|
||||||
"ffmpeg",
|
|
||||||
"--downloader-args",
|
|
||||||
"ffmpeg:-loglevel error",
|
|
||||||
"--output",
|
|
||||||
"-",
|
|
||||||
])
|
|
||||||
.arg(format!("https://www.youtube.com/watch?v={id}"))
|
|
||||||
.stdin(Stdio::null())
|
.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())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
{
|
.map_err(|error| {
|
||||||
Ok(player) => player,
|
io::Error::new(
|
||||||
Err(error) => {
|
|
||||||
stop(&mut extractor);
|
|
||||||
return Err(io::Error::new(
|
|
||||||
error.kind(),
|
error.kind(),
|
||||||
format!("Cannot start player: {error}"),
|
format!("Cannot start Terebi launcher: {error}"),
|
||||||
));
|
)
|
||||||
}
|
})?;
|
||||||
};
|
|
||||||
let mut player = player;
|
|
||||||
let extractor_errors = read_errors(extractor.stderr.take().expect("stderr is piped"));
|
|
||||||
let player_errors = read_errors(player.stderr.take().expect("stderr is piped"));
|
let player_errors = read_errors(player.stderr.take().expect("stderr is piped"));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
extractor,
|
|
||||||
player,
|
player,
|
||||||
extractor_errors,
|
|
||||||
player_errors,
|
player_errors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn poll(&mut self) -> Option<Result<(), String>> {
|
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() {
|
match self.player.try_wait() {
|
||||||
Ok(Some(status)) if status.success() => Some(Ok(())),
|
Ok(Some(status)) if status.success() => Some(Ok(())),
|
||||||
Ok(Some(status)) => {
|
Ok(Some(status)) => {
|
||||||
@@ -536,9 +493,8 @@ impl Playback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Playback {
|
impl Drop for PlayerLaunch {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
stop(&mut self.extractor);
|
|
||||||
stop(&mut self.player);
|
stop(&mut self.player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,6 +503,16 @@ impl Drop for Playback {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() {
|
fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() {
|
||||||
for parse in [search_results, feed_results] {
|
for parse in [search_results, feed_results] {
|
||||||
|
|||||||
Reference in New Issue
Block a user