-- Consolidated Bookhoard Database Schema -- This file contains the complete current schema for the Bookhoard media library management system -- Create extensions for full-text search and fuzzy matching CREATE EXTENSION IF NOT EXISTS pg_trgm; -- Create library types table CREATE TABLE IF NOT EXISTS library_types ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(50) UNIQUE NOT NULL, description TEXT, allowed_extensions TEXT[] NOT NULL, -- Array of allowed file extensions for this type created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Insert default library types INSERT INTO library_types (name, description, allowed_extensions) VALUES ('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']), ('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']), ('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']) ON CONFLICT (name) DO NOTHING; -- Create users table CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, username VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, first_name VARCHAR(255), last_name VARCHAR(255), role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')), theme VARCHAR(50) DEFAULT 'tokyo-night', max_devices INTEGER DEFAULT 10, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), timezone VARCHAR(50) DEFAULT 'UTC', updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Create system_settings table CREATE TABLE IF NOT EXISTS system_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), setting_key VARCHAR(100) UNIQUE NOT NULL, setting_value TEXT NOT NULL, description TEXT, updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Extend system_settings with typed metadata so it can back the admin UI's -- configurable tunables. All columns are nullable for backward compatibility -- with the original three rows and any pre-existing data. ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20); ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40); -- Insert default system settings (original scan/timezone rows + tunables). -- Values match the previous hardcoded literals, so behavior is unchanged on upgrade. -- ON CONFLICT DO NOTHING preserves any admin-modified values. INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES ('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'), ('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'), ('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'), -- security / auth (live) ('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'), ('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'), ('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'), ('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'), ('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'), ('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'), -- security / auth (restart required) ('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'), ('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'), ('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'), -- api (live) ('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'), ('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'), ('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'), ('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'), ('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'), -- sync / performance (live) ('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'), ('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'), -- sync / performance (restart required) ('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'), ('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'), ('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'), ('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance') ON CONFLICT (setting_key) DO NOTHING; -- Create refresh_tokens table CREATE TABLE IF NOT EXISTS refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), expires_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), revoked_at TIMESTAMP WITH TIME ZONE ); -- Create libraries table CREATE TABLE IF NOT EXISTS libraries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, description TEXT, library_type_id UUID NOT NULL REFERENCES library_types(id) ON DELETE RESTRICT, created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Create library_folders table for multiple folders per library CREATE TABLE IF NOT EXISTS library_folders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, folder_path VARCHAR(500) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(library_id, folder_path) ); -- Create library_visibility table for user-specific library visibility CREATE TABLE IF NOT EXISTS library_visibility ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, is_visible BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(user_id, library_id) ); -- Create media_items table CREATE TABLE IF NOT EXISTS media_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, author VARCHAR(255), isbn VARCHAR(13), -- Still relevant for ebooks description TEXT, file_path VARCHAR(500) NOT NULL, file_size BIGINT, mime_type VARCHAR(100), cover_image_path VARCHAR(500), series VARCHAR(255), series_number INTEGER, tags TEXT[], asin VARCHAR(20), -- Still relevant for ebooks date_published DATE, publisher VARCHAR(255), contributors TEXT[], -- Enhanced fields for better metadata and functionality language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1) edition VARCHAR(100), -- Edition information page_count INTEGER, -- Total page count genre VARCHAR(100), -- Genre classification copyright_year INTEGER, -- Copyright/publication year goodreads_id VARCHAR(50), -- Goodreads identifier openlibrary_id VARCHAR(50), -- Open Library identifier google_books_id VARCHAR(100), -- Google Books identifier added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), imported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- Universal Sync Format Detection format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable', format_mimetype VARCHAR(100), is_reflowable BOOLEAN DEFAULT TRUE, has_fixed_layout BOOLEAN DEFAULT FALSE, total_characters BIGINT, chapter_count INTEGER, -- Kobo-Specific Metadata entitlement_id VARCHAR(255) UNIQUE, revision_number INTEGER DEFAULT 1, kobo_content_id VARCHAR(255), kobo_metadata JSONB, -- Manga and comic reading direction support -- Stores raw Manga field from ComicInfo.xml manga_type VARCHAR(30) DEFAULT 'unknown', reading_direction VARCHAR(20) DEFAULT 'auto', CHECK (manga_type IN ('unknown', 'no', 'yes', 'yes_and_right_to_left')), CHECK (reading_direction IN ('auto', 'ltr', 'rtl', 'vertical')), -- Universal series information (applies to ALL formats: ebooks, audiobooks, comics) series_count INTEGER, -- Total items in series (from ComicInfo Count field, or book series count) volume INTEGER, -- Volume/omnibus number for collected editions -- Universal publisher and classification (applies to ALL formats) imprint VARCHAR(255), -- Publisher imprint (e.g., Vertigo, HarperCollinsEpic) age_rating VARCHAR(20), -- Age rating: Everyone, Teen, Mature, Adult (applies to all formats) web_url VARCHAR(500), -- URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.) -- Comic-specific fields story_arc VARCHAR(255), -- Story arc name (e.g., "The Dark Phoenix Saga", "Civil War") is_black_and_white BOOLEAN, -- Black and white flag (mostly comics, some illustrated books) -- Additional metadata (applies to all formats) metadata_notes TEXT, -- Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes community_rating DOUBLE PRECISION, -- Pre-existing community rating from metadata (0.0-10.0) - distinct from user ratings -- Alternate series information (JSONB for flexible schema - comic-specific) alternate_info JSONB, -- Stores AlternateSeries, AlternateNumber, AlternateCount -- Example: {"alternate_series": "Ultimate X-Men", "alternate_number": 1, "alternate_count": 12} -- Scan and publication metadata (comic-specific) scan_information TEXT, -- Scan information (scanner group, resolution, etc.) -- Summary (distinct from description - may merge with Calibre description) summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre) -- Chapter metadata for reader navigation and progress tracking -- Caches detected chapter structure to avoid re-parsing files -- Populated by ReaderService.DetectChapters() on first read -- JSONB structure: -- { -- "chapters": [ -- {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20, "level": 1}, -- {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25, "level": 1} -- ], -- "detected_at": "2024-01-15T10:30:00Z", -- "detection_method": "epub-toc" | "pdf-outline" | "page-breaks" -- } chapter_metadata JSONB, -- Need library_type_name for checks library_type_name VARCHAR(50) ); -- Add GIN indexes for fast array searches CREATE INDEX IF NOT EXISTS idx_media_items_tags_gin ON media_items USING GIN (tags); CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING GIN (contributors); -- Add search fields for case-insensitive, punctuation-free searching ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[]; ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[]; -- Create GIN indexes for fast search field searches CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search); CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search); -- Drop existing trigger if exists DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items; -- Drop existing function if exists DROP FUNCTION IF EXISTS set_library_type_name(); -- Add trigger to populate library_type_name on insert CREATE OR REPLACE FUNCTION set_library_type_name() RETURNS TRIGGER AS $$ BEGIN SELECT lt.name INTO NEW.library_type_name FROM libraries l JOIN library_types lt ON l.library_type_id = lt.id WHERE l.id = NEW.library_id; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER set_library_type_name_on_insert BEFORE INSERT ON media_items FOR EACH ROW EXECUTE FUNCTION set_library_type_name(); -- Add GIN indexes for pg_trgm fuzzy search performance CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm ON media_items USING GIN (author gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_media_items_title_trgm ON media_items USING GIN (title gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_media_items_series_trgm ON media_items USING GIN (series gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_media_items_genre_trgm ON media_items USING GIN (genre gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_media_items_language_trgm ON media_items USING GIN (language gin_trgm_ops); -- Create reading_progress table CREATE TABLE IF NOT EXISTS reading_progress ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, current_page INTEGER DEFAULT 0, total_pages INTEGER, last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(media_item_id, user_id), -- Universal Progress Tracking percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1), character_offset BIGINT, epubcfi TEXT, context_text TEXT, chapter INTEGER, chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1), viewport_x FLOAT DEFAULT 0, viewport_y FLOAT DEFAULT 0, zoom_level FLOAT DEFAULT 1.0, scroll_position_x FLOAT DEFAULT 0, scroll_position_y FLOAT DEFAULT 0, panel_number INTEGER, reading_mode VARCHAR(20), -- Device Sync Metadata last_sync_device VARCHAR(50), last_sync_source VARCHAR(20), last_sync_timestamp TIMESTAMP WITH TIME ZONE, conflict_detected BOOLEAN DEFAULT FALSE, conflict_resolved BOOLEAN DEFAULT TRUE ); -- Create media_ratings table CREATE TABLE IF NOT EXISTS media_ratings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 10), -- 10-point scale for half-star precision created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(media_item_id, user_id) ); -- Create media_notes table for user notes on media items CREATE TABLE IF NOT EXISTS media_notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, content TEXT NOT NULL, position VARCHAR(100), -- optional position (page:offset or CFI) for standalone notes created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- Location Enhancements percentage_location FLOAT, character_start INTEGER, character_end INTEGER, epubcfi_location TEXT, chapter_reference INTEGER, paragraph_reference INTEGER, device_sync_data JSONB ); -- Create media_highlights table for user highlights on media items CREATE TABLE IF NOT EXISTS media_highlights ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, selection_text TEXT NOT NULL, start_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight starts end_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight ends color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- Location Enhancements percentage_start FLOAT, percentage_end FLOAT, character_start INTEGER, character_end INTEGER, epubcfi_start TEXT, epubcfi_end TEXT, chapter_reference INTEGER, paragraph_start INTEGER, paragraph_end INTEGER, panel_number INTEGER, device_sync_data JSONB ); -- ============================================ -- DEVICE REGISTRY -- ============================================ CREATE TABLE IF NOT EXISTS devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, device_name VARCHAR(100) NOT NULL, device_type VARCHAR(20) NOT NULL, device_identifier VARCHAR(255) UNIQUE NOT NULL, auth_token VARCHAR(500) UNIQUE NOT NULL, last_sync TIMESTAMP WITH TIME ZONE, last_seen TIMESTAMP WITH TIME ZONE, sync_enabled BOOLEAN DEFAULT TRUE, auto_sync BOOLEAN DEFAULT TRUE, sync_frequency_minutes INTEGER DEFAULT 5, device_metadata JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- ============================================ -- SYNC QUEUE FOR OFFLINE SUPPORT -- ============================================ CREATE TABLE IF NOT EXISTS sync_queue ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, media_item_id UUID REFERENCES media_items(id) ON DELETE CASCADE, sync_type VARCHAR(20) NOT NULL, sync_data JSONB NOT NULL, priority INTEGER DEFAULT 5, attempts INTEGER DEFAULT 0, max_attempts INTEGER DEFAULT 3, status VARCHAR(20) DEFAULT 'pending', error_message TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), processed_at TIMESTAMP WITH TIME ZONE ); -- ============================================ -- CONFLICT RESOLUTION -- ============================================ CREATE TABLE IF NOT EXISTS sync_conflicts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, conflict_type VARCHAR(20) NOT NULL, conflict_data JSONB NOT NULL, resolution_status VARCHAR(20) DEFAULT 'unresolved', resolution_data JSONB, resolved_by UUID REFERENCES users(id), resolved_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- ============================================ -- KOBO SHELF MANAGEMENT -- ============================================ CREATE TABLE IF NOT EXISTS kobo_shelves ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, shelf_name VARCHAR(100) DEFAULT 'Default', shelf_position INTEGER DEFAULT 0, added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), last_synced_at TIMESTAMP WITH TIME ZONE, UNIQUE(device_id, media_item_id) ); CREATE INDEX IF NOT EXISTS idx_kobo_shelves_device_id ON kobo_shelves(device_id); CREATE INDEX IF NOT EXISTS idx_kobo_shelves_media_item_id ON kobo_shelves(media_item_id); CREATE INDEX IF NOT EXISTS idx_kobo_shelves_shelf_name ON kobo_shelves(shelf_name); -- Table to track items that can't be processed in their library CREATE TABLE IF NOT EXISTS processing_issues ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, issue_type VARCHAR(50) NOT NULL, issue_description TEXT NOT NULL, severity VARCHAR(20) NOT NULL DEFAULT 'warning', resolved BOOLEAN DEFAULT FALSE, resolved_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(media_item_id, issue_type) ); -- Indexes for querying problem items CREATE INDEX IF NOT EXISTS idx_processing_issues_library ON processing_issues(library_id, resolved); CREATE INDEX IF NOT EXISTS idx_processing_issues_severity ON processing_issues(severity, resolved); -- Add comment for documentation COMMENT ON TABLE processing_issues IS 'Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues'; -- ============================================ -- KOBO ENTITLEMENTS TRACKING -- ============================================ CREATE TABLE IF NOT EXISTS kobo_entitlements ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, entitlement_id VARCHAR(255) NOT NULL, content_id VARCHAR(255) NOT NULL, revision_number INTEGER DEFAULT 1, purchase_date TIMESTAMP WITH TIME ZONE, accession_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(), book_status VARCHAR(20) DEFAULT 'installed', sync_status VARCHAR(20) DEFAULT 'synced', kobo_metadata JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(device_id, entitlement_id) ); CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_device_id ON kobo_entitlements(device_id); CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_media_item_id ON kobo_entitlements(media_item_id); CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_entitlement_id ON kobo_entitlements(entitlement_id); CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_content_id ON kobo_entitlements(content_id); -- ============================================ -- READING HISTORY -- ============================================ CREATE TABLE IF NOT EXISTS reading_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, device_id UUID REFERENCES devices(id), progress_percentage FLOAT, reading_session_start TIMESTAMP WITH TIME ZONE, reading_session_end TIMESTAMP WITH TIME ZONE, pages_read INTEGER, time_spent_seconds INTEGER, device_metadata JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Create indexes for better query performance CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone); CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); -- Library indexes CREATE INDEX IF NOT EXISTS idx_libraries_name ON libraries(name); CREATE INDEX IF NOT EXISTS idx_libraries_library_type_id ON libraries(library_type_id); CREATE INDEX IF NOT EXISTS idx_libraries_created_by_admin_id ON libraries(created_by_admin_id); CREATE INDEX IF NOT EXISTS idx_library_folders_library_id ON library_folders(library_id); CREATE INDEX IF NOT EXISTS idx_library_visibility_user_id ON library_visibility(user_id); CREATE INDEX IF NOT EXISTS idx_library_visibility_library_id ON library_visibility(library_id); -- Media items indexes CREATE INDEX IF NOT EXISTS idx_media_items_title ON media_items(title); CREATE INDEX IF NOT EXISTS idx_media_items_author ON media_items(author); CREATE INDEX IF NOT EXISTS idx_media_items_library_id ON media_items(library_id); CREATE INDEX IF NOT EXISTS idx_media_items_added_by_admin_id ON media_items(added_by_admin_id); CREATE INDEX IF NOT EXISTS idx_media_items_genre ON media_items(genre); CREATE INDEX IF NOT EXISTS idx_media_items_language ON media_items(language); CREATE INDEX IF NOT EXISTS idx_media_items_copyright_year ON media_items(copyright_year); CREATE INDEX IF NOT EXISTS idx_media_items_page_count ON media_items(page_count); CREATE INDEX IF NOT EXISTS idx_media_items_series ON media_items(series); -- Index for filtering by reading direction (for manga/comic libraries) CREATE INDEX IF NOT EXISTS idx_media_items_reading_direction ON media_items(reading_direction) WHERE reading_direction IS NOT NULL; -- Index for filtering by story arc (comic-specific) CREATE INDEX IF NOT EXISTS idx_media_items_story_arc ON media_items(story_arc) WHERE story_arc IS NOT NULL; -- Index for filtering by imprint (universal - all formats) CREATE INDEX IF NOT EXISTS idx_media_items_imprint ON media_items(imprint) WHERE imprint IS NOT NULL; -- Index for filtering by age rating (universal - all formats) CREATE INDEX IF NOT EXISTS idx_media_items_age_rating ON media_items(age_rating) WHERE age_rating IS NOT NULL; -- Index for filtering by manga type (comic-specific) CREATE INDEX IF NOT EXISTS idx_media_items_manga_type ON media_items(manga_type) WHERE manga_type IS NOT NULL; -- Index for filtering by series count (universal - all formats) CREATE INDEX IF NOT EXISTS idx_media_items_series_count ON media_items(series_count) WHERE series_count IS NOT NULL; -- Index for filtering by volume (universal - all formats) CREATE INDEX IF NOT EXISTS idx_media_items_volume ON media_items(volume) WHERE volume IS NOT NULL; -- GIN index for alternate_info JSONB queries (comic-specific) CREATE INDEX IF NOT EXISTS idx_media_items_alternate_info_gin ON media_items USING GIN (alternate_info) WHERE alternate_info IS NOT NULL; -- Progress and ratings indexes CREATE INDEX IF NOT EXISTS idx_reading_progress_media_item_id ON reading_progress(media_item_id); CREATE INDEX IF NOT EXISTS idx_reading_progress_user_id ON reading_progress(user_id); CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id); -- Notes and highlights indexes CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id); CREATE INDEX IF NOT EXISTS idx_media_highlights_media_item_id ON media_highlights(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_highlights_user_id ON media_highlights(user_id); CREATE INDEX IF NOT EXISTS idx_media_highlights_note_id ON media_highlights(note_id); -- ============================================ -- NEW TABLE INDEXES -- ============================================ -- Device registry indexes CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id); CREATE INDEX IF NOT EXISTS idx_devices_device_type ON devices(device_type); CREATE INDEX IF NOT EXISTS idx_devices_device_identifier ON devices(device_identifier); -- Sync queue indexes CREATE INDEX IF NOT EXISTS idx_sync_queue_device_id ON sync_queue(device_id); CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status); CREATE INDEX IF NOT EXISTS idx_sync_queue_priority ON sync_queue(priority); -- Sync conflicts indexes CREATE INDEX IF NOT EXISTS idx_sync_conflicts_media_item_id ON sync_conflicts(media_item_id); CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user_id ON sync_conflicts(user_id); CREATE INDEX IF NOT EXISTS idx_sync_conflicts_status ON sync_conflicts(resolution_status); -- Composite indexes for sync queue performance CREATE INDEX IF NOT EXISTS idx_sync_queue_device_status_priority ON sync_queue(device_id, status, priority ASC, created_at ASC); CREATE INDEX IF NOT EXISTS idx_sync_queue_status_priority_created ON sync_queue(status, priority ASC, created_at ASC); CREATE INDEX IF NOT EXISTS idx_sync_queue_device_created_at ON sync_queue(device_id, created_at DESC); -- Composite indexes for reading progress performance CREATE INDEX IF NOT EXISTS idx_reading_progress_user_last_sync ON reading_progress(user_id, last_sync_timestamp DESC); CREATE INDEX IF NOT EXISTS idx_reading_progress_media_last_sync ON reading_progress(media_item_id, last_sync_timestamp DESC); CREATE INDEX IF NOT EXISTS idx_reading_progress_user_sync_source ON reading_progress(user_id, last_sync_source, last_sync_timestamp DESC); -- Composite indexes for devices CREATE INDEX IF NOT EXISTS idx_devices_user_sync_enabled ON devices(user_id, sync_enabled, auto_sync); CREATE INDEX IF NOT EXISTS idx_devices_user_type ON devices(user_id, device_type); CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen DESC) WHERE sync_enabled = true; -- Composite indexes for annotations CREATE INDEX IF NOT EXISTS idx_media_notes_user_media ON media_notes(user_id, media_item_id); CREATE INDEX IF NOT EXISTS idx_media_highlights_user_media ON media_highlights(user_id, media_item_id); -- Reading history indexes CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON reading_history(user_id); CREATE INDEX IF NOT EXISTS idx_reading_history_media_item_id ON reading_history(media_item_id); CREATE INDEX IF NOT EXISTS idx_reading_history_created_at ON reading_history(created_at DESC); CREATE INDEX IF NOT EXISTS idx_reading_history_user_device_created ON reading_history(user_id, device_id, created_at DESC); -- ============================================ -- TRIGGER FUNCTIONS -- ============================================ -- Update updated_at timestamp for devices CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Trigger for devices table DROP TRIGGER IF EXISTS update_devices_updated_at ON devices; CREATE TRIGGER update_devices_updated_at BEFORE UPDATE ON devices FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Add comment explaining the rating system COMMENT ON COLUMN media_ratings.rating IS 'Rating scale 1-10 (odd numbers = half-stars: 1,3,5,7,9 = 0.5,1.5,2.5,3.5,4.5 stars)'; -- Enhanced metadata comments COMMENT ON COLUMN media_items.language IS 'ISO 639-1 language code (e.g., en, es, fr)'; COMMENT ON COLUMN media_items.copyright_year IS 'Copyright or publication year for filtering/sorting'; COMMENT ON COLUMN media_items.page_count IS 'Total page count for progress tracking and sorting'; COMMENT ON COLUMN media_items.genre IS 'Genre classification for filtering and discovery'; COMMENT ON COLUMN media_items.edition IS 'Edition information (e.g., "First Edition", "Revised Edition")'; COMMENT ON COLUMN media_items.goodreads_id IS 'Goodreads book identifier for integration'; COMMENT ON COLUMN media_items.openlibrary_id IS 'Open Library identifier for integration'; COMMENT ON COLUMN media_items.google_books_id IS 'Google Books identifier for integration'; -- Comic-specific fields COMMENT ON COLUMN media_items.manga_type IS 'Raw Manga field from ComicInfo.xml: unknown, no, yes, yes_and_right_to_left'; COMMENT ON COLUMN media_items.reading_direction IS 'Computed reading direction: auto, ltr (left-to-right), rtl (right-to-left), vertical (webtoons/manhwa)'; COMMENT ON COLUMN media_items.story_arc IS 'Story arc name for grouping related issues (e.g., "The Dark Phoenix Saga", "Civil War")'; COMMENT ON COLUMN media_items.is_black_and_white IS 'Black and white comic flag from ComicInfo.xml'; COMMENT ON COLUMN media_items.alternate_info IS 'Alternate series information as JSONB: {alternate_series, alternate_number, alternate_count}'; COMMENT ON COLUMN media_items.scan_information IS 'Scan information from ComicInfo.xml (scanner group, resolution, etc.)'; -- Universal fields (apply to ebooks, audiobooks, comics) COMMENT ON COLUMN media_items.series_count IS 'Total items in series (from ComicInfo.xml Count field, or book series count)'; COMMENT ON COLUMN media_items.volume IS 'Volume/omnibus number for collected editions'; COMMENT ON COLUMN media_items.imprint IS 'Publisher imprint/subdivision (e.g., Vertigo, HarperCollinsEpic, DC Black Label)'; COMMENT ON COLUMN media_items.age_rating IS 'Age rating from metadata: Everyone, Teen, Mature, Adult (applies to all formats)'; COMMENT ON COLUMN media_items.web_url IS 'URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.)'; -- Additional metadata (applies to all formats) COMMENT ON COLUMN media_items.metadata_notes IS 'Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes in media_notes table'; COMMENT ON COLUMN media_items.community_rating IS 'Pre-existing community rating from metadata files (scale 0.0-10.0, DOUBLE PRECISION) - distinct from user ratings in media_ratings table'; COMMENT ON COLUMN media_items.summary IS 'Summary from ComicInfo.xml (may be merged with description from Calibre)'; -- Library Type File Extensions Notes: -- - Ebooks: .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb -- - Comics: .cbz, .cbr, .cb7, .cbt, .pdf -- - Manga: .cbz, .cbr, .png, .jpg, .jpeg, .gif, .bmp, .webp (note: manga includes image folders) -- Role System Notes: -- - All users default to 'user' role -- - Admin users can: create/manage libraries and folders, scan media, modify media metadata, delete media, control library visibility -- - Regular users can: view visible libraries, rate media, track reading progress, create notes and highlights, manage their profile -- - Library visibility is controlled through library_visibility table - admins can hide/show libraries per user -- - Notes and highlights support position data (page:offset or CFI format) for precise location tracking -- - Highlights can have associated notes for detailed annotations -- ============================================ -- PERFORMANCE OPTIMIZATION -- ============================================ -- NOTE: ALTER SYSTEM commands are commented out for sqlc compatibility. -- These should be run manually by DBA or via init script: -- -- Concurrency settings for high-throughput sync operations -- ALTER SYSTEM SET max_connections = 200; -- ALTER SYSTEM SET shared_buffers = '256MB'; -- ALTER SYSTEM SET effective_cache_size = '1GB'; -- ALTER SYSTEM SET maintenance_work_mem = '64MB'; -- ALTER SYSTEM SET checkpoint_completion_target = 0.9; -- ALTER SYSTEM SET wal_buffers = '16MB'; -- ALTER SYSTEM SET default_statistics_target = 100; -- -- Enable parallel query processing for sync operations -- ALTER SYSTEM SET max_parallel_workers_per_gather = 4; -- ALTER SYSTEM SET max_parallel_workers = 8; -- ALTER SYSTEM SET parallel_setup_cost = 100; -- ALTER SYSTEM SET parallel_tuple_cost = 0.1; -- -- Work memory for complex sync queries -- ALTER SYSTEM SET work_mem = '16MB'; -- -- Background writer optimization for sync queue -- ALTER SYSTEM SET bgwriter_delay = 200ms; -- ALTER SYSTEM SET bgwriter_lru_maxpages = 200; -- ALTER SYSTEM SET bgwriter_lru_multiplier = 2.0; -- Vacuum and analyze schedule for sync tables (run via cron) -- VACUUM ANALYZE sync_queue; -- VACUUM ANALYZE reading_progress; -- VACUUM ANALYZE sync_conflicts; -- VACUUM ANALYZE devices; -- REINDEX TABLE CONCURRENTLY sync_queue; -- REINDEX TABLE CONCURRENTLY reading_progress; -- ============================================ -- SYNC HELPER FUNCTIONS -- ============================================ -- Detect format group based on mimetype and file path CREATE OR REPLACE FUNCTION detect_format_group(p_mimetype VARCHAR, p_file_path VARCHAR) RETURNS VARCHAR AS $$ BEGIN CASE -- Reflowable formats WHEN p_mimetype = 'application/epub+zip' THEN RETURN 'reflowable'; WHEN p_mimetype = 'application/x-mobipocket-ebook' THEN RETURN 'reflowable'; WHEN p_mimetype = 'application/vnd.amazon.mobi8-ebook' THEN RETURN 'reflowable'; WHEN p_file_path LIKE '%.epub' THEN RETURN 'reflowable'; WHEN p_file_path LIKE '%.mobi' THEN RETURN 'reflowable'; WHEN p_file_path LIKE '%.azw3' THEN RETURN 'reflowable'; WHEN p_file_path LIKE '%.fb2' THEN RETURN 'reflowable'; WHEN p_file_path LIKE '%.txt' THEN RETURN 'reflowable'; -- Fixed layout formats WHEN p_mimetype = 'application/pdf' THEN RETURN 'fixed_layout'; WHEN p_file_path LIKE '%.pdf' THEN RETURN 'fixed_layout'; WHEN p_file_path LIKE '%.djvu' THEN RETURN 'fixed_layout'; -- Comic archive formats WHEN p_mimetype = 'application/x-cbr' THEN RETURN 'comic_archive'; WHEN p_mimetype = 'application/x-cbz' THEN RETURN 'comic_archive'; WHEN p_file_path LIKE '%.cbz' THEN RETURN 'comic_archive'; WHEN p_file_path LIKE '%.cbr' THEN RETURN 'comic_archive'; WHEN p_file_path LIKE '%.cbt' THEN RETURN 'comic_archive'; WHEN p_file_path LIKE '%.cb7' THEN RETURN 'comic_archive'; ELSE RETURN 'unknown'; END CASE; END; $$ LANGUAGE plpgsql; -- Function to detect fixed-layout EPUBs from OPF content CREATE OR REPLACE FUNCTION detect_fixed_layout_epub(opf_content TEXT) RETURNS BOOLEAN AS $$ BEGIN -- Check for pre-paginated metadata IF opf_content LIKE '%rendition:layout">pre-paginated<%' THEN RETURN TRUE; END IF; IF opf_content LIKE '%rendition:layout="pre-paginated"%' THEN RETURN TRUE; END IF; -- Check for RTL page progression (manga indicator) IF opf_content LIKE '%page-progression-direction="rtl"%' THEN RETURN TRUE; END IF; -- Check for image-heavy content (count tags) -- Threshold of 50 images suggests manga/comic vs novel IF array_length(string_to_array(opf_content, ' 50 THEN RETURN TRUE; END IF; RETURN FALSE; END; $$ LANGUAGE plpgsql; -- Convert progress between format groups CREATE OR REPLACE FUNCTION convert_progress( source_format VARCHAR, target_format VARCHAR, source_progress JSONB ) RETURNS JSONB AS $$ DECLARE result JSONB; percentage FLOAT; BEGIN -- Extract percentage from source CASE source_format WHEN 'reflowable' THEN percentage := (source_progress->>'percentage')::FLOAT; WHEN 'fixed_layout' THEN percentage := ((source_progress->>'page')::FLOAT / (source_progress->>'total_pages')::FLOAT); WHEN 'comic_archive' THEN percentage := ((source_progress->>'page')::FLOAT / (source_progress->>'total_pages')::FLOAT); ELSE percentage := 0.0; END CASE; -- Build target format progress CASE target_format WHEN 'reflowable' THEN result := jsonb_build_object( 'percentage', percentage, 'epubcfi', 'epubcfi(/6/4/2:' || CAST(percentage * 100 AS INTEGER) || ')' ); WHEN 'fixed_layout' THEN result := jsonb_build_object( 'page', CAST(percentage * 200 AS INTEGER), 'total_pages', 200, 'percentage', percentage ); WHEN 'comic_archive' THEN result := jsonb_build_object( 'page', CAST(percentage * 32 AS INTEGER), 'total_pages', 32, 'percentage', percentage ); ELSE result := '{}'::jsonb; END CASE; RETURN result; END; $$ LANGUAGE plpgsql; -- Detect conflicts in sync progress CREATE OR REPLACE FUNCTION detect_conflict( p_media_item_id UUID, new_progress JSONB, device_type VARCHAR ) RETURNS BOOLEAN AS $$ DECLARE existing_progress JSONB; time_diff INTERVAL; BEGIN -- Get most recent progress from different device SELECT jsonb_build_object( 'percentage', percentage, 'timestamp', last_sync_timestamp ) INTO existing_progress FROM reading_progress WHERE media_item_id = p_media_item_id AND last_sync_source != device_type ORDER BY last_sync_timestamp DESC LIMIT 1; -- If no existing progress, no conflict IF existing_progress IS NULL THEN RETURN FALSE; END IF; -- Check time difference (within 5 minutes = potential conflict) time_diff := NOW() - (existing_progress->>'timestamp')::TIMESTAMP WITH TIME ZONE; IF time_diff < INTERVAL '5 minutes' THEN -- Check if progress significantly different IF ABS((new_progress->>'percentage')::FLOAT - (existing_progress->>'percentage')::FLOAT) > 0.01 THEN RETURN TRUE; END IF; END IF; RETURN FALSE; END; $$ LANGUAGE plpgsql; -- Merge progress from two sources CREATE OR REPLACE FUNCTION merge_progress( progress_a JSONB, progress_b JSONB ) RETURNS JSONB AS $$ BEGIN RETURN jsonb_build_object( 'percentage', GREATEST( (progress_a->>'percentage')::FLOAT, (progress_b->>'percentage')::FLOAT ), 'merged_from', ARRAY[ (progress_a->>'source'), (progress_b->>'source') ], 'merge_timestamp', NOW() ); END; $$ LANGUAGE plpgsql; -- ============================================ -- KOREADER SYNC FUNCTIONS -- ============================================ -- Bulk update progress from KOReader sync data CREATE OR REPLACE FUNCTION bulk_update_progress_from_koreader( p_user_id UUID, p_sync_data JSONB ) RETURNS TABLE ( media_item_id UUID, success BOOLEAN, message TEXT ) AS $$ DECLARE book_record JSONB; media_uuid UUID; existing_progress reading_progress%ROWTYPE; conflict_detected BOOLEAN; BEGIN FOR book_record IN SELECT * FROM jsonb_array_elements(p_sync_data->'books') LOOP -- Try to find media item by UUID media_uuid := NULL; SELECT id INTO media_uuid FROM media_items WHERE id = (book_record->>'uuid')::uuid; -- If not found by UUID, try to match by file path IF media_uuid IS NULL AND book_record ? 'file_path' THEN SELECT id INTO media_uuid FROM media_items WHERE file_path = book_record->>'file_path'; END IF; -- If still not found, try to match by title + author IF media_uuid IS NULL THEN SELECT id INTO media_uuid FROM media_items WHERE title = book_record->>'title' AND author = book_record->>'author' LIMIT 1; END IF; -- If no match found, return failure IF media_uuid IS NULL THEN RETURN QUERY SELECT NULL::uuid, FALSE, 'media item not found'; CONTINUE; END IF; -- Check for conflicts conflict_detected := detect_conflict(media_uuid, book_record, 'koreader'); -- Check if progress exists SELECT * INTO existing_progress FROM reading_progress WHERE media_item_id = media_uuid AND user_id = p_user_id; -- Update or insert progress IF existing_progress.media_item_id IS NOT NULL THEN UPDATE reading_progress SET percentage = (book_record->>'percentage')::FLOAT, character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END, epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END, context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END, chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END, chapter_progress = (book_record->>'percentage')::FLOAT, last_sync_device = 'koreader', last_sync_source = 'koreader', last_sync_timestamp = NOW(), conflict_detected = conflict_detected, conflict_resolved = NOT conflict_detected, last_read_at = CASE WHEN book_record ? 'last_read' THEN (book_record->>'last_read')::TIMESTAMP WITH TIME ZONE ELSE NOW() END, current_page = CASE WHEN book_record ? 'page' THEN (book_record->>'page')::INTEGER ELSE existing_progress.current_page END, total_pages = CASE WHEN book_record ? 'total_pages' THEN (book_record->>'total_pages')::INTEGER ELSE existing_progress.total_pages END WHERE media_item_id = media_uuid AND user_id = p_user_id; ELSE INSERT INTO reading_progress ( media_item_id, user_id, percentage, character_offset, epubcfi, context_text, chapter, chapter_progress, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved, last_read_at, current_page, total_pages ) VALUES ( media_uuid, p_user_id, (book_record->>'percentage')::FLOAT, CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END, CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END, CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END, CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END, (book_record->>'percentage')::FLOAT, 'koreader', 'koreader', NOW(), conflict_detected, NOT conflict_detected, CASE WHEN book_record ? 'last_read' THEN (book_record->>'last_read')::TIMESTAMP WITH TIME ZONE ELSE NOW() END, CASE WHEN book_record ? 'page' THEN (book_record->>'page')::INTEGER ELSE NULL END, CASE WHEN book_record ? 'total_pages' THEN (book_record->>'total_pages')::INTEGER ELSE NULL END ); END IF; -- Create conflict record if detected IF conflict_detected THEN INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data) VALUES (media_uuid, p_user_id, 'progress', jsonb_build_object( 'koreader', book_record, 'timestamp', NOW() )); END IF; RETURN QUERY SELECT media_uuid, TRUE, CASE WHEN conflict_detected THEN 'conflict detected' ELSE 'success' END; END LOOP; END; $$ LANGUAGE plpgsql; -- ============================================ --: UNIVERSAL BOOK IDENTIFIERS -- ============================================ -- Add universal identifier columns to media_items table ALTER TABLE media_items ADD COLUMN IF NOT EXISTS file_sha256 CHAR(64); ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_identifier VARCHAR(255); ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_uuid VARCHAR(255); ALTER TABLE media_items ADD COLUMN IF NOT EXISTS hash_confidence VARCHAR(20); -- Create indexes for fast lookup CREATE INDEX IF NOT EXISTS idx_media_items_sha256 ON media_items(file_sha256); CREATE INDEX IF NOT EXISTS idx_media_items_opf_identifier ON media_items(opf_identifier); -- Create media_item_formats table (track all format versions with their hashes) CREATE TABLE IF NOT EXISTS media_item_formats ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, format_type VARCHAR(10) NOT NULL, file_path VARCHAR(500), file_sha256 CHAR(64), file_size_bytes BIGINT, mime_type VARCHAR(100), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), converted_from_format_id UUID REFERENCES media_item_formats(id), UNIQUE(media_item_id, format_type) ); CREATE INDEX IF NOT EXISTS idx_media_item_formats_media ON media_item_formats(media_item_id, format_type); CREATE INDEX IF NOT EXISTS idx_media_item_formats_sha256 ON media_item_formats(file_sha256); -- Create device_file_aliases table (track file paths per device for cross-device matching) CREATE TABLE IF NOT EXISTS device_file_aliases ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, file_path VARCHAR(500) NOT NULL, file_sha256 CHAR(64), confidence_score FLOAT DEFAULT 1.0, last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(device_id, file_path) ); CREATE INDEX IF NOT EXISTS idx_device_file_aliases_media_device ON device_file_aliases(media_item_id, device_id); CREATE INDEX IF NOT EXISTS idx_device_file_aliases_sha256 ON device_file_aliases(file_sha256); -- Create collections table (device-neutral collections) CREATE TABLE IF NOT EXISTS collections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, description TEXT, color VARCHAR(7), icon VARCHAR(50), auto_assign_rules JSONB, view_settings JSONB, show_on_dashboard BOOLEAN DEFAULT false, query_type TEXT DEFAULT 'filter', priority INT DEFAULT 100, is_system_collection BOOLEAN DEFAULT false, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(user_id, name) ); CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); -- Index for dashboard queries CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority) WHERE show_on_dashboard = true; -- Create user_dashboard_preferences table CREATE TABLE IF NOT EXISTS user_dashboard_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id UUID REFERENCES libraries(id) ON DELETE CASCADE, hidden_collections TEXT[] DEFAULT '{}', collection_order TEXT[] DEFAULT '{}', items_per_section INT DEFAULT 20, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(user_id, library_id) ); -- Index for fast lookups CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id); -- Create collection_items table (which books belong to each collection) CREATE TABLE IF NOT EXISTS collection_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, excluded BOOLEAN DEFAULT false, UNIQUE(collection_id, media_item_id) ); CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id); CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id); -- Index for excluding auto-assigned items CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded) WHERE excluded = true; -- System collections are now created per-user upon registration -- See CreateDefaultCollectionsForUser in auth.go -- Create device_shelf_mappings table (map Bookhoard collections to device-specific shelf names) CREATE TABLE IF NOT EXISTS device_shelf_mappings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, device_shelf_name VARCHAR(100), sync_direction VARCHAR(20), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(collection_id, device_id) ); CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_collection ON device_shelf_mappings(collection_id); CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_device ON device_shelf_mappings(device_id); -- Create device_catalogs table (track OPDS downloads and map Bookhoard UUIDs to device ContentIds) CREATE TABLE IF NOT EXISTS device_catalogs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, bookhoard_uuid UUID NOT NULL, kobo_content_id VARCHAR(255) NOT NULL, content_id_type VARCHAR(20), available BOOLEAN DEFAULT TRUE, delivery_date TIMESTAMP WITH TIME ZONE, delivery_method VARCHAR(20), UNIQUE(device_id, kobo_content_id) ); CREATE INDEX IF NOT EXISTS idx_device_catalogs_bookhoard ON device_catalogs(bookhoard_uuid); CREATE INDEX IF NOT EXISTS idx_device_catalogs_kobo ON device_catalogs(kobo_content_id); -- Create system_config table (system-wide configuration) CREATE TABLE IF NOT EXISTS system_config ( key VARCHAR(100) PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_by UUID REFERENCES users(id) ); -- One-time cleanup: clear the old placeholder seed so the startup logic -- can re-seed from the BASE_URL env var (or the setup wizard can set it). UPDATE system_config SET value = '' WHERE key = 'base_url' AND value = 'https://bookhoard.example.com'; UPDATE system_config SET value = '' WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds'; UPDATE system_config SET value = '' WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api'; -- Create opds_tokens table (device-specific OPDS access tokens) CREATE TABLE IF NOT EXISTS opds_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, token VARCHAR(64) UNIQUE NOT NULL, token_type VARCHAR(20), expires_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_opds_tokens_device ON opds_tokens(device_id); CREATE INDEX IF NOT EXISTS idx_opds_tokens_token ON opds_tokens(token); -- Modify kobo_shelves table (reference collections instead of media_items directly) ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS collection_id UUID REFERENCES collections(id); ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS position_in_collection INTEGER; -- ============================================ --: UNLINKED BOOKS TRACKING -- ============================================ -- Create unlinked_books table to track books that couldn't be auto-matched CREATE TABLE IF NOT EXISTS unlinked_books ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, content_id VARCHAR(255) NOT NULL, file_path VARCHAR(500), title VARCHAR(255), author VARCHAR(255), confidence_score FLOAT DEFAULT 0.5, resolved BOOLEAN DEFAULT FALSE, media_item_id UUID REFERENCES media_items(id) ON DELETE SET NULL, resolved_at TIMESTAMP WITH TIME ZONE, resolution_method VARCHAR(50), last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id); CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id); CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved); -- Create saved_filters for users to be able to filter different parts of the application CREATE TABLE IF NOT EXISTS saved_filters ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, resource_type TEXT NOT NULL, -- 'media-items', 'collections', 'devices', etc. filters JSONB NOT NULL, -- {search: "", author_filter: "", genre: "", ...} created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Index for efficient user+resource lookups CREATE INDEX IF NOT EXISTS idx_saved_filters_user_resource ON saved_filters(user_id, resource_type); -- Index for name searches (future feature) CREATE INDEX IF NOT EXISTS idx_saved_filters_name ON saved_filters(user_id, name); -- Unique constraint: One filter name per user per resource type -- Prevents duplicate filter names while allowing: -- - Same name for different users -- - Same name for different resources (media-items vs collections) CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_filters_user_name_resource ON saved_filters(user_id, name, resource_type); -- Trigger to auto-update updated_at timestamp CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ language 'plpgsql'; DROP TRIGGER IF EXISTS update_saved_filters_updated_at ON saved_filters; CREATE TRIGGER update_saved_filters_updated_at BEFORE UPDATE ON saved_filters FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Panel detection data CREATE TABLE IF NOT EXISTS panel_data ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, page_number INTEGER NOT NULL, detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual' panels JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(media_item_id, page_number) ); CREATE INDEX IF NOT EXISTS idx_panel_data_media_item ON panel_data(media_item_id); -- Reading speed tracking CREATE TABLE IF NOT EXISTS reading_speed ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, words_per_minute REAL, pages_per_minute REAL, pages_read INTEGER DEFAULT 0, total_reading_minutes REAL DEFAULT 0, last_read_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, media_item_id) ); CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id); CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id); -- Dictionary cache (for offline use) CREATE TABLE IF NOT EXISTS dictionary_cache ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), word VARCHAR(100) NOT NULL UNIQUE, definition TEXT NOT NULL, part_of_speech VARCHAR(20), example TEXT, etymology TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), accessed_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word); -- Reader settings (per-user preferences) CREATE TABLE IF NOT EXISTS reader_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, setting_key VARCHAR(50) NOT NULL, setting_value JSONB NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, setting_key) ); CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id); -- PDF bookmarks (custom user bookmarks) CREATE TABLE IF NOT EXISTS media_bookmarks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, page_number INTEGER, chapter_number INTEGER, cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position title VARCHAR(255) NOT NULL, position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(media_item_id, user_id, title) ); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id); -- ============================================ -- ANNOTATION SYNC MIGRATIONS -- Adds dedup_key, LWW timestamps, soft-delete, -- and device_sync_data to annotation tables. -- ============================================ ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40); ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ; ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30); ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT; ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -- Widen position columns for existing databases: the API handlers -- validate up to 1000 characters (full Readium locators, KOReader CRE -- xpointers) but VARCHAR(100) rejected anything longer at the database -- layer. VARCHAR -> TEXT is a metadata-only change, safe to re-run. ALTER TABLE media_highlights ALTER COLUMN start_position TYPE TEXT; ALTER TABLE media_highlights ALTER COLUMN end_position TYPE TEXT; ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40); ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ; ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30); ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40); ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30); ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup ON media_highlights (user_id, media_item_id, dedup_key) WHERE dedup_key IS NOT NULL AND deleted = FALSE; CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup ON media_notes (user_id, media_item_id, dedup_key) WHERE dedup_key IS NOT NULL AND deleted = FALSE; CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup ON media_bookmarks (user_id, media_item_id, dedup_key) WHERE dedup_key IS NOT NULL AND deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE; CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE; CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE; -- ============================================ --: MEDIA ITEM DEDUPLICATION + PATH UNIQUENESS -- ============================================ -- A read-then-write race in the scanner historically allowed the same -- (library_id, file_path) to be inserted twice. This block is self-healing: -- it collapses any existing path-duplicates (re-parenting child rows onto a -- survivor so no reading history is lost), then enforces uniqueness going -- forward. Idempotent — safe to re-run on every startup. -- Move every child row that points at p_source so it points at p_target, -- deleting source rows that would violate a UNIQUE constraint on the target. CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID) RETURNS void LANGUAGE plpgsql AS $$ BEGIN IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN RETURN; END IF; DELETE FROM reading_progress WHERE media_item_id = p_source AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target); UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM reading_speed WHERE media_item_id = p_source AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target); UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM media_ratings WHERE media_item_id = p_source AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target); UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM media_bookmarks WHERE media_item_id = p_source AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target); UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM media_item_formats WHERE media_item_id = p_source AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target); UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM collection_items WHERE media_item_id = p_source AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target); UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM kobo_shelves WHERE media_item_id = p_source AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target); UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM panel_data WHERE media_item_id = p_source AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target); UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM processing_issues WHERE media_item_id = p_source AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target); UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source; DELETE FROM device_file_aliases WHERE media_item_id = p_source AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target); UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source; -- Tables whose UNIQUE keys do not include media_item_id. UPDATE device_catalogs SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE media_highlights SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE media_notes SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE reading_history SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE sync_conflicts SET media_item_id = p_target WHERE media_item_id = p_source; UPDATE sync_queue SET media_item_id = p_target WHERE media_item_id = p_source; END; $$; -- Collapse every (library_id, file_path) group into a single row. -- Survivor = the row with the most user data; ties broken by lowest id. CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void LANGUAGE plpgsql AS $$ DECLARE g RECORD; v_surv UUID; v_loser UUID; BEGIN FOR g IN SELECT library_id, file_path FROM media_items GROUP BY library_id, file_path HAVING COUNT(*) > 1 LOOP SELECT mi.id INTO v_surv FROM media_items mi WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path ORDER BY ((SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id) + (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id) + (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id) + (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id) + (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id) + (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id)) DESC, mi.id ASC LIMIT 1; FOR v_loser IN SELECT id FROM media_items WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv ORDER BY id LOOP PERFORM reparent_media_item_children(v_surv, v_loser); DELETE FROM media_items WHERE id = v_loser; END LOOP; END LOOP; END; $$; -- Collapse any existing path-duplicates so the constraint below can be created. SELECT dedup_media_items_by_path(); -- Enforce path uniqueness going forward (guarded so re-runs don't error). DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'media_items_library_id_file_path_key' AND conrelid = 'media_items'::regclass ) THEN ALTER TABLE media_items ADD CONSTRAINT media_items_library_id_file_path_key UNIQUE (library_id, file_path); END IF; END $$; -- ============================================ --: HASH CONFLICTS -- ============================================ -- Records content-duplicate groups discovered during hash backfill or rescan: -- two or more media_items in the same library share a file_sha256 but live at -- different file paths (e.g. the same book imported twice under two names on -- a preexisting database). Unlike path duplicates these cannot be auto-collapsed -- (keeping both copies may be intentional), so each group is surfaced on the -- admin Hash Conflicts page for the user to resolve: -- keep_all - both copies are intentional; just stop flagging -- kept: - merge every other copy's child rows into the kept item -- (via reparent_media_item_children) and delete the losers CREATE TABLE IF NOT EXISTS hash_conflicts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, file_sha256 CHAR(64) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved')), resolution VARCHAR(50), -- 'keep_all' or 'kept:' (41 chars) resolved_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT NOW(), resolved_at TIMESTAMPTZ, UNIQUE(library_id, file_sha256) ); CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status); -- Widen for databases created before the resolution format settled (no-op otherwise) ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);