2026-07-27 08:16:20 +12:00
|
|
|
-- 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 '{}',
|
2026-07-27 08:16:20 +12:00
|
|
|
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
|
|
|
|
2026-08-24 22:56:46 +12:00
|
|
|
-- Gateway access is separate from Emby's own account policy. This lets an operator
|
|
|
|
|
-- suspend Memby access without changing the upstream account or its other clients.
|
|
|
|
|
CREATE TABLE IF NOT EXISTS user_controls (
|
|
|
|
|
emby_user_id TEXT PRIMARY KEY,
|
|
|
|
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
|
|
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS forced_updates (
|
|
|
|
|
emby_user_id TEXT PRIMARY KEY,
|
|
|
|
|
version TEXT NOT NULL,
|
|
|
|
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
|
|
|
);
|
|
|
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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-07-27 08:16:20 +12:00
|
|
|
|
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);
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
-- 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()
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
-- 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.
|
2026-08-10 20:24:22 +12:00
|
|
|
-- 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 '',
|
2026-08-10 20:24:22 +12:00
|
|
|
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
|
|
|
|
2026-08-10 20:24:22 +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;
|