Sync from /srv/compose/unified-media-manager

This commit is contained in:
Christopher Mayor
2026-04-24 10:45:19 -07:00
commit 7dbd00e537
132 changed files with 25394 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
-- Custom types
CREATE TYPE MEDIA_TYPE AS ENUM (
'movie', 'series', 'episode', 'music', 'album',
'audiobook', 'podcast', 'photo', 'other'
);
CREATE TYPE MEDIA_STATUS AS ENUM (
'unavailable', 'searching', 'downloading', 'importing',
'available', 'upgrading', 'failed'
);
CREATE TYPE QUEUE_STATUS AS ENUM (
'pending', 'downloading', 'imported', 'failed',
'blacklisted', 'cancelled'
);
-- Quality profiles
CREATE TABLE quality_profiles (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
media_types MEDIA_TYPE[] NOT NULL,
cutoff_quality JSONB NOT NULL,
allowed_qualities JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Root folders
CREATE TABLE root_folders (
id SERIAL PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
media_type MEDIA_TYPE NOT NULL,
free_space BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tags
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT DEFAULT '#6366f1'
);
-- Scheduled tasks
CREATE TABLE scheduled_tasks (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
cron_expr TEXT NOT NULL,
last_run_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ,
enabled BOOLEAN DEFAULT true,
retention_days INTEGER DEFAULT 7
);
-- Indexers
CREATE TABLE indexers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
implementation TEXT NOT NULL,
url TEXT NOT NULL,
api_key TEXT,
categories JSONB DEFAULT '[]',
settings JSONB DEFAULT '{}',
enabled BOOLEAN DEFAULT true,
priority INTEGER DEFAULT 0,
last_success_at TIMESTAMPTZ,
failure_count INTEGER DEFAULT 0,
disabled_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Download clients
CREATE TABLE download_clients (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
implementation TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT,
password TEXT,
settings JSONB DEFAULT '{}',
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Unified media table (partitioned by type)
CREATE TABLE media (
id BIGSERIAL,
media_type MEDIA_TYPE NOT NULL,
title TEXT NOT NULL,
sort_title TEXT NOT NULL,
original_title TEXT,
overview TEXT,
year INTEGER,
status MEDIA_STATUS NOT NULL DEFAULT 'unavailable',
monitored BOOLEAN NOT NULL DEFAULT false,
external_ids JSONB NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
images JSONB NOT NULL DEFAULT '[]',
quality_profile_id INTEGER REFERENCES quality_profiles(id),
root_folder_id INTEGER REFERENCES root_folders(id),
current_quality JSONB,
desired_quality JSONB,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_search_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
PRIMARY KEY (id, media_type)
) PARTITION BY LIST (media_type);
CREATE TABLE media_movie PARTITION OF media FOR VALUES IN ('movie');
CREATE TABLE media_series PARTITION OF media FOR VALUES IN ('series');
CREATE TABLE media_episode PARTITION OF media FOR VALUES IN ('episode');
CREATE TABLE media_music PARTITION OF media FOR VALUES IN ('music');
CREATE TABLE media_album PARTITION OF media FOR VALUES IN ('album');
CREATE TABLE media_audiobook PARTITION OF media FOR VALUES IN ('audiobook');
CREATE TABLE media_podcast PARTITION OF media FOR VALUES IN ('podcast');
CREATE TABLE media_photo PARTITION OF media FOR VALUES IN ('photo');
CREATE TABLE media_other PARTITION OF media FOR VALUES IN ('other');
-- Media indexes
CREATE INDEX idx_media_title ON media USING gin (to_tsvector('english', coalesce(title, '')));
CREATE INDEX idx_media_monitored ON media (monitored) WHERE monitored = true;
CREATE INDEX idx_media_status ON media (status, media_type);
CREATE INDEX idx_media_external_ids ON media USING gin (external_ids);
-- Media relations (series->episodes, album->tracks)
CREATE TABLE media_relations (
id BIGSERIAL PRIMARY KEY,
parent_id BIGINT NOT NULL,
child_id BIGINT NOT NULL,
relation TEXT NOT NULL,
position INTEGER,
season INTEGER,
UNIQUE(parent_id, child_id, relation)
);
-- Media tags
CREATE TABLE media_tags (
media_id BIGINT NOT NULL,
media_type MEDIA_TYPE NOT NULL,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (media_id, media_type, tag_id)
);
-- Unified file tracking
CREATE TABLE media_files (
id BIGSERIAL PRIMARY KEY,
media_id BIGINT NOT NULL,
media_type MEDIA_TYPE NOT NULL,
path TEXT NOT NULL,
original_path TEXT,
file_name TEXT NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
quality JSONB NOT NULL DEFAULT '{}',
codec TEXT,
resolution TEXT,
source TEXT,
is_hardlinked BOOLEAN DEFAULT false,
checksum TEXT,
transcode_status TEXT DEFAULT 'none',
transcode_preset TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE(media_id, media_type, path)
);
CREATE INDEX idx_media_files_media ON media_files (media_id, media_type);
CREATE INDEX idx_media_files_transcode ON media_files (transcode_status) WHERE transcode_status != 'done';
-- Download queue
CREATE TABLE download_queue (
id BIGSERIAL PRIMARY KEY,
media_id BIGINT NOT NULL,
media_type MEDIA_TYPE NOT NULL,
release_title TEXT NOT NULL,
release_url TEXT,
indexer TEXT NOT NULL,
download_client TEXT NOT NULL,
quality JSONB NOT NULL DEFAULT '{}',
size BIGINT,
protocol TEXT NOT NULL DEFAULT 'torrent',
status QUEUE_STATUS NOT NULL DEFAULT 'pending',
progress REAL DEFAULT 0,
error_message TEXT,
batch_id UUID,
priority INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_queue_status ON download_queue (status, priority DESC);
CREATE INDEX idx_queue_batch ON download_queue (batch_id) WHERE batch_id IS NOT NULL;
CREATE INDEX idx_queue_media ON download_queue (media_id, media_type);
-- Blocklist
CREATE TABLE blocklist (
id BIGSERIAL PRIMARY KEY,
release_title TEXT NOT NULL,
source_title TEXT,
quality JSONB DEFAULT '{}',
indexer TEXT,
protocol TEXT DEFAULT 'torrent',
torrent_hash TEXT,
size BIGINT,
message TEXT,
media_id BIGINT,
media_type MEDIA_TYPE,
block_reason TEXT DEFAULT 'manual',
auto_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blocklist_media ON blocklist (media_id, media_type);
CREATE INDEX idx_blocklist_expires ON blocklist (auto_expires_at) WHERE auto_expires_at IS NOT NULL;
-- Task execution log
CREATE TABLE task_executions (
id BIGSERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES scheduled_tasks(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'running',
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
duration_ms INTEGER,
result JSONB,
error TEXT
);
CREATE INDEX idx_task_exec_task ON task_executions (task_id, started_at DESC);
-- Download history (partitioned for auto-cleanup)
CREATE TABLE download_history (
id BIGSERIAL,
media_id BIGINT NOT NULL,
media_type MEDIA_TYPE NOT NULL,
action TEXT NOT NULL,
release_title TEXT,
quality JSONB DEFAULT '{}',
indexer TEXT,
client TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE download_history_current PARTITION OF download_history
FOR VALUES FROM (CURRENT_DATE - INTERVAL '90 days') TO (MAXVALUE);

View File

@@ -0,0 +1,5 @@
CREATE INDEX IF NOT EXISTS idx_media_quality_upgrade
ON media (monitored, media_type)
WHERE monitored = true AND deleted_at IS NULL
AND current_quality IS NOT NULL
AND desired_quality IS NOT NULL;

View File

@@ -0,0 +1,14 @@
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS url TEXT;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS api_key TEXT;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'umm';
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS protocol TEXT NOT NULL DEFAULT 'nzb';
UPDATE download_clients SET url = 'http://' || host || ':' || port::text WHERE url IS NULL;
ALTER TABLE download_clients ALTER COLUMN url SET NOT NULL;
ALTER TABLE download_clients DROP COLUMN IF EXISTS host;
ALTER TABLE download_clients DROP COLUMN IF EXISTS port;
ALTER TABLE download_clients DROP COLUMN IF EXISTS username;
ALTER TABLE download_clients DROP COLUMN IF EXISTS password;

View File

@@ -0,0 +1,15 @@
CREATE TABLE naming_templates (
id SERIAL PRIMARY KEY,
media_type MEDIA_TYPE NOT NULL UNIQUE,
template TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO naming_templates (media_type, template) VALUES
('movie', '{{sanitize .Title}} ({{.Year}})/{{sanitize .Title}} ({{.Year}}) - {{.Quality}}.{{.Ext}}'),
('series', '{{sanitize .Title}}/Season {{printf "%02d" .Season}}/{{sanitize .Title}} - S{{printf "%02d" .Season}}E{{printf "%02d" .Episode}} - {{.Quality}}.{{.Ext}}'),
('music', '{{sanitize .Artist}}/{{sanitize .Album}}/{{printf "%02d" .Track}} - {{sanitize .Title}}.{{.Ext}}'),
('audiobook', '{{sanitize .Author}}/{{sanitize .Title}}/{{sanitize .Title}} - Ch{{printf "%02d" .Chapter}}.{{.Ext}}'),
('podcast', '{{sanitize .Title}}/{{sanitize .Title}} - {{.Date}}.{{.Ext}}'),
('book', '{{sanitize .Author}}/{{sanitize .Title}} ({{.Year}})/{{sanitize .Title}} ({{.Year}}).{{.Ext}}');

View File

@@ -0,0 +1,12 @@
CREATE TABLE metadata_cache (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
media_type TEXT NOT NULL,
data JSONB NOT NULL,
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX idx_metadata_cache_lookup ON metadata_cache (provider, provider_id);
CREATE INDEX idx_metadata_cache_expired ON metadata_cache (expires_at) WHERE expires_at < NOW();

View File

@@ -0,0 +1 @@
ALTER TABLE download_queue ADD COLUMN IF NOT EXISTS download_id TEXT;

View File

@@ -0,0 +1,32 @@
-- Users table for API key authentication
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
api_key TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users (api_key) WHERE api_key IS NOT NULL;
-- Requests table for media request workflow
CREATE TABLE IF NOT EXISTS requests (
id BIGSERIAL PRIMARY KEY,
media_id BIGINT,
media_type TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
requested_by BIGINT NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
quality_profile_id INTEGER REFERENCES quality_profiles(id),
root_folder_id INTEGER REFERENCES root_folders(id),
notes TEXT DEFAULT '',
reviewed_by BIGINT REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests (status);
CREATE INDEX IF NOT EXISTS idx_requests_requested_by ON requests (requested_by);
CREATE INDEX IF NOT EXISTS idx_requests_media ON requests (media_id, media_type);

View File

@@ -0,0 +1,24 @@
-- Activity events table for unified event logging
CREATE TYPE EVENT_TYPE AS ENUM (
'grab', 'import', 'download_complete', 'download_failed',
'quality_upgrade', 'safety_block', 'error', 'info'
);
CREATE TABLE activity_events (
id BIGSERIAL,
event_type EVENT_TYPE NOT NULL,
media_id BIGINT,
media_type MEDIA_TYPE,
title TEXT NOT NULL,
description TEXT,
data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE activity_events_current PARTITION OF activity_events
FOR VALUES FROM (CURRENT_DATE - INTERVAL '30 days') TO (MAXVALUE);
CREATE INDEX idx_activity_type ON activity_events (event_type, created_at DESC);
CREATE INDEX idx_activity_media ON activity_events (media_id, media_type, created_at DESC) WHERE media_id IS NOT NULL;
CREATE INDEX idx_activity_created ON activity_events (created_at DESC);

View File

@@ -0,0 +1,45 @@
-- Notification system schema
CREATE TYPE NOTIFICATION_CHANNEL_TYPE AS ENUM ('webhook', 'telegram');
CREATE TYPE NOTIFICATION_STATUS AS ENUM ('pending', 'delivering', 'delivered', 'failed', 'dead');
CREATE TABLE notification_channels (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
type NOTIFICATION_CHANNEL_TYPE NOT NULL,
enabled BOOLEAN DEFAULT true,
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE notification_subscriptions (
id BIGSERIAL PRIMARY KEY,
channel_id BIGINT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
event_type EVENT_TYPE NOT NULL,
UNIQUE(channel_id, event_type)
);
CREATE TABLE notification_queue (
id BIGSERIAL PRIMARY KEY,
channel_id BIGINT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
event_type EVENT_TYPE NOT NULL,
title TEXT NOT NULL,
message JSONB NOT NULL DEFAULT '{}',
status NOTIFICATION_STATUS DEFAULT 'pending',
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 5,
last_error TEXT,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
delivered_at TIMESTAMPTZ
);
CREATE TABLE notification_state (
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
last_event_id BIGINT DEFAULT 0,
last_event_created_at TIMESTAMPTZ
);
CREATE INDEX idx_notification_queue_status ON notification_queue (status, next_retry_at);
INSERT INTO notification_state (id) VALUES (1) ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,7 @@
-- Add release_date column for calendar view
ALTER TABLE media ADD COLUMN IF NOT EXISTS release_date TIMESTAMPTZ;
-- Partial index for efficient calendar range queries on monitored, non-deleted items
CREATE INDEX IF NOT EXISTS idx_media_release_date_monitored
ON media (release_date)
WHERE release_date IS NOT NULL AND monitored = true AND deleted_at IS NULL;

View File

@@ -0,0 +1,15 @@
-- Add has_files column to avoid correlated subquery per row
ALTER TABLE media ADD COLUMN IF NOT EXISTS has_files BOOLEAN NOT NULL DEFAULT false;
-- Backfill from existing data
UPDATE media SET has_files = EXISTS (SELECT 1 FROM media_files mf WHERE mf.media_id = media.id AND mf.deleted_at IS NULL);
-- Add index for the upgrade detection query
CREATE INDEX IF NOT EXISTS idx_media_upgrade_candidates
ON media (media_type) WHERE monitored = true AND has_files = true
AND current_quality IS NOT NULL AND desired_quality IS NOT NULL
AND current_quality::text != desired_quality::text;
-- Add trigram index for substring title search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops);

View File

@@ -0,0 +1,14 @@
-- Subtitle cache table to avoid filesystem glob on every detail request
CREATE TABLE IF NOT EXISTS media_subtitles (
id BIGSERIAL PRIMARY KEY,
media_file_id BIGINT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
language TEXT NOT NULL,
language_code TEXT NOT NULL,
hi BOOLEAN NOT NULL DEFAULT false,
forced BOOLEAN NOT NULL DEFAULT false,
source TEXT NOT NULL DEFAULT 'downloaded',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_media_subtitles_file ON media_subtitles (media_file_id);

View File

@@ -0,0 +1,21 @@
-- Fix: migration 003 was tracked but ALTER TABLE statements did not persist.
-- Re-apply the schema changes to download_clients.
-- Add missing columns
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS url TEXT;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS api_key TEXT;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'umm';
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS protocol TEXT NOT NULL DEFAULT 'nzb';
-- Migrate existing rows: combine host+port into url
UPDATE download_clients SET url = 'http://' || host || ':' || port::text WHERE url IS NULL;
-- Now url should be populated — make it NOT NULL
ALTER TABLE download_clients ALTER COLUMN url SET NOT NULL;
-- Drop old columns
ALTER TABLE download_clients DROP COLUMN IF EXISTS host;
ALTER TABLE download_clients DROP COLUMN IF EXISTS port;
ALTER TABLE download_clients DROP COLUMN IF EXISTS username;
ALTER TABLE download_clients DROP COLUMN IF EXISTS password;