yt_tui/yt.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Enzo Costa Fuke
3
4//! Searches YouTube via `yt-dlp` and streams results back incrementally.
5//!
6//! This module only handles *search* — resolving a [`Video`] into an
7//! actual playable stream is left entirely to `mpv` (see
8//! [`crate::mpv`]), which uses its own built-in `yt-dlp` hook.
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use tokio::io::{AsyncBufReadExt, BufReader};
13use tokio::process::Command;
14use tokio::sync::mpsc;
15
16/// A YouTube video — a yt-dlp search result, and also the format saved in
17/// history.toml (hence deriving Serialize in addition to Deserialize).
18#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
19pub struct Video {
20 /// The YouTube video id (the `v=` query parameter of its watch URL).
21 pub id: String,
22 /// The video's title, as reported by yt-dlp.
23 pub title: String,
24 /// Duration in whole seconds, if yt-dlp reported one.
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub duration: Option<u64>,
27 /// Uploader/channel name, if yt-dlp reported one.
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub uploader: Option<String>,
30 /// View count, if yt-dlp reported one.
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub view_count: Option<u64>,
33}
34
35impl Video {
36 /// The canonical `https://www.youtube.com/watch?v=...` URL for this
37 /// video — what gets handed to mpv to play.
38 pub fn url(&self) -> String {
39 format!("https://www.youtube.com/watch?v={}", self.id)
40 }
41
42 /// Formats [`Video::duration`] as `MM:SS`, or `--:--` if unknown.
43 pub fn duration_fmt(&self) -> String {
44 match self.duration {
45 Some(secs) => format!("{:02}:{:02}", secs / 60, secs % 60),
46 None => "--:--".to_string(),
47 }
48 }
49}
50
51/// Parses manually instead of a pure serde_json derive because yt-dlp
52/// sometimes sends `duration` as a float and other fields can be missing —
53/// this way we silently ignore whatever doesn't match instead of dropping
54/// the whole line.
55fn parse_video(raw: &serde_json::Value) -> Option<Video> {
56 Some(Video {
57 id: raw.get("id")?.as_str()?.to_string(),
58 title: raw
59 .get("title")?
60 .as_str()
61 .unwrap_or("(untitled)")
62 .to_string(),
63 duration: raw
64 .get("duration")
65 .and_then(|d| d.as_f64())
66 .map(|d| d.round() as u64),
67 uploader: raw
68 .get("uploader")
69 .and_then(|u| u.as_str())
70 .map(String::from),
71 view_count: raw.get("view_count").and_then(|v| v.as_u64()),
72 })
73}
74
75/// Kicks off `yt-dlp ytsearchN:query --dump-json --flat-playlist` and sends
76/// each video over `tx` as soon as its JSON line arrives on stdout,
77/// instead of waiting for the whole process to finish.
78///
79/// Malformed or partial JSON lines are skipped rather than treated as a
80/// fatal error. Returns once yt-dlp exits or `tx`'s receiver is dropped
81/// (e.g. because a newer search superseded this one).
82///
83/// # Errors
84///
85/// Returns an error if the `yt-dlp` binary can't be spawned (e.g. not on
86/// the `PATH`) or if reading its stdout fails.
87pub async fn search_stream(
88 query: String,
89 limit: u32,
90 tx: mpsc::UnboundedSender<Video>,
91) -> Result<()> {
92 let search_term = format!("ytsearch{}:{}", limit, query);
93
94 let mut child = Command::new("yt-dlp")
95 .args([
96 "--dump-json",
97 "--flat-playlist",
98 "--no-warnings",
99 "--ignore-errors",
100 &search_term,
101 ])
102 .stdout(std::process::Stdio::piped())
103 .stderr(std::process::Stdio::null())
104 .spawn()
105 .context("failed to start yt-dlp — is it installed and on the PATH?")?;
106
107 let stdout = child.stdout.take().context("no stdout from yt-dlp")?;
108 let mut lines = BufReader::new(stdout).lines();
109
110 while let Some(line) = lines.next_line().await? {
111 if line.trim().is_empty() {
112 continue;
113 }
114 if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&line) {
115 if let Some(video) = parse_video(&raw) {
116 if tx.send(video).is_err() {
117 break; // receiver dropped (new search started); stop sending
118 }
119 }
120 }
121 }
122
123 let _ = child.wait().await;
124 Ok(())
125}