feat(schema): subtitle/explicit columns, authors+publishers arrays, float series numbers

Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).

Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
  ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
  them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
  Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
  (narrators A2a precedent). Arrays are write truth; author/publisher
  varchars become trigger-regenerated display strings
  (sync_author_publisher_display) so every varchar consumer (trgm
  search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
  set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
  comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
  authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
  for playlist ordering.

Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.

Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.

Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
This commit is contained in:
John O'Keefe committed 2026-10-19 14:17:50 -04:00
1 parent f7dc8189a1
commit 646477afad
30 files changed
+790 -247

No files matched your search

+84 -2
View File
@@ -136,7 +136,8 @@ CREATE TABLE IF NOT EXISTS media_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
author VARCHAR(255),
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,
@@ -144,10 +145,15 @@ CREATE TABLE IF NOT EXISTS media_items (
mime_type VARCHAR(100),
cover_image_path VARCHAR(500),
series VARCHAR(255),
series_number INTEGER,
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),
@@ -267,6 +273,30 @@ ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
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).
@@ -316,6 +346,20 @@ BEGIN
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;
@@ -333,6 +377,35 @@ CREATE TRIGGER set_library_type_name_on_library_change
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);
@@ -1256,12 +1329,21 @@ CREATE TABLE IF NOT EXISTS collection_items (
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;