persistent entity cache, streaming batche

This commit is contained in:
nvrl committed 2026-08-10 15:22:04 +02:00
1 parent 2336ad9e87
commit a5e03ad8da
33 files changed
+1936 -65

No files matched your search

+8
View File
@@ -178,6 +178,10 @@ impl Db {
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
// Album rows have no favourite state of their own: an album
// photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway.
favorite: false,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -218,6 +222,10 @@ impl Db {
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
// Album rows have no favourite state of their own: an album
// photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway.
favorite: false,
})
})?
.collect::<rusqlite::Result<_>>()?;
+17 -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 = 19;
pub(super) const SCHEMA_VERSION: i64 = 20;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -98,6 +98,9 @@ impl Db {
if current < 19 {
tx.execute_batch(MIGRATION_V19)?;
}
if current < 20 {
tx.execute_batch(MIGRATION_V20)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -511,3 +514,16 @@ CREATE TABLE album_photos (
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
";
/// V20: whether a photo is a favourite.
///
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
/// server's own classification, and the Photos page already derives its tabs
/// from the media type). Defaulting to 0 means an existing timeline stays
/// intact and each row learns its real value on the next refresh, the same way
/// `media_type` did.
const MIGRATION_V20: &str = "
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
";
+1 -1
View File
@@ -49,7 +49,7 @@ pub use ops::{
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
PendingCounts, PendingOp, RenameMeta, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use sync::{StoredSyncEntry, StoredSyncFolder};
pub use trash::StoredTrash;
+51 -18
View File
@@ -32,13 +32,21 @@ pub struct StoredPhoto {
/// Which Photos-page tab this entry belongs to, derived from its name and
/// media type when the timeline was last replaced.
pub kind: crate::control::PhotoKind,
/// Whether the photo carries Proton's `Favorite` tag.
pub favorite: bool,
}
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
/// favorite)`. The two trailing options are "what this refresh learned" — a
/// `None` keeps whatever is already stored rather than clearing it.
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
impl Db {
pub fn photos_replace(
&self,
items: &[(String, i64, Option<String>, Option<String>)],
) -> Result<()> {
/// Replace the timeline wholesale. Each item is
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
/// when the refresh could not resolve that photo's node, in which case the
/// flag already stored is kept rather than silently cleared.
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
@@ -46,10 +54,14 @@ impl Db {
// not know a photo's media type yet when it replaces the timeline. Keep
// any previously learned value so the Photos/Videos/Raw split survives a
// refresh instead of collapsing back to name-extension guesses.
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
let mut stmt =
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
Ok((
r.get::<_, String>(0)?,
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
@@ -58,16 +70,16 @@ impl Db {
{
let mut stmt = tx.prepare(
"INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
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));
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media, learned_favorite) = learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None, false));
let media_type = media_type.clone().or(learned_media);
let favorite = favorite.unwrap_or(learned_favorite);
// The tab this photo lands in is derived here, once, so a page or
// count query is a plain indexed `WHERE kind = ?` rather than a
// reclassification of every row.
@@ -82,6 +94,7 @@ impl Db {
seq as i64,
media_type,
kind.as_i64(),
favorite as i64,
])?;
}
}
@@ -93,18 +106,21 @@ impl Db {
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
/// `favorites` restricts the page to favourited photos.
pub fn photos_page(
&self,
offset: usize,
limit: usize,
kind: Option<crate::control::PhotoKind>,
range: Option<(i64, i64)>,
favorites: bool,
) -> Result<Vec<StoredPhoto>> {
let conn = self.conn.lock();
// Built up so any combination of the optional filters is one indexed
// query rather than a statement per case.
let mut sql =
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
let mut sql = String::from(
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
);
let mut binds: Vec<i64> = Vec::new();
let mut conds: Vec<String> = Vec::new();
if let Some(k) = kind {
@@ -117,6 +133,9 @@ impl Db {
binds.push(to);
conds.push(format!("capture_time < ?{}", binds.len()));
}
if favorites {
conds.push("favorite = 1".to_string());
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conds.join(" AND "));
@@ -139,6 +158,7 @@ impl Db {
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -206,7 +226,7 @@ impl Db {
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 photos
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
WHERE uid IN ({placeholders})"
))?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
@@ -217,6 +237,7 @@ impl Db {
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?;
let mut photos = Vec::new();
@@ -226,6 +247,18 @@ impl Db {
Ok(photos)
}
/// Record a photo's favourite flag locally, after the server accepted the
/// change. A uid the timeline does not hold is a no-op — an album photo on
/// someone else's volume is never in our own `photos` table.
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
params![uid, favorite as i64],
)?;
Ok(())
}
/// Record what a thumbnail attempt learned: whether the photo now has one
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
/// seen. A `None` ratio leaves any previously learned one alone.
+61 -12
View File
@@ -113,9 +113,9 @@ fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
("p1".into(), 300, None, Some("video/mp4".into())),
("p2".into(), 200, None, None),
("p3".into(), 100, None, None),
("p1".into(), 300, None, Some("video/mp4".into()), Some(true)),
("p2".into(), 200, None, None, None),
("p3".into(), 100, None, None, None),
])
.unwrap();
@@ -126,13 +126,13 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
// p1's media type arrives as `None` this time and must not be forgotten.
db.photos_replace(&[
("p0".into(), 400, Some("new.jpg".into()), None),
("p1".into(), 300, None, None),
("p2".into(), 200, None, None),
("p0".into(), 400, Some("new.jpg".into()), None, Some(false)),
("p1".into(), 300, None, None, None),
("p2".into(), 200, None, None, None),
])
.unwrap();
let page = db.photos_page(0, 10, None, None).unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"],
@@ -155,7 +155,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// The counts break down by tab, and a filtered page returns only its tab.
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
let videos = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None)
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false)
.unwrap();
assert_eq!(
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
@@ -167,7 +167,9 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
// not p0 at 400. Combined with a kind filter both conditions apply.
let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap();
let ranged = db
.photos_page(0, 10, None, Some((150, 350)), false)
.unwrap();
assert_eq!(
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1", "p2"]
@@ -178,6 +180,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
10,
Some(crate::control::PhotoKind::Video),
Some((150, 350)),
false,
)
.unwrap();
assert_eq!(
@@ -193,20 +196,52 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
assert_eq!(months[0].2, 3);
}
#[test]
fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
("p1".into(), 300, None, None, Some(true)),
("p2".into(), 200, None, None, Some(false)),
])
.unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1"],
"only the favourited photo is in a favourites page"
);
// A local toggle survives a refresh that could not resolve the photo's node
// (`None`), and loses to one that could.
db.photos_set_favorite("p2", true).unwrap();
db.photos_replace(&[
("p1".into(), 300, None, None, Some(false)),
("p2".into(), 200, None, None, None),
])
.unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2"],
"the server's answer wins where it has one; the local flag holds where it doesn't"
);
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
}
#[test]
fn photos_page_slices_the_timeline_in_order() {
let db = Db::open_in_memory().unwrap();
let items: Vec<_> = (0..5)
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>))
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>, None))
.collect();
db.photos_replace(&items).unwrap();
let page = db.photos_page(2, 2, None, None).unwrap();
let page = db.photos_page(2, 2, None, None, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2", "p3"]
);
assert!(db.photos_page(9, 2, None, None).unwrap().is_empty());
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty());
}
#[test]
@@ -1336,6 +1371,8 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
DROP TABLE share_access;
DROP TABLE album_photos;
DROP TABLE albums;
DROP INDEX idx_photos_favorite;
ALTER TABLE photos DROP COLUMN favorite;
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
)
.unwrap();
@@ -1402,6 +1439,18 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
VALUES
(41, '/home/me/Existing', 'vol~existing', 'share-existing',
'ondemand', 'syncing', 123);
-- The timeline as V17 held it: later migrations add columns to it,
-- so a fixture without it isn't a database any release produced.
CREATE TABLE photos (
uid TEXT PRIMARY KEY,
capture_time INTEGER NOT NULL,
name TEXT,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL,
media_type TEXT,
kind INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE share_access (
root_uid TEXT PRIMARY KEY,
access TEXT NOT NULL