-- Multi-library support. -- Adds `libraries` registry table and a `library_id` column on per-instance -- metadata tables. Renames `file_path` / `photo_name` to `rel_path` for -- semantic clarity (values already stored relative to BASE_PATH). -- Adds `content_hash` + `size_bytes` to `image_exif` to support -- content-based dedup of thumbnails and HLS output across libraries. -- -- SQLite cannot alter column constraints in place, so per-instance tables -- are recreated following the idiom established in -- 2026-04-02-000000_photo_insights_history/up.sql. Existing row `id`s are -- preserved so foreign keys (entity_facts.source_insight_id, etc.) remain -- valid after migration. PRAGMA foreign_keys=OFF; -- --------------------------------------------------------------------------- -- 1. Libraries registry. -- Seeded with a placeholder for the primary library; AppState patches -- `root_path` from the BASE_PATH env var on first boot. Subsequent -- prod-to-dev DB syncs update this row via a single SQL UPDATE. -- --------------------------------------------------------------------------- CREATE TABLE libraries ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL UNIQUE, root_path TEXT NOT NULL, created_at BIGINT NOT NULL ); INSERT INTO libraries (id, name, root_path, created_at) VALUES (1, 'main', 'BASE_PATH_PLACEHOLDER', strftime('%s','now')); -- --------------------------------------------------------------------------- -- 2. image_exif: + library_id, file_path → rel_path, + content_hash/size_bytes. -- --------------------------------------------------------------------------- CREATE TABLE image_exif_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, library_id INTEGER NOT NULL REFERENCES libraries(id), rel_path TEXT NOT NULL, -- Camera information camera_make TEXT, camera_model TEXT, lens_model TEXT, -- Image properties width INTEGER, height INTEGER, orientation INTEGER, -- GPS gps_latitude REAL, gps_longitude REAL, gps_altitude REAL, -- Capture settings focal_length REAL, aperture REAL, shutter_speed TEXT, iso INTEGER, date_taken BIGINT, -- Housekeeping created_time BIGINT NOT NULL, last_modified BIGINT NOT NULL, -- Content identity (backfilled by the `backfill_hashes` binary and by the watcher for new files) content_hash TEXT, size_bytes BIGINT, UNIQUE(library_id, rel_path) ); INSERT INTO image_exif_new ( id, library_id, rel_path, camera_make, camera_model, lens_model, width, height, orientation, gps_latitude, gps_longitude, gps_altitude, focal_length, aperture, shutter_speed, iso, date_taken, created_time, last_modified ) SELECT id, 1, file_path, camera_make, camera_model, lens_model, width, height, orientation, gps_latitude, gps_longitude, gps_altitude, focal_length, aperture, shutter_speed, iso, date_taken, created_time, last_modified FROM image_exif; DROP TABLE image_exif; ALTER TABLE image_exif_new RENAME TO image_exif; CREATE INDEX idx_image_exif_rel_path ON image_exif(rel_path); CREATE INDEX idx_image_exif_camera ON image_exif(camera_make, camera_model); CREATE INDEX idx_image_exif_gps ON image_exif(gps_latitude, gps_longitude); CREATE INDEX idx_image_exif_date_taken ON image_exif(date_taken); CREATE INDEX idx_image_exif_date_path ON image_exif(date_taken DESC, rel_path); CREATE INDEX idx_image_exif_lib_date ON image_exif(library_id, date_taken); CREATE INDEX idx_image_exif_content_hash ON image_exif(content_hash); -- --------------------------------------------------------------------------- -- 3. photo_insights: + library_id, file_path → rel_path. -- Preserve `id` so entity_facts.source_insight_id FKs remain valid. -- --------------------------------------------------------------------------- CREATE TABLE photo_insights_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, library_id INTEGER NOT NULL REFERENCES libraries(id), rel_path TEXT NOT NULL, title TEXT NOT NULL, summary TEXT NOT NULL, generated_at BIGINT NOT NULL, model_version TEXT NOT NULL, is_current BOOLEAN NOT NULL DEFAULT 0, training_messages TEXT, approved BOOLEAN ); INSERT INTO photo_insights_new ( id, library_id, rel_path, title, summary, generated_at, model_version, is_current, training_messages, approved ) SELECT id, 1, file_path, title, summary, generated_at, model_version, is_current, training_messages, approved FROM photo_insights; DROP TABLE photo_insights; ALTER TABLE photo_insights_new RENAME TO photo_insights; CREATE INDEX idx_photo_insights_rel_path ON photo_insights(rel_path); CREATE INDEX idx_photo_insights_current ON photo_insights(library_id, rel_path, is_current); -- --------------------------------------------------------------------------- -- 4. entity_photo_links: + library_id, file_path → rel_path. -- Preserves entity FK; UNIQUE now includes library_id to allow the same -- rel_path to link entities in multiple libraries independently. -- --------------------------------------------------------------------------- CREATE TABLE entity_photo_links_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, entity_id INTEGER NOT NULL, library_id INTEGER NOT NULL REFERENCES libraries(id), rel_path TEXT NOT NULL, role TEXT NOT NULL, CONSTRAINT fk_epl_entity FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE, UNIQUE(entity_id, library_id, rel_path, role) ); INSERT INTO entity_photo_links_new (id, entity_id, library_id, rel_path, role) SELECT id, entity_id, 1, file_path, role FROM entity_photo_links; DROP TABLE entity_photo_links; ALTER TABLE entity_photo_links_new RENAME TO entity_photo_links; CREATE INDEX idx_entity_photo_links_entity ON entity_photo_links(entity_id); CREATE INDEX idx_entity_photo_links_photo ON entity_photo_links(library_id, rel_path); -- --------------------------------------------------------------------------- -- 5. video_preview_clips: + library_id, file_path → rel_path. -- --------------------------------------------------------------------------- CREATE TABLE video_preview_clips_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, library_id INTEGER NOT NULL REFERENCES libraries(id), rel_path TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', duration_seconds REAL, file_size_bytes INTEGER, error_message TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(library_id, rel_path) ); INSERT INTO video_preview_clips_new ( id, library_id, rel_path, status, duration_seconds, file_size_bytes, error_message, created_at, updated_at ) SELECT id, 1, file_path, status, duration_seconds, file_size_bytes, error_message, created_at, updated_at FROM video_preview_clips; DROP TABLE video_preview_clips; ALTER TABLE video_preview_clips_new RENAME TO video_preview_clips; CREATE INDEX idx_preview_clips_rel_path ON video_preview_clips(rel_path); CREATE INDEX idx_preview_clips_status ON video_preview_clips(status); -- --------------------------------------------------------------------------- -- 6. favorites: path → rel_path. Library-agnostic (cross-library sharing). -- --------------------------------------------------------------------------- ALTER TABLE favorites RENAME COLUMN path TO rel_path; DROP INDEX IF EXISTS idx_favorites_path; DROP INDEX IF EXISTS idx_favorites_unique; CREATE INDEX idx_favorites_rel_path ON favorites(rel_path); CREATE UNIQUE INDEX idx_favorites_unique ON favorites(userid, rel_path); -- --------------------------------------------------------------------------- -- 7. tagged_photo: photo_name → rel_path. Library-agnostic. -- Dedup first so the (rel_path, tag_id) unique index can be created safely. -- --------------------------------------------------------------------------- ALTER TABLE tagged_photo RENAME COLUMN photo_name TO rel_path; DELETE FROM tagged_photo WHERE id NOT IN ( SELECT MIN(id) FROM tagged_photo GROUP BY rel_path, tag_id ); DROP INDEX IF EXISTS idx_tagged_photo_photo_name; DROP INDEX IF EXISTS idx_tagged_photo_count; CREATE INDEX idx_tagged_photo_rel_path ON tagged_photo(rel_path); CREATE UNIQUE INDEX idx_tagged_photo_relpath_tag ON tagged_photo(rel_path, tag_id); PRAGMA foreign_keys=ON; ANALYZE;