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:
1 parent
f7dc8189a1
commit
646477afad
30 files changed
+790
-247
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user