feat(core,fuse,gui): show how far photo locations have been read

The first timeline refresh after the Places update reads the metadata of
the whole library, and until it is done the Places view has nothing to show
and no sign that anything is happening.

The refresh now records how many photos it has resolved out of how many,
counting photos read by an earlier pass as done. Response::Places carries
it as mapping (absent from older daemons, which parse as None). The Places
view shows "Mapping photos: 120 of 480" with a bar while it is set, asks
again every two seconds, and loads the new places once the pass ends. An
empty listing during the pass says the photos are being mapped rather than
that there are no places.
This commit is contained in:
nvrl committed 2026-09-28 02:12:59 +02:00
1 parent 4ee2aed29b
commit 8749d6c2c6
17 files changed
+2064 -1747

No files matched your search

+38 -3
View File
@@ -1462,6 +1462,14 @@ pub struct PlaceInfo {
pub cover: PhotoItem,
}
/// How many of the timeline's photos a running refresh has read the metadata
/// (and so the location) of, out of how many. Part of [`Response::Places`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct MappingProgress {
pub done: usize,
pub total: usize,
}
/// One photo in a [`Request::PhotosTimeline`] page.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoItem {
@@ -1823,8 +1831,14 @@ pub enum Response {
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
/// first. Each set lists the copy worth keeping first.
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]).
Places { items: Vec<PlaceInfo> },
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
/// and how far a running timeline refresh has got reading photo locations
/// — `None` when none is reading any.
Places {
items: Vec<PlaceInfo>,
#[serde(default)]
mapping: Option<MappingProgress>,
},
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
@@ -2537,12 +2551,33 @@ mod tests {
let line = r#"{"Places":{"items":[{"id":2950159,"name":"Berlin","country":"DE","photo_count":2,
"cover":{"uid":"vol~b2","capture_time":1700000000,"thumb_path":null,"name":"b2.jpg","ratio":null,"no_thumb":false}}]}}"#;
let Response::Places { items } = serde_json::from_str(line).unwrap() else {
let Response::Places { items, mapping } = serde_json::from_str(line).unwrap() else {
panic!("not a place listing");
};
assert_eq!(items.len(), 1);
assert_eq!((items[0].id, items[0].country.as_str()), (2950159, "DE"));
assert_eq!(items[0].cover.uid, "vol~b2");
// A daemon from before the progress field still parses: no mapping.
assert_eq!(mapping, None);
let reply = Response::Places {
items: Vec::new(),
mapping: Some(MappingProgress {
done: 120,
total: 480,
}),
};
let line = serde_json::to_string(&reply).unwrap();
let Response::Places { mapping, .. } = serde_json::from_str(&line).unwrap() else {
panic!("not a place listing");
};
assert_eq!(
mapping,
Some(MappingProgress {
done: 120,
total: 480
})
);
}
/// The sharing and devices requests must survive the same line-delimited JSON
+4 -1
View File
@@ -466,7 +466,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
Ok(items) => CtlResponse::Places { items },
Ok(items) => CtlResponse::Places {
items,
mapping: core.timeline_progress(),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PlacePhotos { id, offset, limit }) => {
+4
View File
@@ -550,6 +550,10 @@ struct Core {
/// already running, so a burst of page requests against a stale listing kicks
/// off one refresh rather than one per request.
timeline_refreshing: Arc<AtomicBool>,
/// How far the running timeline refresh has got resolving photo metadata,
/// as `(done, total)` photos; `None` when no refresh is resolving any. The
/// Places view shows it while a library's locations are first read.
timeline_progress: Arc<Mutex<Option<(usize, usize)>>>,
/// The same, for the album listing.
albums_refreshing: Arc<AtomicBool>,
trash_refreshing: Arc<AtomicBool>,
+1
View File
@@ -391,6 +391,7 @@ pub fn mount(
upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
timeline_progress: Arc::new(Mutex::new(None)),
albums_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
trash_progress: Arc::new(tokio::sync::Notify::new()),
+31 -3
View File
@@ -18,8 +18,8 @@ use std::time::{Duration, Instant};
use base64::Engine as _;
use pdfs_core::control::{
FileThumbRequest, PhotoItem, PhotoKind, PhotoThumb, PlaceInfo, ThumbnailBuildStatus,
is_raw_image_name, is_thumbnail_image_name,
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoThumb, PlaceInfo,
ThumbnailBuildStatus, is_raw_image_name, is_thumbnail_image_name,
};
use pdfs_core::db::{self, StoredPhoto};
use pdfs_core::{CoreError, CoreResult};
@@ -601,6 +601,16 @@ fn extract_raw_preview(
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
/// Formats supported by the local thumbnail decoder and exposed by the GUI.
/// Clears the timeline refresh's progress when dropped, so an error or a
/// cancelled refresh doesn't leave a bar standing.
struct ProgressGuard<'a>(&'a parking_lot::Mutex<Option<(usize, usize)>>);
impl Drop for ProgressGuard<'_> {
fn drop(&mut self) {
*self.0.lock() = None;
}
}
/// What a timeline enrichment pass resolves for one photo, beyond what the
/// timeline listing already carries.
#[derive(Clone, Debug)]
@@ -1768,7 +1778,14 @@ impl Core {
// can land in different chunks, and one of them may not resolve at all.
let mut main_of: HashMap<String, String> = HashMap::new();
let mut stopped = false;
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
// Photos read before count as done, so the bar picks up where an
// interrupted pass left off. Cleared however the pass ends.
let skipped = items.len() - uids.len();
let progress = ProgressGuard(&self.timeline_progress);
if !uids.is_empty() {
*progress.0.lock() = Some((skipped, items.len()));
}
for (index, chunk) in uids.chunks(TIMELINE_ENRICH_CHUNK).enumerate() {
// Teardown cancels the runtime under this loop, and every chunk
// still in flight then fails its decrypt task — a burst of
// "skipping undecryptable photo ... task was cancelled" warnings
@@ -1822,7 +1839,10 @@ impl Core {
}
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
}
let done = (skipped + (index + 1) * TIMELINE_ENRICH_CHUNK).min(items.len());
*progress.0.lock() = Some((done, items.len()));
}
drop(progress);
let rows: Vec<db::TimelineRow> = items
.iter()
@@ -1884,6 +1904,14 @@ impl Core {
});
}
/// How far the running timeline refresh has got reading photo metadata, or
/// `None` when no refresh is reading any.
pub(crate) fn timeline_progress(&self) -> Option<MappingProgress> {
self.timeline_progress
.lock()
.map(|(done, total)| MappingProgress { done, total })
}
/// Whether a timeline refresh is running. A front-end that asked for one
/// watches this to know when the new timeline is worth re-reading.
pub(crate) fn timeline_refreshing(&self) -> bool {
+6 -2
View File
@@ -55,8 +55,8 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView};
use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
send,
@@ -558,6 +558,10 @@ fn build_window(app: &adw::Application) {
places_status: gallery_widgets.places_status.clone(),
places_btn: gallery_widgets.places_btn.clone(),
places_loading: Cell::new(false),
places_mapping: gallery_widgets.places_mapping.clone(),
places_mapping_label: gallery_widgets.places_mapping_label.clone(),
places_mapping_bar: gallery_widgets.places_mapping_bar.clone(),
places_poll: RefCell::new(None),
place_count: Cell::new(0),
place: RefCell::new(None),
album: RefCell::new(None),
+29 -1
View File
@@ -69,6 +69,13 @@ pub(crate) struct GalleryState {
pub(crate) places_btn: gtk4::ToggleButton,
/// True while the place listing is in flight.
pub(crate) places_loading: Cell<bool>,
/// The row above the place grid saying how far the daemon has got reading
/// photo locations, its label and bar, and the timer that asks again while
/// it is reading.
pub(crate) places_mapping: gtk4::Box,
pub(crate) places_mapping_label: gtk4::Label,
pub(crate) places_mapping_bar: gtk4::ProgressBar,
pub(crate) places_poll: RefCell<Option<glib::SourceId>>,
/// How many places the grid shows, for the subtitle when back returns there.
pub(crate) place_count: Cell<usize>,
/// The place currently open, paged by [`load_gallery`] like an album.
@@ -416,6 +423,9 @@ pub(crate) struct GalleryWidgets {
pub(crate) places_stack: gtk4::Stack,
pub(crate) places_status: adw::StatusPage,
pub(crate) places_btn: gtk4::ToggleButton,
pub(crate) places_mapping: gtk4::Box,
pub(crate) places_mapping_label: gtk4::Label,
pub(crate) places_mapping_bar: gtk4::ProgressBar,
pub(crate) back: gtk4::Button,
/// The kind toggles and date jump, as one box so an album view can hide them.
pub(crate) filters: gtk4::Box,
@@ -817,6 +827,21 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
places_stack.set_vexpand(true);
places_stack.add_named(&places_scroll, Some("grid"));
places_stack.add_named(&places_status, Some("status"));
// While a refresh reads the library's locations (the first one after an
// update reads all of them), a bar says how far it has got.
let places_mapping_label = gtk4::Label::new(None);
places_mapping_label.add_css_class("dim-label");
let places_mapping_bar = gtk4::ProgressBar::builder()
.hexpand(true)
.valign(gtk4::Align::Center)
.build();
let places_mapping = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
places_mapping.append(&places_mapping_label);
places_mapping.append(&places_mapping_bar);
places_mapping.set_visible(false);
let places_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
places_page.append(&places_mapping);
places_page.append(&places_stack);
let content = gtk4::Stack::new();
content.set_vexpand(true);
@@ -824,7 +849,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
content.add_named(&timeline, Some("timeline"));
content.add_named(&status, Some("status"));
content.add_named(&albums_page, Some("albums"));
content.add_named(&places_stack, Some("places"));
content.add_named(&places_page, Some("places"));
let import_banner = adw::Banner::builder()
.title(gettext("Importing from Google Photos…"))
@@ -898,6 +923,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
places_stack,
places_status,
places_btn,
places_mapping,
places_mapping_label,
places_mapping_bar,
back,
filters,
},
+50 -2
View File
@@ -10,6 +10,9 @@ use crate::*;
/// Edge length in px of a place's cover, the same as an album's.
const COVER_EDGE: i32 = 200;
/// How often the listing is asked for again while photo locations are read.
const MAPPING_POLL: std::time::Duration = std::time::Duration::from_secs(2);
/// Show the Places grid and (re)load it. Called when the Places toggle goes on.
pub(crate) fn show_places(ui: &Rc<Ui>) {
show_place_grid(ui);
@@ -49,8 +52,22 @@ pub(crate) fn load_places(ui: &Rc<Ui>) {
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.gallery.places_loading.set(false);
let mapping = match &result {
Ok(Ok(Response::Places { mapping, .. })) => *mapping,
_ => None,
};
sync_mapping(&ui, mapping);
match result {
Ok(Ok(Response::Places { items })) if items.is_empty() => {
Ok(Ok(Response::Places { items, .. })) if items.is_empty() && mapping.is_some() => {
ui.gallery.place_count.set(0);
places_status(
&ui,
"mark-location-symbolic",
&gettext("Mapping your photos…"),
&gettext("Places show up here once the locations of your photos are read."),
)
}
Ok(Ok(Response::Places { items, .. })) if items.is_empty() => {
ui.gallery.place_count.set(0);
if ui.gallery.place.borrow().is_none() {
ui.gallery.title.set_subtitle("");
@@ -64,7 +81,7 @@ pub(crate) fn load_places(ui: &Rc<Ui>) {
),
)
}
Ok(Ok(Response::Places { items })) => {
Ok(Ok(Response::Places { items, .. })) => {
fill_places(&ui, &items);
ui.gallery.places_stack.set_visible_child_name("grid");
}
@@ -87,6 +104,37 @@ pub(crate) fn load_places(ui: &Rc<Ui>) {
});
}
/// Show how far the daemon has got reading photo locations, and ask again in a
/// moment while it is still reading — the new places land once it is done.
fn sync_mapping(ui: &Rc<Ui>, mapping: Option<MappingProgress>) {
if let Some(source) = ui.gallery.places_poll.take() {
source.remove();
}
ui.gallery.places_mapping.set_visible(mapping.is_some());
let Some(MappingProgress { done, total }) = mapping else {
return;
};
ui.gallery.places_mapping_label.set_label(&gettext_f(
// Translators: {done} and {total} are numbers of photos.
"Mapping photos: {done} of {total}",
&[("done", &thousands(done)), ("total", &thousands(total))],
));
ui.gallery.places_mapping_bar.set_fraction(if total == 0 {
0.0
} else {
done as f64 / total as f64
});
let ui_poll = ui.clone();
let source = glib::timeout_add_local_once(MAPPING_POLL, move || {
ui_poll.gallery.places_poll.take();
let on_places = ui_poll.gallery.content.visible_child_name().as_deref() == Some("places");
if on_places && ui_poll.gallery.place.borrow().is_none() {
load_places(&ui_poll);
}
});
ui.gallery.places_poll.replace(Some(source));
}
/// Replace the grid's cards with `places`, the one with the most photos first.
fn fill_places(ui: &Rc<Ui>, places: &[PlaceInfo]) {
let scroll = ui
+212 -193
View File
File diff suppressed because it is too large. Load diff
+212 -193
View File
File diff suppressed because it is too large. Load diff
+212 -193
View File
File diff suppressed because it is too large. Load diff
+212 -193
View File
File diff suppressed because it is too large. Load diff
+212 -193
View File
File diff suppressed because it is too large. Load diff
+212 -193
View File
File diff suppressed because it is too large. Load diff
+205 -191
View File
File diff suppressed because it is too large. Load diff
+212 -193
View File
File diff suppressed because it is too large. Load diff
+212 -193
View File
File diff suppressed because it is too large. Load diff