./blog/yt-tui

yt-tui: a terminal YouTube player in Rust

I wanted a YouTube player that never leaves the terminal: search, queue, play, all from a TUI. The shape of the solution was obvious from the start — the interesting part turned out to be the plumbing, and three specific bugs that only showed up once real playback was involved.

Code’s on GitHub and published on crates.io:

cargo install yt-tui

The stack

  • yt-dlp for search
  • mpv, kept as a single long-lived process, controlled entirely over its JSON IPC socket
  • ratatui for the interface
  • tokio tying it all together with a tokio::select! loop that merges keyboard events, a 250ms sync tick, and background search results

The core idea: spawn mpv --idle once, keep it alive for the life of the app, and never touch its process again — everything else (loading a track, pausing, seeking, checking playback position, enabling video) is a JSON command over a Unix socket. That single decision is what makes the queue, the progress bar, and instant play/pause all cheap.

Three bugs worth writing down

Silence, because I was too clever

My first instinct was to resolve the actual stream URL myself with yt-dlp -f bestvideo+bestaudio/best -g and hand that straight to mpv. It worked for some videos and was dead silent for others. The reason: when YouTube serves video and audio as separate streams — which is the common case at decent quality — that command prints two URLs, one per line. I was only reading the first one. Since the player starts in audio-only mode, I was consistently discarding the one URL that had sound.

The fix was to stop being clever. mpv ships with a built-in yt-dlp hook (ytdl_hook) that already knows how to resolve and mux separate video/audio streams, headers and all. Now the app just hands mpv the plain youtube.com/watch?v=... URL and gets out of the way:

pub async fn load(&self, stream_url: &str, mode: LoadMode) -> Result<()> {
    self.send(json!(["loadfile", stream_url, mode.as_str()])).await?;
    Ok(())
}

Less code, and it actually works.

A queue that ate itself

Early on, Enter played a video by replacing mpv’s entire playlist, and a separate key added to the queue. That’s backwards — selecting something should extend the queue, not wipe it. So the semantics flipped: Enter/a enqueue by default (loadfile ... append-play), and a dedicated r key does the explicit “clear everything and play this now.”

History that silently dropped entries

Each selection persisted a history.toml file so past searches wouldn’t need re-hitting the YouTube search endpoint. Originally I saved it by spawning a detached tokio::spawn task per selection — a small mistake with an unpleasant consequence: select three videos quickly and you fire off three concurrent writes with no ordering guarantee. A more complete write could finish before an older, incomplete one, which then overwrote the file last and quietly dropped entries. The fix was almost embarrassingly simple — await the save instead of spawning it, so writes happen in the same order the keys were pressed — plus a final save on quit as a safety net.

None of these were exotic bugs. They were the ordinary cost of writing code against a moving target (a socket, a subprocess, concurrent tasks) instead of a pure function, and they only surfaced once I actually played something instead of reading the code and assuming it was fine.

Making it docs.rs-friendly

Since this went to crates.io, I split it into a lib.rs exposing app, mpv, yt, history, and ui as public modules, with main.rs reduced to a thin terminal-setup wrapper. Every public item got a doc comment, and #![warn(missing_docs)] keeps that honest. Running cargo doc locally (the same thing docs.rs runs) caught two things cargo check never would have: a filename collision between the bin and lib doc output, and a couple of intra-doc links pointing at private items that would’ve silently failed to resolve for anyone reading the generated docs.

It’s dual-licensed Apache-2.0 OR MIT, the usual choice for anything meant to be reused.

What’s next

No progress bar for buffering vs. actual playback yet, no volume UI (the plumbing’s there, just not wired to a key), and the queue is currently a mirror of mpv’s own playlist rather than something you can reorder from the TUI. Good next steps, not blockers.

  • If it hasn’t worked out yet, it’s because it’s not over yet
rustcli