Skip to main content

videre_core/
import_providers.rs

1//! Which import sources exist, and how to recognise them.
2//!
3//! Deliberately data rather than mechanism: adding a provider should mean
4//! adding a row here, never editing the ladder in `import_location`.
5
6use crate::import_location::{LayoutProbe, Rung};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug)]
10pub struct ProviderDescriptor {
11    pub id: &'static str,
12    pub display: &'static str,
13    /// Which rung this provider starts on. Only Lightroom starts on the
14    /// database, because its files live in arbitrary user folders and there is
15    /// no layout to fall back to.
16    pub default_rung: Rung,
17    pub layouts: &'static [LayoutProbe],
18    /// Globs, relative to a search root, that find this provider's library.
19    pub package_globs: &'static [&'static str],
20    /// Structural test: is this path a library of this kind?
21    pub detect: fn(&Path) -> bool,
22}
23
24/// Apple fans `originals/` out into directories named `0`-`F`, one per leading
25/// hex character of the asset UUID, to keep any single directory small.
26///
27/// This is the signature that survives when only the originals folder is kept:
28/// a backup copy has no `database/Photos.sqlite` to detect by, and a folder
29/// merely *named* "originals" is common in ordinary photo workflows. Requiring
30/// most of the fan-out present, and outnumbering anything else, separates the
31/// two without a catalog. Found against a real 399GB backup of 70,854 files
32/// that detection missed entirely.
33fn has_hex_fanout(dir: &Path) -> bool {
34    let Ok(entries) = std::fs::read_dir(dir) else {
35        return false;
36    };
37    let (mut hex, mut other) = (0usize, 0usize);
38    for e in entries.filter_map(|e| e.ok()) {
39        if !e.path().is_dir() {
40            continue;
41        }
42        let name = e.file_name();
43        let name = name.to_string_lossy();
44        // Tolerates a partial copy: 8 of 16 is still unmistakably the layout.
45        if name.len() == 1 && name.chars().all(|c| c.is_ascii_hexdigit()) {
46            hex += 1;
47        } else {
48            other += 1;
49        }
50    }
51    hex >= 8 && hex > other
52}
53
54fn is_apple_photos(p: &Path) -> bool {
55    // Structure, not name: the folder may be called anything, and the
56    // originals directory has been spelled three ways across generations.
57    let has_db = p.join("database/Photos.sqlite").exists();
58    let has_originals = ["originals", "Masters", "Originals"]
59        .iter()
60        .any(|d| p.join(d).is_dir());
61    (has_db && has_originals)
62        || p.extension().is_some_and(|e| e == "photoslibrary")
63        || (p.join("Masters").is_dir() && p.join("Database").is_dir())
64        // A kept-originals backup: no catalog beside it, so the fan-out is the
65        // only evidence. Both the folder holding it and the folder itself.
66        || ["originals", "Masters", "Originals"]
67            .iter()
68            .any(|d| has_hex_fanout(&p.join(d)))
69        || has_hex_fanout(p)
70}
71
72fn is_lightroom(p: &Path) -> bool {
73    p.extension().is_some_and(|e| e == "lrcat")
74        || std::fs::read_dir(p).is_ok_and(|mut d| {
75            d.any(|e| {
76                e.ok()
77                    .is_some_and(|e| e.path().extension().is_some_and(|x| x == "lrcat"))
78            })
79        })
80}
81
82/// True when `dir` directly contains at least one Takeout sidecar.
83fn has_sidecar(dir: &Path) -> bool {
84    std::fs::read_dir(dir).is_ok_and(|d| {
85        d.filter_map(|e| e.ok()).any(|e| {
86            e.file_name()
87                .to_string_lossy()
88                .contains(".supplemental-metadat")
89        })
90    })
91}
92
93fn is_takeout(p: &Path) -> bool {
94    if p.join("Google Photos").is_dir() || p.join("Takeout/Google Photos").is_dir() {
95        return true;
96    }
97    if has_sidecar(p) {
98        return true;
99    }
100    // Also look one level down. A real export's `Google Photos/` folder holds
101    // only album directories, with the sidecars inside them, so pointing at it
102    // directly (a natural thing to do) found nothing when this checked the
103    // immediate directory alone. Found against a real 36GB Takeout export.
104    std::fs::read_dir(p).is_ok_and(|d| {
105        d.filter_map(|e| e.ok())
106            .filter(|e| e.path().is_dir())
107            .take(64) // bounded: enough to recognise an export, never a deep walk
108            .any(|e| has_sidecar(&e.path()))
109    })
110}
111
112pub static PROVIDERS: &[ProviderDescriptor] = &[
113    ProviderDescriptor {
114        id: "apple-photos",
115        display: "Apple Photos / iPhoto",
116        default_rung: Rung::FolderLayout,
117        layouts: &[
118            // Newest spelling first. Modern Photos requires the database
119            // sibling so a stray `originals/` folder cannot match.
120            LayoutProbe {
121                dir_names: &["originals"],
122                requires_sibling: Some("database/Photos.sqlite"),
123            },
124            LayoutProbe {
125                dir_names: &["Masters", "Originals"],
126                requires_sibling: None,
127            },
128            LayoutProbe {
129                dir_names: &["originals"],
130                requires_sibling: None,
131            },
132        ],
133        package_globs: &[
134            "*.photoslibrary",
135            "*.photolibrary",
136            "*.migratedphotolibrary",
137        ],
138        detect: is_apple_photos,
139    },
140    ProviderDescriptor {
141        id: "lightroom",
142        display: "Adobe Lightroom Classic",
143        default_rung: Rung::Database,
144        layouts: &[],
145        package_globs: &["*.lrcat", "Lightroom/*.lrcat"],
146        detect: is_lightroom,
147    },
148    ProviderDescriptor {
149        id: "google-takeout",
150        display: "Google Takeout",
151        default_rung: Rung::FolderLayout,
152        layouts: &[LayoutProbe {
153            dir_names: &["Google Photos"],
154            requires_sibling: None,
155        }],
156        package_globs: &["Takeout", "Takeout*"],
157        detect: is_takeout,
158    },
159];
160
161/// The first provider whose structural test matches, if any.
162pub fn detect(path: &Path) -> Option<&'static ProviderDescriptor> {
163    PROVIDERS.iter().find(|p| (p.detect)(path))
164}
165
166#[derive(Debug)]
167pub struct Candidate {
168    pub path: PathBuf,
169    pub provider: &'static ProviderDescriptor,
170}
171
172/// Default places to look, per platform.
173///
174/// Chosen over any search index because it is the only approach that works
175/// everywhere: Spotlight is macOS-only and invisible to users who have narrowed
176/// it, and Linux has no dependable equivalent. Measured at 81 ms for the full
177/// macOS set, since each entry is one `read_dir` of one directory.
178pub fn default_search_roots() -> Vec<PathBuf> {
179    let home = std::env::var_os("HOME").map(PathBuf::from);
180    let mut roots = Vec::new();
181    if let Some(h) = home {
182        roots.push(h.join("Pictures"));
183        roots.push(h.join("Pictures/Lightroom"));
184        roots.push(h.join("Documents"));
185        roots.push(h.join("Desktop"));
186        roots.push(h.join("Downloads"));
187    }
188    if cfg!(target_os = "macos") {
189        if let Ok(vols) = std::fs::read_dir("/Volumes") {
190            for v in vols.filter_map(|e| e.ok()) {
191                roots.push(v.path());
192                roots.push(v.path().join("Pictures"));
193            }
194        }
195    }
196    roots
197}
198
199/// One level of `read_dir` per root, testing each entry structurally.
200pub fn discover_in(roots: &[PathBuf]) -> Vec<Candidate> {
201    let mut out = Vec::new();
202    for root in roots {
203        let Ok(entries) = std::fs::read_dir(root) else {
204            continue; // a missing root is normal, not an error
205        };
206        for entry in entries.filter_map(|e| e.ok()) {
207            if let Some(provider) = detect(&entry.path()) {
208                out.push(Candidate {
209                    path: entry.path(),
210                    provider,
211                });
212            }
213        }
214    }
215    out
216}
217
218pub fn discover() -> Vec<Candidate> {
219    discover_in(&default_search_roots())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::fs;
226    use tempfile::tempdir;
227
228    #[test]
229    fn every_provider_has_a_unique_id_and_a_display_name() {
230        let mut ids: Vec<&str> = PROVIDERS.iter().map(|p| p.id).collect();
231        let before = ids.len();
232        ids.sort_unstable();
233        ids.dedup();
234        assert_eq!(ids.len(), before, "provider ids must be unique");
235        assert!(PROVIDERS.iter().all(|p| !p.display.is_empty()));
236    }
237
238    #[test]
239    fn only_lightroom_starts_on_the_database_rung() {
240        for p in PROVIDERS {
241            let expected = if p.id == "lightroom" {
242                Rung::Database
243            } else {
244                Rung::FolderLayout
245            };
246            assert_eq!(
247                p.default_rung, expected,
248                "{} has the wrong default rung",
249                p.id
250            );
251        }
252    }
253
254    #[test]
255    fn detects_a_modern_apple_library_by_structure_not_name() {
256        let d = tempdir().unwrap();
257        let lib = d.path().join("Some Odd Name");
258        fs::create_dir_all(lib.join("originals")).unwrap();
259        fs::create_dir_all(lib.join("database")).unwrap();
260        fs::write(lib.join("database/Photos.sqlite"), b"").unwrap();
261        assert_eq!(detect(&lib).map(|p| p.id), Some("apple-photos"));
262    }
263
264    #[test]
265    fn a_bare_originals_folder_is_not_an_apple_library() {
266        let d = tempdir().unwrap();
267        fs::create_dir_all(d.path().join("originals")).unwrap();
268        assert_eq!(
269            detect(d.path()).map(|p| p.id),
270            None,
271            "needs the database sibling too"
272        );
273    }
274
275    #[test]
276    fn detects_lightroom_by_catalog_file() {
277        let d = tempdir().unwrap();
278        fs::write(d.path().join("Catalog.lrcat"), b"").unwrap();
279        assert_eq!(detect(d.path()).map(|p| p.id), Some("lightroom"));
280    }
281
282    #[test]
283    fn detects_takeout_by_sidecar_presence() {
284        let d = tempdir().unwrap();
285        fs::write(d.path().join("a.jpg"), b"").unwrap();
286        fs::write(d.path().join("a.jpg.supplemental-metadata.json"), b"{}").unwrap();
287        assert_eq!(detect(d.path()).map(|p| p.id), Some("google-takeout"));
288    }
289
290    #[test]
291    fn finds_a_library_in_a_search_root() {
292        let d = tempdir().unwrap();
293        let pics = d.path().join("Pictures");
294        let lib = pics.join("Photos Library.photoslibrary");
295        fs::create_dir_all(lib.join("originals")).unwrap();
296        fs::create_dir_all(lib.join("database")).unwrap();
297        fs::write(lib.join("database/Photos.sqlite"), b"").unwrap();
298
299        let found = discover_in(&[pics]);
300        assert_eq!(found.len(), 1);
301        assert_eq!(found[0].provider.id, "apple-photos");
302        assert_eq!(found[0].path, lib);
303    }
304
305    #[test]
306    fn finds_several_libraries_and_reports_all() {
307        let d = tempdir().unwrap();
308        let pics = d.path().join("Pictures");
309        let lib = pics.join("A.photoslibrary");
310        fs::create_dir_all(lib.join("originals")).unwrap();
311        fs::create_dir_all(lib.join("database")).unwrap();
312        fs::write(lib.join("database/Photos.sqlite"), b"").unwrap();
313        fs::create_dir_all(pics.join("Lightroom")).unwrap();
314        fs::write(pics.join("Lightroom/Catalog.lrcat"), b"").unwrap();
315
316        let found = discover_in(&[pics]);
317        assert_eq!(
318            found.len(),
319            2,
320            "both libraries must be reported, not the first"
321        );
322    }
323
324    #[test]
325    fn a_missing_search_root_is_skipped_silently() {
326        let found = discover_in(&[std::path::PathBuf::from("/definitely/not/here")]);
327        assert!(found.is_empty());
328    }
329
330    #[test]
331    fn detects_takeout_when_sidecars_are_one_level_down() {
332        // A real export's "Google Photos" folder contains only album
333        // directories; the sidecars live inside them. Pointing straight at it
334        // is a natural thing to do and must be recognised.
335        let d = tempdir().unwrap();
336        let album = d.path().join("Photos from 2019");
337        fs::create_dir_all(&album).unwrap();
338        fs::write(album.join("a.jpg"), b"").unwrap();
339        fs::write(album.join("a.jpg.supplemental-metadata.json"), b"{}").unwrap();
340        assert_eq!(detect(d.path()).map(|p| p.id), Some("google-takeout"));
341    }
342
343    #[test]
344    #[test]
345    fn detects_a_kept_originals_backup_with_no_catalog_beside_it() {
346        // A real 399GB backup: someone copied only `originals/` off a Photos
347        // library, so there is no database to detect by. The hex fan-out is
348        // the whole signature.
349        let d = tempdir().unwrap();
350        let orig = d.path().join("originals");
351        for name in "0123456789ABCDEF".chars() {
352            fs::create_dir_all(orig.join(name.to_string())).unwrap();
353        }
354        assert_eq!(detect(d.path()).map(|p| p.id), Some("apple-photos"));
355        // ...and when pointed straight at the originals folder itself.
356        assert_eq!(detect(&orig).map(|p| p.id), Some("apple-photos"));
357    }
358
359    #[test]
360    fn a_folder_merely_named_originals_is_not_an_apple_library() {
361        // The guard that keeps the above from firing on ordinary workflows:
362        // photographers keep an `originals/` folder all the time.
363        let d = tempdir().unwrap();
364        let orig = d.path().join("originals");
365        fs::create_dir_all(orig.join("2024 Holiday")).unwrap();
366        fs::create_dir_all(orig.join("Wedding")).unwrap();
367        fs::write(orig.join("a.jpg"), b"").unwrap();
368        assert!(detect(d.path()).is_none());
369    }
370
371    #[test]
372    fn an_ordinary_folder_of_photos_detects_as_nothing() {
373        let d = tempdir().unwrap();
374        fs::write(d.path().join("a.jpg"), b"").unwrap();
375        fs::write(d.path().join("b.jpg"), b"").unwrap();
376        assert!(
377            detect(d.path()).is_none(),
378            "a plain folder needs videre scan, not import"
379        );
380    }
381}