stremio_addon_core/
cache.rs1use 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 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 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}