Files
PYTV/schema.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);