feat: hand YouTube videos to the Terebi player

This commit is contained in:
2026-10-11 00:59:26 +02:00
parent 0d7ccba482
commit 6f65b7f6f7
3 changed files with 61 additions and 88 deletions
+31 -24
View File
File diff suppressed because one or more lines are too long
+29 -63
View File
@@ -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] {