bckplayer 0.1.0

A cross-platform CLI media player interface for VLC
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use crate::types::TuiLogger;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SortBy {
    TimeAdded,
    Duration,
    Random,
    MostPlayed,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlaylistItem {
    pub name: String,
    pub id: String,       // YouTube ID or UUID
    pub url: String,      // file path or URL
    pub num_played: u32,  // increment when played
    pub duration: String, // seconds as string or MM:SS / HH:MM:SS
    pub time_added: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playlist {
    pub name: String,
    pub id: String, // uuid
    pub created_at: u64,
    pub last_updated: u64,
    pub sort_by: SortBy,
    pub items: Vec<PlaylistItem>,
}

impl Playlist {
    pub fn new(name: impl Into<String>) -> Self {
        let now = now_epoch();
        Self {
            name: name.into(),
            id: Uuid::new_v4().to_string(),
            created_at: now,
            last_updated: now,
            sort_by: SortBy::TimeAdded,
            items: Vec::new(),
        }
    }

    pub fn touch(&mut self) {
        self.last_updated = now_epoch();
    }

    pub fn add_item(&mut self, mut item: PlaylistItem) {
        if item.id.is_empty() {
            item.id = Uuid::new_v4().to_string();
        }
        item.time_added = now_epoch();
        self.items.push(item);
        self.touch();
    }

    pub fn remove_item(&mut self, item_id: &str) -> bool {
        let len_before = self.items.len();
        self.items.retain(|i| i.id != item_id);
        let removed = self.items.len() != len_before;
        if removed {
            self.touch();
        }
        removed
    }

    pub fn increment_play(&mut self, item_id: &str) {
        if let Some(it) = self.items.iter_mut().find(|i| i.id == item_id) {
            it.num_played = it.num_played.saturating_add(1);
            self.touch();
        }
    }

    pub fn sort_items(&mut self) {
        match self.sort_by {
            SortBy::TimeAdded => self.items.sort_by_key(|i| i.time_added),
            SortBy::Duration => self
                .items
                .sort_by_key(|i| parse_duration_to_secs(&i.duration).unwrap_or(0)),
            SortBy::Random => {
                use rand::seq::SliceRandom;
                let mut rng = rand::thread_rng();
                self.items.shuffle(&mut rng);
            }
            SortBy::MostPlayed => self
                .items
                .sort_by(|a, b| b.num_played.cmp(&a.num_played)),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PlaylistsStore {
    pub playlists: Vec<Playlist>,
    pub last_selected_playlist_id: Option<String>,
}

#[derive(Clone)]
pub struct PlaylistsManager {
    path: PathBuf,
    pub store: PlaylistsStore,
    #[allow(dead_code)]
    logger: Option<TuiLogger>,
}

impl PlaylistsManager {
    pub fn new() -> Result<Self> {
        let mut path = dirs::home_dir().context("home_dir not found")?;
        path.push(".bckplayer");
        if !path.exists() {
            fs::create_dir_all(&path).with_context(|| format!("create dir: {:?}", path))?;
        }
        path.push("playlists.config");

        let store = if path.exists() {
            let data = fs::read_to_string(&path).with_context(|| format!("read: {:?}", path))?;
            serde_json::from_str(&data).with_context(|| "parse playlists.config")?
        } else {
            PlaylistsStore::default()
        };
        Ok(Self { path, store, logger: None })
    }

    /// Try to create manager; fall back to default in current dir if needed
    pub fn new_or_default() -> Self {
        match Self::new() {
            Ok(m) => m,
            Err(_) => {
                let path = PathBuf::from("playlists.config");
                Self { path, store: PlaylistsStore::default(), logger: None }
            }
        }
    }

    pub fn with_logger(mut self, logger: Option<TuiLogger>) -> Self {
        self.logger = logger;
        self
    }

    pub fn save(&self) -> Result<()> {
        let data = serde_json::to_string_pretty(&self.store)?;
        fs::write(&self.path, data).with_context(|| format!("write: {:?}", self.path))?;
        if let Some(l) = &self.logger { l.debug("Playlists saved to disk"); }
        Ok(())
    }

    /// Reload from disk into memory if file exists
    pub fn reload(&mut self) -> Result<()> {
        if self.path.exists() {
            let data = fs::read_to_string(&self.path).with_context(|| format!("read: {:?}", self.path))?;
            self.store = serde_json::from_str(&data).with_context(|| "parse playlists.config")?;
            if let Some(l) = &self.logger { l.debug("Playlists reloaded from disk"); }
        }
        Ok(())
    }

    /// Snapshot a clone of current store (for UI thread)
    pub fn snapshot(&self) -> PlaylistsStore {
        self.store.clone()
    }

    pub fn list(&self) -> &[Playlist] {
        &self.store.playlists
    }

    pub fn list_mut(&mut self) -> &mut [Playlist] {
        &mut self.store.playlists
    }

    pub fn get_mut(&mut self, id: &str) -> Option<&mut Playlist> {
        self.store.playlists.iter_mut().find(|p| p.id == id)
    }

    pub fn add_playlist(&mut self, name: impl Into<String>) -> &Playlist {
        let playlist = Playlist::new(name);
        self.store.playlists.push(playlist);
        let idx = self.store.playlists.len() - 1;
        if let Some(l) = &self.logger { l.info(&format!("Playlist created: {}", self.store.playlists[idx].name)); }
        &self.store.playlists[idx]
    }

    pub fn remove_playlist(&mut self, id: &str) -> bool {
        let len_before = self.store.playlists.len();
        self.store.playlists.retain(|p| p.id != id);
        let removed = self.store.playlists.len() != len_before;
        if removed && self.store.last_selected_playlist_id.as_deref() == Some(id) {
            self.store.last_selected_playlist_id = None;
        }
        if removed { if let Some(l) = &self.logger { l.info(&format!("Playlist deleted: {}", id)); } }
        removed
    }

    pub fn ensure_default(&mut self) {
        if self.store.playlists.is_empty() {
            let p = Playlist::new("My Playlist");
            self.store.last_selected_playlist_id = Some(p.id.clone());
            self.store.playlists.push(p);
            if let Some(l) = &self.logger { l.info("Created default playlist: My Playlist"); }
        }
    }

    pub fn find_item(&self, playlist_id: &str, item_id: &str) -> Option<(usize, usize)> {
        self.store.playlists.iter().enumerate().find_map(|(pi, p)| {
            if p.id == playlist_id {
                p.items.iter().enumerate().find_map(|(ii, it)| if it.id == item_id { Some((pi, ii)) } else { None })
            } else { None }
        })
    }
}

fn now_epoch() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

fn parse_duration_to_secs(s: &str) -> Option<u64> {
    // Accept formats: seconds as integer string, MM:SS, HH:MM:SS
    if let Ok(v) = s.parse::<u64>() {
        return Some(v);
    }
    let parts: Vec<&str> = s.split(':').collect();
    match parts.as_slice() {
        [mm, ss] => {
            let m: u64 = mm.parse().ok()?;
            let s: u64 = ss.parse().ok()?;
            Some(m * 60 + s)
        }
        [hh, mm, ss] => {
            let h: u64 = hh.parse().ok()?;
            let m: u64 = mm.parse().ok()?;
            let s: u64 = ss.parse().ok()?;
            Some(h * 3600 + m * 60 + s)
        }
        _ => None,
    }
}