gallery/src/db.rs
Greg Pomerantz 572409b702 M2: thumbnail engine — turbojpeg DCT-shrink decode, worker pool, disk cache
- images.rs: libjpeg-turbo 1/2..1/8 shrink decode (48MP @1/8 ~2.3MB peak),
  all 8 EXIF orientations, imageops::resize final fit, PNG via image crate,
  q85 re-encode for the disk cache
- thumb.rs: dispatcher + N=cores/2 workers (2..8), cache
  thumbs/{sha1(path|mtime|size|target)}.jpg (target in key: list 64px and
  grid 500px coexist), atomic rename, generation-based cancellation,
  per-worker sqlite set_thumb, 256-job cadence logs
- lib.rs: AppState OnceLock, 10Hz drain timer (leaked !Send Timer),
  scroll prefetch (50-row margin), do_prefetch gating for scan batches
- main.slint/db.rs: rows carry images; list_entries + set_thumb
- build.sh: NDK CMake toolchain env for turbojpeg-sys (ANDROID_JAR vs
  ANDROID_PLATFORM conflict), nasm on PATH, ADB_SERIAL, debug-aarch64
- HANDOFF: M2 done + acceptance data; HEIC deferred (0 HEIC on device);
  transient first-launch black screen parked as §10.7 (unreproducible)

Verified: 12 unit tests + 5k engine test; Pixel 9 Pro 8577 photos —
cold scan ~5s streaming, warm relaunch instant, thumbnails in every row,
~100% cache hits on relaunch; release aarch64 APK 9.8 MB.
2026-10-27 16:32:12 -04:00

246 lines
8.6 KiB
Rust

//! SQLite metadata store (M1). Schema follows HANDOFF.md §4:
//! one row per image; M1 fills `path/mtime/size`, the thumbnail engine (M2)
//! fills `width/height/orientation/thumb_path`, `date_taken` later.
use rusqlite::{params, Connection};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use crate::scan::Entry;
#[cfg(target_os = "android")]
const ANDROID_PACKAGE: &str = "wow.gmp.gallery";
/// App-writable data dir on Android. Prefers the per-app external dir
/// (/storage/emulated/0/Android/data/<pkg>/files) so the DB is easy to
/// inspect via `adb`; falls back to the internal files dir, which is always
/// writable. Neither `EXTERNAL_DATA_DIR` (not set by this NativeActivity
/// setup) nor `temp_dir()` (=/data/user/0, not writable) can be trusted.
#[cfg(target_os = "android")]
fn android_data_dir() -> PathBuf {
if let Some(ext) = std::env::var_os("EXTERNAL_DATA_DIR").map(PathBuf::from) {
if std::fs::create_dir_all(&ext).is_ok() {
return ext;
}
}
if let Some(storage) = std::env::var_os("EXTERNAL_STORAGE").map(PathBuf::from) {
let ext = storage
.join("Android/data")
.join(ANDROID_PACKAGE)
.join("files");
if std::fs::create_dir_all(&ext).is_ok() {
return ext;
}
}
PathBuf::from(format!("/data/data/{ANDROID_PACKAGE}/files"))
}
/// DB file lives in app-private storage:
/// - Android: per-app external dir (see `android_data_dir`), internal fallback.
/// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery).
pub fn db_path() -> PathBuf {
if let Ok(p) = std::env::var("GALLERY_DB_PATH") {
return PathBuf::from(p); // test hook
}
#[cfg(target_os = "android")]
let dir = android_data_dir();
#[cfg(not(target_os = "android"))]
let dir = {
let data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from);
let base = data_home.unwrap_or_else(|| {
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
home.join(".local/share")
});
base.join("gallery")
};
dir.join("gallery.db")
}
pub fn open(path: &std::path::Path) -> Result<Connection, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS items (
path TEXT PRIMARY KEY,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
orientation INTEGER,
thumb_path TEXT,
date_taken INTEGER
);
CREATE INDEX IF NOT EXISTS items_mtime ON items (mtime DESC);",
)?;
Ok(conn)
}
/// Apply a full filesystem snapshot incrementally: insert new paths, refresh
/// rows whose mtime/size changed, delete vanished ones.
/// Returns (inserted, updated, deleted).
pub fn apply(
conn: &mut Connection,
entries: &[Entry],
) -> Result<(usize, usize, usize), rusqlite::Error> {
// Existing state: path -> (mtime, size)
let mut existing: HashMap<String, (i64, i64)> = HashMap::with_capacity(entries.len());
{
let mut st = conn.prepare("SELECT path, mtime, size FROM items")?;
let rows = st.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
))
})?;
for row in rows {
let (p, m, s) = row?;
existing.insert(p, (m, s));
}
}
let mut inserted = 0;
let mut updated = 0;
let mut deleted = 0;
let tx = conn.transaction()?;
{
let mut ins = tx.prepare(
"INSERT INTO items (path, mtime, size) VALUES (?1, ?2, ?3)",
)?;
// mtime/size changed → content changed → derived columns are stale.
let mut upd = tx.prepare(
"UPDATE items SET mtime = ?2, size = ?3, width = NULL, height = NULL, \
orientation = NULL, thumb_path = NULL, date_taken = NULL WHERE path = ?1",
)?;
let mut del = tx.prepare("DELETE FROM items WHERE path = ?1")?;
let mut seen = HashSet::with_capacity(entries.len());
for e in entries {
seen.insert(e.path.clone());
match existing.get(&e.path) {
Some(&(m, s)) if m == e.mtime && s == e.size => {}
Some(_) => {
upd.execute(params![e.path, e.mtime, e.size])?;
updated += 1;
}
None => {
ins.execute(params![e.path, e.mtime, e.size])?;
inserted += 1;
}
}
}
for p in existing.keys() {
if !seen.contains(p) {
del.execute(params![p])?;
deleted += 1;
}
}
}
tx.commit()?;
Ok((inserted, updated, deleted))
}
/// All known entries, newest first.
pub fn list_entries(conn: &mut Connection) -> Result<Vec<Entry>, rusqlite::Error> {
let mut st = conn.prepare("SELECT path, mtime, size FROM items ORDER BY mtime DESC, path ASC")?;
let rows = st.query_map([], |r| {
Ok(Entry { path: r.get(0)?, mtime: r.get(1)?, size: r.get(2)? })
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
/// Update the derived thumbnail columns once the pool has finished a job.
pub fn set_thumb(
conn: &mut Connection,
path: &str,
width: i64,
height: i64,
orientation: i64,
thumb_path: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE items SET width = ?2, height = ?3, orientation = ?4, thumb_path = ?5 \
WHERE path = ?1",
params![path, width, height, orientation, thumb_path],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan::{walk, Entry};
fn entry(path: &str, mtime: i64, size: i64) -> Entry {
Entry { path: path.into(), mtime, size }
}
#[test]
fn incremental_apply() {
let dir = tempfile::tempdir().unwrap();
let mut conn = open(&dir.path().join("t.db")).unwrap();
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap();
assert_eq!((i, u, d), (2, 0, 0));
// No changes → no-op.
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap();
assert_eq!((i, u, d), (0, 0, 0));
// mtime changed → update; 3.jpg new; 2.jpg removed.
let (i, u, d) = apply(
&mut conn,
&[entry("/a/1.jpg", 5, 11), entry("/a/3.jpg", 3, 30)],
)
.unwrap();
assert_eq!((i, u, d), (1, 1, 1));
let list = list_entries(&mut conn).unwrap();
let paths: Vec<&str> = list.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]);
}
#[test]
fn set_thumb_updates_row_and_invalidation() {
let dir = tempfile::tempdir().unwrap();
let mut conn = open(&dir.path().join("t.db")).unwrap();
apply(&mut conn, &[entry("/a/1.jpg", 1, 10)]).unwrap();
set_thumb(&mut conn, "/a/1.jpg", 8000, 6016, 6, "/cache/abc.jpg").unwrap();
let thumb: String = conn
.query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0))
.unwrap();
assert_eq!(thumb, "/cache/abc.jpg");
// mtime change → apply() invalidates the derived columns.
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 2, 12)]).unwrap();
assert_eq!((i, u, d), (0, 1, 0));
let thumb: Option<String> = conn
.query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0))
.unwrap();
assert_eq!(thumb, None);
}
#[test]
fn scan_then_apply_roundtrip() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("a.jpg"), b"xx").unwrap();
std::fs::write(dir.path().join("sub").join("b.png"), b"y").unwrap();
std::fs::write(dir.path().join("notes.txt"), b"nope").unwrap();
let db_dir = tempfile::tempdir().unwrap();
let mut conn = open(&db_dir.path().join("t.db")).unwrap();
let entries = walk(&[dir.path().to_path_buf()], &|_| {});
let (i, u, d) = apply(&mut conn, &entries).unwrap();
assert_eq!((i, u, d), (2, 0, 0));
let list = list_entries(&mut conn).unwrap();
assert_eq!(list.len(), 2);
assert!(list.iter().all(|e| e.path.contains(dir.path().to_str().unwrap())));
}
}