feat(main): initial commit
This commit is contained in:
386
schema.sql
Normal file
386
schema.sql
Normal file
@@ -0,0 +1,386 @@
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user