Database changes: - Add unique index on saved_filters(user_id, name, resource_type) Prevents duplicate filter names while allowing same name across different users or different resource types Search functionality fix: - Remove DISTINCT ON (mi.id) from SearchMediaItemsUnified query - Remove mi.id from ORDER BY clause (was required by DISTINCT ON) - This allows user-selected sort field to be primary sort criteria - Previously results were always sorted by ID first, making sort dropdown ineffective - Relevance score and title remain as fallback sorts This fixes the sort dropdown functionality on the bookshelf page where changing the sort option appeared to have no effect.
1050 lines
43 KiB
PL/PgSQL
1050 lines
43 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', '.pdf']),
|
|
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'])
|
|
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(),
|
|
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()
|
|
);
|
|
|
|
-- Insert default system settings
|
|
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
|
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
|
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide')
|
|
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(),
|
|
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
|
|
);
|
|
|
|
-- 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);
|
|
|
|
-- 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,
|
|
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);
|
|
|
|
-- ============================================
|
|
-- 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_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);
|
|
|
|
-- 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';
|
|
|
|
-- 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;
|
|
|
|
-- 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,
|
|
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,
|
|
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 ? '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)
|
|
);
|
|
|
|
-- Pre-seeded values
|
|
INSERT INTO system_config (key, value) VALUES
|
|
('base_url', 'https://bookhoard.example.com'),
|
|
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
|
('api_base_url', 'https://bookhoard.example.com/api')
|
|
ON CONFLICT (key) DO NOTHING;
|
|
|
|
-- 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();
|