feat: preview video thumbnails in a floating window
This commit is contained in:
+116
-5
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user