Skip to main content

stremio_addon_core/
cache.rs

1//! File-backed JSON cache with stable md5 file names.
2//!
3//! - Path: `<cache_dir>/<md5(key)>.json`
4//! - Payload: `{"timestamp": <unix_seconds>, "value": <json>}`
5//! - Expired entries are removed on read.
6
7use serde::{de::DeserializeOwned, Serialize};
8use serde_json::Value;
9use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11use std::{fs, io};
12
13#[derive(Clone, Debug)]
14pub struct FileJsonCache {
15    dir: PathBuf,
16    ttl: Duration,
17}
18
19impl FileJsonCache {
20    pub fn new(dir: impl Into<PathBuf>, ttl: Duration) -> Self {
21        Self {
22            dir: dir.into(),
23            ttl,
24        }
25    }
26
27    pub fn ttl(&self) -> Duration {
28        self.ttl
29    }
30
31    pub fn dir(&self) -> &Path {
32        &self.dir
33    }
34
35    pub fn ensure_dir(&self) -> io::Result<()> {
36        fs::create_dir_all(&self.dir)
37    }
38
39    pub fn path_for(&self, key: &str) -> PathBuf {
40        let digest = format!("{:x}", md5::compute(key.as_bytes()));
41        self.dir.join(format!("{digest}.json"))
42    }
43
44    /// Read cached value when within TTL. Expired entries are removed.
45    pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
46        let path = self.path_for(key);
47        let raw = fs::read(&path).ok()?;
48        let envelope: Value = serde_json::from_slice(&raw).ok()?;
49        let ts = envelope.get("timestamp")?.as_u64()?;
50        let now = now_secs();
51        if now.saturating_sub(ts) < self.ttl.as_secs() {
52            let value = envelope.get("value")?;
53            return serde_json::from_value(value.clone()).ok();
54        }
55
56        let _ = fs::remove_file(path);
57        None
58    }
59
60    /// Store value in cache envelope.
61    pub fn set<T: Serialize>(&self, key: &str, value: &T) -> io::Result<()> {
62        self.ensure_dir()?;
63        let path = self.path_for(key);
64        let envelope = serde_json::json!({
65            "timestamp": now_secs(),
66            "value": value,
67        });
68        let bytes = serde_json::to_vec(&envelope).map_err(io::Error::other)?;
69        fs::write(path, bytes)
70    }
71}
72
73fn now_secs() -> u64 {
74    SystemTime::now()
75        .duration_since(UNIX_EPOCH)
76        .map(|duration| duration.as_secs())
77        .unwrap_or_default()
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use std::time::Duration;
84
85    #[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
86    struct Sample {
87        title: String,
88        score: u32,
89    }
90
91    #[test]
92    fn cache_path_is_deterministic_by_md5() {
93        let cache = FileJsonCache::new("/tmp/stremio-core-cache-test", Duration::from_secs(10));
94        let path = cache.path_for("movie:test:sample");
95        let expected = format!("{:x}.json", md5::compute("movie:test:sample".as_bytes()));
96        assert_eq!(
97            path.file_name()
98                .and_then(|value| value.to_str())
99                .expect("file name"),
100            expected
101        );
102    }
103
104    #[test]
105    fn cache_path_matches_php_layout() {
106        let cache = FileJsonCache::new("/tmp/stremio-core-cache-test", Duration::from_secs(10));
107        let path = cache.path_for("search:foo");
108        assert_eq!(
109            path.file_name().and_then(|value| value.to_str()),
110            Some("8176f390f3984e8f44d851bcff380bb0.json")
111        );
112    }
113
114    #[test]
115    fn cache_roundtrip_and_expiration_removal() {
116        let tmp = tempfile::tempdir().expect("tempdir");
117        let cache = FileJsonCache::new(tmp.path(), Duration::from_secs(0));
118        let sample = Sample {
119            title: "The Movie".to_string(),
120            score: 42,
121        };
122
123        cache.set("immediate:expiry", &sample).expect("cache set");
124        let path = cache.path_for("immediate:expiry");
125        assert!(path.exists());
126
127        let back: Option<Sample> = cache.get("immediate:expiry");
128        assert!(back.is_none());
129        assert!(!path.exists());
130    }
131
132    #[test]
133    fn cache_keeps_value_when_not_expired() {
134        let tmp = tempfile::tempdir().expect("tempdir");
135        let cache = FileJsonCache::new(tmp.path(), Duration::from_secs(60));
136
137        let sample = Sample {
138            title: "Keep Me".to_string(),
139            score: 7,
140        };
141        cache.set("keep:alive", &sample).expect("cache set");
142
143        let back: Option<Sample> = cache.get("keep:alive");
144        assert_eq!(back, Some(sample));
145    }
146}