Files
memby/server/internal/store/schema.sql
T

297 lines
14 KiB
SQL

-- Gateway sessions: one row per signed-in TV.
--
-- token_hash is SHA-256 of the bearer token handed to the device, so a database dump
-- does not hand over working gateway tokens. emby_token IS the live upstream token and
-- is stored as-is: treat this volume as a secret store.
CREATE TABLE IF NOT EXISTS sessions (
token_hash BYTEA PRIMARY KEY,
emby_user_id TEXT NOT NULL,
emby_token TEXT NOT NULL,
username TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '',
device_name TEXT NOT NULL DEFAULT 'Memby TV',
client_version TEXT NOT NULL DEFAULT '',
client_protocol TEXT NOT NULL DEFAULT '',
client_capabilities TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_capabilities TEXT[] NOT NULL DEFAULT '{}';
-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
DELETE FROM sessions older
USING sessions newer
WHERE older.emby_user_id = newer.emby_user_id
AND older.device_id = newer.device_id
AND (
older.last_seen_at < newer.last_seen_at
OR (older.last_seen_at = newer.last_seen_at AND older.token_hash < newer.token_hash)
);
CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id);
CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at);
CREATE UNIQUE INDEX IF NOT EXISTS sessions_user_device_idx
ON sessions (emby_user_id, device_id);
-- The imported library.
--
-- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to
-- rows served live. Deliberately holds NO per-user state: everything is imported with
-- EnableUserData=false, because one household shares this table and watched/favourite
-- flags are not shareable. Anything user-specific still comes from Emby live.
CREATE TABLE IF NOT EXISTS library_items (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
series_id TEXT NOT NULL DEFAULT '',
series_name TEXT NOT NULL DEFAULT '',
production_year INT,
community_rating REAL,
genres TEXT[] NOT NULL DEFAULT '{}',
studios TEXT[] NOT NULL DEFAULT '{}',
date_created TIMESTAMPTZ,
search_text TEXT NOT NULL DEFAULT '',
payload JSONB NOT NULL,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- 'simple' rather than 'english': film titles are proper nouns, and stemming
-- "Arrival" into "arriv" helps nobody.
search_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', search_text)) STORED
);
CREATE INDEX IF NOT EXISTS library_items_search_idx ON library_items USING GIN (search_tsv);
CREATE INDEX IF NOT EXISTS library_items_genres_idx ON library_items USING GIN (genres);
CREATE INDEX IF NOT EXISTS library_items_type_created_idx ON library_items (type, date_created DESC);
CREATE INDEX IF NOT EXISTS library_items_synced_idx ON library_items (synced_at);
-- One row per import, so the admin page can show what happened and when.
CREATE TABLE IF NOT EXISTS sync_runs (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL, -- full | incremental
trigger TEXT NOT NULL DEFAULT 'schedule', -- schedule | manual | startup
status TEXT NOT NULL, -- running | success | failed
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
items_seen INT NOT NULL DEFAULT 0,
items_upserted INT NOT NULL DEFAULT 0,
items_removed INT NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS sync_runs_started_idx ON sync_runs (started_at DESC);
-- Small key/value store for operator switches (currently just maintenance mode). Kept in
-- Postgres rather than memory so a restart cannot silently bring the app back up.
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Row-level engagement. One row per reported event; aggregation happens at read time,
-- which is fine at household scale and keeps the write path trivial.
CREATE TABLE IF NOT EXISTS row_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL,
row_id TEXT NOT NULL,
row_kind TEXT NOT NULL DEFAULT '',
event TEXT NOT NULL, -- impression | focus | select
item_id TEXT NOT NULL DEFAULT '',
dwell_ms INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
-- Search terms are retained separately from row engagement so they can inform future
-- ranking/recommendation work without coupling that analysis to rendered rows.
CREATE TABLE IF NOT EXISTS search_history (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL,
query TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS search_history_user_time_idx
ON search_history (emby_user_id, occurred_at DESC);
-- A user's explicitly followed TV series. Unlike library_items this is intentionally
-- user-scoped: following a show is a Memby preference, not Emby library state.
CREATE TABLE IF NOT EXISTS user_shows (
emby_user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
title TEXT NOT NULL,
year INT,
image_tag TEXT NOT NULL DEFAULT '',
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, item_id)
);
CREATE INDEX IF NOT EXISTS user_shows_user_added_idx
ON user_shows (emby_user_id, added_at);
CREATE TABLE IF NOT EXISTS user_notification_preferences (
emby_user_id TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT true,
show_return_alerts BOOLEAN NOT NULL DEFAULT true,
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Notifications are materialised so read/dismissed state follows the user to every TV.
-- source_key is deterministic, preventing the same return date from being announced
-- again whenever the app refreshes.
CREATE TABLE IF NOT EXISTS user_notifications (
id BIGSERIAL PRIMARY KEY,
emby_user_id TEXT NOT NULL,
source_key TEXT NOT NULL,
kind TEXT NOT NULL,
item_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
message TEXT NOT NULL,
event_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ,
dismissed_at TIMESTAMPTZ,
UNIQUE (emby_user_id, source_key)
);
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
ON user_notifications (emby_user_id, created_at DESC);
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
CREATE TABLE IF NOT EXISTS tracearr_sessions (
server_id TEXT NOT NULL DEFAULT '',
tracearr_session_id TEXT NOT NULL,
tracearr_user_id TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
media_type TEXT NOT NULL DEFAULT '',
media_title TEXT NOT NULL DEFAULT '',
show_title TEXT NOT NULL DEFAULT '',
season_number INT,
episode_number INT,
production_year INT,
started_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
duration_ms BIGINT NOT NULL DEFAULT 0,
progress_ms BIGINT NOT NULL DEFAULT 0,
total_duration_ms BIGINT NOT NULL DEFAULT 0,
watched BOOLEAN NOT NULL DEFAULT false,
device TEXT NOT NULL DEFAULT '',
player TEXT NOT NULL DEFAULT '',
product TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
is_transcode BOOLEAN NOT NULL DEFAULT false,
video_decision TEXT NOT NULL DEFAULT '',
audio_decision TEXT NOT NULL DEFAULT '',
source_video_codec TEXT NOT NULL DEFAULT '',
source_audio_codec TEXT NOT NULL DEFAULT '',
emby_item_id TEXT NOT NULL DEFAULT '',
emby_series_id TEXT NOT NULL DEFAULT '',
source_fingerprint BYTEA NOT NULL,
source_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (server_id, tracearr_session_id)
);
CREATE INDEX IF NOT EXISTS tracearr_sessions_user_time_idx
ON tracearr_sessions (tracearr_user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS tracearr_sessions_username_time_idx
ON tracearr_sessions (lower(username), started_at DESC);
CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_item_idx
ON tracearr_sessions (emby_item_id) WHERE emby_item_id <> '';
CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_series_idx
ON tracearr_sessions (emby_series_id) WHERE emby_series_id <> '';
-- One compact derived profile per Emby user. Variable affinity maps stay together as
-- JSON because the builder reads and replaces the whole profile; no request filters
-- inside these maps.
CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
emby_user_id TEXT PRIMARY KEY,
tracearr_user_id TEXT NOT NULL DEFAULT '',
tracearr_username TEXT NOT NULL DEFAULT '',
source_session_count INT NOT NULL DEFAULT 0,
mean_completion_ratio REAL NOT NULL DEFAULT 0,
typical_session_minutes INT NOT NULL DEFAULT 0,
genre_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
title_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
studio_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
context_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
weighted_profile JSONB NOT NULL DEFAULT '{}'::jsonb,
algorithm_version TEXT NOT NULL DEFAULT '',
signals_through TIMESTAMPTZ,
built_at TIMESTAMPTZ,
pool_built_at TIMESTAMPTZ,
dirty_since TIMESTAMPTZ DEFAULT now(),
last_error TEXT NOT NULL DEFAULT ''
);
ALTER TABLE recommendation_user_profiles
ADD COLUMN IF NOT EXISTS context_affinity JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE recommendation_user_profiles
ADD COLUMN IF NOT EXISTS algorithm_version TEXT NOT NULL DEFAULT '';
ALTER TABLE recommendation_user_profiles
ADD COLUMN IF NOT EXISTS weighted_profile JSONB NOT NULL DEFAULT '{}'::jsonb;
-- Explicit recommendation feedback is separate from Emby favourites: More Like This
-- changes discovery affinity, while Not for Me is a hard user-scoped exclusion.
CREATE TABLE IF NOT EXISTS recommendation_actions (
emby_user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('more_like_this', 'not_for_me')),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, item_id)
);
CREATE INDEX IF NOT EXISTS recommendation_actions_user_idx
ON recommendation_actions (emby_user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS recommendation_onboarding (
emby_user_id TEXT PRIMARY KEY,
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS recommendation_profiles_dirty_idx
ON recommendation_user_profiles (dirty_since)
WHERE dirty_since IS NOT NULL;
-- Every eligible ranked title is retained. At household scale this is only tens of
-- thousands of compact rows and gives short runtime filters far more headroom than the
-- old 240-title request pool.
CREATE TABLE IF NOT EXISTS for_you_candidates (
emby_user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
base_rank INT NOT NULL,
base_score REAL NOT NULL DEFAULT 0,
runtime_minutes INT NOT NULL DEFAULT 0,
affinity_score REAL NOT NULL DEFAULT 0,
compatibility_score REAL NOT NULL DEFAULT 0,
compatibility_label TEXT NOT NULL DEFAULT '',
reason_kind TEXT NOT NULL DEFAULT '',
reason_genre TEXT NOT NULL DEFAULT '',
reason_source_session_id TEXT NOT NULL DEFAULT '',
reason_source_item_id TEXT NOT NULL DEFAULT '',
reason_source_title TEXT NOT NULL DEFAULT '',
recommendation_reason TEXT NOT NULL DEFAULT '',
built_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, item_id),
FOREIGN KEY (item_id) REFERENCES library_items(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS for_you_candidates_user_rank_idx
ON for_you_candidates (emby_user_id, base_rank);
CREATE INDEX IF NOT EXISTS for_you_candidates_user_runtime_rank_idx
ON for_you_candidates (emby_user_id, runtime_minutes, base_rank);