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