mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
perf(core): find Takeout sidecars by prefix lookup, not by ranking all
Each photo was ranked against every sidecar in its folder, so a folder of 10,000 photos took about 30 s to scan. The sidecars are now indexed by stem and each photo is ranked only against the stems a few range lookups return: 0.1 s for the same folder, with the same matches (B235).
This commit is contained in:
1 parent
51800c77d7
commit
60a5a98174
2 files changed
+165
-6
No files matched your search
@@ -346,6 +346,11 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
.insert(file.to_string(), body);
|
.insert(file.to_string(), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let indexes: HashMap<&str, SidecarIndex<'_>> = per_folder_sidecars
|
||||||
|
.iter()
|
||||||
|
.map(|(folder, in_folder)| (folder.as_str(), SidecarIndex::new(in_folder)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut scan = TakeoutScan::default();
|
let mut scan = TakeoutScan::default();
|
||||||
let mut seen_albums: HashMap<String, ()> = HashMap::new();
|
let mut seen_albums: HashMap<String, ()> = HashMap::new();
|
||||||
|
|
||||||
@@ -364,9 +369,9 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let found = per_folder_sidecars
|
let found = indexes
|
||||||
.get(&folder)
|
.get(folder.as_str())
|
||||||
.and_then(|in_folder| find_sidecar(&file_name, in_folder));
|
.and_then(|index| find_sidecar(&file_name, index));
|
||||||
// An edited copy keeps its own name: the title is its original's.
|
// An edited copy keeps its own name: the title is its original's.
|
||||||
let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY);
|
let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY);
|
||||||
let sidecar = found
|
let sidecar = found
|
||||||
@@ -640,19 +645,98 @@ const SIDECAR_NAME_CAP: usize = 51;
|
|||||||
/// sidecar. Its date is the original's, but its name is its own.
|
/// sidecar. Its date is the original's, but its name is its own.
|
||||||
const EDITED_COPY: u8 = 4;
|
const EDITED_COPY: u8 = 4;
|
||||||
|
|
||||||
|
/// The sidecars of one folder by their `.json`-stripped stem, so a photo is
|
||||||
|
/// ranked only against those that can match it.
|
||||||
|
///
|
||||||
|
/// Every rule in [`sidecar_rank`] needs the stem to start with a prefix of the
|
||||||
|
/// photo name that the name itself determines, and a sorted map finds the
|
||||||
|
/// stems with a given prefix as one range. Ranking every photo against every
|
||||||
|
/// sidecar instead took 27 s for a folder of 10,000 photos.
|
||||||
|
struct SidecarIndex<'a> {
|
||||||
|
by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SidecarIndex<'a> {
|
||||||
|
fn new(in_folder: &'a BTreeMap<String, &'a Vec<u8>>) -> Self {
|
||||||
|
let mut by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>> = BTreeMap::new();
|
||||||
|
for (name, body) in in_folder {
|
||||||
|
by_stem
|
||||||
|
.entry(strip_json_suffix(name))
|
||||||
|
.or_default()
|
||||||
|
.push((name.as_str(), *body));
|
||||||
|
}
|
||||||
|
Self { by_stem }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add every sidecar whose stem starts with `prefix` to `out`.
|
||||||
|
fn starting_with(&self, prefix: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
|
||||||
|
let from = (
|
||||||
|
std::ops::Bound::Included(prefix),
|
||||||
|
std::ops::Bound::Unbounded,
|
||||||
|
);
|
||||||
|
for (stem, sidecars) in self.by_stem.range::<str, _>(from) {
|
||||||
|
if !stem.starts_with(prefix) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.extend(sidecars.iter().copied());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add every sidecar whose stem is `head`, or starts with `head` and a `.`:
|
||||||
|
/// those that have `head` as a boundary ([`suffix_boundaries`]).
|
||||||
|
fn bounded_by(&self, head: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
|
||||||
|
if let Some(sidecars) = self.by_stem.get(head) {
|
||||||
|
out.extend(sidecars.iter().copied());
|
||||||
|
}
|
||||||
|
self.starting_with(&format!("{head}."), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every sidecar [`sidecar_rank`] could match `file_name` to, by name. A
|
||||||
|
/// superset: each one is still ranked.
|
||||||
|
fn candidates(&self, file_name: &str) -> BTreeMap<&'a str, &'a Vec<u8>> {
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
// Rules 0 and 2: the stem starts with the name, or with its swapped form.
|
||||||
|
self.starting_with(file_name, &mut out);
|
||||||
|
if let Some(swapped) = swap_duplicate_marker(file_name) {
|
||||||
|
self.starting_with(&swapped, &mut out);
|
||||||
|
}
|
||||||
|
// Rule 1: the stem of `A(N).B` starts with `A.B(N)`, so with `A.`.
|
||||||
|
for (paren, _) in file_name.match_indices('(') {
|
||||||
|
self.starting_with(&format!("{}.", &file_name[..paren]), &mut out);
|
||||||
|
}
|
||||||
|
// Rule 3: a boundary of the stem is a prefix of the name.
|
||||||
|
for end in file_name
|
||||||
|
.char_indices()
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.skip(1)
|
||||||
|
.chain([file_name.len()])
|
||||||
|
{
|
||||||
|
self.bounded_by(&file_name[..end], &mut out);
|
||||||
|
}
|
||||||
|
// Rule 4: `<original>-<word>.<ext>` has `<original>.<ext>` as a boundary.
|
||||||
|
if let Some((_, extension)) = file_name.rsplit_once('.') {
|
||||||
|
for (dash, _) in file_name.match_indices('-') {
|
||||||
|
self.bounded_by(&format!("{}.{extension}", &file_name[..dash]), &mut out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the sidecar belonging to `file_name` among the JSON files of its folder,
|
/// Find the sidecar belonging to `file_name` among the JSON files of its folder,
|
||||||
/// with the rank it matched at.
|
/// with the rank it matched at.
|
||||||
///
|
///
|
||||||
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
||||||
/// unknown trailing suffix, a prefix match for the case where truncation cut
|
/// unknown trailing suffix, a prefix match for the case where truncation cut
|
||||||
/// into the photo name itself, and last an edited copy's original. The
|
/// into the photo name itself, and last an edited copy's original. The
|
||||||
/// best-ranked candidate in the folder wins.
|
/// best-ranked candidate in the folder wins; of equal ranks, the first by
|
||||||
|
/// sidecar name.
|
||||||
fn find_sidecar<'a>(
|
fn find_sidecar<'a>(
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
index: &SidecarIndex<'a>,
|
||||||
) -> Option<((u8, usize), &'a Vec<u8>)> {
|
) -> Option<((u8, usize), &'a Vec<u8>)> {
|
||||||
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
||||||
for (sidecar_name, body) in in_folder {
|
for (sidecar_name, body) in index.candidates(file_name) {
|
||||||
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -1108,6 +1192,57 @@ mod tests {
|
|||||||
assert_eq!(scan.photos[1].capture_time, Some(7));
|
assert_eq!(scan.photos[1].capture_time, Some(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The index looks only at the sidecars that can match, and must find the
|
||||||
|
/// same one that ranking every sidecar in the folder finds.
|
||||||
|
#[test]
|
||||||
|
fn the_sidecar_index_finds_what_ranking_every_sidecar_finds() {
|
||||||
|
let long = "A_very_long_holiday_photo_name_from_the_beach_2019";
|
||||||
|
let names = [
|
||||||
|
"IMG_1.jpg".to_string(),
|
||||||
|
"IMG_12.jpg".to_string(),
|
||||||
|
"IMG_1(1).jpg".to_string(),
|
||||||
|
"IMG_1-edited.jpg".to_string(),
|
||||||
|
"IMG_1-bearbeitet.jpg".to_string(),
|
||||||
|
"my.photo(2).jpg".to_string(),
|
||||||
|
"x(1).jpg".to_string(),
|
||||||
|
"Ölbild.png".to_string(),
|
||||||
|
"PXL_20230101_101010.mp4".to_string(),
|
||||||
|
format!("{long}.jpg"),
|
||||||
|
format!("{long}_2.jpg"),
|
||||||
|
"nothing.heic".to_string(),
|
||||||
|
];
|
||||||
|
let sidecar_names = [
|
||||||
|
"IMG_1.jpg.supplemental-metadata.json".to_string(),
|
||||||
|
"IMG_1.jpg(1).supplemental-metadata.json".to_string(),
|
||||||
|
"IMG_12.jpg.ergänzende-Metadaten.json".to_string(),
|
||||||
|
"my.photo.jpg(2).json".to_string(),
|
||||||
|
"x.jpg(1).json".to_string(),
|
||||||
|
"Ölbild.png.suppl.json".to_string(),
|
||||||
|
"PXL_20230101_101010.mp4.JSON".to_string(),
|
||||||
|
format!("{}.json", &long[..44]),
|
||||||
|
format!("{long}.jp.json"),
|
||||||
|
"IMG.json".to_string(),
|
||||||
|
"unrelated.json".to_string(),
|
||||||
|
];
|
||||||
|
let bodies: Vec<Vec<u8>> = (0..sidecar_names.len())
|
||||||
|
.map(|i| i.to_string().into_bytes())
|
||||||
|
.collect();
|
||||||
|
let in_folder: BTreeMap<String, &Vec<u8>> =
|
||||||
|
sidecar_names.iter().cloned().zip(bodies.iter()).collect();
|
||||||
|
let index = SidecarIndex::new(&in_folder);
|
||||||
|
for name in &names {
|
||||||
|
let mut every: Option<((u8, usize), &Vec<u8>)> = None;
|
||||||
|
for (sidecar_name, body) in &in_folder {
|
||||||
|
if let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), name)
|
||||||
|
&& every.as_ref().is_none_or(|(current, _)| rank < *current)
|
||||||
|
{
|
||||||
|
every = Some((rank, *body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(find_sidecar(name, &index), every, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidecar_title_replaces_a_mangled_on_disk_name() {
|
fn sidecar_title_replaces_a_mangled_on_disk_name() {
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B235](#b235--scanning-a-large-takeout-folder-takes-half-a-minute) | Scanning a large Takeout folder takes half a minute |
|
||||||
| [B234](#b234--any-folder-can-become-a-sync-root-the-home-folder-included) | Any folder can become a sync root, the home folder included |
|
| [B234](#b234--any-folder-can-become-a-sync-root-the-home-folder-included) | Any folder can become a sync root, the home folder included |
|
||||||
| [B233](#b233--signing-out-leaves-the-session-valid-on-proton) | Signing out leaves the session valid on Proton |
|
| [B233](#b233--signing-out-leaves-the-session-valid-on-proton) | Signing out leaves the session valid on Proton |
|
||||||
| [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup |
|
| [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup |
|
||||||
@@ -337,6 +338,29 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B235 — Scanning a large Takeout folder takes half a minute
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.1).
|
||||||
|
**Where:** `SidecarIndex` and `find_sidecar` in `crates/pdfs-core/src/takeout.rs`
|
||||||
|
|
||||||
|
**Repro:** Scan a Google Takeout export with 10,000 photos in one folder, as "Photos from 2019"
|
||||||
|
folders often are.
|
||||||
|
|
||||||
|
**Cause.** Each photo was ranked against every sidecar of its folder, so the scan grew with the
|
||||||
|
square of the folder size: 2,000 photos took 0.8 s, 10,000 took 27–32 s before the import
|
||||||
|
showed anything.
|
||||||
|
|
||||||
|
**Fix.** The sidecars of a folder are kept in a sorted map by stem. Every matching rule needs the
|
||||||
|
stem to start with a prefix that the photo name determines, so each photo is ranked only against
|
||||||
|
the stems found by a few range lookups. On the same 10,000 photos the scan takes 0.1 s, with the
|
||||||
|
same matches: ties still go to the first sidecar by name.
|
||||||
|
|
||||||
|
**Test:** `takeout::tests::the_sidecar_index_finds_what_ranking_every_sidecar_finds`, which
|
||||||
|
compares the index with ranking every sidecar, across every rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B234 — Any folder can become a sync root, the home folder included
|
### B234 — Any folder can become a sync root, the home folder included
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
|||||||
Reference in new issue
Block a user