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, pos: u64, }
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) {
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 }
}
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;
}
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;
}
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)
}
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()
}
pub fn date(&self, path: &str) -> Option<String> {
self.map.get(path).filter(|e| e.ts > 0).map(|e| fmt_date(e.ts))
}
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",
];
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; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; 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"); assert_eq!(fmt_date(1_609_804_800), "05 Jan 21"); assert_eq!(fmt_date(1_740_787_200), "01 Mar 25"); }
}