387 lines
18 KiB
SQL
387 lines
18 KiB
SQL
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
|
-- ------------------------------------------------------------
|
|
-- users and access control
|
|
-- ------------------------------------------------------------
|
|
|
|
CREATE TABLE app_user (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
username VARCHAR(64) NOT NULL UNIQUE,
|
|
email VARCHAR(320) UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_login_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE library (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
owner_user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
visibility VARCHAR(16) NOT NULL DEFAULT 'private'
|
|
CHECK (visibility IN ('private', 'shared', 'public')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (owner_user_id, name)
|
|
);
|
|
|
|
CREATE TABLE library_member (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
library_id BIGINT NOT NULL REFERENCES library(id) ON DELETE CASCADE,
|
|
user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
role VARCHAR(16) NOT NULL
|
|
CHECK (role IN ('viewer', 'editor', 'manager')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (library_id, user_id)
|
|
);
|
|
|
|
-- ------------------------------------------------------------
|
|
-- media metadata
|
|
-- ------------------------------------------------------------
|
|
|
|
CREATE TABLE genre (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
parent_genre_id BIGINT REFERENCES genre(id) ON DELETE SET NULL,
|
|
name VARCHAR(128) NOT NULL UNIQUE,
|
|
description TEXT
|
|
);
|
|
|
|
CREATE TABLE content_rating (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
system_name VARCHAR(64) NOT NULL,
|
|
code VARCHAR(32) NOT NULL,
|
|
description TEXT,
|
|
min_age INTEGER,
|
|
UNIQUE (system_name, code)
|
|
);
|
|
|
|
CREATE TABLE media_source (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
library_id BIGINT NOT NULL REFERENCES library(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
source_type VARCHAR(32) NOT NULL
|
|
CHECK (source_type IN (
|
|
'local_directory',
|
|
'network_share',
|
|
'manual_import',
|
|
'playlist',
|
|
'stream',
|
|
'api_feed'
|
|
)),
|
|
uri TEXT NOT NULL,
|
|
recursive_scan BOOLEAN NOT NULL DEFAULT TRUE,
|
|
include_commercials BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
scan_interval_minutes INTEGER,
|
|
last_scanned_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (library_id, name)
|
|
);
|
|
|
|
CREATE TABLE media_source_rule (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
media_source_id BIGINT NOT NULL REFERENCES media_source(id) ON DELETE CASCADE,
|
|
rule_type VARCHAR(24) NOT NULL
|
|
CHECK (rule_type IN ('include_glob', 'exclude_glob', 'include_regex', 'exclude_regex')),
|
|
rule_value TEXT NOT NULL,
|
|
sort_order INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE series (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
title VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
release_year INTEGER,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE media_item (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
media_source_id BIGINT NOT NULL REFERENCES media_source(id) ON DELETE CASCADE,
|
|
series_id BIGINT REFERENCES series(id) ON DELETE SET NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
sort_title VARCHAR(255),
|
|
description TEXT,
|
|
item_kind VARCHAR(24) NOT NULL
|
|
CHECK (item_kind IN (
|
|
'movie',
|
|
'episode',
|
|
'special',
|
|
'music_video',
|
|
'bumper',
|
|
'interstitial',
|
|
'commercial'
|
|
)),
|
|
season_number INTEGER,
|
|
episode_number INTEGER,
|
|
release_year INTEGER,
|
|
runtime_seconds INTEGER NOT NULL CHECK (runtime_seconds > 0),
|
|
file_path TEXT NOT NULL,
|
|
file_hash VARCHAR(128),
|
|
thumbnail_path TEXT,
|
|
language_code VARCHAR(16),
|
|
content_rating_id BIGINT REFERENCES content_rating(id) ON DELETE SET NULL,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
date_added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
metadata_json JSONB NOT NULL DEFAULT '{}'::JSONB,
|
|
UNIQUE (media_source_id, file_path)
|
|
);
|
|
|
|
CREATE INDEX idx_media_item_series_id ON media_item(series_id);
|
|
CREATE INDEX idx_media_item_item_kind ON media_item(item_kind);
|
|
CREATE INDEX idx_media_item_rating_id ON media_item(content_rating_id);
|
|
CREATE INDEX idx_media_item_source_id ON media_item(media_source_id);
|
|
|
|
CREATE TABLE media_item_genre (
|
|
media_item_id BIGINT NOT NULL REFERENCES media_item(id) ON DELETE CASCADE,
|
|
genre_id BIGINT NOT NULL REFERENCES genre(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (media_item_id, genre_id)
|
|
);
|
|
|
|
CREATE TABLE media_collection (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
library_id BIGINT NOT NULL REFERENCES library(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
collection_type VARCHAR(24) NOT NULL
|
|
CHECK (collection_type IN ('manual', 'smart')),
|
|
definition_json JSONB NOT NULL DEFAULT '{}'::JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (library_id, name)
|
|
);
|
|
|
|
CREATE TABLE media_collection_item (
|
|
media_collection_id BIGINT NOT NULL REFERENCES media_collection(id) ON DELETE CASCADE,
|
|
media_item_id BIGINT NOT NULL REFERENCES media_item(id) ON DELETE CASCADE,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (media_collection_id, media_item_id)
|
|
);
|
|
|
|
-- ------------------------------------------------------------
|
|
-- channels and programming policy
|
|
-- ------------------------------------------------------------
|
|
|
|
CREATE TABLE channel (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
owner_user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
library_id BIGINT NOT NULL REFERENCES library(id) ON DELETE CASCADE,
|
|
default_genre_id BIGINT REFERENCES genre(id) ON DELETE SET NULL,
|
|
name VARCHAR(255) NOT NULL,
|
|
slug VARCHAR(64) NOT NULL UNIQUE,
|
|
channel_number INTEGER,
|
|
description TEXT,
|
|
timezone_name VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
|
visibility VARCHAR(16) NOT NULL DEFAULT 'private'
|
|
CHECK (visibility IN ('private', 'shared', 'public')),
|
|
scheduling_mode VARCHAR(24) NOT NULL DEFAULT 'template_driven'
|
|
CHECK (scheduling_mode IN ('template_driven', 'algorithmic', 'mixed')),
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (library_id, name),
|
|
UNIQUE (library_id, channel_number)
|
|
);
|
|
|
|
CREATE TABLE channel_member (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
channel_id BIGINT NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
|
|
user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
role VARCHAR(16) NOT NULL
|
|
CHECK (role IN ('viewer', 'editor', 'manager')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (channel_id, user_id)
|
|
);
|
|
|
|
CREATE TABLE channel_source_rule (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
channel_id BIGINT NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
|
|
media_source_id BIGINT REFERENCES media_source(id) ON DELETE CASCADE,
|
|
media_collection_id BIGINT REFERENCES media_collection(id) ON DELETE CASCADE,
|
|
rule_mode VARCHAR(24) NOT NULL
|
|
CHECK (rule_mode IN ('allow', 'prefer', 'avoid', 'block')),
|
|
weight NUMERIC(10,4) NOT NULL DEFAULT 1.0 CHECK (weight >= 0),
|
|
max_items_per_day INTEGER,
|
|
max_runs_per_day INTEGER,
|
|
min_repeat_gap_hours INTEGER,
|
|
active_from TIMESTAMPTZ,
|
|
active_to TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CHECK (
|
|
(media_source_id IS NOT NULL AND media_collection_id IS NULL)
|
|
OR
|
|
(media_source_id IS NULL AND media_collection_id IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE TABLE commercial_policy (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
name VARCHAR(255) NOT NULL UNIQUE,
|
|
mode VARCHAR(24) NOT NULL
|
|
CHECK (mode IN ('none', 'replace_breaks', 'fill_to_target', 'probabilistic')),
|
|
target_break_seconds INTEGER,
|
|
max_break_seconds INTEGER,
|
|
allow_same_ad_back_to_back BOOLEAN NOT NULL DEFAULT FALSE,
|
|
description TEXT
|
|
);
|
|
|
|
CREATE TABLE channel_branding (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
channel_id BIGINT NOT NULL UNIQUE REFERENCES channel(id) ON DELETE CASCADE,
|
|
commercial_policy_id BIGINT REFERENCES commercial_policy(id) ON DELETE SET NULL,
|
|
logo_path TEXT,
|
|
bumper_collection_id BIGINT REFERENCES media_collection(id) ON DELETE SET NULL,
|
|
fallback_fill_collection_id BIGINT REFERENCES media_collection(id) ON DELETE SET NULL,
|
|
station_id_audio_path TEXT,
|
|
config_json JSONB NOT NULL DEFAULT '{}'::JSONB
|
|
);
|
|
|
|
-- ------------------------------------------------------------
|
|
-- schedule templates: recurring editorial structure
|
|
-- ------------------------------------------------------------
|
|
|
|
CREATE TABLE schedule_template (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
channel_id BIGINT NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
timezone_name VARCHAR(64) NOT NULL,
|
|
valid_from_date DATE,
|
|
valid_to_date DATE,
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (channel_id, name)
|
|
);
|
|
|
|
CREATE TABLE schedule_block (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
schedule_template_id BIGINT NOT NULL REFERENCES schedule_template(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
block_type VARCHAR(24) NOT NULL
|
|
CHECK (block_type IN ('programming', 'commercial', 'filler', 'off_air', 'special_event')),
|
|
start_local_time TIME NOT NULL,
|
|
end_local_time TIME NOT NULL,
|
|
day_of_week_mask SMALLINT NOT NULL CHECK (day_of_week_mask BETWEEN 1 AND 127),
|
|
spills_past_midnight BOOLEAN NOT NULL DEFAULT FALSE,
|
|
default_genre_id BIGINT REFERENCES genre(id) ON DELETE SET NULL,
|
|
min_content_rating_id BIGINT REFERENCES content_rating(id) ON DELETE SET NULL,
|
|
max_content_rating_id BIGINT REFERENCES content_rating(id) ON DELETE SET NULL,
|
|
preferred_collection_id BIGINT REFERENCES media_collection(id) ON DELETE SET NULL,
|
|
preferred_source_id BIGINT REFERENCES media_source(id) ON DELETE SET NULL,
|
|
rotation_strategy VARCHAR(24) NOT NULL DEFAULT 'shuffle'
|
|
CHECK (rotation_strategy IN ('shuffle', 'sequential', 'least_recent', 'weighted_random')),
|
|
pad_strategy VARCHAR(24) NOT NULL DEFAULT 'fill_with_interstitials'
|
|
CHECK (pad_strategy IN ('hard_stop', 'truncate', 'fill_with_interstitials', 'allow_overrun')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CHECK (
|
|
end_local_time > start_local_time OR spills_past_midnight = TRUE
|
|
)
|
|
);
|
|
|
|
CREATE TABLE block_slot (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
schedule_block_id BIGINT NOT NULL REFERENCES schedule_block(id) ON DELETE CASCADE,
|
|
slot_order INTEGER NOT NULL,
|
|
slot_kind VARCHAR(24) NOT NULL
|
|
CHECK (slot_kind IN ('fixed_item', 'dynamic_pick', 'commercial_break', 'bumper', 'station_id')),
|
|
media_item_id BIGINT REFERENCES media_item(id) ON DELETE SET NULL,
|
|
media_collection_id BIGINT REFERENCES media_collection(id) ON DELETE SET NULL,
|
|
expected_duration_seconds INTEGER,
|
|
max_duration_seconds INTEGER,
|
|
is_mandatory BOOLEAN NOT NULL DEFAULT TRUE,
|
|
selection_rule_json JSONB NOT NULL DEFAULT '{}'::JSONB,
|
|
UNIQUE (schedule_block_id, slot_order),
|
|
CHECK (
|
|
slot_kind <> 'fixed_item' OR media_item_id IS NOT NULL
|
|
)
|
|
);
|
|
|
|
-- ------------------------------------------------------------
|
|
-- concrete schedule: actual planned / generated airtimes
|
|
-- ------------------------------------------------------------
|
|
|
|
CREATE TABLE airing (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
channel_id BIGINT NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
|
|
schedule_template_id BIGINT REFERENCES schedule_template(id) ON DELETE SET NULL,
|
|
schedule_block_id BIGINT REFERENCES schedule_block(id) ON DELETE SET NULL,
|
|
media_item_id BIGINT NOT NULL REFERENCES media_item(id) ON DELETE RESTRICT,
|
|
starts_at TIMESTAMPTZ NOT NULL,
|
|
ends_at TIMESTAMPTZ NOT NULL,
|
|
slot_kind VARCHAR(24) NOT NULL
|
|
CHECK (slot_kind IN ('program', 'commercial', 'bumper', 'interstitial', 'station_id')),
|
|
status VARCHAR(24) NOT NULL DEFAULT 'scheduled'
|
|
CHECK (status IN ('scheduled', 'playing', 'played', 'skipped', 'interrupted', 'cancelled')),
|
|
source_reason VARCHAR(24) NOT NULL
|
|
CHECK (source_reason IN ('template', 'autofill', 'manual', 'recovery')),
|
|
generation_batch_uuid UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CHECK (ends_at > starts_at)
|
|
);
|
|
|
|
CREATE INDEX idx_airing_channel_time ON airing(channel_id, starts_at, ends_at);
|
|
CREATE INDEX idx_airing_media_item_time ON airing(media_item_id, starts_at);
|
|
CREATE INDEX idx_airing_status ON airing(status);
|
|
|
|
CREATE TABLE airing_event (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
airing_id BIGINT NOT NULL REFERENCES airing(id) ON DELETE CASCADE,
|
|
event_type VARCHAR(24) NOT NULL
|
|
CHECK (event_type IN ('scheduled', 'started', 'ended', 'skipped', 'interrupted', 'resumed', 'cancelled')),
|
|
event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
details_json JSONB NOT NULL DEFAULT '{}'::JSONB
|
|
);
|
|
|
|
-- ------------------------------------------------------------
|
|
-- viewer state and history
|
|
-- ------------------------------------------------------------
|
|
|
|
CREATE TABLE user_channel_state (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
channel_id BIGINT NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
|
|
is_favorite BOOLEAN NOT NULL DEFAULT FALSE,
|
|
last_tuned_at TIMESTAMPTZ,
|
|
last_known_airing_id BIGINT REFERENCES airing(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (user_id, channel_id)
|
|
);
|
|
|
|
CREATE TABLE watch_session (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
channel_id BIGINT REFERENCES channel(id) ON DELETE SET NULL,
|
|
airing_id BIGINT REFERENCES airing(id) ON DELETE SET NULL,
|
|
started_at TIMESTAMPTZ NOT NULL,
|
|
ended_at TIMESTAMPTZ,
|
|
position_seconds INTEGER NOT NULL DEFAULT 0 CHECK (position_seconds >= 0),
|
|
client_id VARCHAR(128),
|
|
session_metadata JSONB NOT NULL DEFAULT '{}'::JSONB,
|
|
CHECK (ended_at IS NULL OR ended_at >= started_at)
|
|
);
|
|
|
|
CREATE TABLE media_resume_point (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
media_item_id BIGINT NOT NULL REFERENCES media_item(id) ON DELETE CASCADE,
|
|
resume_seconds INTEGER NOT NULL CHECK (resume_seconds >= 0),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (user_id, media_item_id)
|
|
);
|
|
|
|
-- ------------------------------------------------------------
|
|
-- useful integrity helpers
|
|
-- ------------------------------------------------------------
|
|
|
|
CREATE INDEX idx_channel_source_rule_channel ON channel_source_rule(channel_id);
|
|
CREATE INDEX idx_schedule_template_channel ON schedule_template(channel_id, is_active);
|
|
CREATE INDEX idx_schedule_block_template ON schedule_block(schedule_template_id, day_of_week_mask);
|
|
CREATE INDEX idx_block_slot_block_order ON block_slot(schedule_block_id, slot_order);
|
|
CREATE INDEX idx_user_channel_state_user ON user_channel_state(user_id);
|
|
CREATE INDEX idx_watch_session_user_time ON watch_session(user_id, started_at);
|
|
|