feat: add modal YouTube browsing and configurable streaming

This commit is contained in:
2026-10-09 21:03:34 +02:00
parent 7e5bcc9087
commit 79a14dce1e
4 changed files with 1569 additions and 2 deletions
+268
View File
@@ -0,0 +1,268 @@
use std::{
ffi::{OsStr, OsString},
io::{self, Read},
process::{Child, Command, Stdio},
sync::mpsc::{self, Receiver, TryRecvError},
thread,
};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Video {
pub id: String,
pub title: String,
pub channel: Option<String>,
}
#[derive(Deserialize)]
struct SearchResults {
entries: Vec<Option<Video>>,
}
pub fn search_results(output: &str) -> Result<Vec<Video>, String> {
let results: SearchResults = serde_json::from_str(output)
.map_err(|error| format!("Invalid search response: {error}"))?;
let mut videos = Vec::new();
for mut video in results.entries.into_iter().flatten() {
if video.id.len() != 11
|| !video
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return Err("Search returned an invalid YouTube video ID".into());
}
video.title.retain(|c| !c.is_control());
if let Some(channel) = &mut video.channel {
channel.retain(|c| !c.is_control());
}
videos.push(video);
}
Ok(videos)
}
// Own the extractor so replacing a request or quitting also cancels its process.
pub struct Job {
child: Child,
output: Receiver<io::Result<(String, String)>>,
}
impl Job {
pub fn search(query: &str) -> io::Result<Self> {
let mut child = extractor()
.args(["--flat-playlist", "--dump-single-json"])
.arg(format!("ytsearch20:{query}"))
.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 mut stdout = child.stdout.take().expect("stdout is piped");
let mut stderr = child.stderr.take().expect("stderr is piped");
let (sender, output) = mpsc::channel();
// Drain both pipes while the process runs; a full pipe must not stall the TUI.
thread::spawn(move || {
let stdout_reader = thread::spawn(move || {
let mut text = String::new();
stdout.read_to_string(&mut text).map(|_| text)
});
let mut errors = String::new();
let stderr_result = stderr.read_to_string(&mut errors);
let result = stdout_reader
.join()
.unwrap_or_else(|_| Err(io::Error::other("Output reader panicked")))
.and_then(|text| stderr_result.map(|_| (text, errors)));
let _ = sender.send(result);
});
Ok(Self { child, output })
}
pub fn poll(&mut self) -> Option<Result<String, String>> {
let status = match self.child.try_wait() {
Ok(Some(status)) => status,
Ok(None) => return None,
Err(error) => return Some(Err(format!("Cannot wait for yt-dlp: {error}"))),
};
match self.output.try_recv() {
Ok(Ok((output, _))) if status.success() => Some(Ok(output)),
Ok(Ok((_, errors))) => Some(Err(if errors.trim().is_empty() {
format!("yt-dlp exited with {status}")
} else {
errors.trim().to_owned()
})),
Ok(Err(error)) => Some(Err(format!("Cannot read yt-dlp output: {error}"))),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
Some(Err("yt-dlp output reader disconnected".into()))
}
}
}
}
impl Drop for Job {
fn drop(&mut self) {
stop(&mut self.child);
}
}
fn isolated_command(program: &OsStr) -> Command {
let mut command = Command::new(program);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
command
}
fn extractor() -> Command {
let mut command = isolated_command(OsStr::new("yt-dlp"));
command.args(["--ignore-config", "--no-warnings", "--socket-timeout", "15"]);
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.
#[cfg(unix)]
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.kill();
}
let _ = child.wait();
}
fn read_errors(mut pipe: impl Read + Send + 'static) -> Receiver<io::Result<String>> {
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let mut text = String::new();
let _ = sender.send(pipe.read_to_string(&mut text).map(|_| text));
});
receiver
}
fn failure(errors: &Receiver<io::Result<String>>, fallback: String) -> Option<Result<(), String>> {
match errors.try_recv() {
Ok(Ok(text)) => Some(Err(if text.trim().is_empty() {
fallback
} else {
text.trim().to_owned()
})),
Ok(Err(error)) => Some(Err(format!("{fallback}: {error}"))),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err(fallback)),
}
}
pub struct Playback {
extractor: Child,
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}"))
.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("-")
.stdin(Stdio::from(stream))
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
{
Ok(player) => player,
Err(error) => {
stop(&mut extractor);
return Err(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"));
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)) => {
failure(&self.player_errors, format!("Player exited with {status}"))
}
Ok(None) => None,
Err(error) => Some(Err(format!("Cannot wait for player: {error}"))),
}
}
}
impl Drop for Playback {
fn drop(&mut self) {
stop(&mut self.extractor);
stop(&mut self.player);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_handles_unavailable_entries_and_rejects_invalid_ids() {
let videos = search_results(
r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null}]}"#,
)
.unwrap();
assert_eq!(videos[0].title, "Rust");
assert_eq!(videos[0].channel, None);
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
assert!(search_results("not JSON").is_err());
}
}