feat(core,fuse): group the RAW and the JPEG of one shot

A camera that writes both puts two files on the volume, and the timeline showed
two tiles of the same photo. They are one shot to the person who took it.

Schema v30 records what ties them together: the server's own duplicate hash and
main-photo relation, plus a `group_key` every member of a shot carries. The key
is computed when the timeline is replaced, in three steps — the server relation
first, because it knows about live photos and bursts and we do not have to guess
at them; then same capture day, same file-name stem and one member raw while the
other is not, which is the pair a camera writes; otherwise the photo is its own
group. The member the grid shows is the non-raw one, since a JPEG decodes in
milliseconds and is what the person expects to see.

The gallery therefore pages by group and carries `group_size` and `has_raw` on
each entry, and the date scrubber counts what the page shows. The Raw tab is the
exception and still lists every raw file, because that tab exists to answer
"which raws do I have". Album pages are not grouped at all: an album is a list
someone made, and it shows exactly what they put in it.

`TimelineRow` is a struct now. It had grown to five positional fields and was
about to grow to seven, at which point nobody can read a call site.
This commit is contained in:
nvrl committed 2026-09-19 15:34:17 +02:00
1 parent c6bdba024f
commit afd71713d5
6 files changed
+554 -111

No files matched your search

+9
View File
@@ -1287,6 +1287,15 @@ pub struct PhotoItem {
/// tags it has not learned yet. /// tags it has not learned yet.
#[serde(default)] #[serde(default)]
pub favorite: bool, pub favorite: bool,
/// How many files this entry stands for: a shot stored as a RAW and a JPEG
/// is one entry and two files. Older daemons omit it, and `0` reads the same
/// as `1` — one file, nothing to indicate.
#[serde(default)]
pub group_size: u32,
/// Whether one of those files is a camera raw. The tile badges it, because
/// the JPEG it shows is not the whole of what is stored.
#[serde(default)]
pub has_raw: bool,
} }
/// A daemon too old to classify a timeline entry is assumed to have served a /// A daemon too old to classify a timeline entry is assumed to have served a
+8
View File
@@ -182,6 +182,10 @@ impl Db {
// photo on someone else's volume is not in our timeline, and // photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway. // favouriting one is not supported yet anyway.
favorite: false, favorite: false,
// Nor are album rows grouped: an album is a list the user
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
}) })
})? })?
.collect::<rusqlite::Result<_>>()?; .collect::<rusqlite::Result<_>>()?;
@@ -239,6 +243,10 @@ impl Db {
// photo on someone else's volume is not in our timeline, and // photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway. // favouriting one is not supported yet anyway.
favorite: false, favorite: false,
// Nor are album rows grouped: an album is a list the user
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
}) })
})? })?
.collect::<rusqlite::Result<_>>()?; .collect::<rusqlite::Result<_>>()?;
+38 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result; use crate::Result;
/// Current schema version. Bump on every forward migration added below. /// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 29; pub(super) const SCHEMA_VERSION: i64 = 30;
impl Db { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -236,6 +236,24 @@ impl Db {
tx.execute_batch(MIGRATION_V29)?; tx.execute_batch(MIGRATION_V29)?;
} }
} }
if current < 30 {
// Same guards as V26-V29: the column may already be there on a
// fixture rewound from the current schema, and a very old fixture
// may not have `photos` at all.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_photos: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_photos && !has_column {
tx.execute_batch(MIGRATION_V30)?;
}
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -881,3 +899,22 @@ const MIGRATION_V29: &str = "
ALTER TABLE trash ADD COLUMN parent_uid TEXT; ALTER TABLE trash ADD COLUMN parent_uid TEXT;
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid); CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
"; ";
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
/// who took it, so the timeline records what ties them together.
///
/// `content_hash` and `main_uid` come from the server's own photo properties —
/// the duplicate-detection hash, and the main photo a related one belongs to.
/// `group_key` is what the gallery pages by: every member of one shot carries
/// the same key, and the member the grid shows is the one whose `main_uid` is
/// its own uid.
///
/// All three are `NULL` until the next timeline refresh resolves the photo's
/// node, and a row without a `group_key` is its own group — which is exactly how
/// the gallery behaved before this column existed.
const MIGRATION_V30: &str = "
ALTER TABLE photos ADD COLUMN content_hash TEXT;
ALTER TABLE photos ADD COLUMN main_uid TEXT;
ALTER TABLE photos ADD COLUMN group_key TEXT;
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
";
+307 -89
View File
@@ -34,18 +34,200 @@ pub struct StoredPhoto {
pub kind: crate::control::PhotoKind, pub kind: crate::control::PhotoKind,
/// Whether the photo carries Proton's `Favorite` tag. /// Whether the photo carries Proton's `Favorite` tag.
pub favorite: bool, pub favorite: bool,
/// How many photos this photo's group holds — one shot taken as RAW+JPEG is
/// two files and one group. `1` for a photo that stands on its own.
pub group_size: usize,
/// Whether any member of the group is a camera raw file. The grid badges
/// that, because the JPEG it shows is not the whole of what is stored.
pub has_raw: bool,
} }
/// One row of a timeline replacement: `(uid, capture_time, name, media_type, /// One row of a timeline replacement. Everything but the uid and the capture
/// favorite)`. The two trailing options are "what this refresh learned" — a /// time is "what this refresh learned": a `None` keeps whatever is already
/// `None` keeps whatever is already stored rather than clearing it. /// stored rather than clearing it, because the timeline DTO carries only the uid
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>); /// and the capture time and resolving the node can fail.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TimelineRow {
pub uid: String,
pub capture_time: i64,
pub name: Option<String>,
pub media_type: Option<String>,
pub favorite: Option<bool>,
/// The server's duplicate-detection hash for the plaintext.
pub content_hash: Option<String>,
/// The main photo this one is *related* to (a live-photo video, a burst
/// sibling), when the server says it belongs to one.
pub main_uid: Option<String>,
}
impl TimelineRow {
/// A row carrying only what the timeline listing itself gives.
pub fn new(uid: impl Into<String>, capture_time: i64) -> Self {
Self {
uid: uid.into(),
capture_time,
..Self::default()
}
}
}
/// What a previous refresh learned about a photo and a new one must not lose.
#[derive(Clone, Debug, Default)]
struct Learned {
ratio: Option<f64>,
thumb_state: i64,
media_type: Option<String>,
favorite: bool,
content_hash: Option<String>,
main_uid: Option<String>,
}
/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string.
/// Grouping by day rather than by exact second is deliberate: a camera writes
/// the RAW and the JPEG of one shot a moment apart, and two shots of the same
/// subject on different days are not one photo.
fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option<String> {
conn.query_row(
"SELECT strftime('%Y-%m-%d', ?1, 'unixepoch', 'localtime')",
params![capture_time],
|r| r.get::<_, Option<String>>(0),
)
.ok()
.flatten()
}
/// The file name without its extension, lowercased — `IMG_1234.CR2` and
/// `IMG_1234.JPG` share `img_1234`.
fn name_stem(name: Option<&str>) -> Option<String> {
let name = name?;
let stem = match name.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem,
_ => name,
};
Some(stem.to_ascii_lowercase())
}
/// One entry of the grouping pass: what rule 2 needs, plus what choosing a
/// representative needs.
struct Grouped {
uid: String,
kind: crate::control::PhotoKind,
main_uid: Option<String>,
day: Option<String>,
stem: Option<String>,
}
/// Which group each photo belongs to, and which member of it the gallery shows.
///
/// Precedence, as agreed:
///
/// 1. The server's own relation — a photo with a `main_photo_uid` joins that
/// photo's group. The server knows about live photos and bursts; we do not
/// have to guess at them.
/// 2. Otherwise, same capture day **and** same file-name stem **and** one of the
/// two is a camera raw while the other is not. That is the RAW+JPEG pair a
/// camera writes, and the differing-kind clause keeps two JPEGs that happen
/// to share a name apart.
/// 3. Otherwise the photo is its own group.
///
/// The representative is the non-raw member when the group has one — a JPEG
/// decodes in milliseconds and is what the person expects to see — and the
/// earliest member in server order otherwise.
fn group_photos(entries: &[Grouped]) -> Vec<String> {
let mut parent: Vec<usize> = (0..entries.len()).collect();
fn find(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
let union = |parent: &mut [usize], a: usize, b: usize| {
let (a, b) = (find(parent, a), find(parent, b));
if a != b {
parent[b] = a;
}
};
let index: HashMap<&str, usize> = entries
.iter()
.enumerate()
.map(|(i, e)| (e.uid.as_str(), i))
.collect();
for (i, entry) in entries.iter().enumerate() {
if let Some(main) = entry.main_uid.as_deref()
&& let Some(&j) = index.get(main)
{
union(&mut parent, j, i);
}
}
// Rule 2 needs only one pass: everything sharing a day and a stem lands in
// the same bucket, and the raw / non-raw split is checked pairwise there.
let mut by_name: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
for (i, entry) in entries.iter().enumerate() {
if let (Some(day), Some(stem)) = (entry.day.as_deref(), entry.stem.as_deref()) {
by_name.entry((day, stem)).or_default().push(i);
}
}
let is_raw = |i: usize| entries[i].kind == crate::control::PhotoKind::Raw;
for bucket in by_name.values() {
for (n, &i) in bucket.iter().enumerate() {
for &j in &bucket[n + 1..] {
if is_raw(i) != is_raw(j) {
union(&mut parent, i, j);
}
}
}
}
// Representative per root, then the key every member carries.
let mut representative: HashMap<usize, usize> = HashMap::new();
for i in 0..entries.len() {
let root = find(&mut parent, i);
match representative.get(&root).copied() {
Some(current) if !is_raw(current) || is_raw(i) => {}
_ => {
representative.insert(root, i);
}
}
}
(0..entries.len())
.map(|i| {
let root = find(&mut parent, i);
entries[representative[&root]].uid.clone()
})
.collect()
}
/// The columns every photo read selects, with the group aggregates joined in.
/// `g.n` is how many photos the group holds and `g.raw` whether any of them is a
/// camera raw file — one query rather than a second lookup per tile.
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
p.kind, p.favorite, g.n, g.raw \
FROM photos p JOIN (SELECT group_key, COUNT(*) AS n, MAX(kind = 2) AS raw \
FROM photos GROUP BY group_key) g ON g.group_key = p.group_key";
/// Read one row of [`PHOTO_SELECT`].
fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
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)?),
favorite: r.get::<_, i64>(6)? != 0,
group_size: r.get::<_, i64>(7)?.max(1) as usize,
has_raw: r.get::<_, i64>(8)? != 0,
})
}
impl Db { impl Db {
/// Replace the timeline wholesale. Each item is /// Replace the timeline wholesale. `favorite`, `media_type`, `content_hash`
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None` /// and `main_uid` are `None` when the refresh could not resolve that photo's
/// when the refresh could not resolve that photo's node, in which case the /// node, in which case what is already stored is kept rather than silently
/// flag already stored is kept rather than silently cleared. /// cleared.
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> { pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
let mut conn = self.conn.lock(); let mut conn = self.conn.lock();
let tx = conn.transaction()?; let tx = conn.transaction()?;
@@ -53,48 +235,89 @@ impl Db {
// timeline DTO carries only the uid and capture time, so the daemon may // timeline DTO carries only the uid and capture time, so the daemon may
// not know a photo's media type yet when it replaces the timeline. Keep // 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 // any previously learned value so the Photos/Videos/Raw split survives a
// refresh instead of collapsing back to name-extension guesses. // refresh instead of collapsing back to name-extension guesses. The photo
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = { // relation is kept for the same reason: losing it would break a group up
let mut stmt = // until the next successful resolve.
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?; let learned: HashMap<String, Learned> = {
let mut stmt = tx.prepare(
"SELECT uid, ratio, thumb_state, media_type, favorite, content_hash, main_uid \
FROM photos",
)?;
let rows = stmt.query_map([], |r| { let rows = stmt.query_map([], |r| {
Ok(( Ok((
r.get::<_, String>(0)?, r.get::<_, String>(0)?,
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0), Learned {
ratio: r.get(1)?,
thumb_state: r.get(2)?,
media_type: r.get(3)?,
favorite: r.get::<_, i64>(4)? != 0,
content_hash: r.get(5)?,
main_uid: r.get(6)?,
},
)) ))
})?; })?;
rows.collect::<rusqlite::Result<_>>()? rows.collect::<rusqlite::Result<_>>()?
}; };
// Everything the insert and the grouping need, resolved once per photo.
let resolved: Vec<(&TimelineRow, Learned, crate::control::PhotoKind)> = items
.iter()
.map(|row| {
let mut carried = learned.get(&row.uid).cloned().unwrap_or(Learned {
thumb_state: THUMB_UNKNOWN,
..Learned::default()
});
carried.media_type = row.media_type.clone().or(carried.media_type);
carried.favorite = row.favorite.unwrap_or(carried.favorite);
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
// 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.
let kind = crate::control::PhotoKind::classify(
row.name.as_deref(),
carried.media_type.as_deref(),
);
(row, carried, kind)
})
.collect();
let entries: Vec<Grouped> = resolved
.iter()
.map(|(row, carried, kind)| Grouped {
uid: row.uid.clone(),
kind: *kind,
main_uid: carried.main_uid.clone(),
day: capture_day(&tx, row.capture_time),
stem: name_stem(row.name.as_deref()),
})
.collect();
let group_keys = group_photos(&entries);
tx.execute("DELETE FROM photos", [])?; tx.execute("DELETE FROM photos", [])?;
{ {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"INSERT INTO photos "INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite) (uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", content_hash, main_uid, group_key)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)?; )?;
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() { for (seq, ((row, carried, kind), group_key)) in
let (ratio, thumb_state, learned_media, learned_favorite) = learned resolved.iter().zip(group_keys.iter()).enumerate()
.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.
let kind =
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
stmt.execute(params![ stmt.execute(params![
uid, row.uid,
capture_time, row.capture_time,
name, row.name,
ratio, carried.ratio,
thumb_state, carried.thumb_state,
seq as i64, seq as i64,
media_type, carried.media_type,
kind.as_i64(), kind.as_i64(),
favorite as i64, carried.favorite as i64,
carried.content_hash,
carried.main_uid,
group_key,
])?; ])?;
} }
} }
@@ -132,6 +355,10 @@ impl Db {
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the /// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave. /// date scrubber's jump. `offset` is relative to whatever the filters leave.
/// `favorites` restricts the page to favourited photos. /// `favorites` restricts the page to favourited photos.
///
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
/// one tile. The Raw tab is the exception and lists every raw file, because
/// that tab exists to answer "which raws do I have".
pub fn photos_page( pub fn photos_page(
&self, &self,
offset: usize, offset: usize,
@@ -143,23 +370,24 @@ impl Db {
let conn = self.read(); let conn = self.read();
// Built up so any combination of the optional filters is one indexed // Built up so any combination of the optional filters is one indexed
// query rather than a statement per case. // query rather than a statement per case.
let mut sql = String::from( let mut sql = String::from(PHOTO_SELECT);
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
);
let mut binds: Vec<i64> = Vec::new(); let mut binds: Vec<i64> = Vec::new();
let mut conds: Vec<String> = Vec::new(); let mut conds: Vec<String> = Vec::new();
if kind != Some(crate::control::PhotoKind::Raw) {
conds.push("p.uid = p.group_key".to_string());
}
if let Some(k) = kind { if let Some(k) = kind {
binds.push(k.as_i64()); binds.push(k.as_i64());
conds.push(format!("kind = ?{}", binds.len())); conds.push(format!("p.kind = ?{}", binds.len()));
} }
if let Some((from, to)) = range { if let Some((from, to)) = range {
binds.push(from); binds.push(from);
conds.push(format!("capture_time >= ?{}", binds.len())); conds.push(format!("p.capture_time >= ?{}", binds.len()));
binds.push(to); binds.push(to);
conds.push(format!("capture_time < ?{}", binds.len())); conds.push(format!("p.capture_time < ?{}", binds.len()));
} }
if favorites { if favorites {
conds.push("favorite = 1".to_string()); conds.push("p.favorite = 1".to_string());
} }
if !conds.is_empty() { if !conds.is_empty() {
sql.push_str(" WHERE "); sql.push_str(" WHERE ");
@@ -170,47 +398,41 @@ impl Db {
binds.push(offset as i64); binds.push(offset as i64);
let offset_pos = binds.len(); let offset_pos = binds.len();
sql.push_str(&format!( sql.push_str(&format!(
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}" " ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
)); ));
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt let rows = stmt
.query_map(rusqlite::params_from_iter(binds), |r| { .query_map(rusqlite::params_from_iter(binds), stored_photo)?
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)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?; .collect::<rusqlite::Result<_>>()?;
Ok(rows) Ok(rows)
} }
/// The whole persisted timeline, in stored order. Only for whole-library /// Every photo of one photo's group, in server order — the RAW and the JPEG
/// passes (the photo re-date) — the gallery pages with /// of a shot, or just the photo itself when it stands alone. The lightbox
/// [`Db::photos_page`]. /// switches between these, and deleting a tile trashes all of them.
pub fn photos_group(&self, uid: &str) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE p.group_key = \
(SELECT group_key FROM photos WHERE uid = ?1) ORDER BY p.seq"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![uid], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// The whole persisted timeline, in stored order — every file, not one row
/// per group. Only for whole-library passes (the photo re-date); the gallery
/// pages with [`Db::photos_page`].
pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> { pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let sql = format!("{PHOTO_SELECT} ORDER BY p.seq");
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite \ let mut stmt = conn.prepare(&sql)?;
FROM photos ORDER BY seq",
)?;
let rows = stmt let rows = stmt
.query_map([], |r| { .query_map([], stored_photo)?
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)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?; .collect::<rusqlite::Result<_>>()?;
Ok(rows) Ok(rows)
} }
@@ -225,9 +447,12 @@ impl Db {
kind: Option<crate::control::PhotoKind>, kind: Option<crate::control::PhotoKind>,
) -> Result<Vec<(i32, i32, usize)>> { ) -> Result<Vec<(i32, i32, usize)>> {
let conn = self.read(); let conn = self.read();
// The scrubber has to agree with the page it scrubs, so it counts what
// the page shows: groups everywhere except on the Raw tab.
let (filter, binds): (&str, Vec<i64>) = match kind { let (filter, binds): (&str, Vec<i64>) = match kind {
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]), Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
None => ("", Vec::new()), Some(k) => (" WHERE uid = group_key AND kind = ?1", vec![k.as_i64()]),
None => (" WHERE uid = group_key", Vec::new()),
}; };
let sql = format!( let sql = format!(
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \ "SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
@@ -252,7 +477,12 @@ impl Db {
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> { pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
use crate::control::PhotoKind; use crate::control::PhotoKind;
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?; // Photos and Videos count groups — one shot is one entry, however many
// files it was stored as. Raw counts files, because the Raw tab lists
// them individually.
let mut stmt = conn.prepare(
"SELECT kind, COUNT(*) FROM photos WHERE uid = group_key OR kind = 2 GROUP BY kind",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize); let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
for row in rows { for row in rows {
@@ -275,21 +505,9 @@ impl Db {
} }
let conn = self.read(); let conn = self.read();
let placeholders = vec!["?"; uids.len()].join(","); let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!( let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos let mut stmt = conn.prepare(&sql)?;
WHERE uid IN ({placeholders})" let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
))?;
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)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?;
let mut photos = Vec::new(); let mut photos = Vec::new();
for row in rows { for row in rows {
photos.push(row?); photos.push(row?);
+145 -14
View File
@@ -113,9 +113,13 @@ 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() { fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
db.photos_replace(&[ db.photos_replace(&[
("p1".into(), 300, None, Some("video/mp4".into()), Some(true)), TimelineRow {
("p2".into(), 200, None, None, None), media_type: Some("video/mp4".into()),
("p3".into(), 100, None, None, None), favorite: Some(true),
..TimelineRow::new("p1", 300)
},
TimelineRow::new("p2", 200),
TimelineRow::new("p3", 100),
]) ])
.unwrap(); .unwrap();
@@ -126,9 +130,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. // 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. // p1's media type arrives as `None` this time and must not be forgotten.
db.photos_replace(&[ db.photos_replace(&[
("p0".into(), 400, Some("new.jpg".into()), None, Some(false)), TimelineRow {
("p1".into(), 300, None, None, None), name: Some("new.jpg".into()),
("p2".into(), 200, None, None, None), favorite: Some(false),
..TimelineRow::new("p0", 400)
},
TimelineRow::new("p1", 300),
TimelineRow::new("p2", 200),
]) ])
.unwrap(); .unwrap();
@@ -200,8 +208,14 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
fn favorites_are_remembered_across_refreshes_and_filter_a_page() { fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
db.photos_replace(&[ db.photos_replace(&[
("p1".into(), 300, None, None, Some(true)), TimelineRow {
("p2".into(), 200, None, None, Some(false)), favorite: Some(true),
..TimelineRow::new("p1", 300)
},
TimelineRow {
favorite: Some(false),
..TimelineRow::new("p2", 200)
},
]) ])
.unwrap(); .unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap(); let favorites = db.photos_page(0, 10, None, None, true).unwrap();
@@ -215,8 +229,11 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
// (`None`), and loses to one that could. // (`None`), and loses to one that could.
db.photos_set_favorite("p2", true).unwrap(); db.photos_set_favorite("p2", true).unwrap();
db.photos_replace(&[ db.photos_replace(&[
("p1".into(), 300, None, None, Some(false)), TimelineRow {
("p2".into(), 200, None, None, None), favorite: Some(false),
..TimelineRow::new("p1", 300)
},
TimelineRow::new("p2", 200),
]) ])
.unwrap(); .unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap(); let favorites = db.photos_page(0, 10, None, None, true).unwrap();
@@ -232,7 +249,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
fn photos_page_slices_the_timeline_in_order() { fn photos_page_slices_the_timeline_in_order() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
let items: Vec<_> = (0..5) let items: Vec<_> = (0..5)
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>, None)) .map(|i| TimelineRow::new(format!("p{i}"), 500 - i as i64))
.collect(); .collect();
db.photos_replace(&items).unwrap(); db.photos_replace(&items).unwrap();
@@ -3926,9 +3943,9 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
fn photos_delete_removes_the_photo_and_its_album_membership() { fn photos_delete_removes_the_photo_and_its_album_membership() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
db.photos_replace(&[ db.photos_replace(&[
("p1".into(), 300, None, None, None), TimelineRow::new("p1", 300),
("p2".into(), 200, None, None, None), TimelineRow::new("p2", 200),
("p3".into(), 100, None, None, None), TimelineRow::new("p3", 100),
]) ])
.unwrap(); .unwrap();
db.albums_replace(&[StoredAlbum { db.albums_replace(&[StoredAlbum {
@@ -3970,3 +3987,117 @@ fn photos_delete_removes_the_photo_and_its_album_membership() {
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 0); assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 0);
assert_eq!(db.photos_delete(&[]).unwrap(), 0); assert_eq!(db.photos_delete(&[]).unwrap(), 0);
} }
/// A shot taken as RAW+JPEG is one photo to the person who took it: the grid
/// shows the JPEG once, and the Raw tab still lists the raw file.
#[test]
fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
TimelineRow {
name: Some("IMG_1234.JPG".into()),
..TimelineRow::new("jpeg", 300)
},
TimelineRow {
name: Some("img_1234.cr2".into()),
..TimelineRow::new("raw", 300)
},
TimelineRow {
name: Some("IMG_9999.JPG".into()),
..TimelineRow::new("alone", 200)
},
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["jpeg", "alone"],
"the pair shows once, as the member that can be displayed"
);
assert_eq!(page[0].group_size, 2);
assert!(page[0].has_raw, "the tile can say the raw is there");
assert_eq!(page[1].group_size, 1);
assert!(!page[1].has_raw);
// The Raw tab answers "which raws do I have", so it lists files.
let raws = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Raw), None, false)
.unwrap();
assert_eq!(
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["raw"]
);
assert_eq!(db.photos_counts().unwrap(), (2, 0, 1));
// Both members are reachable from either uid: the lightbox switches between
// them, and deleting the tile has to trash both.
let group = db.photos_group("raw").unwrap();
assert_eq!(
group.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["jpeg", "raw"]
);
assert_eq!(db.photos_group("alone").unwrap().len(), 1);
}
/// Two files that merely share a name are not one shot: the same stem on
/// different days, or two files of the same kind, stay apart.
#[test]
fn a_shared_name_alone_does_not_group_photos() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
TimelineRow {
name: Some("IMG_1234.JPG".into()),
..TimelineRow::new("today", 1_700_000_000)
},
TimelineRow {
name: Some("IMG_1234.CR2".into()),
..TimelineRow::new("last-year", 1_600_000_000)
},
TimelineRow {
name: Some("IMG_1234.PNG".into()),
..TimelineRow::new("same-kind", 1_700_000_000)
},
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
assert_eq!(page.len(), 3, "three shots, three tiles");
assert!(page.iter().all(|p| p.group_size == 1));
}
/// The server knows about live photos and bursts, so its own relation groups
/// files this client would never pair by name.
#[test]
fn the_servers_photo_relation_groups_a_live_photo() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
TimelineRow {
name: Some("IMG_0001.HEIC".into()),
..TimelineRow::new("main", 300)
},
TimelineRow {
name: Some("clip.mov".into()),
main_uid: Some("main".into()),
..TimelineRow::new("related", 299)
},
])
.unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["main"]
);
assert_eq!(page[0].group_size, 2);
assert!(!page[0].has_raw, "a live photo's video is not a raw file");
// The relation is learned-and-kept: a refresh that could not resolve the
// nodes must not break the group up.
db.photos_replace(&[
TimelineRow::new("main", 300),
TimelineRow::new("related", 299),
])
.unwrap();
assert_eq!(db.photos_group("related").unwrap().len(), 2);
}
+47 -7
View File
@@ -407,6 +407,16 @@ fn extract_raw_preview(
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>); pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
/// Formats supported by the local thumbnail decoder and exposed by the GUI. /// Formats supported by the local thumbnail decoder and exposed by the GUI.
/// What a timeline enrichment pass resolves for one photo, beyond what the
/// timeline listing already carries.
#[derive(Clone, Debug)]
struct PhotoMeta {
name: Option<String>,
media_type: Option<String>,
favorite: bool,
content_hash: Option<String>,
}
impl Core { impl Core {
pub(crate) fn photos_timeline( pub(crate) fn photos_timeline(
&self, &self,
@@ -458,6 +468,8 @@ impl Core {
no_thumb: photo.thumb_state == db::THUMB_NONE, no_thumb: photo.thumb_state == db::THUMB_NONE,
kind: photo.kind, kind: photo.kind,
favorite: photo.favorite, favorite: photo.favorite,
group_size: photo.group_size as u32,
has_raw: photo.has_raw,
} }
} }
@@ -1283,7 +1295,11 @@ impl Core {
// learned before (or classifies from nothing, i.e. a still photo), so a // learned before (or classifies from nothing, i.e. a still photo), so a
// partial resolve never blanks the timeline. // partial resolve never blanks the timeline.
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect(); let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
let mut meta: HashMap<String, (Option<String>, Option<String>, bool)> = HashMap::new(); let mut meta: HashMap<String, PhotoMeta> = HashMap::new();
// A main photo names its related photos rather than the other way round,
// so the relation is recorded from whichever end resolves — the two ends
// can land in different chunks, and one of them may not resolve at all.
let mut main_of: HashMap<String, String> = HashMap::new();
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) { for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
match photos.enumerate_nodes(chunk).await { match photos.enumerate_nodes(chunk).await {
Ok(nodes) => { Ok(nodes) => {
@@ -1299,9 +1315,24 @@ impl Core {
.photo .photo
.as_ref() .as_ref()
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite)); .is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
let key = node.uid.to_string();
let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone());
if let Some(photo) = node.photo.as_ref() {
if let Some(main) = photo.main_photo_uid.as_ref() {
main_of.insert(key.clone(), main.to_string());
}
for related in &photo.related_photo_uids {
main_of.insert(related.to_string(), key.clone());
}
}
meta.insert( meta.insert(
node.uid.to_string(), key,
(Some(node.name), media_type, favorite), PhotoMeta {
name: Some(node.name),
media_type,
favorite,
content_hash,
},
); );
} }
} }
@@ -1313,13 +1344,22 @@ impl Core {
.iter() .iter()
.map(|it| { .map(|it| {
let key = it.uid.to_string(); let key = it.uid.to_string();
let main_uid = main_of.get(&key).cloned();
// An unresolved photo keeps whatever was learned before, for the // An unresolved photo keeps whatever was learned before, for the
// favourite flag as much as for the name and media type. // favourite flag as much as for the name and media type.
match meta.get(&key).cloned() { match meta.get(&key).cloned() {
Some((name, media_type, favorite)) => { Some(m) => db::TimelineRow {
(key, it.capture_time, name, media_type, Some(favorite)) name: m.name,
} media_type: m.media_type,
None => (key, it.capture_time, None, None, None), favorite: Some(m.favorite),
content_hash: m.content_hash,
main_uid,
..db::TimelineRow::new(key, it.capture_time)
},
None => db::TimelineRow {
main_uid,
..db::TimelineRow::new(key, it.capture_time)
},
} }
}) })
.collect(); .collect();