mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
added album to photos, renamed gallery view
This commit is contained in:
1 parent
d988236bfa
commit
2336ad9e87
25 files changed
+1510
-378
No files matched your search
@@ -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> },
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
";
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user