A read-then-write race in processMediaFile allowed the same file to be imported twice: two concurrent scan jobs (startup scan, fsnotify dirty- directory scan, periodic backup poll, or a manual scan each run on separate worker goroutines with separate MediaScanner instances) could both SELECT 'not found' and both INSERT. There was no transaction, no row lock, no unique constraint on (library_id, file_path), and no ON CONFLICT clause, so nothing stopped the double insert. Observed in production as two identical 'Head First SQL' rows created in the same second (same sha256, size, path, library). Database enforcement: - schema.sql: add UNIQUE(library_id, file_path) constraint, guarded so re-runs don't error - schema.sql: add self-healing migration that runs on every startup - dedup_media_items_by_path() collapses existing path-duplicates and reparent_media_item_children() moves all child rows (progress, highlights, bookmarks, notes, collections, formats, aliases, kobo entitlements, etc.) onto a survivor before deleting losers, so the constraint applies cleanly on already-duplicated servers without losing reading history. Survivor picks the row with the most user data, ties broken by lowest id - CreateMediaItem: upsert via ON CONFLICT (library_id, file_path) DO UPDATE so concurrent inserts collapse to one row and return it - CreateMediaItemFormat: upsert via ON CONFLICT (media_item_id, format_type), closing the same race on format rows Application-level guards: - media_scanner processMediaFile: after computing the file hash, check GetMediaItemBySHA256AndLibrary (new query) and treat the file as existing when identical content is already in the library under a different path (content dedup, library-scoped so multi-library setups still work) Ops tooling: - scripts/dedup_media_items.sql: standalone idempotent maintenance script with a dry-run report (path + content duplicate groups, child row counts) and transactional cleanup, for servers that prefer to dedup manually before upgrading Verified against the live database: the duplicate pair was collapsed (reading_progress preserved on the survivor), schema.sql re-runs are a no-op, and the constraint is in place with 62 unique books remaining.
1536 lines
67 KiB
PL/PgSQL
1536 lines
67 KiB
PL/PgSQL
-- 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 VARCHAR(100), -- position (page:offset or CFI) where highlight starts
|
|
end_position VARCHAR(100), -- position (page:offset or CFI) 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 <img> tags)
|
|
-- Threshold of 50 images suggests manga/comic vs novel
|
|
IF array_length(string_to_array(opf_content, '<img'), 1) - 1 > 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;
|
|
|
|
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 $$;
|