feat(core,fuse,gui,cli): filter the photos timeline to photos in no album

A new pill next to the favorites filter narrows the timeline to the photos
no album of ours holds yet, so what is left to file is easy to find. A
RAW+JPEG pair counts as filed when any of its files is in an album.

PhotosTimeline gains an optional not_in_album flag; older front-ends omit it
and get the whole timeline. Album contents are fetched lazily, so the first
filtered page fetches the contents of any album not yet listed. Photos added
to an album while the filter is on leave the timeline at once.
This commit is contained in:
nvrl committed 2026-09-28 02:12:58 +02:00
1 parent 0135c29a4f
commit 5c1da9d7a5
20 files changed
+2304 -1995

No files matched your search

+34
View File
@@ -175,6 +175,40 @@ impl Core {
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
}
/// Make sure every album of ours has its contents on disk, for a filter
/// that asks which photos no album holds. Contents are otherwise fetched
/// the first time an album is opened, so an album never opened would read
/// as empty and its photos as unfiled.
///
/// Shared-with-me albums are skipped: their photos live on the sharer's
/// volume and are never in our timeline. The freshness stamps are no guide
/// here — any album change clears all of them — so only an album whose
/// server count disagrees with an empty local copy is waited for.
pub(crate) fn ensure_album_contents(&self) -> CoreResult<()> {
if self.db.albums_count().map_err(CoreError::from)? == 0
&& !self.rt.block_on(self.refresh_albums())?
{
return Ok(());
}
let albums = self.db.albums_list().map_err(CoreError::from)?;
for album in albums.iter().filter(|a| !a.shared && a.photo_count > 0) {
let stored = self
.db
.album_photos_count(&album.uid)
.map_err(CoreError::from)?;
if stored > 0 {
continue;
}
let Some(uid) = parse_uid(&album.uid) else {
continue;
};
if let Err(error) = self.rt.block_on(self.refresh_album(&uid)) {
warn!(album = %album.uid, %error, "fetching an album's contents failed");
}
}
Ok(())
}
/// Re-enumerate one album's photos and replace its persisted contents.
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
let photos = self.photos();
+9 -1
View File
@@ -440,7 +440,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
kind,
range,
favorites,
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
not_in_album,
}) => match core.photos_timeline(
offset,
clamp_limit(limit),
kind,
range,
favorites,
not_in_album,
) {
Ok(Some(items)) => CtlResponse::Photos {
available: true,
items,
+8 -1
View File
@@ -637,6 +637,7 @@ impl Core {
kind: Option<PhotoKind>,
range: Option<(i64, i64)>,
favorites: bool,
not_in_album: bool,
) -> CoreResult<Option<Vec<PhotoItem>>> {
let count = self.db.photos_count().map_err(CoreError::from)?;
if count == 0 {
@@ -653,10 +654,16 @@ impl Core {
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
self.spawn_timeline_refresh();
}
// The filter is only as good as the album contents on disk, and those
// are fetched lazily, per album opened. Fill in the missing ones before
// the first page; later pages reuse what that fetch stored.
if not_in_album && offset == 0 {
self.ensure_album_contents()?;
}
let page = self
.db
.photos_page(offset, limit, kind, range, favorites)
.photos_page(offset, limit, kind, range, favorites, not_in_album)
.map_err(CoreError::from)?;
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
}