smriti/services/
tile_cache.rs1use 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 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 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}