feat: multi-library foundation (schema + libraries module)

Adds a `libraries` registry table and threads library_id through
per-instance metadata tables (image_exif, photo_insights,
entity_photo_links, video_preview_clips). File-path columns renamed to
rel_path to make the relative-to-root semantics explicit. Adds
content_hash + size_bytes on image_exif to support future hash-keyed
thumbnail/HLS dedup. Tags and favorites stay library-agnostic so they
share across libraries by rel_path.

Behavior is unchanged: a single primary library (id=1) is seeded from
BASE_PATH on first boot; all handlers and DAOs route through it as a
transitional shim until the API gains a library query param.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-17 15:28:30 -04:00
committed by cameron
parent 2f4edba08c
commit ffcddbb843
17 changed files with 750 additions and 108 deletions

View File

@@ -86,10 +86,14 @@ impl InsightDao for SqliteInsightDao {
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
// Mark all existing insights for this file as no longer current
diesel::update(photo_insights.filter(file_path.eq(&insight.file_path)))
.set(is_current.eq(false))
.execute(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Update is_current error"))?;
diesel::update(
photo_insights
.filter(library_id.eq(insight.library_id))
.filter(rel_path.eq(&insight.file_path)),
)
.set(is_current.eq(false))
.execute(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Update is_current error"))?;
// Insert the new insight as current
diesel::insert_into(photo_insights)
@@ -99,7 +103,8 @@ impl InsightDao for SqliteInsightDao {
// Retrieve the inserted record (is_current = true)
photo_insights
.filter(file_path.eq(&insight.file_path))
.filter(library_id.eq(insight.library_id))
.filter(rel_path.eq(&insight.file_path))
.filter(is_current.eq(true))
.first::<PhotoInsight>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))
@@ -118,7 +123,7 @@ impl InsightDao for SqliteInsightDao {
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
photo_insights
.filter(file_path.eq(path))
.filter(rel_path.eq(path))
.filter(is_current.eq(true))
.first::<PhotoInsight>(connection.deref_mut())
.optional()
@@ -138,7 +143,7 @@ impl InsightDao for SqliteInsightDao {
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
photo_insights
.filter(file_path.eq(path))
.filter(rel_path.eq(path))
.order(generated_at.desc())
.load::<PhotoInsight>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))
@@ -156,7 +161,7 @@ impl InsightDao for SqliteInsightDao {
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
diesel::delete(photo_insights.filter(file_path.eq(path)))
diesel::delete(photo_insights.filter(rel_path.eq(path)))
.execute(connection.deref_mut())
.map(|_| ())
.map_err(|_| anyhow::anyhow!("Delete error"))
@@ -195,7 +200,7 @@ impl InsightDao for SqliteInsightDao {
diesel::update(
photo_insights
.filter(file_path.eq(path))
.filter(rel_path.eq(path))
.filter(is_current.eq(true)),
)
.set(approved.eq(Some(is_approved)))

View File

@@ -550,8 +550,8 @@ impl KnowledgeDao for SqliteKnowledgeDao {
// 3. Copy photo links to target (INSERT OR IGNORE to skip duplicates)
let links_updated = diesel::sql_query(
"INSERT OR IGNORE INTO entity_photo_links (entity_id, file_path, role) \
SELECT ?, file_path, role FROM entity_photo_links WHERE entity_id = ?",
"INSERT OR IGNORE INTO entity_photo_links (entity_id, library_id, rel_path, role) \
SELECT ?, library_id, rel_path, role FROM entity_photo_links WHERE entity_id = ?",
)
.bind::<diesel::sql_types::Integer, _>(target_id)
.bind::<diesel::sql_types::Integer, _>(source_id)
@@ -781,11 +781,12 @@ impl KnowledgeDao for SqliteKnowledgeDao {
) -> Result<(), DbError> {
trace_db_call(cx, "insert", "upsert_photo_link", |_span| {
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
// INSERT OR IGNORE respects the UNIQUE(entity_id, file_path, role) constraint
// INSERT OR IGNORE respects the UNIQUE(entity_id, library_id, rel_path, role) constraint
diesel::sql_query(
"INSERT OR IGNORE INTO entity_photo_links (entity_id, file_path, role) VALUES (?, ?, ?)"
"INSERT OR IGNORE INTO entity_photo_links (entity_id, library_id, rel_path, role) VALUES (?, ?, ?, ?)"
)
.bind::<diesel::sql_types::Integer, _>(link.entity_id)
.bind::<diesel::sql_types::Integer, _>(link.library_id)
.bind::<diesel::sql_types::Text, _>(&link.file_path)
.bind::<diesel::sql_types::Text, _>(&link.role)
.execute(conn.deref_mut())
@@ -803,7 +804,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
trace_db_call(cx, "delete", "delete_photo_links_for_file", |_span| {
use schema::entity_photo_links::dsl::*;
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
diesel::delete(entity_photo_links.filter(file_path.eq(file_path_val)))
diesel::delete(entity_photo_links.filter(rel_path.eq(file_path_val)))
.execute(conn.deref_mut())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))
@@ -820,7 +821,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
use schema::entity_photo_links::dsl::*;
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
entity_photo_links
.filter(file_path.eq(file_path_val))
.filter(rel_path.eq(file_path_val))
.load::<EntityPhotoLink>(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})

View File

@@ -184,7 +184,7 @@ impl FavoriteDao for SqliteFavoriteDao {
let mut connection = self.connection.lock().expect("Unable to get FavoriteDao");
if favorites
.filter(userid.eq(user_id).and(path.eq(&favorite_path)))
.filter(userid.eq(user_id).and(rel_path.eq(&favorite_path)))
.first::<Favorite>(connection.deref_mut())
.is_err()
{
@@ -204,7 +204,7 @@ impl FavoriteDao for SqliteFavoriteDao {
use schema::favorites::dsl::*;
diesel::delete(favorites)
.filter(userid.eq(user_id).and(path.eq(favorite_path)))
.filter(userid.eq(user_id).and(rel_path.eq(favorite_path)))
.execute(self.connection.lock().unwrap().deref_mut())
.unwrap();
}
@@ -221,8 +221,8 @@ impl FavoriteDao for SqliteFavoriteDao {
fn update_path(&mut self, old_path: &str, new_path: &str) -> Result<(), DbError> {
use schema::favorites::dsl::*;
diesel::update(favorites.filter(path.eq(old_path)))
.set(path.eq(new_path))
diesel::update(favorites.filter(rel_path.eq(old_path)))
.set(rel_path.eq(new_path))
.execute(self.connection.lock().unwrap().deref_mut())
.map_err(|_| DbError::new(DbErrorKind::UpdateError))?;
Ok(())
@@ -232,7 +232,7 @@ impl FavoriteDao for SqliteFavoriteDao {
use schema::favorites::dsl::*;
favorites
.select(path)
.select(rel_path)
.distinct()
.load(self.connection.lock().unwrap().deref_mut())
.map_err(|_| DbError::new(DbErrorKind::QueryError))
@@ -349,7 +349,8 @@ impl ExifDao for SqliteExifDao {
.map_err(|_| anyhow::anyhow!("Insert error"))?;
image_exif
.filter(file_path.eq(&exif_data.file_path))
.filter(library_id.eq(exif_data.library_id))
.filter(rel_path.eq(&exif_data.file_path))
.first::<ImageExif>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))
})
@@ -372,7 +373,7 @@ impl ExifDao for SqliteExifDao {
let windows_path = path.replace('/', "\\");
match image_exif
.filter(file_path.eq(&normalized).or(file_path.eq(&windows_path)))
.filter(rel_path.eq(&normalized).or(rel_path.eq(&windows_path)))
.first::<ImageExif>(connection.deref_mut())
{
Ok(exif) => Ok(Some(exif)),
@@ -393,29 +394,34 @@ impl ExifDao for SqliteExifDao {
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
diesel::update(image_exif.filter(file_path.eq(&exif_data.file_path)))
.set((
camera_make.eq(&exif_data.camera_make),
camera_model.eq(&exif_data.camera_model),
lens_model.eq(&exif_data.lens_model),
width.eq(&exif_data.width),
height.eq(&exif_data.height),
orientation.eq(&exif_data.orientation),
gps_latitude.eq(&exif_data.gps_latitude),
gps_longitude.eq(&exif_data.gps_longitude),
gps_altitude.eq(&exif_data.gps_altitude),
focal_length.eq(&exif_data.focal_length),
aperture.eq(&exif_data.aperture),
shutter_speed.eq(&exif_data.shutter_speed),
iso.eq(&exif_data.iso),
date_taken.eq(&exif_data.date_taken),
last_modified.eq(&exif_data.last_modified),
))
.execute(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Update error"))?;
diesel::update(
image_exif
.filter(library_id.eq(exif_data.library_id))
.filter(rel_path.eq(&exif_data.file_path)),
)
.set((
camera_make.eq(&exif_data.camera_make),
camera_model.eq(&exif_data.camera_model),
lens_model.eq(&exif_data.lens_model),
width.eq(&exif_data.width),
height.eq(&exif_data.height),
orientation.eq(&exif_data.orientation),
gps_latitude.eq(&exif_data.gps_latitude),
gps_longitude.eq(&exif_data.gps_longitude),
gps_altitude.eq(&exif_data.gps_altitude),
focal_length.eq(&exif_data.focal_length),
aperture.eq(&exif_data.aperture),
shutter_speed.eq(&exif_data.shutter_speed),
iso.eq(&exif_data.iso),
date_taken.eq(&exif_data.date_taken),
last_modified.eq(&exif_data.last_modified),
))
.execute(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Update error"))?;
image_exif
.filter(file_path.eq(&exif_data.file_path))
.filter(library_id.eq(exif_data.library_id))
.filter(rel_path.eq(&exif_data.file_path))
.first::<ImageExif>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))
})
@@ -426,7 +432,7 @@ impl ExifDao for SqliteExifDao {
trace_db_call(context, "delete", "delete_exif", |_span| {
use schema::image_exif::dsl::*;
diesel::delete(image_exif.filter(file_path.eq(path)))
diesel::delete(image_exif.filter(rel_path.eq(path)))
.execute(self.connection.lock().unwrap().deref_mut())
.map(|_| ())
.map_err(|_| anyhow::anyhow!("Delete error"))
@@ -444,7 +450,7 @@ impl ExifDao for SqliteExifDao {
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
image_exif
.select((file_path, date_taken))
.select((rel_path, date_taken))
.filter(date_taken.is_not_null())
.load::<(String, Option<i64>)>(connection.deref_mut())
.map(|records| {
@@ -473,7 +479,7 @@ impl ExifDao for SqliteExifDao {
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
image_exif
.filter(file_path.eq_any(file_paths))
.filter(rel_path.eq_any(file_paths))
.load::<ImageExif>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))
})
@@ -572,8 +578,8 @@ impl ExifDao for SqliteExifDao {
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
diesel::update(image_exif.filter(file_path.eq(old_path)))
.set(file_path.eq(new_path))
diesel::update(image_exif.filter(rel_path.eq(old_path)))
.set(rel_path.eq(new_path))
.execute(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Update error"))?;
Ok(())
@@ -591,7 +597,7 @@ impl ExifDao for SqliteExifDao {
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
image_exif
.select(file_path)
.select(rel_path)
.load(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))
})
@@ -627,7 +633,7 @@ impl ExifDao for SqliteExifDao {
// Otherwise filter by path prefix
if !base_path.is_empty() && base_path != "/" {
// Match base path as prefix (with wildcard)
query = query.filter(file_path.like(format!("{}%", base_path)));
query = query.filter(rel_path.like(format!("{}%", base_path)));
span.set_attribute(KeyValue::new("path_filter_applied", true));
} else {

View File

@@ -1,6 +1,6 @@
use crate::database::schema::{
entities, entity_facts, entity_photo_links, favorites, image_exif, photo_insights, users,
video_preview_clips,
entities, entity_facts, entity_photo_links, favorites, image_exif, libraries, photo_insights,
users, video_preview_clips,
};
use serde::Serialize;
@@ -23,6 +23,7 @@ pub struct User {
#[diesel(table_name = favorites)]
pub struct InsertFavorite<'a> {
pub userid: &'a i32,
#[diesel(column_name = rel_path)]
pub path: &'a str,
}
@@ -30,12 +31,15 @@ pub struct InsertFavorite<'a> {
pub struct Favorite {
pub id: i32,
pub userid: i32,
#[diesel(column_name = rel_path)]
pub path: String,
}
#[derive(Insertable)]
#[diesel(table_name = image_exif)]
pub struct InsertImageExif {
pub library_id: i32,
#[diesel(column_name = rel_path)]
pub file_path: String,
pub camera_make: Option<String>,
pub camera_model: Option<String>,
@@ -53,11 +57,16 @@ pub struct InsertImageExif {
pub date_taken: Option<i64>,
pub created_time: i64,
pub last_modified: i64,
pub content_hash: Option<String>,
pub size_bytes: Option<i64>,
}
// Field order matches the post-migration column order in `image_exif`.
#[derive(Serialize, Queryable, Clone, Debug)]
pub struct ImageExif {
pub id: i32,
pub library_id: i32,
#[diesel(column_name = rel_path)]
pub file_path: String,
pub camera_make: Option<String>,
pub camera_model: Option<String>,
@@ -75,11 +84,15 @@ pub struct ImageExif {
pub date_taken: Option<i64>,
pub created_time: i64,
pub last_modified: i64,
pub content_hash: Option<String>,
pub size_bytes: Option<i64>,
}
#[derive(Insertable)]
#[diesel(table_name = photo_insights)]
pub struct InsertPhotoInsight {
pub library_id: i32,
#[diesel(column_name = rel_path)]
pub file_path: String,
pub title: String,
pub summary: String,
@@ -92,6 +105,8 @@ pub struct InsertPhotoInsight {
#[derive(Serialize, Queryable, Clone, Debug)]
pub struct PhotoInsight {
pub id: i32,
pub library_id: i32,
#[diesel(column_name = rel_path)]
pub file_path: String,
pub title: String,
pub summary: String,
@@ -102,6 +117,24 @@ pub struct PhotoInsight {
pub approved: Option<bool>,
}
// --- Libraries ---
#[derive(Serialize, Queryable, Clone, Debug)]
pub struct LibraryRow {
pub id: i32,
pub name: String,
pub root_path: String,
pub created_at: i64,
}
#[derive(Insertable)]
#[diesel(table_name = libraries)]
pub struct InsertLibrary<'a> {
pub name: &'a str,
pub root_path: &'a str,
pub created_at: i64,
}
// --- Knowledge memory models ---
#[derive(Insertable)]
@@ -162,6 +195,8 @@ pub struct EntityFact {
#[diesel(table_name = entity_photo_links)]
pub struct InsertEntityPhotoLink {
pub entity_id: i32,
pub library_id: i32,
#[diesel(column_name = rel_path)]
pub file_path: String,
pub role: String,
}
@@ -170,6 +205,8 @@ pub struct InsertEntityPhotoLink {
pub struct EntityPhotoLink {
pub id: i32,
pub entity_id: i32,
pub library_id: i32,
#[diesel(column_name = rel_path)]
pub file_path: String,
pub role: String,
}
@@ -177,6 +214,8 @@ pub struct EntityPhotoLink {
#[derive(Insertable)]
#[diesel(table_name = video_preview_clips)]
pub struct InsertVideoPreviewClip {
pub library_id: i32,
#[diesel(column_name = rel_path)]
pub file_path: String,
pub status: String,
pub created_at: String,
@@ -186,6 +225,8 @@ pub struct InsertVideoPreviewClip {
#[derive(Serialize, Queryable, Clone, Debug)]
pub struct VideoPreviewClip {
pub id: i32,
pub library_id: i32,
#[diesel(column_name = rel_path)]
pub file_path: String,
pub status: String,
pub duration_seconds: Option<f32>,

View File

@@ -84,6 +84,7 @@ impl PreviewDao for SqlitePreviewDao {
diesel::insert_or_ignore_into(video_preview_clips)
.values(InsertVideoPreviewClip {
library_id: 1,
file_path: file_path_val.to_string(),
status: status_val.to_string(),
created_at: now.clone(),
@@ -111,7 +112,7 @@ impl PreviewDao for SqlitePreviewDao {
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
let now = chrono::Utc::now().to_rfc3339();
diesel::update(video_preview_clips.filter(file_path.eq(file_path_val)))
diesel::update(video_preview_clips.filter(rel_path.eq(file_path_val)))
.set((
status.eq(status_val),
duration_seconds.eq(duration),
@@ -137,7 +138,7 @@ impl PreviewDao for SqlitePreviewDao {
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
match video_preview_clips
.filter(file_path.eq(file_path_val))
.filter(rel_path.eq(file_path_val))
.first::<VideoPreviewClip>(connection.deref_mut())
{
Ok(clip) => Ok(Some(clip)),
@@ -163,7 +164,7 @@ impl PreviewDao for SqlitePreviewDao {
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
video_preview_clips
.filter(file_path.eq_any(file_paths))
.filter(rel_path.eq_any(file_paths))
.load::<VideoPreviewClip>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})

View File

@@ -64,7 +64,8 @@ diesel::table! {
entity_photo_links (id) {
id -> Integer,
entity_id -> Integer,
file_path -> Text,
library_id -> Integer,
rel_path -> Text,
role -> Text,
}
}
@@ -73,14 +74,15 @@ diesel::table! {
favorites (id) {
id -> Integer,
userid -> Integer,
path -> Text,
rel_path -> Text,
}
}
diesel::table! {
image_exif (id) {
id -> Integer,
file_path -> Text,
library_id -> Integer,
rel_path -> Text,
camera_make -> Nullable<Text>,
camera_model -> Nullable<Text>,
lens_model -> Nullable<Text>,
@@ -97,18 +99,17 @@ diesel::table! {
date_taken -> Nullable<BigInt>,
created_time -> BigInt,
last_modified -> BigInt,
content_hash -> Nullable<Text>,
size_bytes -> Nullable<BigInt>,
}
}
diesel::table! {
knowledge_embeddings (id) {
libraries (id) {
id -> Integer,
keyword -> Text,
description -> Text,
category -> Nullable<Text>,
embedding -> Binary,
name -> Text,
root_path -> Text,
created_at -> BigInt,
model_version -> Text,
}
}
@@ -129,23 +130,11 @@ diesel::table! {
}
}
diesel::table! {
message_embeddings (id) {
id -> Integer,
contact -> Text,
body -> Text,
timestamp -> BigInt,
is_sent -> Bool,
embedding -> Binary,
created_at -> BigInt,
model_version -> Text,
}
}
diesel::table! {
photo_insights (id) {
id -> Integer,
file_path -> Text,
library_id -> Integer,
rel_path -> Text,
title -> Text,
summary -> Text,
generated_at -> BigInt,
@@ -171,7 +160,7 @@ diesel::table! {
diesel::table! {
tagged_photo (id) {
id -> Integer,
photo_name -> Text,
rel_path -> Text,
tag_id -> Integer,
created_time -> BigInt,
}
@@ -196,7 +185,8 @@ diesel::table! {
diesel::table! {
video_preview_clips (id) {
id -> Integer,
file_path -> Text,
library_id -> Integer,
rel_path -> Text,
status -> Text,
duration_seconds -> Nullable<Float>,
file_size_bytes -> Nullable<Integer>,
@@ -208,7 +198,11 @@ diesel::table! {
diesel::joinable!(entity_facts -> photo_insights (source_insight_id));
diesel::joinable!(entity_photo_links -> entities (entity_id));
diesel::joinable!(entity_photo_links -> libraries (library_id));
diesel::joinable!(image_exif -> libraries (library_id));
diesel::joinable!(photo_insights -> libraries (library_id));
diesel::joinable!(tagged_photo -> tags (tag_id));
diesel::joinable!(video_preview_clips -> libraries (library_id));
diesel::allow_tables_to_appear_in_same_query!(
calendar_events,
@@ -218,9 +212,8 @@ diesel::allow_tables_to_appear_in_same_query!(
entity_photo_links,
favorites,
image_exif,
knowledge_embeddings,
libraries,
location_history,
message_embeddings,
photo_insights,
search_history,
tagged_photo,