Skip to main content

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}