Skip to main content

smriti/services/
tile_cache.rs

1//! On-demand tile fetching + disk cache.
2//!
3//! Cache layout: `<drive>/.photovault/tile_cache/{z}/{x}/{y}.png`.
4
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::SystemTime;
8
9use tokio::sync::Semaphore;
10
11use crate::db;
12
13use super::map_math::TileId;
14
15pub const DEFAULT_CACHE_LIMIT_BYTES: u64 = 500 * 1024 * 1024;
16pub const USER_AGENT: &str = concat!(
17    "Smriti/",
18    env!("CARGO_PKG_VERSION"),
19    " (https://github.com/ChivukulaVirinchi/photovault)"
20);
21
22const MAX_CONCURRENT_FETCHES: usize = 6;
23const FETCH_TIMEOUT_SECS: u64 = 8;
24const CARTO_SUBDOMAINS: [&str; 3] = ["a", "b", "c"];
25
26#[derive(Clone, Debug)]
27pub struct TileCache {
28    root: PathBuf,
29    client: reqwest::Client,
30    sem: Arc<Semaphore>,
31    limit_bytes: u64,
32}
33
34impl TileCache {
35    pub fn new(drive: &Path, limit_bytes: u64) -> Self {
36        let root = db::tile_cache_dir(drive);
37        let _ = std::fs::create_dir_all(&root);
38
39        let client = reqwest::Client::builder()
40            .user_agent(USER_AGENT)
41            .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
42            .build()
43            .expect("reqwest client");
44
45        Self {
46            root,
47            client,
48            sem: Arc::new(Semaphore::new(MAX_CONCURRENT_FETCHES)),
49            limit_bytes,
50        }
51    }
52
53    pub fn tile_path(&self, t: TileId) -> PathBuf {
54        self.root
55            .join(t.z.to_string())
56            .join(t.x.to_string())
57            .join(format!("{}.png", t.y))
58    }
59
60    pub fn has(&self, t: TileId) -> bool {
61        self.tile_path(t).exists()
62    }
63
64    /// Return cached tile path, or fetch/store first.
65    pub async fn get_or_fetch(&self, t: TileId) -> Result<PathBuf, String> {
66        let path = self.tile_path(t);
67        if path.exists() {
68            touch_mtime(&path);
69            return Ok(path);
70        }
71
72        let _permit = self.sem.acquire().await.map_err(|e| e.to_string())?;
73
74        if path.exists() {
75            touch_mtime(&path);
76            return Ok(path);
77        }
78
79        let sub = CARTO_SUBDOMAINS[(t.x as usize + t.y as usize) % CARTO_SUBDOMAINS.len()];
80        let url = format!(
81            "https://{}.basemaps.cartocdn.com/light_all/{}/{}/{}.png",
82            sub, t.z, t.x, t.y
83        );
84
85        let bytes = self
86            .client
87            .get(&url)
88            .send()
89            .await
90            .map_err(|e| format!("fetch {}: {}", url, e))?
91            .error_for_status()
92            .map_err(|e| format!("status {}: {}", url, e))?
93            .bytes()
94            .await
95            .map_err(|e| format!("read {}: {}", url, e))?;
96
97        if let Some(parent) = path.parent() {
98            let _ = std::fs::create_dir_all(parent);
99        }
100
101        std::fs::write(&path, &bytes).map_err(|e| format!("write {}: {}", path.display(), e))?;
102        let _ = self.evict_if_over_limit();
103        Ok(path)
104    }
105
106    /// Walk cache and evict oldest tiles until under cap.
107    pub fn evict_if_over_limit(&self) -> Result<u64, String> {
108        let mut entries: Vec<(PathBuf, SystemTime, u64)> = Vec::new();
109        let mut total: u64 = 0;
110        walk_cache(&self.root, &mut entries, &mut total)?;
111
112        if total <= self.limit_bytes {
113            return Ok(total);
114        }
115
116        entries.sort_by_key(|e| e.1);
117        let mut freed = 0u64;
118
119        for (path, _, size) in entries {
120            if total.saturating_sub(freed) <= self.limit_bytes {
121                break;
122            }
123            if std::fs::remove_file(&path).is_ok() {
124                freed += size;
125            }
126        }
127
128        Ok(total.saturating_sub(freed))
129    }
130
131    pub fn clear(&self) -> Result<(), String> {
132        if self.root.exists() {
133            std::fs::remove_dir_all(&self.root).map_err(|e| e.to_string())?;
134        }
135        std::fs::create_dir_all(&self.root).map_err(|e| e.to_string())?;
136        Ok(())
137    }
138
139    pub fn current_size_bytes(&self) -> u64 {
140        let mut total = 0u64;
141        let mut entries = Vec::new();
142        let _ = walk_cache(&self.root, &mut entries, &mut total);
143        total
144    }
145
146    pub fn set_limit_bytes(&mut self, limit: u64) {
147        self.limit_bytes = limit;
148    }
149}
150
151fn touch_mtime(path: &Path) {
152    let _ = std::fs::File::open(path).and_then(|f| f.set_modified(SystemTime::now()));
153}
154
155fn walk_cache(
156    dir: &Path,
157    out: &mut Vec<(PathBuf, SystemTime, u64)>,
158    total: &mut u64,
159) -> Result<(), String> {
160    let Ok(rd) = std::fs::read_dir(dir) else {
161        return Ok(());
162    };
163
164    for entry in rd.flatten() {
165        let p = entry.path();
166        if p.is_dir() {
167            walk_cache(&p, out, total)?;
168        } else if p.extension().and_then(|s| s.to_str()) == Some("png") {
169            if let Ok(md) = entry.metadata() {
170                let size = md.len();
171                *total += size;
172                out.push((p, md.modified().unwrap_or(SystemTime::UNIX_EPOCH), size));
173            }
174        }
175    }
176
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use tempfile::TempDir;
184
185    #[test]
186    fn tile_path_layout() {
187        let td = TempDir::new().expect("tempdir");
188        let cache = TileCache::new(td.path(), DEFAULT_CACHE_LIMIT_BYTES);
189        let p = cache.tile_path(TileId { z: 5, x: 7, y: 11 });
190        assert!(p.ends_with("tile_cache/5/7/11.png"));
191    }
192
193    #[test]
194    fn has_false_on_empty() {
195        let td = TempDir::new().expect("tempdir");
196        let cache = TileCache::new(td.path(), DEFAULT_CACHE_LIMIT_BYTES);
197        assert!(!cache.has(TileId { z: 0, x: 0, y: 0 }));
198    }
199
200    #[test]
201    fn clear_removes_all() {
202        let td = TempDir::new().expect("tempdir");
203        let cache = TileCache::new(td.path(), DEFAULT_CACHE_LIMIT_BYTES);
204
205        let p = cache.tile_path(TileId { z: 0, x: 0, y: 0 });
206        std::fs::create_dir_all(p.parent().expect("parent")).expect("mkdirs");
207        std::fs::write(&p, b"PNG").expect("write");
208
209        assert!(cache.has(TileId { z: 0, x: 0, y: 0 }));
210        cache.clear().expect("clear");
211        assert!(!cache.has(TileId { z: 0, x: 0, y: 0 }));
212    }
213
214    #[test]
215    fn eviction_respects_limit() {
216        let td = TempDir::new().expect("tempdir");
217        let cache = TileCache::new(td.path(), 100);
218
219        for i in 0..10 {
220            let p = cache.tile_path(TileId { z: 1, x: i, y: 0 });
221            std::fs::create_dir_all(p.parent().expect("parent")).expect("mkdirs");
222            std::fs::write(&p, vec![0u8; 50]).expect("write");
223        }
224
225        let remaining = cache.evict_if_over_limit().expect("evict");
226        assert!(remaining <= 100, "remaining {} > 100", remaining);
227    }
228}