//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked //! of each publisher's server for every visit, and still there when a publisher is not. use std::path::{Path, PathBuf}; use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; pub fn dir() -> PathBuf { crate::config::data_dir().join("art") } /// Where an image's bytes are kept; its content type is beside it, in `.type`. // ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which // only makes the cache miss once and refill; a collision among a few thousand images is about // one in 10^12. Move to a SHA if either ever matters. pub fn path(url: &str) -> PathBuf { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); url.hash(&mut h); dir().join(format!("{:016x}", h.finish())) } /// A kept image and its type, marked as just used, which is what keeps it from being trimmed. pub fn read(file: &Path) -> Option<(String, Vec)> { let kind = std::fs::read_to_string(file.with_extension("type")).ok()?; let body = std::fs::read(file).ok()?; if let Ok(f) = std::fs::File::options().write(true).open(file) { let _ = f.set_modified(std::time::SystemTime::now()); } Some((kind, body)) } /// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile /// never reads half of one. pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> { std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?; let tmp = file.with_extension("part"); std::fs::write(&tmp, body)?; std::fs::write(file.with_extension("type"), kind)?; std::fs::rename(&tmp, file) } /// How long an address that could not be fetched is answered from memory before it is tried again. const RETRY: Duration = Duration::from_secs(60 * 60); /// Addresses that could not be fetched lately, and why. Without it a page shown again fetched /// them again: five covers too big to keep were downloaded from Patreon on every load (#169). // ponytail: in memory, and an entry goes only when it is asked for after RETRY. It holds at most // one per artwork address a feed or item names; move it to disk or prune it on trim if that grows. static FAILED: LazyLock>> = LazyLock::new(Default::default); /// Why `url` could not be fetched, if it failed less than an hour ago. pub fn failed(url: &str) -> Option { failed_as_of(url, Instant::now()) } fn failed_as_of(url: &str, now: Instant) -> Option { let mut map = FAILED.lock().unwrap_or_else(|e| e.into_inner()); match map.get(url) { Some((at, why)) if now.duration_since(*at) < RETRY => Some(why.clone()), Some(_) => { map.remove(url); None } None => None, } } pub fn fail(url: &str, why: &str) { FAILED.lock().unwrap_or_else(|e| e.into_inner()).insert(url.to_owned(), (Instant::now(), why.to_owned())); } /// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it. /// Returns how many went. pub fn trim(dir: &Path, limit: u64) -> usize { let Ok(entries) = std::fs::read_dir(dir) else { return 0 }; let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries .flatten() .map(|e| e.path()) .filter(|p| p.extension().is_none()) .filter_map(|p| { let m = p.metadata().ok()?; Some((m.modified().ok()?, m.len(), p)) }) .collect(); let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum(); kept.sort(); let mut gone = 0; for (_, n, p) in kept { if total <= limit { break; } let _ = std::fs::remove_file(p.with_extension("type")); if std::fs::remove_file(&p).is_ok() { total -= n; gone += 1; } } crate::metrics::STATS.art_cache_bytes.store(total, std::sync::atomic::Ordering::Relaxed); crate::metrics::STATS.art_evicted.fetch_add(gone as u64, std::sync::atomic::Ordering::Relaxed); gone } #[cfg(test)] mod tests { use super::*; #[test] fn the_least_recently_used_go_first_until_it_fits() { let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] { let f = d.join(name); write(&f, "image/png", &[0; 1000]).unwrap(); let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age); std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap(); } // Shown just now: no longer the oldest. assert_eq!(read(&d.join("old")).unwrap().0, "image/png"); assert_eq!(trim(&d, 2000), 1); assert!(!d.join("mid").exists() && !d.join("mid.type").exists()); assert!(d.join("old").exists() && d.join("new").exists()); assert_eq!(trim(&d, 0), 2); let _ = std::fs::remove_dir_all(&d); } #[test] fn a_failure_is_remembered_for_an_hour() { let u = "https://example.com/a-failure-is-remembered.png"; assert_eq!(failed(u), None); fail(u, "larger than 5 MB"); assert_eq!(failed(u).as_deref(), Some("larger than 5 MB")); assert_eq!(failed_as_of(u, Instant::now() + RETRY), None); // Asked for after the hour, it is forgotten, not just ignored. assert_eq!(failed(u), None); } }