ytmusic-cli 0.3.0

A terminal YouTube Music player built with Rust, Ratatui, mpv, yt-dlp, and rs-ytmusic-api
use std::{fs, path::PathBuf};

use anyhow::Context;
use serde::{de::DeserializeOwned, Serialize};

use crate::types::{lyrics::LyricsData, playlist::LocalPlaylist, track::Track};

#[derive(Debug, Clone)]
pub struct StorageService {
    data_dir: PathBuf,
}

impl StorageService {
    pub fn new() -> anyhow::Result<Self> {
        let base = dirs::data_local_dir()
            .or_else(dirs::data_dir)
            .or_else(dirs::home_dir)
            .context("failed to resolve data directory")?;
        let data_dir = base.join("ytmusic-cli");
        fs::create_dir_all(&data_dir)
            .with_context(|| format!("failed to create data dir: {}", data_dir.display()))?;
        Ok(Self { data_dir })
    }

    pub fn load_history(&self) -> Vec<Track> {
        self.read_json("history.json").unwrap_or_default()
    }

    pub fn save_history(&self, history: &[Track]) -> anyhow::Result<()> {
        self.write_json("history.json", history)
    }

    pub fn load_favorites(&self) -> Vec<Track> {
        self.read_json("favorites.json").unwrap_or_default()
    }

    pub fn save_favorites(&self, favorites: &[Track]) -> anyhow::Result<()> {
        self.write_json("favorites.json", favorites)
    }

    pub fn load_playlists(&self) -> Vec<LocalPlaylist> {
        self.read_json("playlists.json").unwrap_or_default()
    }

    pub fn save_playlists(&self, playlists: &[LocalPlaylist]) -> anyhow::Result<()> {
        self.write_json("playlists.json", playlists)
    }

    pub fn load_lyrics_cache(&self) -> Vec<LyricsData> {
        self.read_json("lyrics.json").unwrap_or_default()
    }

    pub fn get_cached_lyrics(&self, video_id: &str) -> Option<LyricsData> {
        self.load_lyrics_cache()
            .into_iter()
            .find(|item| item.video_id == video_id)
    }

    pub fn save_lyrics(&self, lyrics: &LyricsData) -> anyhow::Result<()> {
        let mut cache = self.load_lyrics_cache();
        cache.retain(|item| item.video_id != lyrics.video_id);
        cache.insert(0, lyrics.clone());
        cache.truncate(500);
        self.write_json("lyrics.json", &cache)
    }

    fn read_json<T: DeserializeOwned>(&self, name: &str) -> anyhow::Result<T> {
        let path = self.data_dir.join(name);
        let data = fs::read_to_string(&path)
            .with_context(|| format!("failed to read {}", path.display()))?;
        serde_json::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
    }

    fn write_json<T: Serialize + ?Sized>(&self, name: &str, value: &T) -> anyhow::Result<()> {
        let path = self.data_dir.join(name);
        let data = serde_json::to_string_pretty(value)?;
        fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))
    }
}