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
parent 8bc948b297
commit 00da97fe86
17 changed files with 750 additions and 108 deletions

View File

@@ -0,0 +1,216 @@
-- 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;