ytmusic-cli 0.3.0

A terminal YouTube Music player built with Rust, Ratatui, mpv, yt-dlp, and rs-ytmusic-api
use std::{
    fs,
    io::{BufRead, BufReader, Write},
    os::unix::net::UnixStream,
    path::PathBuf,
    process::Stdio,
};

use anyhow::Context;
use serde_json::json;
use tokio::{
    process::{Child, Command},
    time::{sleep, Duration},
};

use crate::types::track::Track;

const DEFAULT_VOLUME: u8 = 70;

#[derive(Debug)]
pub struct PlayerService {
    child: Option<Child>,
    socket_path: PathBuf,
    paused: bool,
    volume: u8,
}

impl PlayerService {
    pub fn new() -> Self {
        let socket_path =
            std::env::temp_dir().join(format!("ytmusic-cli-{}.sock", std::process::id()));
        Self {
            child: None,
            socket_path,
            paused: false,
            volume: DEFAULT_VOLUME,
        }
    }

    pub async fn play(&mut self, track: &Track) -> anyhow::Result<()> {
        self.stop().await?;
        let input = track
            .cached_path
            .as_ref()
            .map(|path| path.to_string_lossy().to_string())
            .unwrap_or_else(|| track.url());

        let _ = fs::remove_file(&self.socket_path);
        let child = Command::new("mpv")
            .arg("--no-video")
            .arg("--force-window=no")
            .arg("--terminal=no")
            .arg(format!("--volume={}", self.volume))
            .arg(format!(
                "--input-ipc-server={}",
                self.socket_path.to_string_lossy()
            ))
            .arg(input)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .context("failed to spawn mpv")?;

        self.child = Some(child);
        self.paused = false;
        sleep(Duration::from_millis(90)).await;
        Ok(())
    }

    pub async fn stop(&mut self) -> anyhow::Result<()> {
        if let Some(mut child) = self.child.take() {
            let _ = self.command(json!({ "command": ["quit"] }));
            let _ = child.kill().await;
            let _ = child.wait().await;
        }
        let _ = fs::remove_file(&self.socket_path);
        self.paused = false;
        Ok(())
    }

    pub fn is_paused(&self) -> bool {
        self.paused
    }
    pub fn volume(&self) -> u8 {
        self.volume
    }

    pub fn has_exited(&mut self) -> bool {
        let Some(child) = self.child.as_mut() else {
            return false;
        };

        match child.try_wait() {
            Ok(Some(_status)) => {
                self.child = None;
                self.paused = false;
                let _ = fs::remove_file(&self.socket_path);
                true
            }
            Ok(None) => false,
            Err(_) => {
                self.child = None;
                self.paused = false;
                let _ = fs::remove_file(&self.socket_path);
                true
            }
        }
    }

    pub fn toggle_pause(&mut self) -> anyhow::Result<()> {
        self.paused = !self.paused;
        self.command(json!({ "command": ["set_property", "pause", self.paused] }))
    }

    pub fn seek(&self, seconds: i64) -> anyhow::Result<()> {
        self.command(json!({ "command": ["seek", seconds, "relative"] }))
    }

    pub fn set_volume(&mut self, volume: u8) -> anyhow::Result<()> {
        self.volume = volume.min(100);
        self.command(json!({ "command": ["set_property", "volume", self.volume] }))
            .or(Ok(()))
    }

    pub fn volume_up(&mut self) -> anyhow::Result<()> {
        self.set_volume(self.volume.saturating_add(5).min(100))
    }

    pub fn volume_down(&mut self) -> anyhow::Result<()> {
        self.set_volume(self.volume.saturating_sub(5))
    }

    pub fn position(&self) -> Option<f64> {
        self.get_property("time-pos").and_then(|v| v.as_f64())
    }

    pub fn duration(&self) -> Option<f64> {
        self.get_property("duration").and_then(|v| v.as_f64())
    }

    fn get_property(&self, property: &str) -> Option<serde_json::Value> {
        let mut stream = UnixStream::connect(&self.socket_path).ok()?;
        let payload = json!({ "command": ["get_property", property] });
        let mut bytes = serde_json::to_vec(&payload).ok()?;
        bytes.push(b'\n');
        stream.write_all(&bytes).ok()?;
        let mut reader = BufReader::new(stream);
        let mut line = String::new();
        reader.read_line(&mut line).ok()?;
        let value: serde_json::Value = serde_json::from_str(&line).ok()?;
        value.get("data").cloned()
    }

    fn command(&self, command: serde_json::Value) -> anyhow::Result<()> {
        let mut stream = UnixStream::connect(&self.socket_path).with_context(|| {
            format!("failed to connect mpv IPC: {}", self.socket_path.display())
        })?;
        let mut payload = serde_json::to_vec(&command)?;
        payload.push(b'\n');
        stream.write_all(&payload)?;
        Ok(())
    }
}

impl Drop for PlayerService {
    fn drop(&mut self) {
        if let Some(mut child) = self.child.take() {
            let _ = child.start_kill();
        }
        let _ = fs::remove_file(&self.socket_path);
    }
}