medias 0.3.8

TV media player on bare DRM/KMS: Vulkan UI plane over libmpv video, for local files, UPnP/DLNA servers and YouTube, controlled by HDMI-CEC remote, evdev or terminal
//! Playback dates and whole-second resume positions in ~/medias/.history.

use std::collections::HashMap;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Clone, Copy, Default)]
struct Entry {
    ts: u64,  // last-played unix seconds (for the date)
    pos: u64, // last playback position in seconds (for resume)
}

pub struct History {
    map: HashMap<String, Entry>,
    file: PathBuf,
}

impl History {
    pub fn load() -> History {
        let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
        let file = PathBuf::from(home).join("medias").join(".history");
        Self::load_from(file)
    }

    pub fn load_from(file: PathBuf) -> History {
        let mut map = HashMap::new();
        if let Ok(text) = std::fs::read_to_string(&file) {
            // "ts\tpos\tpath" (older "ts\tpath" lines read with pos 0).
            for line in text.lines() {
                let mut f = line.splitn(3, '\t');
                match (f.next(), f.next(), f.next()) {
                    (Some(ts), Some(pos), Some(path)) => {
                        if let Ok(ts) = ts.parse() {
                            map.insert(path.to_string(), Entry { ts, pos: pos.parse().unwrap_or(0) });
                        }
                    }
                    (Some(ts), Some(path), None) => {
                        if let Ok(ts) = ts.parse() {
                            map.insert(path.to_string(), Entry { ts, pos: 0 });
                        }
                    }
                    _ => {}
                }
            }
        }
        History { map, file }
    }

    /// Mark `path` played now (updates the date).
    pub fn touch(&mut self, path: &str) {
        let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
        self.map.entry(path.to_string()).or_default().ts = now;
    }

    /// Record the playback position (seconds) for `path`.
    pub fn set_pos(&mut self, path: &str, pos: f64) {
        if path.is_empty() || !pos.is_finite() { return; }
        self.map.entry(path.to_string()).or_default().pos = pos.max(0.0) as u64;
    }

    /// Saved resume position for `path`, if worth resuming (> 5s).
    pub fn pos(&self, path: &str) -> Option<f64> {
        self.map.get(path).map(|e| e.pos).filter(|&p| p > 5).map(|p| p as f64)
    }

    /// Played paths, most recent first (entries without a play date excluded).
    pub fn recent(&self) -> Vec<&str> {
        let mut played: Vec<(&str, u64)> =
            self.map.iter().filter(|(_, e)| e.ts > 0).map(|(path, e)| (path.as_str(), e.ts)).collect();
        played.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
        played.into_iter().map(|(path, _)| path).collect()
    }

    /// Formatted last-played date for `path`, if any (e.g. "05 Jan 25").
    pub fn date(&self, path: &str) -> Option<String> {
        self.map.get(path).filter(|e| e.ts > 0).map(|e| fmt_date(e.ts))
    }

    /// Atomically replace the history file. Write failures are reported.
    pub fn save(&self) {
        if let Err(error) = self.save_file() {
            eprintln!("save history {}: {error}", self.file.display());
        }
    }

    fn save_file(&self) -> std::io::Result<()> {
        let body: String = self.map.iter().map(|(p, e)| format!("{}\t{}\t{}\n", e.ts, e.pos, p)).collect();
        if let Some(dir) = self.file.parent() {
            std::fs::create_dir_all(dir)?;
        }
        let temporary = self.file.with_extension(format!("tmp.{}", std::process::id()));
        let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&temporary)?;
        let result = file.write_all(body.as_bytes()).and_then(|_| std::fs::rename(&temporary, &self.file));
        if result.is_err() { let _ = std::fs::remove_file(temporary); }
        result
    }
}

const MONTHS: [&str; 12] = [
    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];

/// Format a Unix time (UTC) as "DD Mon YY". Civil date via Howard Hinnant's
/// days-from-civil inverse.
pub fn fmt_date(secs: u64) -> String {
    let days = (secs / 86400) as i64;
    let z = days + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = z - era * 146097; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
    let y = if m <= 2 { y + 1 } else { y };
    format!("{:02} {} {:02}", d, MONTHS[(m - 1) as usize], y.rem_euclid(100))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::fs::PermissionsExt;

    #[test]
    fn atomic_replacement_keeps_history_private() {
        let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
        let dir = std::env::temp_dir().join(format!("medias-history-{}-{stamp}", std::process::id()));
        std::fs::create_dir(&dir).unwrap();
        let path = dir.join(".history");
        std::fs::write(&path, "1\t8\tvideo\n").unwrap();
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
        let mut history = History::load_from(path.clone());
        history.set_pos("video", 17.9);
        history.save_file().unwrap();
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
        assert_eq!(History::load_from(path).pos("video"), Some(17.0));
        std::fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn recent_orders_by_last_played_and_skips_undated() {
        let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
        let dir = std::env::temp_dir().join(format!("medias-recent-{}-{stamp}", std::process::id()));
        std::fs::create_dir(&dir).unwrap();
        let path = dir.join(".history");
        std::fs::write(&path, "100\t0\told\n300\t0\tnew\n0\t40\tundated\n200\t0\tmid\n").unwrap();
        assert_eq!(History::load_from(path).recent(), ["new", "mid", "old"]);
        std::fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn known_dates() {
        assert_eq!(fmt_date(0), "01 Jan 70"); // 1970-01-01
        assert_eq!(fmt_date(1_609_804_800), "05 Jan 21"); // 2021-01-05
        assert_eq!(fmt_date(1_740_787_200), "01 Mar 25"); // 2025-03-01
    }
}