Files
silo-server/migrations/sql/20260609150000_literary_works.sql
e084cdd1d6 Add unified literary works for ebooks and audiobooks (#107)
* docs: add literary works design and plan

* feat(literary): add work link schema

* feat(literary): add work domain primitives

* feat(literary): persist work links

* feat(literary): score work matches

* feat(catalog): include literary work summary on item detail

* feat(literary): expose work detail API

* feat(literary): assemble work detail

* feat(literary): add admin work linking primitives

* feat(catalog): group literary items by work

* feat(literary): auto-link works during book scans

* fix(literary): narrow work match candidates

* fix(literary): address work merge blockers

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 18:17:08 -04:00

86 lines
3.2 KiB
PL/PgSQL

-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS literary_works (
work_id TEXT PRIMARY KEY,
canonical_title TEXT NOT NULL,
sort_title TEXT,
normalized_title TEXT NOT NULL,
primary_author_key TEXT NOT NULL DEFAULT '',
primary_cover_content_id TEXT REFERENCES media_items(content_id) ON DELETE SET NULL,
description TEXT,
published_date DATE,
publisher TEXT,
genres TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS literary_work_items (
work_id TEXT NOT NULL REFERENCES literary_works(work_id) ON DELETE CASCADE,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
format_type TEXT NOT NULL,
link_source TEXT NOT NULL,
confidence DOUBLE PRECISION NOT NULL DEFAULT 1,
confirmed_at TIMESTAMPTZ,
ignored_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (work_id, content_id),
UNIQUE (content_id),
CHECK (format_type IN ('ebook', 'audiobook', 'comic', 'manga')),
CHECK (link_source IN ('manual', 'external_id', 'metadata_match', 'series_match', 'scan_seed')),
CHECK (confidence >= 0 AND confidence <= 1)
);
CREATE TABLE IF NOT EXISTS literary_work_match_decisions (
source_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
target_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
decision TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (source_content_id, target_content_id),
CHECK (source_content_id <> target_content_id),
CHECK (decision IN ('confirmed', 'ignored'))
);
CREATE INDEX IF NOT EXISTS idx_literary_works_normalized
ON literary_works (normalized_title, primary_author_key);
CREATE INDEX IF NOT EXISTS idx_literary_work_items_content
ON literary_work_items (content_id);
CREATE INDEX IF NOT EXISTS idx_literary_work_items_format
ON literary_work_items (format_type, work_id);
CREATE INDEX IF NOT EXISTS idx_literary_work_match_decisions_target
ON literary_work_match_decisions (target_content_id, decision);
CREATE OR REPLACE FUNCTION delete_empty_literary_works()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM literary_works lw
WHERE lw.work_id = OLD.work_id
AND NOT EXISTS (
SELECT 1 FROM literary_work_items lwi WHERE lwi.work_id = OLD.work_id
);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_delete_empty_literary_works ON literary_work_items;
CREATE TRIGGER trg_delete_empty_literary_works
AFTER DELETE ON literary_work_items
FOR EACH ROW EXECUTE FUNCTION delete_empty_literary_works();
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TRIGGER IF EXISTS trg_delete_empty_literary_works ON literary_work_items;
DROP FUNCTION IF EXISTS delete_empty_literary_works();
DROP TABLE IF EXISTS literary_work_match_decisions;
DROP TABLE IF EXISTS literary_work_items;
DROP TABLE IF EXISTS literary_works;
-- +goose StatementEnd