plavno 0.4.0

A lightweight terminal music player for Linux, macOS and Windows with service waves and library shuffle
use crate::backend::Track;
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use std::{
    collections::VecDeque,
    fs,
    io::Write,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

pub const LIMIT: usize = 2000;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Entry {
    pub source: String,
    pub track: Track,
    pub played_at: u64,
}

impl Entry {
    pub fn new(source: &str, track: &Track) -> Self {
        let mut track = track.clone();
        track.batch.clear();
        Self {
            source: if track.source.is_empty() {
                source.into()
            } else {
                track.source.clone()
            },
            track,
            played_at: SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs(),
        }
    }

    pub fn matches(&self, query: &str) -> bool {
        let title = self.track.title.to_lowercase();
        let artist = self.track.artist.to_lowercase();
        query
            .to_lowercase()
            .split_whitespace()
            .all(|word| title.contains(word) || artist.contains(word))
    }
}

pub fn push(entries: &mut VecDeque<Entry>, entry: Entry) {
    entries.push_back(entry);
    while entries.len() > LIMIT {
        entries.pop_front();
    }
}

pub fn path() -> Result<PathBuf> {
    let base = plavno_platform::state_home()?;
    Ok(base.join("plavno/history.json"))
}

pub fn load(path: &Path) -> Result<VecDeque<Entry>> {
    let file = match fs::File::open(path) {
        Ok(file) => file,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(VecDeque::new()),
        Err(error) => return Err(error).context("Failed to open history"),
    };
    ensure!(
        file.metadata()?.len() <= 8 * 1024 * 1024,
        "The history file is too large"
    );
    let mut entries: VecDeque<Entry> =
        serde_json::from_reader(file).context("Failed to read history")?;
    while entries.len() > LIMIT {
        entries.pop_front();
    }
    Ok(entries)
}

pub fn save_entry(path: &Path, entry: Entry) -> Result<()> {
    let parent = path.parent().context("The history directory is not set")?;
    plavno_platform::private_directory(parent)?;
    let lock = plavno_platform::private_options()
        .create(true)
        .truncate(false)
        .read(true)
        .write(true)
        .open(parent.join("history.lock"))?;
    lock.lock().context("Failed to lock history")?;
    let mut entries = load(path)?;
    push(&mut entries, entry);
    let temp = parent.join(format!(".history-{}", std::process::id()));
    let result = (|| -> Result<()> {
        let mut file = plavno_platform::private_options()
            .write(true)
            .create_new(true)
            .open(&temp)?;
        serde_json::to_writer(&mut file, &entries)?;
        file.write_all(b"\n")?;
        file.sync_all()?;
        fs::rename(&temp, path)?;
        Ok(())
    })();
    if result.is_err() {
        let _ = fs::remove_file(temp);
    }
    result.context("Failed to save history")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn entry(id: usize) -> Entry {
        Entry::new(
            "yandex",
            &Track {
                source: String::new(),
                id: id.to_string(),
                title: "Night wave".into(),
                artist: "My artist".into(),
                duration: Some(60.0),
                cover_url: Some("https://example.com/cover.jpg".into()),
                genre: None,
                batch: "old-session".into(),
            },
        )
    }

    #[test]
    fn history_survives_restart_preserves_sources_and_does_not_overwrite_corruption() {
        let dir = std::env::temp_dir().join(format!("plavno-history-{}", std::process::id()));
        let path = dir.join("history.json");
        assert!(load(&path).unwrap().is_empty());
        save_entry(&path, entry(1)).unwrap();
        let mut local = entry(2);
        local.source = "local".into();
        save_entry(&path, local).unwrap();
        let entries = load(&path).unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].track.id, "1");
        assert_eq!(entries[1].source, "local");
        assert_eq!(
            entries[0].track.cover_url.as_deref(),
            Some("https://example.com/cover.jpg")
        );
        assert!(entries[0].track.batch.is_empty());
        assert!(entries[0].matches("WAVE artist"));
        assert!(!entries[0].matches("wave other"));
        let bytes = fs::read(&path).unwrap();
        assert!(!String::from_utf8(bytes).unwrap().contains("batch"));
        let mut legacy = serde_json::to_value(&entries).unwrap();
        for entry in legacy.as_array_mut().unwrap() {
            entry["track"].as_object_mut().unwrap().remove("cover_url");
        }
        fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
        assert!(
            load(&path)
                .unwrap()
                .iter()
                .all(|entry| entry.track.cover_url.is_none())
        );
        fs::write(&path, b"broken").unwrap();
        assert!(save_entry(&path, entry(3)).is_err());
        assert_eq!(fs::read(&path).unwrap(), b"broken");
        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn history_keeps_recent_plays_in_order() {
        let mut entries = VecDeque::new();
        for id in 0..LIMIT + 5 {
            push(&mut entries, entry(id));
        }
        assert_eq!(entries.len(), LIMIT);
        assert_eq!(entries.front().unwrap().track.id, "5");
        assert_eq!(entries.back().unwrap().track.id, (LIMIT + 4).to_string());
    }
}