feat: preview video thumbnails in a floating window

This commit is contained in:
2026-10-09 22:08:34 +02:00
parent 656474df5b
commit 689f14a634
5 changed files with 1152 additions and 36 deletions
+116 -5
View File
@@ -45,13 +45,15 @@ fn valid_channel_id(id: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
}
fn validate_video(video: &mut Video) -> Result<(), String> {
if video.id.len() != 11
|| !video
.id
fn valid_video_id(id: &str) -> bool {
id.len() == 11
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
}
fn validate_video(video: &mut Video) -> Result<(), String> {
if !valid_video_id(&video.id) {
return Err("an invalid YouTube video ID".into());
}
if video
@@ -222,6 +224,96 @@ impl Drop for Job {
}
}
const MAX_THUMBNAIL_BYTES: usize = 2 * 1024 * 1024;
pub struct ThumbnailJob {
child: Child,
output: Receiver<io::Result<(Vec<u8>, String)>>,
}
impl ThumbnailJob {
pub fn start(id: &str) -> io::Result<Self> {
if !valid_video_id(id) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid YouTube video ID",
));
}
let mut child = isolated_command(OsStr::new("curl"))
.args([
"--disable",
"--silent",
"--show-error",
"--fail",
"--location",
"--proto",
"=https",
"--proto-redir",
"=https",
"--connect-timeout",
"5",
"--max-time",
"15",
"--max-filesize",
"2097152", // 2 MiB; read_thumbnail also enforces the limit.
])
.arg(format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg"))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| io::Error::new(error.kind(), format!("Cannot start curl: {error}")))?;
let stdout = child.stdout.take().expect("stdout is piped");
let errors = read_errors(child.stderr.take().expect("stderr is piped"));
let (sender, output) = mpsc::channel();
thread::spawn(move || {
let bytes = read_thumbnail(stdout);
let result = errors
.recv()
.unwrap_or_else(|_| Err(io::Error::other("curl error reader disconnected")))
.and_then(|errors| bytes.map(|bytes| (bytes, errors)));
let _ = sender.send(result);
});
Ok(Self { child, output })
}
pub fn poll(&mut self) -> Option<Result<Vec<u8>, String>> {
// EOF alone is not success: curl can write a partial body and then fail.
let status = match self.child.try_wait() {
Ok(Some(status)) => status,
Ok(None) => return None,
Err(error) => return Some(Err(format!("Cannot wait for curl: {error}"))),
};
match self.output.try_recv() {
Ok(Ok((bytes, _))) if status.success() => Some(Ok(bytes)),
Ok(Ok((_, errors))) => Some(Err(if errors.trim().is_empty() {
format!("curl exited with {status}")
} else {
errors.trim().to_owned()
})),
Ok(Err(error)) => Some(Err(format!("Cannot read thumbnail: {error}"))),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err("curl output reader disconnected".into())),
}
}
}
impl Drop for ThumbnailJob {
fn drop(&mut self) {
stop(&mut self.child);
}
}
fn read_thumbnail(pipe: impl Read) -> io::Result<Vec<u8>> {
let mut bytes = Vec::new();
pipe.take((MAX_THUMBNAIL_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_THUMBNAIL_BYTES {
return Err(io::Error::other("Thumbnail exceeds 2 MiB"));
}
Ok(bytes)
}
fn isolated_command(program: &OsStr) -> Command {
let mut command = Command::new(program);
#[cfg(unix)]
@@ -371,6 +463,25 @@ impl Drop for Playback {
mod tests {
use super::*;
#[test]
fn thumbnails_bound_binary_input_and_reject_unsafe_ids() {
let bytes = [0xff, 0xd8, 0x00, 0xfe];
assert_eq!(read_thumbnail(bytes.as_slice()).unwrap(), bytes);
assert_eq!(
read_thumbnail(io::repeat(0).take(MAX_THUMBNAIL_BYTES as u64))
.unwrap()
.len(),
MAX_THUMBNAIL_BYTES
);
assert!(read_thumbnail(io::repeat(0)).is_err());
for id in ["../secrets", "abcdefghij/", "abcdefghijé", "--bad"] {
assert!(matches!(
ThumbnailJob::start(id),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
}
#[test]
fn search_handles_unavailable_entries_and_rejects_invalid_ids() {
let videos = search_results(