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

985 lines
51 KiB
SQL
Raw Normal View History

-- 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 '',
2026-07-27 21:06:51 +12:00
device_name TEXT NOT NULL DEFAULT 'Memby TV',
2026-07-29 15:26:27 +12:00
client_version TEXT NOT NULL DEFAULT '',
client_protocol TEXT NOT NULL DEFAULT '',
2026-08-02 22:10:19 +12:00
client_capabilities TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
2026-07-27 21:06:51 +12:00
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
2026-07-29 15:26:27 +12:00
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 '';
2026-08-02 22:10:19 +12:00
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_capabilities TEXT[] NOT NULL DEFAULT '{}';
2026-07-27 21:06:51 +12:00
-- 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);
2026-07-27 21:06:51 +12:00
CREATE UNIQUE INDEX IF NOT EXISTS sessions_user_device_idx
ON sessions (emby_user_id, device_id);
2026-08-06 22:33:56 +12:00
-- Every app build a television has been seen running.
--
-- A session row carries only the version in force right now, which is overwritten by the
-- next call that reports a different one, so on its own the answer to "what has this set
-- been running" is one value deep. This is keyed on device_id alone because the history
-- belongs to the television rather than to whoever is signed into it, and it outlives a
-- sign-out: the set is the same set when it comes back.
CREATE TABLE IF NOT EXISTS device_versions (
device_id TEXT NOT NULL,
client_version TEXT NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, client_version)
);
CREATE INDEX IF NOT EXISTS device_versions_recent_idx
ON device_versions (device_id, last_seen_at DESC);
2026-08-17 19:09:17 +12:00
-- One row per television per day it was used.
--
-- It exists to answer "is this the first time this set has opened Memby today", which is
-- a question a counter or a timestamp cannot answer safely: every television in the house
-- asks at once, so the answer has to come from the insert itself. The primary key is what
-- makes it atomic — the row either went in, which means first, or it did not.
--
-- The day is the *household's* local day, computed against MEMBY_TIMEZONE and stored as a
-- plain date, because "today" is a thing the people watching have an opinion about and
-- UTC does not agree with it for most of a New Zealand evening.
CREATE TABLE IF NOT EXISTS device_activity_days (
device_id TEXT NOT NULL,
emby_user_id TEXT NOT NULL,
day DATE NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, emby_user_id, day)
);
CREATE INDEX IF NOT EXISTS device_activity_days_day_idx
ON device_activity_days (day);
-- 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);
2026-08-20 15:06:00 +12:00
-- Every episode of one series, which is what a viewer's Next Up walks and what a series
-- card's watched count is computed from. Both run on the tail of an ordinary request, and
-- without this each is a scan of every episode in the library.
CREATE INDEX IF NOT EXISTS library_items_series_episodes_idx
ON library_items (series_id) WHERE type = 'Episode';
2026-08-06 22:33:56 +12:00
-- Durable raw MDBList responses. Source selection and display formatting happen at read
-- time, so changing the visible sources does not require another external API request.
CREATE TABLE IF NOT EXISTS external_media_ratings (
media_type TEXT NOT NULL,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
ratings JSONB NOT NULL DEFAULT '[]'::jsonb,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (media_type, provider, provider_id)
);
CREATE INDEX IF NOT EXISTS external_media_ratings_fetched_idx
ON external_media_ratings (fetched_at);
-- Emby item id -> external provider identity, learned as televisions navigate.
--
-- The rating itself is keyed by the provider's id, which Emby only reveals in a
-- ProviderIds lookup. Remembering the answer is what lets a home row attach ratings to
-- forty cards from one indexed read instead of forty Emby requests, and it works for
-- items the library import has not yet re-read.
CREATE TABLE IF NOT EXISTS item_rating_refs (
item_id TEXT PRIMARY KEY,
media_type TEXT NOT NULL,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 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);
2026-07-29 15:26:27 +12:00
2026-08-12 08:25:15 +12:00
-- Significant, user-scoped app journeys. Item names make content events recognisable;
-- search terms, setting values and other arbitrary free text do not belong in this table.
-- journey_id is generated by the client for one foreground visit; emby_user_id is always
-- taken from the authenticated gateway session rather than trusted from the payload.
CREATE TABLE IF NOT EXISTS journey_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL,
journey_id TEXT NOT NULL,
sequence INT NOT NULL DEFAULT 0,
category TEXT NOT NULL,
action TEXT NOT NULL,
screen TEXT NOT NULL DEFAULT '',
feature TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL DEFAULT '',
2026-08-12 08:25:15 +12:00
item_name TEXT NOT NULL DEFAULT '',
item_type TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT ''
);
2026-08-12 08:25:15 +12:00
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS item_name TEXT NOT NULL DEFAULT '';
2026-08-18 08:41:48 +12:00
-- Emby's id for the stream a playback step describes. Empty on every other kind of step,
-- and on every row written before playback steps carried one.
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS play_session_id TEXT NOT NULL DEFAULT '';
2026-08-12 08:25:15 +12:00
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
ON journey_events (emby_user_id, journey_id, sequence);
CREATE INDEX IF NOT EXISTS journey_events_user_time_idx
ON journey_events (emby_user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS journey_events_feature_time_idx
ON journey_events (feature, occurred_at DESC);
2026-07-29 15:26:27 +12:00
-- 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);
2026-08-02 22:10:19 +12:00
-- 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,
2026-08-15 09:23:26 +12:00
sonarr_alerts BOOLEAN NOT NULL DEFAULT true,
radarr_alerts BOOLEAN NOT NULL DEFAULT true,
update_alerts BOOLEAN NOT NULL DEFAULT true,
library_alerts BOOLEAN NOT NULL DEFAULT true,
system_alerts BOOLEAN NOT NULL DEFAULT true,
2026-08-18 08:41:48 +12:00
watch_time_digest BOOLEAN NOT NULL DEFAULT true,
2026-08-02 22:10:19 +12:00
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
2026-08-15 09:23:26 +12:00
-- CREATE TABLE IF NOT EXISTS does not add fields to an existing installation. These
-- additive, permissive defaults make the feature safe to roll out without changing what
-- any current viewer receives.
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS sonarr_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS radarr_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS update_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS library_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS system_alerts BOOLEAN NOT NULL DEFAULT true;
2026-08-18 08:41:48 +12:00
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS watch_time_digest BOOLEAN NOT NULL DEFAULT true;
2026-08-15 09:23:26 +12:00
2026-08-02 22:10:19 +12:00
-- 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);
2026-08-11 23:41:10 +12:00
-- A change-only history of Sonarr's lifecycle answer for every series. The first reading
-- is a baseline; later rows mean Sonarr changed its answer, which lets the daily scanner
-- distinguish a show that was already over from one that has just been cancelled.
CREATE TABLE IF NOT EXISTS sonarr_series_status_history (
id BIGSERIAL PRIMARY KEY,
series_key TEXT NOT NULL,
sonarr_series_id INT NOT NULL DEFAULT 0,
tvdb_id INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
year INT NOT NULL DEFAULT 0,
status TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sonarr_series_status_history_series_time_idx
ON sonarr_series_status_history (series_key, observed_at DESC, id DESC);
2026-08-12 08:25:15 +12:00
-- Separates the scanner's first baseline from a genuinely new series discovered later.
-- A dedicated marker also handles an initially empty Sonarr library correctly.
CREATE TABLE IF NOT EXISTS sonarr_lifecycle_scan_state (
singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
2026-07-29 15:26:27 +12:00
-- 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,
2026-08-02 22:10:19 +12:00
context_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
2026-07-29 15:26:27 +12:00
codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
2026-08-02 22:10:19 +12:00
weighted_profile JSONB NOT NULL DEFAULT '{}'::jsonb,
algorithm_version TEXT NOT NULL DEFAULT '',
2026-07-29 15:26:27 +12:00
signals_through TIMESTAMPTZ,
built_at TIMESTAMPTZ,
pool_built_at TIMESTAMPTZ,
dirty_since TIMESTAMPTZ DEFAULT now(),
last_error TEXT NOT NULL DEFAULT ''
);
2026-08-02 22:10:19 +12:00
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()
);
2026-07-29 15:26:27 +12:00
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);
2026-08-06 22:33:56 +12:00
-- One viewer's TV settings, so they follow the person rather than the television. The
-- document is opaque here on purpose: the vocabulary lives in internal/api next to the
-- client contract, so adding a setting never needs a migration. What this table owns is
-- the revision, which is how a TV notices from the status poll alone that an operator (or
-- another television) changed something.
CREATE TABLE IF NOT EXISTS user_preferences (
emby_user_id TEXT PRIMARY KEY,
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
revision BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
source TEXT NOT NULL DEFAULT 'device'
);
-- Every accepted write of the document above, so the operator can read what changed, who
-- changed it, and put a previous version back.
--
-- The document is stored whole rather than as a delta. A delta would have to be
-- interpreted against a vocabulary that lives in internal/api and can gain a setting
-- between two revisions, and restoring one would then mean replaying a chain; a whole
-- document is restorable on its own and normalised on the way out. History is capped per
-- person at write time (preferenceHistoryLimit) — this is a household, and the value of an
-- entry falls off a cliff once nobody remembers the change.
CREATE TABLE IF NOT EXISTS user_preference_revisions (
emby_user_id TEXT NOT NULL,
revision BIGINT NOT NULL,
preferences JSONB NOT NULL,
source TEXT NOT NULL DEFAULT 'device',
-- Which television wrote it, captured at write time rather than joined from sessions:
-- a set that has since been signed out still has to be nameable in the history.
device_id TEXT NOT NULL DEFAULT '',
device_name TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
-- The revision this one was restored from, when it was. Never a rewind: a restore is
-- a new revision carrying an old document, because the revision is what tells a TV
-- something changed and one that went backwards would leave every set believing it
-- was already up to date.
restored_from BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, revision)
);
CREATE INDEX IF NOT EXISTS user_preference_revisions_user_idx
ON user_preference_revisions (emby_user_id, revision DESC);
-- Which televisions have actually taken a revision, recorded when a set fetches the
-- document rather than when the server writes it.
--
-- The status poll carries the revision to every open TV, but a TV being told is not a TV
-- having adopted: it may be switched off, mid-film, or unable to reach /v1/preferences.
-- The fetch is the only proof, so it is what writes here.
CREATE TABLE IF NOT EXISTS user_preference_acks (
emby_user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
revision BIGINT NOT NULL,
device_name TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
acked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, device_id, revision)
);
CREATE INDEX IF NOT EXISTS user_preference_acks_user_revision_idx
ON user_preference_acks (emby_user_id, revision DESC);
2026-08-09 08:25:50 +12:00
-- Which colour schemes an operator has decided a particular viewer may choose from.
--
-- Deliberately not part of user_preferences: that document is the viewer's own choices and
-- is written by every television they own, where this is policy about them and is written
-- only by the console. Keeping them apart is what stops a TV pushing itself a theme it was
-- not offered simply by including the id in a settings write.
--
-- **A person with no rows here may choose anything.** Absence is permissive, because no row
-- exists for anybody until an operator restricts somebody — reading it the other way round
-- would empty every picker in the house the day this ships. It also means "allowed
-- everything" and "never configured" are stored identically, which is correct: they are the
-- same decision.
--
-- Seasonal themes are never in here. They are not grantable per person; the only switch is
-- the seasonal_themes feature flag, and it is the operator's, for the whole household.
CREATE TABLE IF NOT EXISTS user_themes (
emby_user_id TEXT NOT NULL,
theme_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, theme_id)
);
-- Subtitles the gateway fetched itself, which is the one place Memby holds a subtitle.
--
-- Bazarr does not need this: it writes the file beside the media file, so Emby finds it
-- and the track arrives down the ordinary PlaybackInfo path. OpenSubtitles has no such
-- reach — the gateway has no access to the media directory — so a file fetched from it is
-- kept here and served back as a sidecar. That is the whole difference between the two
-- providers, and it is why this table exists at all.
--
-- It is deliberately durable rather than a cache. A subtitle somebody fetched mid-film is
-- one they will want again on the next episode of the same evening and on a rewatch a year
-- later; spending a provider's daily download quota twice for the same file would be the
-- feature working against the household. Rows are small — a subtitle is tens of kilobytes
-- — and are deleted with nothing else, because nothing else knows the file exists.
CREATE TABLE IF NOT EXISTS downloaded_subtitles (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
language TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
forced BOOLEAN NOT NULL DEFAULT false,
hearing_impaired BOOLEAN NOT NULL DEFAULT false,
format TEXT NOT NULL DEFAULT 'srt',
provider TEXT NOT NULL DEFAULT '',
content BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS downloaded_subtitles_item_idx ON downloaded_subtitles (item_id);
2026-08-12 14:13:19 +12:00
-- What a viewer has asked the household to get hold of.
--
-- Radarr and Sonarr are the things that actually fetch a title, and neither keeps any idea
-- of *who* wanted it: an added movie is an added movie. So this table is the only record of
-- authorship, and it is what makes "My requests" a per-person page rather than a list of
-- everything the household has ever added.
--
-- It deliberately stores no status. A request's state — waiting for a release, searching,
-- downloaded, in the library — is Radarr's and Sonarr's to answer and changes without
-- anybody touching Memby, so a status column here would be a second copy that is wrong
-- within the hour. What is stored is the identity (which title, from which catalogue) plus
-- enough metadata to draw the card before the *arr lookup returns; the state is derived per
-- request by requestStatusFor.
--
-- The primary key is (viewer, catalogue, id) rather than a serial, so asking twice for the
-- same film is the same request rather than two rows a viewer has to tell apart. The repeat
-- refreshes requested_at, because the second ask is the one they remember making.
CREATE TABLE IF NOT EXISTS media_requests (
emby_user_id TEXT NOT NULL,
media_type TEXT NOT NULL,
foreign_id INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
year INTEGER NOT NULL DEFAULT 0,
poster_url TEXT NOT NULL DEFAULT '',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, media_type, foreign_id)
);
2026-08-19 14:25:44 +12:00
-- last_status is the one piece of request state that *is* stored, and only because a
-- transition cannot be derived from a single read. Everything else on a request card is
-- computed per read from the *arrs and the library; "it has just become ready" is not a
-- property of the present, it is the difference between two observations, and the viewer
-- has to be told about it exactly once.
--
-- It is seeded when the request is recorded rather than left blank for a sweep to fill in,
-- because a film that downloads in the three minutes before the first sweep would otherwise
-- have its arrival recorded as its opening state and nobody would ever be told.
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS last_status TEXT NOT NULL DEFAULT '';
2026-08-12 14:13:19 +12:00
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
ON media_requests (emby_user_id, requested_at DESC);
2026-08-14 09:40:03 +12:00
-- Every sign-in attempt, successful or not.
--
-- The sessions table above holds one row per television and is overwritten by the next
-- sign-in, replaced when a device id moves and deleted when a set is removed — so it can
-- say what is true now and nothing at all about what happened. This is the history: how
-- often a set has connected, at what times, from which addresses, on which build, and
-- whether the attempt got in. Every column records what was true at the moment of the
-- attempt, including the ones that later change, which is why device_name and
-- client_version are copied here rather than joined from the session.
--
-- A failed attempt has no emby_user_id: Emby refused the credentials, so there is no
-- verified identity to attribute it to. The username is what was typed, and it is kept
-- precisely because a run of failures against one name is the thing worth noticing.
CREATE TABLE IF NOT EXISTS login_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '',
device_name TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
client_protocol TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
success BOOLEAN NOT NULL,
method TEXT NOT NULL DEFAULT 'password',
failure_reason TEXT NOT NULL DEFAULT '',
new_device BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS login_events_time_idx ON login_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS login_events_device_time_idx
ON login_events (device_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS login_events_user_time_idx
ON login_events (emby_user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS login_events_ip_idx ON login_events (ip_address);
-- Administrative events: the operational feed behind the console's notification bell.
--
-- Deliberately generic. A publisher supplies a type, a severity, who or what it concerns
-- and a sentence; nothing here knows about sign-ins, devices or scheduled tasks
-- specifically. That is what lets a service added later publish into the same feed, and
-- what lets one integration deliver every kind of event without a case per kind.
--
-- read_at is a single operator's read state rather than a per-account one: the console is
-- guarded by one shared admin token, so there is one reader by construction.
CREATE TABLE IF NOT EXISTS admin_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
type TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '',
link TEXT NOT NULL DEFAULT '',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
read_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS admin_events_time_idx ON admin_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS admin_events_unread_idx
ON admin_events (occurred_at DESC) WHERE read_at IS NULL;
CREATE INDEX IF NOT EXISTS admin_events_type_time_idx ON admin_events (type, occurred_at DESC);
-- One row per run of a scheduled task.
--
-- The scheduler keeps its next-run time in memory because it is derived from the schedule
-- and the clock, but what *happened* has to outlive the process: an operator asking why
-- the overnight housekeeping did not run is asking about a container that has since been
-- replaced. Duration is stored rather than derived so a run killed by a restart, which
-- has a start and no finish, is distinguishable from one that took no time.
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
id BIGSERIAL PRIMARY KEY,
task_id TEXT NOT NULL,
trigger TEXT NOT NULL DEFAULT 'schedule', -- schedule | manual | startup
status TEXT NOT NULL, -- running | success | failed | skipped
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
duration_ms BIGINT NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT ''
);
2026-08-19 14:25:44 +12:00
-- Which integration a run belongs to, and what it actually did.
--
-- Deliberately more columns on this table rather than a second one: an integration run IS
-- a scheduled task run, read with a different question in mind. Operations history and
-- "what does the gateway do in the background" are the same rows; giving integrations
-- their own table would mean two schedulers, two retention jobs and two places a run can
-- be recorded as having failed.
--
-- The counters are nullable-by-default zeroes because most tasks count nothing: a
-- housekeeping prune has one number and it is already in `detail`. A run that counted
-- nothing is drawn without figures rather than as four zeroes, which is why the API sends
-- them only when `processed` is non-zero.
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS integration_id TEXT NOT NULL DEFAULT '';
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS processed INT NOT NULL DEFAULT 0;
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS changed INT NOT NULL DEFAULT 0;
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS skipped INT NOT NULL DEFAULT 0;
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS failed INT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS scheduled_task_runs_integration_idx
ON scheduled_task_runs (integration_id, started_at DESC)
WHERE integration_id <> '';
2026-08-14 09:40:03 +12:00
CREATE INDEX IF NOT EXISTS scheduled_task_runs_task_idx
ON scheduled_task_runs (task_id, started_at DESC);
CREATE INDEX IF NOT EXISTS scheduled_task_runs_time_idx
ON scheduled_task_runs (started_at DESC);
-- An operator's per-task overrides. A task that has never been touched has no row, and
-- absence is "run as the code declares" — reading it the other way would leave every task
-- disabled on the day this shipped.
CREATE TABLE IF NOT EXISTS scheduled_task_settings (
task_id TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT true,
interval_seconds INT NOT NULL DEFAULT 0, -- 0 keeps the task's declared interval
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- What an integration actually managed to deliver.
--
-- Kept because the two questions an operator has about a webhook are "is it working" and
-- "why did that one not arrive", and neither is answerable from configuration. The body
-- is not stored: it is reconstructible from the event, and a webhook payload is the one
-- place a URL containing a secret would otherwise come to rest in the database.
CREATE TABLE IF NOT EXISTS integration_deliveries (
id BIGSERIAL PRIMARY KEY,
integration_id TEXT NOT NULL,
event_type TEXT NOT NULL DEFAULT '',
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
success BOOLEAN NOT NULL,
status_code INT NOT NULL DEFAULT 0,
duration_ms BIGINT NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS integration_deliveries_idx
ON integration_deliveries (integration_id, attempted_at DESC);
2026-08-14 13:32:14 +12:00
-- A viewer's report about one concrete Emby movie or episode. A replacement is a
-- separate state on the report so an ordinary playback complaint can never start a
-- download. The uniqueness constraint is the first duplicate guard: one open workflow
-- owns an item until an operator resolves or dismisses it.
CREATE TABLE IF NOT EXISTS media_reports (
id BIGSERIAL PRIMARY KEY,
emby_item_id TEXT NOT NULL,
media_type TEXT NOT NULL, -- movie | episode
title TEXT NOT NULL,
series_title TEXT NOT NULL DEFAULT '',
season_number INT NOT NULL DEFAULT 0,
episode_number INT NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
comment TEXT NOT NULL DEFAULT '',
reported_by_user_id TEXT NOT NULL,
reported_by_username TEXT NOT NULL,
reported_by_device TEXT NOT NULL DEFAULT '',
replacement_requested BOOLEAN NOT NULL DEFAULT false,
replacement_status TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'new',
arr_item_id INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS media_reports_open_item_idx
ON media_reports (emby_item_id) WHERE status IN ('new', 'acknowledged', 'replacement_requested', 'downloading');
CREATE INDEX IF NOT EXISTS media_reports_created_idx ON media_reports (created_at DESC);
CREATE TABLE IF NOT EXISTS media_report_actions (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL REFERENCES media_reports(id) ON DELETE CASCADE,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS media_report_actions_report_idx
ON media_report_actions (report_id, created_at ASC);
2026-08-15 09:23:26 +12:00
-- Where an episode's closing credits begin, for the small number of episodes a household is
-- actually about to watch. Written by internal/credits.
--
-- The primary key is the whole design. Keyed on the media *version* rather than on the item,
-- so a file Sonarr replaces stops matching its old marker and becomes a scan candidate again
-- with nothing having to notice the swap — no invalidation pass, no staleness check, and no
-- possibility of a Skip Credits button positioned against a file that no longer exists.
--
-- Deliberately the only durable output of that subsystem. Queue state, candidate priorities
-- and scan progress are all held in RAM and rebuilt from Tracearr on restart, because a
-- persistent job scheduler would cost more writes than the scanning it coordinates.
CREATE TABLE IF NOT EXISTS credits_markers (
item_id TEXT NOT NULL,
media_fingerprint TEXT NOT NULL,
credits_start_ms BIGINT NOT NULL,
confidence REAL NOT NULL DEFAULT 0,
detection_method TEXT NOT NULL DEFAULT '',
-- Only so a season can be read back in one query. That read is what narrows the next
-- episode's scan from ten minutes of file to two.
series_id TEXT NOT NULL DEFAULT '',
season_number INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (item_id, media_fingerprint)
);
CREATE INDEX IF NOT EXISTS credits_markers_season_idx
ON credits_markers (series_id, season_number, confidence DESC)
WHERE series_id <> '';
2026-08-16 12:13:51 +12:00
-- Operational history for credits scans. This is deliberately separate from queue state:
-- the queue remains disposable, while completed attempts explain repeated candidates and
-- provide the cooldown that stops an inconclusive episode being scanned every ten minutes.
CREATE TABLE IF NOT EXISTS credits_scan_history (
id BIGSERIAL PRIMARY KEY,
item_id TEXT NOT NULL,
series_id TEXT NOT NULL DEFAULT '',
season_number INT NOT NULL DEFAULT 0,
episode_number INT NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
priority INT NOT NULL DEFAULT 0,
outcome TEXT NOT NULL,
marker_ms BIGINT NOT NULL DEFAULT 0,
confidence REAL NOT NULL DEFAULT 0,
method TEXT NOT NULL DEFAULT '',
frames_sampled INT NOT NULL DEFAULT 0,
error_text TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ NOT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS credits_scan_history_item_time_idx
ON credits_scan_history (item_id, finished_at DESC);
CREATE INDEX IF NOT EXISTS credits_scan_history_time_idx
ON credits_scan_history (finished_at DESC);
2026-08-18 14:59:29 +12:00
-- Work Sonarr and Radarr told the gateway about.
--
-- This is the one queue in the schema that is durable, and the reason is that a webhook is
-- gone once it has been dropped: a Tracearr-derived credits candidate is rebuilt from one
-- query on restart, while "Sonarr imported this at 19:05" cannot be rederived from
-- anything. A container restarted during the settle delay must still re-read the file.
--
-- The key is derived from the *file* rather than from the delivery, so ON CONFLICT is what
-- makes repeated webhook delivery safe: two notifications about one import collapse onto
-- one row, while a file deleted and re-imported is a different file and its own work.
--
-- Completed rows are kept rather than deleted. They are the operator's record of why an
-- item was re-read, which is the question the Imports page exists to answer; housekeeping
-- prunes them.
CREATE TABLE IF NOT EXISTS library_ingest_queue (
key TEXT PRIMARY KEY,
action TEXT NOT NULL,
kind TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
state TEXT NOT NULL DEFAULT 'pending',
outcome TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL DEFAULT '',
attempts INT NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
due_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The worker's only query: what is due. Partial, because settled rows outnumber pending
-- ones by orders of magnitude within a day of the feature being switched on.
CREATE INDEX IF NOT EXISTS library_ingest_pending_idx
ON library_ingest_queue (due_at)
WHERE state = 'pending';
CREATE INDEX IF NOT EXISTS library_ingest_recent_idx
ON library_ingest_queue (updated_at DESC);
2026-08-19 06:57:59 +12:00
-- The outbound notification log: what Memby sent, to whom, over which channel, and what
-- became of it. Written only by internal/notify, which every producer now goes through,
-- so this is one audit trail rather than a per-feature guess.
--
-- Deliberately separate from user_notifications. That table is one viewer's undismissed
-- list — state they empty — where this is history: it keeps the row for a notification
-- that was dismissed, for one that was deliberately skipped, and for a broadcast that
-- belongs to no viewer at all, none of which the other table can represent.
--
-- emby_user_id is '' rather than NULL for a household broadcast, so every filter is an
-- equality test and no query needs a NULL case.
CREATE TABLE IF NOT EXISTS notification_log (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
channel TEXT NOT NULL, -- in-app | broadcast | webhook
kind TEXT NOT NULL DEFAULT '', -- show-return, watch-time-week, …
source TEXT NOT NULL DEFAULT '', -- the service that decided to send
emby_user_id TEXT NOT NULL DEFAULT '', -- '' is the whole household
username TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '', -- a destination's NAME, never its address
source_key TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL, -- sent | delivered | failed | pending | skipped
detail TEXT NOT NULL DEFAULT '', -- the failure, or why it was skipped
duration_ms BIGINT NOT NULL DEFAULT 0,
event_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- The page's default read is the whole log newest-first, and every filtered read still
-- bounds on the date; the remaining three cover the columns the filter bar offers.
CREATE INDEX IF NOT EXISTS notification_log_time_idx ON notification_log (occurred_at DESC);
CREATE INDEX IF NOT EXISTS notification_log_user_idx
ON notification_log (emby_user_id, occurred_at DESC) WHERE emby_user_id <> '';
CREATE INDEX IF NOT EXISTS notification_log_status_idx ON notification_log (status, occurred_at DESC);
CREATE INDEX IF NOT EXISTS notification_log_kind_idx ON notification_log (kind, occurred_at DESC);
2026-08-20 15:06:00 +12:00
-- Viewers: the people using one Memby account.
--
-- A Memby account is the household's relationship with an Emby user; a viewer is one
-- person under it. Every account has exactly one MAIN viewer, whose state is Emby's and
-- which behaves exactly as the account did before viewers existed, and any number of
-- SHADOW viewers whose state is Memby's alone.
--
-- The main viewer's id IS the Emby user id, and that is the whole of why this feature
-- needed no migration. Every table in this schema keys a person by a bare emby_user_id
-- with no foreign key behind it, so substituting a viewer id for it leaves an existing
-- household's preferences, notifications, followed shows, search history and row stats
-- exactly where they were. A shadow id is prefixed 'v' and is therefore distinguishable
-- from Emby's 32-hex GUIDs by inspection, which is what makes that substitution safe.
CREATE TABLE IF NOT EXISTS viewers (
id TEXT PRIMARY KEY,
emby_user_id TEXT NOT NULL,
name TEXT NOT NULL,
short_name TEXT NOT NULL DEFAULT '',
colour TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- main | shadow
pin_hash BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS viewers_account_idx ON viewers (emby_user_id, created_at);
-- One main viewer per account, enforced rather than assumed: the main viewer is what a
-- request falls back to, so an account with two of them would resolve differently
-- depending on which row a query happened to return first.
CREATE UNIQUE INDEX IF NOT EXISTS viewers_account_main_idx
ON viewers (emby_user_id) WHERE kind = 'main';
-- A shadow viewer's own viewing state, in the shape of the Emby UserData block it stands
-- in for. Only the fields Memby actually renders are here: the Emby item id is the common
-- identifier, so no library metadata is duplicated and nothing here needs invalidating
-- when the catalogue changes.
--
-- There is deliberately no row for a main viewer. Their state lives in Emby, and a copy
-- of it here would be a second answer free to disagree with the one the household's other
-- Emby clients see.
CREATE TABLE IF NOT EXISTS viewer_playback_state (
viewer_id TEXT NOT NULL,
item_id TEXT NOT NULL,
series_id TEXT NOT NULL DEFAULT '',
season_id TEXT NOT NULL DEFAULT '',
position_ticks BIGINT NOT NULL DEFAULT 0,
runtime_ticks BIGINT NOT NULL DEFAULT 0,
played BOOLEAN NOT NULL DEFAULT false,
play_count INT NOT NULL DEFAULT 0,
favourite BOOLEAN NOT NULL DEFAULT false,
hidden_from_resume BOOLEAN NOT NULL DEFAULT false,
last_played_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (viewer_id, item_id)
);
-- Continue Watching for a shadow viewer is this index: what they are part-way through,
-- most recent first. The partial predicate keeps it to the rows that row can draw from
-- rather than to everything they have ever pressed Play on.
CREATE INDEX IF NOT EXISTS viewer_playback_resume_idx
ON viewer_playback_state (viewer_id, last_played_at DESC)
WHERE position_ticks > 0 AND NOT played AND NOT hidden_from_resume;
-- Next Up walks a series' episodes for the newest completion; favourites are their own
-- row, and both are asked for per viewer.
CREATE INDEX IF NOT EXISTS viewer_playback_series_idx
ON viewer_playback_state (viewer_id, series_id, last_played_at DESC)
WHERE series_id <> '';
CREATE INDEX IF NOT EXISTS viewer_playback_favourite_idx
ON viewer_playback_state (viewer_id, updated_at DESC) WHERE favourite;