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