collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
2183 lines
102 KiB
PL/PgSQL
2183 lines
102 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']),
|
|
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac']),
|
|
('podcasts', 'RSS podcast/feed libraries: feeds subscribe-and-download; the scanner never walks these folders', ARRAY[]::TEXT[])
|
|
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()
|
|
);
|
|
|
|
-- User-level default audiobook playback speed (player plan §8): books
|
|
-- without a per-book audio_playback_speed row pick this up. REAL
|
|
-- mirrors the per-book table's column.
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
|
|
|
|
-- Keep-screen-awake preference: when true, the web reader and audiobook
|
|
-- player request a Screen Wake Lock so the display doesn't blank/lock
|
|
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
|
|
|
|
-- 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'),
|
|
('scan_extractor_version', '1', 'Parser version stamped on each book scan manifest; raising it re-extracts metadata from books scanned under an older parser (bounded batch per scan)', 'int', '1', '1000000', FALSE, 'scanner'),
|
|
('auto_playlist_collections', 'true', 'Auto-create curated playlist collections from sidecar series marked as reading orders', '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'),
|
|
('registration_enabled', 'false', 'Allow users to create their own accounts (admins can always create accounts)', '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'),
|
|
-- podcasts
|
|
('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts'),
|
|
('podcast_download_concurrency', '4', 'Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)', 'int', '1', '16', FALSE, 'podcasts'),
|
|
('podcast_download_speed_mbps', '0', 'Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)', 'float', '0', '10000', FALSE, 'podcasts')
|
|
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,
|
|
subtitle TEXT, -- Series-y/edition subtitle from sidecars; display field, never part of `title`
|
|
author VARCHAR(255), -- DEPRECATED for writes: display string regenerated from `authors` by trigger
|
|
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 DOUBLE PRECISION, -- Real ordinals are decimals (0.5, 1.5 novellas); was INTEGER
|
|
tags TEXT[],
|
|
narrators TEXT[], -- Audiobook narrators (sidecar first; user-editable)
|
|
authors TEXT[] NOT NULL DEFAULT '{}', -- Write truth for authorship; `author` above is trigger-regenerated display
|
|
authors_search TEXT[] NOT NULL DEFAULT '{}',
|
|
publishers TEXT[] NOT NULL DEFAULT '{}', -- Write truth; `publisher` below is trigger-regenerated display
|
|
publishers_search TEXT[] NOT NULL DEFAULT '{}',
|
|
abridged BOOLEAN NOT NULL DEFAULT FALSE, -- Libation/Audible abridged flag
|
|
explicit BOOLEAN NOT NULL DEFAULT FALSE, -- Adult flag from sidecars (ABS explicit / Audible isAdult)
|
|
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 TEXT, -- Genre classification (smart-genre shelf OR raw tag free text — Audible category strings exceed 100 chars)
|
|
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)
|
|
|
|
-- Per-field user-override tracking. Column names listed here (e.g.
|
|
-- 'description', 'tags', 'cover_image_path') are user-customized and MUST
|
|
-- NOT be overwritten by library scans or per-book rescans; only the
|
|
-- reset-to-scanned-defaults action clears them.
|
|
metadata_overrides TEXT[] NOT NULL DEFAULT '{}',
|
|
|
|
-- Archive lifecycle: a file missing from disk for two consecutive scans
|
|
-- gets archived_at set (hidden from all browsing, reading history kept).
|
|
-- If the file returns, the row is un-archived. Archived rows are purged
|
|
-- after ARCHIVE_RETENTION_DAYS (0 = manual purge only).
|
|
missing_scan_count INT NOT NULL DEFAULT 0,
|
|
archived_at TIMESTAMPTZ,
|
|
|
|
-- 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),
|
|
-- Audiobook metadata (format-scoped JSONB, the kobo_metadata
|
|
-- precedent: only the audio paths read it — ebook paths never do).
|
|
-- { "narrators": [], "duration_seconds": N, "codec_profile": "LC",
|
|
-- "bit_rate": N, "asin": "...", "chapters": [
|
|
-- { "index": 0, "title": "...", "start": 0.0, "end": 168.0 } ] }
|
|
-- The chapters array IS the format abstraction: embedded m4b chapters,
|
|
-- folder-of-tracks books and zip-of-audio books all write this exact
|
|
-- shape. Multi-file books add additive keys: "layout"
|
|
-- ("folder" | "zip"; absent = single file) and "tracks" (one entry
|
|
-- per playable file with global-seconds start/end plus the path the
|
|
-- /audio/:trackIndex endpoint resolves).
|
|
audio_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[];
|
|
|
|
-- Widen genre for existing databases: the audio tag fallback stores the
|
|
-- raw file genre atom (Audible category strings exceed 100 chars —
|
|
-- e.g. "Politicians, Presidents & Heads of State, United States, ..."),
|
|
-- and VARCHAR(100) rejected the whole import/update at the database
|
|
-- layer (SQLSTATE 22001). VARCHAR -> TEXT is a metadata-only change,
|
|
-- safe to re-run (media_highlights position widening precedent).
|
|
ALTER TABLE media_items ALTER COLUMN genre TYPE TEXT;
|
|
|
|
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
|
|
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
|
|
-- the scan-derived tech record (duration/codec/chapters).
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators TEXT[] NOT NULL DEFAULT '{}';
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators_search TEXT[] NOT NULL DEFAULT '{}';
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS abridged BOOLEAN NOT NULL DEFAULT FALSE;
|
|
|
|
-- A2b: subtitle + explicit flags, authors/publishers arrays (narrators
|
|
-- precedent — arrays are write truth, varchar display columns regenerate).
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS subtitle TEXT;
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS explicit BOOLEAN NOT NULL DEFAULT FALSE;
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS authors TEXT[] NOT NULL DEFAULT '{}';
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS authors_search TEXT[] NOT NULL DEFAULT '{}';
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS publishers TEXT[] NOT NULL DEFAULT '{}';
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS publishers_search TEXT[] NOT NULL DEFAULT '{}';
|
|
|
|
-- Series ordinals are real-world decimals (0.5, 1.5 novellas); INTEGER
|
|
-- truncated them to the wrong shelf order. In-place widening; no-op on a
|
|
-- fresh install (column is already double precision from CREATE TABLE).
|
|
ALTER TABLE media_items ALTER COLUMN series_number TYPE double precision
|
|
USING series_number::double precision;
|
|
|
|
-- Backfill the arrays from the legacy single-value columns. Deliberately NO
|
|
-- comma-splitting ("Tolkien, J.R.R." would corrupt); every existing row is
|
|
-- effectively single-valued because the scanner historically kept only
|
|
-- authors[0]. Guarded so the pass is free after the first startup.
|
|
UPDATE media_items SET authors = ARRAY[author]
|
|
WHERE author IS NOT NULL AND author <> '' AND authors = '{}';
|
|
UPDATE media_items SET publishers = ARRAY[publisher]
|
|
WHERE publisher IS NOT NULL AND publisher <> '' AND publishers = '{}';
|
|
-- audio_metadata is declared in the CREATE TABLE above, which only fresh
|
|
-- installs ever see — this ALTER is the upgrade path for pre-A2 installs
|
|
-- (its absence 42703'd every scan and archive pass on upgraded deployments).
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS audio_metadata JSONB;
|
|
|
|
-- Per-field user-override tracking (idempotent backfill for existing
|
|
-- installs; the column is also declared in the media_items table
|
|
-- definition above for fresh databases)
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS metadata_overrides TEXT[] NOT NULL DEFAULT '{}';
|
|
|
|
-- Archive lifecycle (idempotent backfill for existing installs)
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS missing_scan_count INT NOT NULL DEFAULT 0;
|
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at) WHERE archived_at IS NOT NULL;
|
|
|
|
-- 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);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_narrators ON media_items USING GIN (narrators);
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_narrators_search ON media_items USING GIN (narrators_search);
|
|
|
|
-- Drop existing triggers if exists. Both must be dropped BEFORE the function:
|
|
-- dropping a function that triggers still reference fails (SQLSTATE 2BP01),
|
|
-- which would abort the schema transaction on every startup after the first.
|
|
DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items;
|
|
DROP TRIGGER IF EXISTS set_library_type_name_on_library_change 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
|
|
-- Recompute the denormalized library type on insert, and NORMALIZE the
|
|
-- A2 NOT NULL narrators columns: narrators/narrators_search are NOT NULL
|
|
-- DEFAULT '{}' but an explicit NULL in an INSERT/UPDATE column list
|
|
-- bypasses the column default and 23502s (the API create path without
|
|
-- narrators 500'd exactly this way). "No narrators known" is an empty
|
|
-- array, never NULL — coalesce at the row level so every write path
|
|
-- (API, scanner, direct seeds) is safe by construction.
|
|
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;
|
|
IF NEW.narrators IS NULL THEN
|
|
NEW.narrators := '{}';
|
|
END IF;
|
|
IF NEW.narrators_search IS NULL THEN
|
|
NEW.narrators_search := '{}';
|
|
END IF;
|
|
-- A2b: authors/publishers arrays are NOT NULL — Go nil slices become
|
|
-- explicit NULLs in params and would 23502 (the narrators lesson).
|
|
IF NEW.authors IS NULL THEN
|
|
NEW.authors := '{}';
|
|
END IF;
|
|
IF NEW.authors_search IS NULL THEN
|
|
NEW.authors_search := '{}';
|
|
END IF;
|
|
IF NEW.publishers IS NULL THEN
|
|
NEW.publishers := '{}';
|
|
END IF;
|
|
IF NEW.publishers_search IS NULL THEN
|
|
NEW.publishers_search := '{}';
|
|
END IF;
|
|
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();
|
|
|
|
-- Keep library_type_name truthful when an item is repointed to another
|
|
-- library (cross-library move detection reuses the same function; the row
|
|
-- keeps its ID while its library changes). Fires on ALL updates so the
|
|
-- narrators normalization guards every write path, not just library moves.
|
|
CREATE TRIGGER set_library_type_name_on_library_change
|
|
BEFORE UPDATE ON media_items
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION set_library_type_name();
|
|
|
|
-- Display sync: `author`/`publisher` are denormalized display strings
|
|
-- regenerated from the authors/publishers arrays on every write. The varchar
|
|
-- columns face search (trgm), facets and API responses; the arrays are the
|
|
-- write truth. array_to_string is STABLE on PG15 (not IMMUTABLE), so this
|
|
-- must be a trigger — a generated column would reject it. Absent arrays
|
|
-- yield NULL display (matches the legacy no-author row shape).
|
|
DROP TRIGGER IF EXISTS sync_author_publisher_display_trg ON media_items;
|
|
DROP FUNCTION IF EXISTS sync_author_publisher_display();
|
|
CREATE OR REPLACE FUNCTION sync_author_publisher_display() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
-- Defense in depth: normalize NULL arrays first (this trigger may fire
|
|
-- before set_library_type_name depending on name-ordered execution).
|
|
IF NEW.authors IS NULL THEN
|
|
NEW.authors := '{}';
|
|
END IF;
|
|
IF NEW.publishers IS NULL THEN
|
|
NEW.publishers := '{}';
|
|
END IF;
|
|
NEW.author := CASE WHEN NEW.authors = '{}' THEN NULL
|
|
ELSE array_to_string(NEW.authors, ', ') END;
|
|
NEW.publisher := CASE WHEN NEW.publishers = '{}' THEN NULL
|
|
ELSE array_to_string(NEW.publishers, ', ') END;
|
|
RETURN NEW;
|
|
END $$ LANGUAGE plpgsql;
|
|
CREATE TRIGGER sync_author_publisher_display_trg
|
|
BEFORE INSERT OR UPDATE ON media_items
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION sync_author_publisher_display();
|
|
|
|
-- 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),
|
|
-- Audiobook locator: seconds into the chapter (honest units, not an
|
|
-- overloaded fraction — a 2h chapter at float precision drifts).
|
|
audio_offset_seconds INTEGER CHECK (audio_offset_seconds >= 0),
|
|
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
|
|
);
|
|
|
|
-- audio_offset_seconds upgrade path: declared in the CREATE TABLE above
|
|
-- (Batch A) with no ALTER, so pre-A installs lacked the column and every
|
|
-- progress save — the shared upsert names it — failed with SQLSTATE 42703.
|
|
ALTER TABLE reading_progress ADD COLUMN IF NOT EXISTS audio_offset_seconds INTEGER CHECK (audio_offset_seconds >= 0);
|
|
|
|
-- 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_reviews table: the user's markdown book review. Sibling of
|
|
-- media_ratings (same uniqueness + cascade shape) but standalone — a review
|
|
-- without a rating (and vice versa) must work. Stored as RAW markdown and
|
|
-- rendered only at display sites (same doctrine as notes).
|
|
CREATE TABLE IF NOT EXISTS media_reviews (
|
|
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,
|
|
review TEXT NOT NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(media_item_id, user_id)
|
|
);
|
|
|
|
-- Create media_notes table for user notes on media items
|
|
CREATE TABLE IF NOT EXISTS media_notes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
content TEXT NOT NULL,
|
|
position VARCHAR(100), -- optional position (page:offset or CFI) for standalone notes
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
-- Location Enhancements
|
|
percentage_location FLOAT,
|
|
character_start INTEGER,
|
|
character_end INTEGER,
|
|
epubcfi_location TEXT,
|
|
chapter_reference INTEGER,
|
|
paragraph_reference INTEGER,
|
|
device_sync_data JSONB
|
|
);
|
|
|
|
-- Create media_highlights table for user highlights on media items
|
|
CREATE TABLE IF NOT EXISTS media_highlights (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
selection_text TEXT NOT NULL,
|
|
start_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight starts
|
|
end_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight ends
|
|
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
|
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
-- Location Enhancements
|
|
percentage_start FLOAT,
|
|
percentage_end FLOAT,
|
|
character_start INTEGER,
|
|
character_end INTEGER,
|
|
epubcfi_start TEXT,
|
|
epubcfi_end TEXT,
|
|
chapter_reference INTEGER,
|
|
paragraph_start INTEGER,
|
|
paragraph_end INTEGER,
|
|
panel_number INTEGER,
|
|
device_sync_data JSONB
|
|
);
|
|
|
|
-- ============================================
|
|
-- DEVICE REGISTRY
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
device_name VARCHAR(100) NOT NULL,
|
|
device_type VARCHAR(20) NOT NULL,
|
|
device_identifier VARCHAR(255) UNIQUE NOT NULL,
|
|
auth_token VARCHAR(500) UNIQUE NOT NULL,
|
|
last_sync TIMESTAMP WITH TIME ZONE,
|
|
last_seen TIMESTAMP WITH TIME ZONE,
|
|
sync_enabled BOOLEAN DEFAULT TRUE,
|
|
auto_sync BOOLEAN DEFAULT TRUE,
|
|
sync_frequency_minutes INTEGER DEFAULT 5,
|
|
device_metadata JSONB,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- ============================================
|
|
-- SYNC QUEUE FOR OFFLINE SUPPORT
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS sync_queue (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
media_item_id UUID REFERENCES media_items(id) ON DELETE CASCADE,
|
|
sync_type VARCHAR(20) NOT NULL,
|
|
sync_data JSONB NOT NULL,
|
|
priority INTEGER DEFAULT 5,
|
|
attempts INTEGER DEFAULT 0,
|
|
max_attempts INTEGER DEFAULT 3,
|
|
status VARCHAR(20) DEFAULT 'pending',
|
|
error_message TEXT,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
processed_at TIMESTAMP WITH TIME ZONE
|
|
);
|
|
|
|
-- ============================================
|
|
-- CONFLICT RESOLUTION
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS sync_conflicts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
conflict_type VARCHAR(20) NOT NULL,
|
|
conflict_data JSONB NOT NULL,
|
|
resolution_status VARCHAR(20) DEFAULT 'unresolved',
|
|
resolution_data JSONB,
|
|
resolved_by UUID REFERENCES users(id),
|
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- ============================================
|
|
-- KOBO SHELF MANAGEMENT
|
|
-- ============================================
|
|
CREATE TABLE IF NOT EXISTS kobo_shelves (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
shelf_name VARCHAR(100) DEFAULT 'Default',
|
|
shelf_position INTEGER DEFAULT 0,
|
|
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
last_synced_at TIMESTAMP WITH TIME ZONE,
|
|
UNIQUE(device_id, media_item_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_device_id ON kobo_shelves(device_id);
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_media_item_id ON kobo_shelves(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_shelf_name ON kobo_shelves(shelf_name);
|
|
|
|
-- Table to track items that can't be processed in their library
|
|
CREATE TABLE IF NOT EXISTS processing_issues (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
issue_type VARCHAR(50) NOT NULL,
|
|
issue_description TEXT NOT NULL,
|
|
severity VARCHAR(20) NOT NULL DEFAULT 'warning',
|
|
resolved BOOLEAN DEFAULT FALSE,
|
|
resolved_at TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
UNIQUE(media_item_id, issue_type)
|
|
);
|
|
|
|
-- Indexes for querying problem items
|
|
CREATE INDEX IF NOT EXISTS idx_processing_issues_library
|
|
ON processing_issues(library_id, resolved);
|
|
CREATE INDEX IF NOT EXISTS idx_processing_issues_severity
|
|
ON processing_issues(severity, resolved);
|
|
|
|
-- Phase 11 (faceted plan): the ledger records ROW-LESS import failures
|
|
-- too — a book whose media_items row was never created has no item to
|
|
-- reference. media_item_id becomes nullable; file_path carries the
|
|
-- failing file's library-relative path for those rows. The table-level
|
|
-- UNIQUE stays (item-backed safety; row-less rows have NULL item ids,
|
|
-- which unique constraints treat as pairwise-distinct). The widened
|
|
-- dedup key is a COALESCE expression index covering BOTH classes in one
|
|
-- shape: item-backed = (item, library, type, ''), row-less =
|
|
-- (zero-uuid, library, type, path). An expression index (not NULLS NOT
|
|
-- DISTINCT) because the two classes key on different columns.
|
|
ALTER TABLE processing_issues ALTER COLUMN media_item_id DROP NOT NULL;
|
|
-- TEXT, deliberately NOT VARCHAR(500): the ledger must be able to
|
|
-- record paths LONGER than media_items' limit — an over-long path is
|
|
-- itself a failure mode, and a 500-char cap would make the issue row
|
|
-- die on the exact error it exists to report.
|
|
ALTER TABLE processing_issues ADD COLUMN IF NOT EXISTS file_path TEXT;
|
|
ALTER TABLE processing_issues ALTER COLUMN file_path TYPE TEXT;
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_processing_issues_dedup
|
|
ON processing_issues (
|
|
COALESCE(media_item_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
library_id,
|
|
issue_type,
|
|
COALESCE(file_path, '')
|
|
);
|
|
|
|
-- Add comment for documentation
|
|
COMMENT ON TABLE processing_issues IS 'Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.';
|
|
|
|
|
|
-- ============================================
|
|
-- 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);
|
|
|
|
-- Reviews indexes
|
|
CREATE INDEX IF NOT EXISTS idx_media_reviews_media_item_id ON media_reviews(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_reviews_user_id ON media_reviews(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);
|
|
|
|
-- media_scan_state: the scanner's per-item fingerprint cache (faceted
|
|
-- plan Part 2). Deliberately NOT a media_items column: the library-
|
|
-- scoped media list emits raw sqlc rows as JSON, and scanner
|
|
-- bookkeeping must never leak into API payloads (nor churn the model
|
|
-- or the contract pins). One row per item; cascade on delete.
|
|
-- manifest shape v1:
|
|
-- {"v":1,"entries":[{"n":"metadata.opf","s":3013013,"m":1697500000123456789,"h":"<sha256>"}, ...]}
|
|
-- n basename, s size, m mtime as unix NANOS (second granularity
|
|
-- would hide edits landing in the backfill scan's own second), h
|
|
-- sha256 of the content — present for ALL entries, covers included:
|
|
-- the repoint reconciliation needs a baseline hash for any stat-
|
|
-- drifted sidecar. "ev" (extractor version, default 1): the parser
|
|
-- version that produced the item's row. ABSENT means "v1-or-unknown"
|
|
-- — backfill stamps are unset so a row never claims a version its
|
|
-- data can't prove; every post-extraction store stamps the setting's
|
|
-- current value. A stored ev != scan_extractor_version → re-extract
|
|
-- (bounded per scan, resumable across polls).
|
|
CREATE TABLE IF NOT EXISTS media_scan_state (
|
|
media_item_id UUID PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE,
|
|
manifest JSONB
|
|
);
|
|
|
|
-- 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,
|
|
-- Smart-collection match semantics (plan item 2.4): 'any' = OR
|
|
-- (the original behavior — every legacy row's default), 'all' =
|
|
-- AND (the daily briefing needs feed∈{…} ∧ unplayed ∧ <7d).
|
|
-- Applied only when rules exist; static collections ignore it.
|
|
rule_match_mode TEXT NOT NULL DEFAULT 'any',
|
|
-- The unified filter tree (plan item 3.2): when present the qf
|
|
-- engine drives membership; see FilterTree. NULL = legacy rules.
|
|
filter_tree 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(),
|
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
|
UNIQUE(user_id, name)
|
|
);
|
|
|
|
-- System collections (the 5 dashboard sections) are permanent: "delete"
|
|
-- tombstones the row (deleted_at) and restore un-tombstones it in place —
|
|
-- the row id (and everything referencing it) never churn.
|
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
|
|
-- Smart-collection match mode (plan item 2.4): 'any' (OR, legacy
|
|
-- default) | 'all' (AND — the daily briefing). Idempotent; NOT NULL
|
|
-- DEFAULT covers existing rows.
|
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS rule_match_mode TEXT NOT NULL DEFAULT 'any';
|
|
|
|
-- Smart collections in the UNIFIED filter engine (plan item 3.2): the
|
|
-- qf tree (same DSL as saved filters, See FilterTree). NULL = no tree
|
|
-- (the legacy rules array governs until the collection is rewritten).
|
|
-- Legacy rule_match_mode stays authoritative for rules-array
|
|
-- collections; a tree's root mode owns its semantics.
|
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB;
|
|
|
|
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);
|
|
|
|
-- Global (all-libraries) dashboard preferences: exactly one row per user.
|
|
-- Backs the dashboard's "All Libraries" view (library_id NULL convention
|
|
-- rejected: the upsert's ON CONFLICT cannot dedupe NULLs and intent would
|
|
-- be invisible — a dedicated table makes the scope structural). System
|
|
-- and user collections are user-scoped, so collection_order/
|
|
-- hidden_collections IDs are library-independent and valid in this scope.
|
|
CREATE TABLE IF NOT EXISTS user_dashboard_preferences_global (
|
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
hidden_collections TEXT[] DEFAULT '{}',
|
|
collection_order TEXT[] DEFAULT '{}',
|
|
items_per_section INT DEFAULT 20,
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- 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,
|
|
-- Manual playlist ordering (reading-order collections). NULL = un-ordered
|
|
-- legacy item; ordering falls back to added_at ASC.
|
|
position INTEGER,
|
|
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);
|
|
|
|
-- Playlist ordering (A2c): position-first ordering for collection listings;
|
|
-- NULLs fall back to added_at ASC.
|
|
ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS position INTEGER;
|
|
CREATE INDEX IF NOT EXISTS idx_collection_items_order
|
|
ON collection_items(collection_id, position ASC NULLS LAST);
|
|
|
|
-- 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()
|
|
);
|
|
|
|
-- Soft-dismiss for unlinked_books: ignored rows stay on the device's sync
|
|
-- queue (UpsertUnlinkedBook keeps refreshing their file metadata, and must
|
|
-- never clear this column) but are hidden from the page list, badge count,
|
|
-- and per-device API. Reversible via UnignoreUnlinkedBook.
|
|
ALTER TABLE unlinked_books ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMP WITH TIME ZONE;
|
|
|
|
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);
|
|
-- One queue row per (device, document): the sync ingest upserts on this pair.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_unlinked_books_device_content ON unlinked_books(device_id, 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);
|
|
|
|
-- Per-user PER-BOOK playback speed preference (player plan §8): a
|
|
-- preference, NOT a measurement (reading_speed holds measured rates),
|
|
-- and NOT a reading_progress column (a speed tap must not create a
|
|
-- "currently reading" row). Resolution lives at the chapters endpoint:
|
|
-- per-book row → users.default_audio_speed → 1.0.
|
|
CREATE TABLE IF NOT EXISTS audio_playback_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,
|
|
speed REAL NOT NULL DEFAULT 1.0,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(user_id, media_item_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_user ON audio_playback_speed(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_item ON audio_playback_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(120) NOT NULL,
|
|
setting_value JSONB NOT NULL,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(user_id, setting_key)
|
|
);
|
|
|
|
-- Key-width headroom (podcast plan §10 R5, item 2.1): the composite
|
|
-- `podcast_order:<uuid>` key sits at exactly 50 chars, and future
|
|
-- per-feed/per-collection preference keys must not be forced to
|
|
-- payload-stuff by a column width. Widening a varchar is metadata-only
|
|
-- (no table rewrite); idempotent on every boot.
|
|
ALTER TABLE reader_settings ALTER COLUMN setting_key TYPE VARCHAR(120);
|
|
|
|
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()
|
|
-- No UNIQUE(media_item_id, user_id, title): bookmarks are identified by
|
|
-- their location (dedup_key), titles are display labels shared verbatim
|
|
-- across clients (KOReader auto-labels repeat within a chapter).
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SYNC MIGRATIONS
|
|
-- Adds dedup_key, LWW timestamps, soft-delete,
|
|
-- and device_sync_data to annotation tables.
|
|
-- ============================================
|
|
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
|
|
-- Widen position columns for existing databases: the API handlers
|
|
-- validate up to 1000 characters (full Readium locators, KOReader CRE
|
|
-- xpointers) but VARCHAR(100) rejected anything longer at the database
|
|
-- layer. VARCHAR -> TEXT is a metadata-only change, safe to re-run.
|
|
ALTER TABLE media_highlights ALTER COLUMN start_position TYPE TEXT;
|
|
ALTER TABLE media_highlights ALTER COLUMN end_position TYPE TEXT;
|
|
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
-- Provenance: the client that CREATED the bookmark (unlike
|
|
-- last_modified_source, which tracks the last writer). Set once at insert.
|
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS origin_source VARCHAR(30);
|
|
-- Identity is the dedup_key (location), not the title; drop the legacy
|
|
-- unique-title constraint so same-title bookmarks on different pages can
|
|
-- coexist (re-creating over a tombstone with a changed position also
|
|
-- relied on this). Catalog-only change, safe to re-run.
|
|
ALTER TABLE media_bookmarks DROP CONSTRAINT IF EXISTS media_bookmarks_media_item_id_user_id_title_key;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
|
ON media_highlights (user_id, media_item_id, dedup_key)
|
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
|
|
ON media_notes (user_id, media_item_id, dedup_key)
|
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
|
|
ON media_bookmarks (user_id, media_item_id, dedup_key)
|
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
|
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|
|
|
|
-- ============================================
|
|
--: MEDIA ITEM DEDUPLICATION + PATH UNIQUENESS
|
|
-- ============================================
|
|
-- A read-then-write race in the scanner historically allowed the same
|
|
-- (library_id, file_path) to be inserted twice. This block is self-healing:
|
|
-- it collapses any existing path-duplicates (re-parenting child rows onto a
|
|
-- survivor so no reading history is lost), then enforces uniqueness going
|
|
-- forward. Idempotent — safe to re-run on every startup.
|
|
|
|
-- Move every child row that points at p_source so it points at p_target,
|
|
-- deleting source rows that would violate a UNIQUE constraint on the target.
|
|
CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN
|
|
RETURN;
|
|
END IF;
|
|
|
|
DELETE FROM reading_progress
|
|
WHERE media_item_id = p_source
|
|
AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target);
|
|
UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM reading_speed
|
|
WHERE media_item_id = p_source
|
|
AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target);
|
|
UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM media_ratings
|
|
WHERE media_item_id = p_source
|
|
AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target);
|
|
UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM media_bookmarks
|
|
WHERE media_item_id = p_source
|
|
AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target);
|
|
UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM media_item_formats
|
|
WHERE media_item_id = p_source
|
|
AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target);
|
|
UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM collection_items
|
|
WHERE media_item_id = p_source
|
|
AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target);
|
|
UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM kobo_shelves
|
|
WHERE media_item_id = p_source
|
|
AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target);
|
|
UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM panel_data
|
|
WHERE media_item_id = p_source
|
|
AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target);
|
|
UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM processing_issues
|
|
WHERE media_item_id = p_source
|
|
AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target);
|
|
UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
DELETE FROM device_file_aliases
|
|
WHERE media_item_id = p_source
|
|
AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target);
|
|
UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
|
|
-- Tables whose UNIQUE keys do not include media_item_id.
|
|
UPDATE device_catalogs SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
UPDATE media_highlights SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
UPDATE media_notes SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
UPDATE reading_history SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
UPDATE sync_conflicts SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
UPDATE sync_queue SET media_item_id = p_target WHERE media_item_id = p_source;
|
|
END;
|
|
$$;
|
|
|
|
-- Collapse every (library_id, file_path) group into a single row.
|
|
-- Survivor = the row with the most user data; ties broken by lowest id.
|
|
CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
g RECORD;
|
|
v_surv UUID;
|
|
v_loser UUID;
|
|
BEGIN
|
|
FOR g IN
|
|
SELECT library_id, file_path
|
|
FROM media_items
|
|
GROUP BY library_id, file_path
|
|
HAVING COUNT(*) > 1
|
|
LOOP
|
|
SELECT mi.id INTO v_surv
|
|
FROM media_items mi
|
|
WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path
|
|
ORDER BY
|
|
((SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id)
|
|
+ (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id)
|
|
+ (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id)
|
|
+ (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id)
|
|
+ (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id)
|
|
+ (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id)) DESC,
|
|
mi.id ASC
|
|
LIMIT 1;
|
|
|
|
FOR v_loser IN
|
|
SELECT id FROM media_items
|
|
WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv
|
|
ORDER BY id
|
|
LOOP
|
|
PERFORM reparent_media_item_children(v_surv, v_loser);
|
|
DELETE FROM media_items WHERE id = v_loser;
|
|
END LOOP;
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
-- Collapse any existing path-duplicates so the constraint below can be created.
|
|
SELECT dedup_media_items_by_path();
|
|
|
|
-- Enforce path uniqueness going forward (guarded so re-runs don't error).
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conname = 'media_items_library_id_file_path_key'
|
|
AND conrelid = 'media_items'::regclass
|
|
) THEN
|
|
ALTER TABLE media_items
|
|
ADD CONSTRAINT media_items_library_id_file_path_key UNIQUE (library_id, file_path);
|
|
END IF;
|
|
END $$;
|
|
|
|
-- ============================================
|
|
--: HASH CONFLICTS
|
|
-- ============================================
|
|
-- Records content-duplicate groups discovered during hash backfill or rescan:
|
|
-- two or more media_items in the same library share a file_sha256 but live at
|
|
-- different file paths (e.g. the same book imported twice under two names on
|
|
-- a preexisting database). Unlike path duplicates these cannot be auto-collapsed
|
|
-- (keeping both copies may be intentional), so each group is surfaced on the
|
|
-- admin Hash Conflicts page for the user to resolve:
|
|
-- keep_all - both copies are intentional; just stop flagging
|
|
-- kept:<uuid> - merge every other copy's child rows into the kept item
|
|
-- (via reparent_media_item_children) and delete the losers
|
|
CREATE TABLE IF NOT EXISTS hash_conflicts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
file_sha256 CHAR(64) NOT NULL,
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved')),
|
|
resolution VARCHAR(50), -- 'keep_all' or 'kept:<media_item_uuid>' (41 chars)
|
|
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
resolved_at TIMESTAMPTZ,
|
|
UNIQUE(library_id, file_sha256)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
|
|
|
|
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
|
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
|
|
|
-- ============================================================
|
|
-- PODCASTS / RSS FEEDS
|
|
-- Plan: ~/.opencode/plan/podcast-integration.md (§6).
|
|
-- Feeds are per-library entities (UNIQUE(library_id, feed_url)); users
|
|
-- subscribe personally (podcast_subscriptions). Episodes are
|
|
-- media_items rows with source='podcast_episode' — per-episode
|
|
-- progress/stats for free — grouped by source_feed_id. Visibility is
|
|
-- RLS-enforced (see the policy block at the end of this file): an
|
|
-- episode/feed row is SELECT-visible only to subscribers of the feed
|
|
-- and to admins. The fetcher/download worker is the SOLE writer of
|
|
-- feed/episode metadata; users own only their subscription row and the
|
|
-- reader_settings order override. ALL DDL IDEMPOTENT: schema.sql
|
|
-- re-executes on EVERY boot.
|
|
-- ============================================================
|
|
|
|
-- First per-library boolean knob (plain column, not KV): when false,
|
|
-- only admins may add feeds to the library (members keep subscribe
|
|
-- rights to existing feeds).
|
|
ALTER TABLE libraries ADD COLUMN IF NOT EXISTS allow_member_feeds BOOLEAN NOT NULL DEFAULT TRUE;
|
|
|
|
-- Folder purpose: 'scan' (default; the FS walker's root) vs 'podcast'
|
|
-- (a podcasts library's download root — scanner skips it wholesale,
|
|
-- belt-and-braces on top of the library-type skip).
|
|
ALTER TABLE library_folders ADD COLUMN IF NOT EXISTS purpose VARCHAR(20) NOT NULL DEFAULT 'scan';
|
|
|
|
CREATE TABLE IF NOT EXISTS podcast_feeds (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
feed_url TEXT NOT NULL,
|
|
-- podcast:guid — the feed's canonical identity; dedupes across URL
|
|
-- migrations (fetcher honors itunes:new-feed-url + redirects).
|
|
podcast_guid TEXT,
|
|
title VARCHAR(500) NOT NULL,
|
|
description TEXT,
|
|
-- Channel itunes:author (semantically the host) and itunes:owner.
|
|
host VARCHAR(500),
|
|
publisher VARCHAR(500),
|
|
owner_name VARCHAR(500),
|
|
owner_email VARCHAR(500),
|
|
language VARCHAR(50),
|
|
tags TEXT[] DEFAULT '{}',
|
|
explicit BOOLEAN NOT NULL DEFAULT FALSE,
|
|
artwork_path VARCHAR(500),
|
|
-- itunes:type: 'serial' | 'episodic'
|
|
feed_type VARCHAR(20),
|
|
-- podcast:medium: 'audiobook' | 'podcast' | 'music' | ...
|
|
medium VARCHAR(20),
|
|
-- itunes:complete: poller backs off once the publisher ends the feed.
|
|
is_complete BOOLEAN NOT NULL DEFAULT FALSE,
|
|
-- Conditional-GET validators (opaque; echoed verbatim).
|
|
etag VARCHAR(500),
|
|
last_modified VARCHAR(500),
|
|
last_fetched_at TIMESTAMPTZ,
|
|
last_build_date TIMESTAMPTZ,
|
|
created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE(library_id, feed_url)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_library ON podcast_feeds(library_id);
|
|
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_guid ON podcast_feeds(podcast_guid);
|
|
|
|
-- User-owned subscription row: the ONLY podcast state a user writes.
|
|
-- display_name NULL renders as the feed title. auto_download:
|
|
-- 'off' | 'all' | 'newest' (+ auto_download_count for newest N).
|
|
CREATE TABLE IF NOT EXISTS podcast_subscriptions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
feed_id UUID NOT NULL REFERENCES podcast_feeds(id) ON DELETE CASCADE,
|
|
display_name VARCHAR(500),
|
|
auto_download VARCHAR(20) NOT NULL DEFAULT 'off',
|
|
auto_download_count INT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE(user_id, feed_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_podcast_subs_user ON podcast_subscriptions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_podcast_subs_feed ON podcast_subscriptions(feed_id);
|
|
|
|
-- Episode columns (NULL for scan-sourced items). See the plan §5 for
|
|
-- the full RSS/iTunes/Podcasting-2.0 field mapping — existing columns
|
|
-- (title, subtitle, description, authors, explicit, date_published,
|
|
-- cover_image_path, file_size, mime_type, language, genre/tags) carry
|
|
-- the shared fields; only genuinely podcast-only storage lives here.
|
|
ALTER TABLE media_items
|
|
ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'scan',
|
|
ADD COLUMN IF NOT EXISTS source_feed_id UUID REFERENCES podcast_feeds(id) ON DELETE CASCADE,
|
|
-- Stable reorder-proof addressing key (invariant 3): progress
|
|
-- anchors on guid, never ordinal.
|
|
ADD COLUMN IF NOT EXISTS episode_guid TEXT,
|
|
-- Native order as of the latest fetch (renumbered per fetch).
|
|
ADD COLUMN IF NOT EXISTS feed_position INTEGER,
|
|
ADD COLUMN IF NOT EXISTS season_number INTEGER,
|
|
ADD COLUMN IF NOT EXISTS episode_number INTEGER,
|
|
ADD COLUMN IF NOT EXISTS enclosure_url TEXT,
|
|
-- itunes:duration claim (often HH:MM:SS or prose); display until
|
|
-- ffprobe reconciles after download.
|
|
ADD COLUMN IF NOT EXISTS declared_duration_seconds INTEGER,
|
|
ADD COLUMN IF NOT EXISTS transcript_url TEXT,
|
|
ADD COLUMN IF NOT EXISTS chapters_url TEXT,
|
|
ADD COLUMN IF NOT EXISTS hosts TEXT[] DEFAULT '{}',
|
|
ADD COLUMN IF NOT EXISTS guests TEXT[] DEFAULT '{}',
|
|
-- Download state machine: pending | queued | downloading |
|
|
-- downloaded | failed (+ reason text + attempt count). The episode
|
|
-- list renders this inline (D8/D19) — never Processing Issues.
|
|
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
ADD COLUMN IF NOT EXISTS download_error TEXT,
|
|
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0,
|
|
-- Monotonic queue ticket (Postgres SEQUENCE nextval at enqueue
|
|
-- time): the download pool claims in ticket order, so the enqueue
|
|
-- loop's ordering IS the download order (effective listening order
|
|
-- — §0 items 4/5). NULL for rows never queued.
|
|
ADD COLUMN IF NOT EXISTS download_queue_pos BIGINT;
|
|
|
|
-- Queue ticket source: atomic across concurrent enqueues (per-feed
|
|
-- locks serialize within a feed; the sequence covers across feeds).
|
|
CREATE SEQUENCE IF NOT EXISTS podcast_queue_seq;
|
|
|
|
-- Identity + ordering + worker-worklist indexes (all partial: scan
|
|
-- rows never touch these columns).
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_feed_guid
|
|
ON media_items(source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_feed_pos
|
|
ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded
|
|
ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded';
|
|
CREATE INDEX IF NOT EXISTS idx_media_items_queue
|
|
ON media_items(download_queue_pos) WHERE source = 'podcast_episode' AND download_state = 'queued';
|
|
|
|
|
|
-- ============================================================
|
|
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
|
|
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
|
|
-- The policy joins library_visibility DIRECTLY — the same table the
|
|
-- visibility UI writes. The app declares the acting user per
|
|
-- transaction via the LOCAL GUC app.user_id (ScopedDB, set_config
|
|
-- is_local=true). Unset identity FAILS LOUD, never fails open:
|
|
-- - GUC never set in the session -> current_setting() raises
|
|
-- ("unrecognized configuration parameter")
|
|
-- - GUC set-then-transaction-ended -> reverts to '' -> the ::uuid
|
|
-- cast raises
|
|
-- - NULL GUC (unreachable via ScopedDB) -> IS NOT NULL guard ->
|
|
-- zero rows (fail-closed)
|
|
-- The runtime role bookhoard_srv OWNS these tables and is subject to
|
|
-- the policies via FORCE (z_rls_role.sh). Never connect the app as
|
|
-- the bootstrap superuser — superusers bypass RLS unconditionally.
|
|
-- IDEMPOTENT: schema.sql re-executes on EVERY app boot
|
|
-- (database.Initialize -> executeSchema); DROP .. IF EXISTS + CREATE
|
|
-- matches the repo's trigger idiom. DO NOT make this block
|
|
-- non-idempotent — the second boot would brick the app.
|
|
-- ============================================================
|
|
|
|
DROP POLICY IF EXISTS p_media_items_visible ON media_items;
|
|
DROP POLICY IF EXISTS p_media_items_insert ON media_items;
|
|
DROP POLICY IF EXISTS p_media_items_update ON media_items;
|
|
DROP POLICY IF EXISTS p_media_items_delete ON media_items;
|
|
|
|
ALTER TABLE media_items ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
|
|
|
|
-- READ policy: EXACTLY the app's COALESCE(lv.is_visible, true)
|
|
-- semantics — row absent -> visible; row true -> visible; row false
|
|
-- -> hidden. No new index needed: library_visibility's
|
|
-- UNIQUE(user_id, library_id) constraint already indexes the join.
|
|
-- PODCAST EPISODES (plan D20): source='podcast_episode' rows carry the
|
|
-- ADDITIONAL subscription predicate — visible only to subscribers of
|
|
-- source_feed_id, or to admins (admins manage feeds regardless of
|
|
-- subscription; the escape also satisfies background jobs, which
|
|
-- declare a first-admin identity). Scan rows pass unchanged via the
|
|
-- IS DISTINCT FROM guard — the predicate costs them one NULL compare.
|
|
-- Unset identity FAILS LOUD, never fails open (see the block header).
|
|
CREATE POLICY p_media_items_visible ON media_items
|
|
FOR SELECT
|
|
USING (
|
|
current_setting('app.user_id') IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM library_visibility lv
|
|
WHERE lv.user_id = current_setting('app.user_id')::uuid
|
|
AND lv.library_id = media_items.library_id
|
|
AND lv.is_visible = false
|
|
)
|
|
AND (
|
|
media_items.source IS DISTINCT FROM 'podcast_episode'
|
|
OR EXISTS (
|
|
SELECT 1 FROM podcast_subscriptions s
|
|
WHERE s.user_id = current_setting('app.user_id')::uuid
|
|
AND s.feed_id = media_items.source_feed_id
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1 FROM users u
|
|
WHERE u.id = current_setting('app.user_id')::uuid
|
|
AND u.role = 'admin'
|
|
)
|
|
)
|
|
);
|
|
|
|
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
|
|
-- lens. MANDATORY at ENABLE time — with RLS enabled and no applicable
|
|
-- policy, INSERT fails (42501) and UPDATE/DELETE silently affect zero
|
|
-- rows, bricking the media scanner. Per-command split is deliberate:
|
|
-- a single FOR ALL policy with USING (true) would OR into SELECT and
|
|
-- disable the read policy above. Writes still need SELECT visibility
|
|
-- of the target rows (the read policy governs that), which internal
|
|
-- identities satisfy (they have no hidden rows).
|
|
-- ON CONFLICT upserts (scanner: (library_id, file_path)) need the
|
|
-- conflicting row SELECT-visible to the acting identity — holds for
|
|
-- every internal identity by construction.
|
|
CREATE POLICY p_media_items_insert ON media_items
|
|
FOR INSERT WITH CHECK (true);
|
|
CREATE POLICY p_media_items_update ON media_items
|
|
FOR UPDATE USING (true) WITH CHECK (true);
|
|
CREATE POLICY p_media_items_delete ON media_items
|
|
FOR DELETE USING (true);
|
|
|
|
-- Other tables: collection_items reaches media only via INNER
|
|
-- JOIN media_items (the join drops filtered rows). collections
|
|
-- metadata (book_count) is a separate counts question — plan A13.
|
|
|
|
-- PODCAST FEEDS (plan D20): governed the same way. SELECT-visible to
|
|
-- subscribers of the feed (their subscription IS the grant — this is
|
|
-- the predicate's lookup table, podcast_subscriptions itself stays
|
|
-- UNGOVERNED) plus the library-visibility join (a hidden library
|
|
-- hides its feeds too), and to admins. WRITE policies are permissive
|
|
-- stand-ins for the same reason as media_items above: mandatory at
|
|
-- ENABLE time, and the fetcher/worker (first-admin identity) plus the
|
|
-- add-feed upsert (ON CONFLICT DO UPDATE needs no separate SELECT)
|
|
-- cover every legitimate writer.
|
|
DROP POLICY IF EXISTS p_podcast_feeds_visible ON podcast_feeds;
|
|
DROP POLICY IF EXISTS p_podcast_feeds_insert ON podcast_feeds;
|
|
DROP POLICY IF EXISTS p_podcast_feeds_update ON podcast_feeds;
|
|
DROP POLICY IF EXISTS p_podcast_feeds_delete ON podcast_feeds;
|
|
|
|
ALTER TABLE podcast_feeds ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE podcast_feeds FORCE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY p_podcast_feeds_visible ON podcast_feeds
|
|
FOR SELECT
|
|
USING (
|
|
current_setting('app.user_id') IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM library_visibility lv
|
|
WHERE lv.user_id = current_setting('app.user_id')::uuid
|
|
AND lv.library_id = podcast_feeds.library_id
|
|
AND lv.is_visible = false
|
|
)
|
|
AND (
|
|
EXISTS (
|
|
SELECT 1 FROM podcast_subscriptions s
|
|
WHERE s.user_id = current_setting('app.user_id')::uuid
|
|
AND s.feed_id = podcast_feeds.id
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1 FROM users u
|
|
WHERE u.id = current_setting('app.user_id')::uuid
|
|
AND u.role = 'admin'
|
|
)
|
|
)
|
|
);
|
|
|
|
CREATE POLICY p_podcast_feeds_insert ON podcast_feeds
|
|
FOR INSERT WITH CHECK (true);
|
|
CREATE POLICY p_podcast_feeds_update ON podcast_feeds
|
|
FOR UPDATE USING (true) WITH CHECK (true);
|
|
CREATE POLICY p_podcast_feeds_delete ON podcast_feeds
|
|
FOR DELETE USING (true);
|
|
|
|
-- podcast_subscriptions: UNGOVERNED, deliberately — it IS the read
|
|
-- policy's lookup table, and a user must always see their own
|
|
-- subscription rows (subscribe -> visibility is a single INSERT).
|
|
-- ============================================================
|