added album to photos, renamed gallery view

This commit is contained in:
nvrl committed 2026-07-31 20:31:25 +02:00
1 parent d988236bfa
commit 2336ad9e87
25 files changed
+1510 -378

No files matched your search

+44
View File
@@ -71,6 +71,20 @@ pub enum Request {
#[serde(default)]
kind: Option<PhotoKind>,
},
/// List the account's photo albums, newest activity first, including the
/// albums other people share with us. Metadata only — an album's cover
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
/// Replies with [`Response::Albums`].
PhotoAlbums,
/// Fetch a page of one album's photos, newest capture first. Same reply
/// shape as [`Request::PhotosTimeline`] ([`Response::Photos`]), so a
/// front-end paints an album with the gallery it already has. `uid` is the
/// album node's uid, as reported by [`Request::PhotoAlbums`].
AlbumPhotos {
uid: String,
offset: usize,
limit: usize,
},
/// Fetch thumbnails for the given photo uids, downloading the ones not
/// already cached (one batched round-trip) and replying with their on-disk
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
@@ -952,6 +966,29 @@ pub struct PhotoMonth {
pub count: usize,
}
/// One album in a [`Response::Albums`] listing.
///
/// An album is a folder node on the photos volume carrying album properties, so
/// it has an ordinary name; everything else here comes from those properties.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AlbumInfo {
/// Album node uid in `volume~link` form — what [`Request::AlbumPhotos`]
/// takes.
pub uid: String,
pub name: String,
/// How many photos the server says the album holds.
pub photo_count: usize,
/// The photo shown as the album's cover, when it has one. Its thumbnail is
/// fetched with [`Request::PhotoThumbs`] like any other photo.
pub cover_uid: Option<String>,
/// Epoch seconds of the last change to the album's contents, when the server
/// reports one. The listing is ordered by it, newest first.
pub last_activity: Option<i64>,
/// True when this album lives on someone else's photos volume — it is shared
/// with us rather than ours.
pub shared: bool,
}
/// One photo in a [`Request::PhotosTimeline`] page.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoItem {
@@ -1107,6 +1144,13 @@ pub enum Response {
#[serde(default)]
counts: Option<(usize, usize, usize)>,
},
/// The account's photo albums (reply to [`Request::PhotoAlbums`]).
/// `available` is false when the account has no photos volume, matching
/// [`Response::Photos`].
Albums {
available: bool,
items: Vec<AlbumInfo>,
},
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
/// newest first.
PhotoMonths { months: Vec<PhotoMonth> },
+238
View File
@@ -0,0 +1,238 @@
//! Photo albums: the album listing and each album's contents, persisted like
//! the timeline so the Albums view opens from disk instead of re-enumerating the
//! photos volume on every launch.
//!
//! An album's photos are stored per album rather than as a join against
//! `photos`: an album shared with us lives on the sharer's volume, so its photos
//! never appear in our own timeline and have nowhere else to record what a
//! thumbnail attempt learned about them.
use rusqlite::params;
use std::collections::HashMap;
use super::{Db, StoredPhoto, THUMB_UNKNOWN};
use crate::Result;
/// One album of the persisted listing, ordered newest-activity-first in `seq`.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredAlbum {
pub uid: String,
pub name: String,
pub photo_count: usize,
/// The photo shown as the album's cover, when the server named one.
pub cover_uid: Option<String>,
/// Epoch seconds of the last change to the album's contents.
pub last_activity: Option<i64>,
/// True when the album lives on someone else's photos volume.
pub shared: bool,
}
impl Db {
/// Replace the album listing wholesale, in the given order.
pub fn albums_replace(&self, albums: &[StoredAlbum]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// Contents of an album that no longer exists (unshared, deleted) go with
// it — otherwise they would linger forever, keyed by an album uid no
// listing mentions again.
{
let kept: std::collections::HashSet<&str> =
albums.iter().map(|a| a.uid.as_str()).collect();
let stale: Vec<String> = {
let mut stmt = tx.prepare("SELECT DISTINCT album_uid FROM album_photos")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.filter_map(|r| r.ok())
.filter(|uid| !kept.contains(uid.as_str()))
.collect()
};
let mut stmt = tx.prepare("DELETE FROM album_photos WHERE album_uid = ?1")?;
for uid in stale {
stmt.execute([uid])?;
}
}
tx.execute("DELETE FROM albums", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO albums
(uid, name, photo_count, cover_uid, last_activity, shared, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for (seq, album) in albums.iter().enumerate() {
stmt.execute(params![
album.uid,
album.name,
album.photo_count as i64,
album.cover_uid,
album.last_activity,
album.shared as i64,
seq as i64,
])?;
}
}
tx.commit()?;
Ok(())
}
/// The persisted album listing, in stored order.
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
FROM albums ORDER BY seq",
)?;
let rows = stmt
.query_map([], |r| {
Ok(StoredAlbum {
uid: r.get(0)?,
name: r.get(1)?,
photo_count: r.get::<_, i64>(2)?.max(0) as usize,
cover_uid: r.get(3)?,
last_activity: r.get(4)?,
shared: r.get::<_, i64>(5)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Number of albums in the persisted listing.
pub fn albums_count(&self) -> Result<usize> {
let conn = self.conn.lock();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
/// Replace one album's contents, in the given (newest-capture-first) order.
/// Learned aspect ratios and thumbnail verdicts survive the replacement, the
/// same way [`Db::photos_replace`] keeps them for the timeline.
pub fn album_photos_replace(
&self,
album_uid: &str,
items: &[(String, i64, Option<String>, Option<String>)],
) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
let mut stmt = tx.prepare(
"SELECT uid, ratio, thumb_state, media_type FROM album_photos
WHERE album_uid = ?1",
)?;
let rows = stmt.query_map([album_uid], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
tx.execute("DELETE FROM album_photos WHERE album_uid = ?1", [album_uid])?;
{
let mut stmt = tx.prepare(
"INSERT INTO album_photos
(album_uid, uid, capture_time, name, media_type, kind, ratio, thumb_state, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)?;
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media) =
learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let media_type = media_type.clone().or(learned_media);
let kind =
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
stmt.execute(params![
album_uid,
uid,
capture_time,
name,
media_type,
kind.as_i64(),
ratio,
thumb_state,
seq as i64,
])?;
}
}
tx.commit()?;
Ok(())
}
/// One page of an album's photos, in stored order.
pub fn album_photos_page(
&self,
album_uid: &str,
offset: usize,
limit: usize,
) -> Result<Vec<StoredPhoto>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
)?;
let rows = stmt
.query_map(params![album_uid, limit as i64, offset as i64], |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// How many photos of `album_uid` are persisted.
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
let conn = self.conn.lock();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
[album_uid],
|r| r.get(0),
)?;
Ok(n.max(0) as usize)
}
/// The album-held photos among `uids`, one row per uid (a photo in two albums
/// is returned once). Backs the thumbnail path for photos that are in an
/// album but not in our own timeline — everything in a shared album, and an
/// album cover whose photo the current timeline page hasn't reached.
pub fn album_photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
WHERE uid IN ({placeholders}) GROUP BY uid"
))?;
let rows = stmt
.query_map(rusqlite::params_from_iter(uids), |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Record a thumbnail verdict against every album row for `uid`, so what one
/// attempt learned is not re-learned per album the photo sits in. The
/// timeline's own copy is updated separately by [`Db::photo_set_thumb`].
pub fn album_photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE album_photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
params![uid, state, ratio],
)?;
Ok(())
}
}
+40 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 18;
pub(super) const SCHEMA_VERSION: i64 = 19;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -95,6 +95,9 @@ impl Db {
if current < 18 {
tx.execute_batch(MIGRATION_V18)?;
}
if current < 19 {
tx.execute_batch(MIGRATION_V19)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -472,3 +475,39 @@ BEGIN
VALUES ('device', NEW.id);
END;
";
/// Schema v19: photo albums and their contents. Both are projections of the
/// server's listings — like `photos`, they are replaced wholesale on refresh —
/// so the Albums view opens from disk instead of re-enumerating every launch.
///
/// `album_photos` carries its own `ratio` / `thumb_state` rather than joining
/// `photos`: an album shared with us lives on the sharer's volume, so its
/// photos are not in our timeline at all and would otherwise have nowhere to
/// remember what a thumbnail attempt learned.
const MIGRATION_V19: &str = "
CREATE TABLE albums (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
photo_count INTEGER NOT NULL DEFAULT 0,
cover_uid TEXT,
last_activity INTEGER,
shared INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL
);
CREATE TABLE album_photos (
album_uid TEXT NOT NULL,
uid TEXT NOT NULL,
capture_time INTEGER NOT NULL,
name TEXT,
media_type TEXT,
kind INTEGER NOT NULL DEFAULT 0,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL,
PRIMARY KEY (album_uid, uid)
);
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
";
+2
View File
@@ -22,6 +22,7 @@ use crate::Result;
use std::path::Path;
mod activity;
mod albums;
mod cache;
mod devices;
mod local;
@@ -38,6 +39,7 @@ mod sync;
mod trash;
mod utils;
pub use albums::StoredAlbum;
pub use cache::CacheEntryInput;
pub use devices::StoredDevice;
pub use local::LocalFileHit;
+22
View File
@@ -94,4 +94,26 @@ impl Db {
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
Ok(())
}
/// Drop every `sync_state` key starting with `prefix` — the per-entity
/// stamps (one album, one shared folder) that are keyed by uid and so have
/// no fixed name to clear individually.
pub fn clear_state_prefix(&self, prefix: &str) -> Result<()> {
let conn = self.conn.lock();
// `\` escapes the LIKE wildcards, so a prefix containing `%` or `_`
// (a uid never does today, but this is not the place to rely on that)
// matches literally instead of sweeping unrelated keys.
let pattern = format!(
"{}%",
prefix
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
conn.execute(
"DELETE FROM sync_state WHERE key LIKE ?1 ESCAPE '\\'",
params![pattern],
)?;
Ok(())
}
}
+143 -2
View File
@@ -1334,6 +1334,8 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
"DROP TRIGGER mount_sync_folder_insert;
DROP TABLE mount;
DROP TABLE share_access;
DROP TABLE album_photos;
DROP TABLE albums;
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
)
.unwrap();
@@ -1347,9 +1349,10 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
let root = uid("offline-share");
db.set_share_access(&root, crate::Access::Viewer).unwrap();
assert_eq!(db.share_access(&root).unwrap(), Some(crate::Access::Viewer));
// Migrating an old fixture always lands on head, whatever head is today.
assert_eq!(
db.state_str("schema_version").unwrap().as_deref(),
Some("18")
Some(SCHEMA_VERSION.to_string().as_str())
);
drop(db);
@@ -1438,9 +1441,10 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
1,
"the V18 foreign key must remove the device projection"
);
// Migrating an old fixture always lands on head, whatever head is today.
assert_eq!(
db.state_str("schema_version").unwrap().as_deref(),
Some("18")
Some(SCHEMA_VERSION.to_string().as_str())
);
drop(db);
@@ -2842,6 +2846,8 @@ fn test_db_has_children_and_trashed_filtering() {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
};
db.upsert_node(&parent_node).unwrap();
@@ -2866,6 +2872,8 @@ fn test_db_has_children_and_trashed_filtering() {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
};
db.upsert_node(&child_node).unwrap();
@@ -2909,3 +2917,136 @@ fn test_db_has_create_op() {
db.delete_ops_for_uid(local_uid).unwrap();
assert!(!db.has_create_op(local_uid).unwrap(), "op deleted");
}
/// The album listing is a wholesale replacement, and an album that leaves the
/// listing must take its contents with it — nothing else ever deletes those rows.
#[test]
fn albums_replace_drops_the_contents_of_an_album_that_left() {
let db = Db::open_in_memory().unwrap();
let album = |uid: &str, name: &str, activity: Option<i64>| StoredAlbum {
uid: uid.into(),
name: name.into(),
photo_count: 2,
cover_uid: Some("cover".into()),
last_activity: activity,
shared: false,
};
db.albums_replace(&[
album("a1", "Trip", Some(500)),
album("a2", "Cats", Some(400)),
])
.unwrap();
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
.unwrap();
db.album_photos_replace("a2", &[("p2".into(), 200, None, None)])
.unwrap();
// The next refresh no longer has a2 — it was deleted, or unshared.
db.albums_replace(&[album("a1", "Trip", Some(600))])
.unwrap();
assert_eq!(db.albums_count().unwrap(), 1);
assert_eq!(db.album_photos_count("a1").unwrap(), 1);
assert_eq!(
db.album_photos_count("a2").unwrap(),
0,
"a departed album's photos go with it"
);
}
/// An album's contents are replaced like the timeline: server order is kept, and
/// what a thumbnail attempt cost a download to learn survives the refresh.
#[test]
fn album_photos_replace_keeps_what_was_learned() {
let db = Db::open_in_memory().unwrap();
db.albums_replace(&[StoredAlbum {
uid: "a1".into(),
name: "Trip".into(),
photo_count: 3,
cover_uid: None,
last_activity: None,
shared: true,
}])
.unwrap();
db.album_photos_replace(
"a1",
&[
("p1".into(), 300, None, Some("video/mp4".into())),
("p2".into(), 200, None, None),
],
)
.unwrap();
db.album_photo_set_thumb("p1", THUMB_HAVE, Some(1.5))
.unwrap();
db.album_photo_set_thumb("p2", THUMB_NONE, None).unwrap();
db.album_photos_replace(
"a1",
&[
("p0".into(), 400, Some("new.jpg".into()), None),
("p1".into(), 300, None, None),
("p2".into(), 200, None, None),
],
)
.unwrap();
let page = db.album_photos_page("a1", 0, 10).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"],
"the album's own order is preserved"
);
assert_eq!(page[1].ratio, Some(1.5));
assert_eq!(page[1].thumb_state, THUMB_HAVE);
assert_eq!(page[2].thumb_state, THUMB_NONE);
// The media type is learned-and-kept, so p1 stays classified as a video even
// though the second refresh carried none.
assert_eq!(page[1].kind, crate::control::PhotoKind::Video);
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
// Paging is relative to the album, and the shared flag round-trips.
let second = db.album_photos_page("a1", 1, 1).unwrap();
assert_eq!(second.len(), 1);
assert_eq!(second[0].uid, "p1");
assert!(db.albums_list().unwrap()[0].shared);
}
/// A photo held only by an album — everything in an album shared with us — is
/// still resolvable for its capture time, which is what tags its cached
/// thumbnail. One row per uid, even when the photo is in several albums.
#[test]
fn album_photos_by_uid_resolves_a_photo_outside_the_timeline() {
let db = Db::open_in_memory().unwrap();
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
.unwrap();
db.album_photos_replace("a2", &[("p1".into(), 300, None, None)])
.unwrap();
let found = db
.album_photos_by_uid(&["p1".into(), "absent".into()])
.unwrap();
assert_eq!(found.len(), 1, "a photo in two albums resolves once");
assert_eq!(found[0].capture_time, 300);
assert!(db.album_photos_by_uid(&[]).unwrap().is_empty());
}
/// Per-album freshness stamps are keyed by uid, so invalidating them all is a
/// prefix sweep — and it must not take unrelated keys with it.
#[test]
fn clear_state_prefix_takes_only_the_prefixed_keys() {
let db = Db::open_in_memory().unwrap();
db.set_state_i64("album_synced_ms:vol~a1", 1).unwrap();
db.set_state_i64("album_synced_ms:vol~a2", 2).unwrap();
db.set_state_i64("albums_synced_ms", 3).unwrap();
db.clear_state_prefix("album_synced_ms:").unwrap();
assert_eq!(db.state_i64("album_synced_ms:vol~a1").unwrap(), None);
assert_eq!(db.state_i64("album_synced_ms:vol~a2").unwrap(), None);
assert_eq!(
db.state_i64("albums_synced_ms").unwrap(),
Some(3),
"the listing's own stamp is not a per-album one"
);
}