feat: add modal YouTube browsing and configurable streaming
This commit is contained in:
+333
-2
@@ -1,3 +1,334 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
//! A Ratatui YouTube browser backed by yt-dlp and FFmpeg, with no API key.
|
||||
//!
|
||||
//! Run `cargo run -- --player mpv --player-arg=--volume=50 "rust programming"`.
|
||||
//! Without a CLI query, start in insert mode; otherwise search and start in normal mode.
|
||||
//! Insert: Enter submits a changed query and returns to normal; Esc returns without searching.
|
||||
//! Normal: j/k selects, Enter streams, and i returns to insert mode to edit the query.
|
||||
//! The player must read media from standard input when passed `-`.
|
||||
//! Audio and video are streamed through a pipe, never saved to a video file.
|
||||
//! See `--help` for controls; replacing playback or quitting cancels its processes.
|
||||
|
||||
mod youtube;
|
||||
|
||||
use std::{ffi::OsString, io, time::Duration};
|
||||
|
||||
use clap::Parser;
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
|
||||
layout::{Constraint, Layout},
|
||||
style::{Color, Style, Stylize},
|
||||
widgets::{Block, Paragraph, Row, Table, TableState, Wrap},
|
||||
};
|
||||
use youtube::{Job, Playback, Video};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "Search YouTube and stream videos to your chosen player",
|
||||
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH.\nThe player receives audio/video on standard input, with '-' as its last argument.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; q quits.\nCtrl-U clears in INSERT; Ctrl-C quits in either mode.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
|
||||
)]
|
||||
struct Options {
|
||||
/// Player executable (must support '-' for media on standard input).
|
||||
#[arg(long, default_value = "mpv")]
|
||||
player: OsString,
|
||||
|
||||
/// Argument passed to the player before '-'; repeat for multiple arguments.
|
||||
#[arg(long, allow_hyphen_values = true)]
|
||||
player_arg: Vec<OsString>,
|
||||
|
||||
/// Optional initial YouTube search.
|
||||
query: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
Insert,
|
||||
Normal,
|
||||
}
|
||||
|
||||
struct App {
|
||||
options: Options,
|
||||
query: String,
|
||||
mode: Mode,
|
||||
submitted_query: String,
|
||||
videos: Vec<Video>,
|
||||
selection: TableState,
|
||||
job: Option<Job>,
|
||||
playback: Option<Playback>,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(options: Options) -> Self {
|
||||
let query = options.query.clone().unwrap_or_default();
|
||||
let mode = if query.trim().is_empty() {
|
||||
Mode::Insert
|
||||
} else {
|
||||
Mode::Normal
|
||||
};
|
||||
Self {
|
||||
options,
|
||||
query,
|
||||
mode,
|
||||
submitted_query: String::new(),
|
||||
videos: Vec::new(),
|
||||
selection: TableState::default(),
|
||||
job: None,
|
||||
playback: None,
|
||||
status: "Type a query, then press Enter.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn search(&mut self) {
|
||||
let query = self.query.trim();
|
||||
if query.is_empty() {
|
||||
self.status = "Enter a search first.".into();
|
||||
return;
|
||||
}
|
||||
if query == self.submitted_query {
|
||||
return;
|
||||
}
|
||||
match Job::search(query) {
|
||||
Ok(job) => {
|
||||
self.job = Some(job);
|
||||
self.submitted_query = query.to_owned();
|
||||
self.videos.clear();
|
||||
self.selection = TableState::default();
|
||||
self.status = format!("Searching for {query}...");
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn play(&mut self) {
|
||||
if self.job.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(video) = self
|
||||
.selection
|
||||
.selected()
|
||||
.and_then(|index| self.videos.get(index))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match Playback::start(&video.id, &self.options.player, &self.options.player_arg) {
|
||||
Ok(playback) => {
|
||||
self.status = format!(
|
||||
"Streaming {} in {}...",
|
||||
video.title,
|
||||
self.options.player.to_string_lossy()
|
||||
);
|
||||
self.playback = Some(playback);
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
|
||||
self.job = None;
|
||||
self.status = match result.and_then(|output| youtube::search_results(&output)) {
|
||||
Ok(videos) => {
|
||||
self.videos = videos;
|
||||
self.selection = TableState::default();
|
||||
self.selection
|
||||
.select((!self.videos.is_empty()).then_some(0));
|
||||
format!("{} results. Select a video to stream.", self.videos.len())
|
||||
}
|
||||
Err(error) => error,
|
||||
};
|
||||
}
|
||||
if let Some(result) = self.playback.as_mut().and_then(Playback::poll) {
|
||||
self.playback = None;
|
||||
self.status = match result {
|
||||
Ok(()) => "Playback finished.".into(),
|
||||
Err(error) => error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, key: KeyEvent) -> bool {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return true;
|
||||
}
|
||||
if self.mode == Mode::Insert {
|
||||
match key.code {
|
||||
KeyCode::Esc => self.mode = Mode::Normal,
|
||||
KeyCode::Enter => {
|
||||
self.search();
|
||||
self.mode = Mode::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.query.pop();
|
||||
}
|
||||
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.query.clear();
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||
{
|
||||
self.query.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return true,
|
||||
KeyCode::Char('i') => self.mode = Mode::Insert,
|
||||
KeyCode::Enter => self.play(),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.selection.select_next(),
|
||||
KeyCode::Up | KeyCode::Char('k') => self.selection.select_previous(),
|
||||
KeyCode::Home | KeyCode::Char('g') => self.selection.select_first(),
|
||||
KeyCode::End | KeyCode::Char('G') => self.selection.select_last(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(index) = self
|
||||
.selection
|
||||
.selected()
|
||||
.filter(|_| !self.videos.is_empty())
|
||||
{
|
||||
self.selection
|
||||
.select(Some(index.min(self.videos.len() - 1)));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let [search, results, status, help] = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(frame.area());
|
||||
let search_style = if self.mode == Mode::Insert {
|
||||
Style::new().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::new()
|
||||
};
|
||||
// End-only editing; scroll long queries without counting UTF-8 bytes as columns.
|
||||
let query = ratatui::text::Line::raw(self.query.as_str());
|
||||
let scroll = query
|
||||
.width()
|
||||
.saturating_sub(search.width.saturating_sub(3) as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(query)
|
||||
.scroll((0, scroll.min(u16::MAX as usize) as u16))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" tubular / YouTube search ")
|
||||
.border_style(search_style),
|
||||
),
|
||||
search,
|
||||
);
|
||||
let table = Table::new(
|
||||
self.videos.iter().map(|video| {
|
||||
Row::new([
|
||||
video.title.as_str(),
|
||||
video.channel.as_deref().unwrap_or("Unknown"),
|
||||
])
|
||||
}),
|
||||
[Constraint::Percentage(75), Constraint::Percentage(25)],
|
||||
)
|
||||
.header(Row::new(["Video", "Channel"]).bold())
|
||||
.block(Block::bordered().title(" Results "))
|
||||
.row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
|
||||
.highlight_symbol("> ");
|
||||
frame.render_stateful_widget(table, results, &mut self.selection);
|
||||
frame.render_widget(
|
||||
Paragraph::new(self.status.as_str())
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(Block::bordered().title(" Status ")),
|
||||
status,
|
||||
);
|
||||
let help_text = match self.mode {
|
||||
Mode::Insert => {
|
||||
"INSERT · Enter search → normal · Esc normal · Ctrl-U clear · Ctrl-C quit"
|
||||
}
|
||||
Mode::Normal => "NORMAL · j/k or ↑/↓ select · Enter stream · i insert · q quit",
|
||||
};
|
||||
frame.render_widget(Paragraph::new(help_text).dim(), help);
|
||||
}
|
||||
|
||||
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
if !self.query.trim().is_empty() {
|
||||
self.search();
|
||||
}
|
||||
loop {
|
||||
self.update();
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
if event::poll(Duration::from_millis(100))?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
&& key.kind != KeyEventKind::Release
|
||||
&& self.key(key)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let app = App::new(Options::parse());
|
||||
ratatui::run(|terminal| app.run(terminal))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
|
||||
let mut app = App::new(Options::parse_from(["tubular"]));
|
||||
assert_eq!(app.mode, Mode::Insert);
|
||||
app.videos = vec![
|
||||
Video {
|
||||
id: "jNQXAC9IVRw".into(),
|
||||
title: "Me at the zoo".into(),
|
||||
channel: None,
|
||||
},
|
||||
Video {
|
||||
id: "rQ_J9WH6CGk".into(),
|
||||
title: "Rust course".into(),
|
||||
channel: None,
|
||||
},
|
||||
];
|
||||
app.selection.select(Some(0));
|
||||
|
||||
for c in ['j', 'k', 'q'] {
|
||||
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
|
||||
}
|
||||
app.update();
|
||||
assert_eq!(app.query, "jkq");
|
||||
assert_eq!(app.selection.selected(), Some(0));
|
||||
assert!(app.job.is_none());
|
||||
|
||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert_eq!(app.mode, Mode::Normal);
|
||||
assert!(app.job.is_none());
|
||||
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||
assert_eq!(app.selection.selected(), Some(1));
|
||||
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
|
||||
assert_eq!(app.selection.selected(), Some(0));
|
||||
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
assert_eq!(app.mode, Mode::Insert);
|
||||
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(app.mode, Mode::Normal);
|
||||
assert!(app.query.is_empty());
|
||||
assert!(app.job.is_none());
|
||||
|
||||
assert_eq!(
|
||||
App::new(Options::parse_from(["tubular", "rust"])).mode,
|
||||
Mode::Normal
|
||||
);
|
||||
assert_eq!(
|
||||
App::new(Options::parse_from(["tubular", " "])).mode,
|
||||
Mode::Insert
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+268
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user