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);