Skip to main content

videre_core/
thumb_cache.rs

1use std::path::PathBuf;
2
3/// Directory holding pre-converted HEIC thumbnails, keyed by content hash
4/// rather than file path, the same photo scanned into different databases
5/// only needs converting once. Mirrors the convention hf-hub already uses for
6/// cached model weights under `~/.cache/huggingface/`.
7///
8/// Lives under `VIDERE_HOME` when that is set, and under `~/.cache/videre`
9/// otherwise. The override exists because this cache was previously keyed on
10/// `$HOME` alone, which nothing in the test suite isolates: every
11/// `cargo test --workspace` run therefore pruned the developer's **real**
12/// thumbnail cache, since a temp test database shares no hashes with it and
13/// every cached file reads as an orphan. Two prune tests running in parallel
14/// then raced each other's deletions and failed on the second one's missing
15/// files, which is how this was found.
16///
17/// Deleting a thumbnail is not data loss, but it is expensive to undo: a HEIC
18/// full-resolution decode costs ~7.6s, against ~108ms to read a cached one.
19///
20/// The default path is unchanged when `VIDERE_HOME` is unset, so this does not
21/// move any existing user's cache.
22pub fn cache_dir() -> PathBuf {
23    if let Some(home) = std::env::var_os("VIDERE_HOME") {
24        return PathBuf::from(home).join("cache").join("thumbnails");
25    }
26    dirs_cache_dir().join("videre").join("thumbnails")
27}
28
29/// Path to a cached thumbnail for `hash` at `size` pixels (e.g. 240 or
30/// 1200), whether or not it currently exists on disk.
31pub fn thumb_path(hash: &str, size: u32) -> PathBuf {
32    cache_dir().join(format!("{hash}_{size}.jpg"))
33}
34
35/// True if a cached thumbnail already exists for this hash/size.
36pub fn thumb_exists(hash: &str, size: u32) -> bool {
37    thumb_path(hash, size).exists()
38}
39
40/// Cache path for a single face crop. Distinct from `thumb_path` because
41/// many faces can share one source `hash`, the face id disambiguates.
42pub fn face_thumb_path(hash: &str, face_id: i64, size: u32) -> PathBuf {
43    cache_dir().join(format!("{hash}_face{face_id}_{size}.jpg"))
44}
45
46/// True if a cached face crop already exists for this hash/face_id/size.
47pub fn face_thumb_exists(hash: &str, face_id: i64, size: u32) -> bool {
48    face_thumb_path(hash, face_id, size).exists()
49}
50
51/// Cache path for a full-resolution HEIC-converted original. One per hash
52/// (not per face, the original photo is the same regardless of which face
53/// on it was clicked).
54pub fn original_path(hash: &str) -> PathBuf {
55    cache_dir().join(format!("{hash}_original.jpg"))
56}
57
58/// True if a cached full-resolution original already exists for this hash.
59pub fn original_exists(hash: &str) -> bool {
60    original_path(hash).exists()
61}
62
63/// Length of a BLAKE3 hex digest (32 bytes -> 64 hex chars), every
64/// content-hash-keyed cache filename starts with exactly this many hex
65/// chars, followed by `_` and a purpose-specific suffix
66/// (`_240.jpg`, `_face3_140.jpg`, `_original.jpg`, `_original.tmp1234`, ...).
67const HASH_HEX_LEN: usize = 64;
68
69/// Extracts the leading content hash from a cache filename (the `.jpg`
70/// files this module writes, `thumb_path`, `face_thumb_path`,
71/// `original_path`), or `None` if `filename` doesn't match that shape.
72/// Used by `videre prune` to find cache entries whose hash no longer has a
73/// surviving `file_hashes` row, without hardcoding every suffix pattern this
74/// module can produce. Deliberately does NOT match `.tmp*` scratch files
75/// (see `thumb_tmp_path`/`original_tmp_path`), those may be actively being
76/// written by a concurrently running `videre watch`, and reusing this same
77/// hash-existence check against them could delete an in-flight write for a
78/// hash that is still perfectly valid.
79pub fn hash_from_cache_filename(filename: &str) -> Option<&str> {
80    if !filename.ends_with(".jpg") {
81        return None;
82    }
83    let bytes = filename.as_bytes();
84    if bytes.len() <= HASH_HEX_LEN || bytes[HASH_HEX_LEN] != b'_' {
85        return None;
86    }
87    let hash = &filename[..HASH_HEX_LEN];
88    if hash.bytes().all(|b| b.is_ascii_hexdigit()) {
89        Some(hash)
90    } else {
91        None
92    }
93}
94
95/// Scratch path for writing a full-res original before it's atomically
96/// renamed into place at `original_path`, mirrors `thumb_tmp_path`'s
97/// same-filesystem-atomic-rename pattern and process-id disambiguation.
98pub fn original_tmp_path(hash: &str) -> PathBuf {
99    cache_dir().join(format!("{hash}_original.tmp{}", std::process::id()))
100}
101
102/// Path to a scratch file for writing a thumbnail before it's atomically
103/// renamed into place at `thumb_path`. Lives in the same directory as the
104/// final file so the rename is same-filesystem (and thus atomic on POSIX).
105/// Includes the current process ID so concurrent writers (e.g. two
106/// `videre watch` instances, or a leftover file from a crashed process) don't
107/// collide on the same temp name.
108pub fn thumb_tmp_path(hash: &str, size: u32) -> PathBuf {
109    cache_dir().join(format!("{hash}_{size}.tmp{}", std::process::id()))
110}
111
112fn dirs_cache_dir() -> PathBuf {
113    std::env::var_os("HOME")
114        .map(|home| PathBuf::from(home).join(".cache"))
115        .unwrap_or_else(|| PathBuf::from(".cache"))
116}
117
118/// One-time migration from the pre-rename cache location. Thumbnails are
119/// content-hash keyed and expensive to regenerate for large HEIC libraries,
120/// so a rename of the tool should not orphan them. Only fires when the old
121/// dir exists and the new one does not; a plain rename, so it is atomic on
122/// the same filesystem and a no-op on any error (cache regenerates lazily).
123pub fn migrate_legacy_dupe_cache() {
124    let old = dirs_cache_dir().join("dupe").join("thumbnails");
125    let new = cache_dir();
126    migrate_dir(&old, &new);
127}
128
129fn migrate_dir(old: &std::path::Path, new: &std::path::Path) {
130    if old.is_dir() && !new.exists() {
131        if let Some(parent) = new.parent() {
132            let _ = std::fs::create_dir_all(parent);
133        }
134        let _ = std::fs::rename(old, new);
135        if let Some(old_parent) = old.parent() {
136            let _ = std::fs::remove_dir(old_parent); // only removes if empty
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn thumb_path_is_keyed_by_hash_and_size() {
147        let p1 = thumb_path("abc123", 240);
148        let p2 = thumb_path("abc123", 1200);
149        let p3 = thumb_path("def456", 240);
150        assert_ne!(p1, p2, "different sizes must produce different paths");
151        assert_ne!(p1, p3, "different hashes must produce different paths");
152        assert!(p1.to_string_lossy().contains("abc123_240.jpg"));
153    }
154
155    #[test]
156    fn thumb_exists_false_for_missing_file() {
157        assert!(!thumb_exists("nonexistent-hash-xyz", 240));
158    }
159
160    #[test]
161    fn cache_dir_is_under_videre() {
162        assert!(cache_dir().to_string_lossy().contains("videre"));
163        assert!(!cache_dir().to_string_lossy().contains("/dupe/"));
164    }
165
166    #[test]
167    fn face_thumb_path_is_keyed_by_hash_face_id_and_size() {
168        let p1 = face_thumb_path("abc123", 1, 140);
169        let p2 = face_thumb_path("abc123", 2, 140);
170        let p3 = face_thumb_path("def456", 1, 140);
171        assert_ne!(p1, p2, "different face ids must produce different paths");
172        assert_ne!(p1, p3, "different hashes must produce different paths");
173        assert!(p1.to_string_lossy().contains("abc123_face1_140.jpg"));
174    }
175
176    #[test]
177    fn face_thumb_exists_false_for_missing_file() {
178        assert!(!face_thumb_exists("nonexistent-hash-xyz", 99, 140));
179    }
180
181    #[test]
182    fn original_path_is_keyed_by_hash() {
183        let p1 = original_path("abc123");
184        let p2 = original_path("def456");
185        assert_ne!(p1, p2);
186        assert!(p1.to_string_lossy().contains("abc123_original.jpg"));
187    }
188
189    #[test]
190    fn original_exists_false_for_missing_file() {
191        assert!(!original_exists("nonexistent-hash-xyz"));
192    }
193
194    #[test]
195    fn original_tmp_path_differs_from_final_path_and_is_keyed_by_hash() {
196        let tmp = original_tmp_path("abc123");
197        let final_path = original_path("abc123");
198        assert_ne!(tmp, final_path);
199        assert!(tmp.to_string_lossy().contains("abc123_original.tmp"));
200    }
201
202    fn test_hash(seed: &str) -> String {
203        seed.repeat((HASH_HEX_LEN / seed.len()) + 1)[..HASH_HEX_LEN].to_string()
204    }
205
206    #[test]
207    fn hash_from_cache_filename_parses_thumb_path() {
208        let h1 = test_hash("0123456789abcdef");
209        assert_eq!(
210            hash_from_cache_filename(&format!("{h1}_240.jpg")),
211            Some(h1.as_str())
212        );
213        assert_eq!(
214            hash_from_cache_filename(&format!("{h1}_1200.jpg")),
215            Some(h1.as_str())
216        );
217    }
218
219    #[test]
220    fn hash_from_cache_filename_parses_face_thumb_path() {
221        let h1 = test_hash("0123456789abcdef");
222        assert_eq!(
223            hash_from_cache_filename(&format!("{h1}_face3_140.jpg")),
224            Some(h1.as_str())
225        );
226    }
227
228    #[test]
229    fn hash_from_cache_filename_parses_original_path() {
230        let h1 = test_hash("0123456789abcdef");
231        assert_eq!(
232            hash_from_cache_filename(&format!("{h1}_original.jpg")),
233            Some(h1.as_str())
234        );
235    }
236
237    #[test]
238    fn hash_from_cache_filename_distinguishes_different_hashes() {
239        let h2 = test_hash("fedcba9876543210");
240        assert_eq!(
241            hash_from_cache_filename(&format!("{h2}_240.jpg")),
242            Some(h2.as_str())
243        );
244    }
245
246    #[test]
247    fn hash_from_cache_filename_rejects_tmp_files() {
248        let h1 = test_hash("0123456789abcdef");
249        assert_eq!(
250            hash_from_cache_filename(&format!("{h1}_original.tmp1234")),
251            None
252        );
253        assert_eq!(hash_from_cache_filename(&format!("{h1}_240.tmp5678")), None);
254    }
255
256    #[test]
257    fn hash_from_cache_filename_rejects_too_short_or_malformed_names() {
258        assert_eq!(hash_from_cache_filename("short_240.jpg"), None);
259        assert_eq!(hash_from_cache_filename(".DS_Store"), None);
260        let non_hex_64 = "g".repeat(HASH_HEX_LEN);
261        assert_eq!(
262            hash_from_cache_filename(&format!("{non_hex_64}_240.jpg")),
263            None
264        );
265    }
266
267    #[test]
268    fn migrate_dir_moves_old_into_place() {
269        let tmp = std::env::temp_dir().join(format!("thumb_migrate_{}", std::process::id()));
270        let old = tmp.join("old_cache");
271        let new = tmp.join("new_cache");
272        std::fs::create_dir_all(&old).unwrap();
273        std::fs::write(old.join("h_240.jpg"), b"x").unwrap();
274        migrate_dir(&old, &new);
275        assert!(
276            new.join("h_240.jpg").exists(),
277            "cached file must survive migration"
278        );
279        assert!(!old.exists(), "old dir must be gone after migration");
280        let _ = std::fs::remove_dir_all(&tmp);
281    }
282}