Big changes
This commit is contained in:
@@ -2,12 +2,51 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// BrowsingCandidates returns library items the user actively focused or selected,
|
||||
// strongest first. Impressions are intentionally excluded: merely scrolling past a row
|
||||
// is not evidence of taste.
|
||||
func (s *Store) BrowsingCandidates(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
limit int,
|
||||
) ([]json.RawMessage, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT li.payload
|
||||
FROM row_events re
|
||||
JOIN library_items li ON li.id = re.item_id
|
||||
WHERE re.emby_user_id = $1
|
||||
AND re.occurred_at >= $2
|
||||
AND re.event IN ('focus', 'select')
|
||||
GROUP BY li.id, li.payload
|
||||
ORDER BY
|
||||
count(*) FILTER (WHERE re.event = 'select') * 20 +
|
||||
count(*) FILTER (WHERE re.event = 'focus') * 2 +
|
||||
coalesce(sum(re.dwell_ms), 0) / 10000 DESC,
|
||||
max(re.occurred_at) DESC
|
||||
LIMIT $3`, userID, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: browsing candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []json.RawMessage{}
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, json.RawMessage(payload))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RowEvent is one reported interaction with a home-screen row.
|
||||
type RowEvent struct {
|
||||
OccurredAt time.Time
|
||||
|
||||
@@ -138,6 +138,21 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// AllRecommendationCandidates returns the complete Movie/Series catalogue for an
|
||||
// offline For You rebuild. The resulting per-user pool is deliberately over-provisioned
|
||||
// so a short runtime filter still has enough ranked titles to fill the TV row.
|
||||
func (s *Store) AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE type IN ('Movie', 'Series')
|
||||
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: all recommendation candidates: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
|
||||
// are matched case-insensitively because Emby studio capitalisation is not consistent.
|
||||
func (s *Store) CuratedCandidates(
|
||||
|
||||
@@ -11,11 +11,15 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT NOT NULL DEFAULT 'Memby TV',
|
||||
client_version TEXT NOT NULL DEFAULT '',
|
||||
client_protocol TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Older builds could create more than one token for the same physical TV. Keep the most
|
||||
-- recently used row before adding the identity constraint.
|
||||
@@ -102,3 +106,116 @@ CREATE TABLE IF NOT EXISTS row_events (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
|
||||
|
||||
-- Search terms are retained separately from row engagement so they can inform future
|
||||
-- ranking/recommendation work without coupling that analysis to rendered rows.
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
emby_user_id TEXT NOT NULL,
|
||||
query TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_history_user_time_idx
|
||||
ON search_history (emby_user_id, occurred_at DESC);
|
||||
|
||||
-- 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,
|
||||
codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
signals_through TIMESTAMPTZ,
|
||||
built_at TIMESTAMPTZ,
|
||||
pool_built_at TIMESTAMPTZ,
|
||||
dirty_since TIMESTAMPTZ DEFAULT now(),
|
||||
last_error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@@ -104,10 +104,12 @@ func (s *Store) SetUpdatePolicy(ctx context.Context, policy appupdate.Policy) er
|
||||
func (s *Store) NewestSession(ctx context.Context) (Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
|
||||
device_name, client_version, client_protocol, last_seen_at
|
||||
FROM sessions ORDER BY last_seen_at DESC LIMIT 1`).
|
||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
|
||||
&sess.ClientProtocol, &sess.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
|
||||
@@ -20,14 +20,16 @@ var ErrNotFound = errors.New("store: session not found")
|
||||
var ErrDeviceLimit = errors.New("store: device limit reached")
|
||||
|
||||
type Session struct {
|
||||
TokenHash []byte
|
||||
EmbyUserID string
|
||||
EmbyToken string
|
||||
Username string
|
||||
ServerID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
LastSeenAt time.Time
|
||||
TokenHash []byte
|
||||
EmbyUserID string
|
||||
EmbyToken string
|
||||
Username string
|
||||
ServerID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
ClientVersion string
|
||||
ClientProtocol string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -50,6 +52,58 @@ func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
|
||||
// RecordSearch stores a normalized query for future per-user ranking analysis.
|
||||
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`WITH inserted AS (
|
||||
INSERT INTO search_history (emby_user_id, query) VALUES ($1, $2)
|
||||
RETURNING id
|
||||
)
|
||||
DELETE FROM search_history
|
||||
WHERE emby_user_id = $1
|
||||
AND occurred_at < now() - interval '30 days'`,
|
||||
userID, query)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecentSearches returns a user's distinct queries in most-recently-used order.
|
||||
// Case-only duplicates collapse to the spelling used most recently.
|
||||
func (s *Store) RecentSearches(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
limit int,
|
||||
) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT query
|
||||
FROM (
|
||||
SELECT DISTINCT ON (lower(query)) query, occurred_at
|
||||
FROM search_history
|
||||
WHERE emby_user_id = $1 AND occurred_at >= $2
|
||||
ORDER BY lower(query), occurred_at DESC
|
||||
) AS latest
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $3`,
|
||||
userID, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: recent searches: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
queries := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var query string
|
||||
if err := rows.Scan(&query); err != nil {
|
||||
return nil, fmt.Errorf("store: scan recent search: %w", err)
|
||||
}
|
||||
queries = append(queries, query)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read recent searches: %w", err)
|
||||
}
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
// Migrate applies the schema. It is idempotent, so it runs on every boot.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
if _, err := s.pool.Exec(ctx, schema); err != nil {
|
||||
@@ -94,18 +148,21 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO sessions (
|
||||
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name
|
||||
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name,
|
||||
client_version, client_protocol
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
|
||||
token_hash = EXCLUDED.token_hash,
|
||||
emby_token = EXCLUDED.emby_token,
|
||||
username = EXCLUDED.username,
|
||||
server_id = EXCLUDED.server_id,
|
||||
device_name = EXCLUDED.device_name,
|
||||
client_version = EXCLUDED.client_version,
|
||||
client_protocol = EXCLUDED.client_protocol,
|
||||
last_seen_at = now()`,
|
||||
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
|
||||
sess.ServerID, sess.DeviceID, sess.DeviceName)
|
||||
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
@@ -121,10 +178,12 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
|
||||
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name, last_seen_at
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
|
||||
device_name, client_version, client_protocol, last_seen_at
|
||||
FROM sessions WHERE token_hash = $1`, hash).
|
||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt)
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
|
||||
&sess.ClientProtocol, &sess.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
@@ -141,6 +200,23 @@ func (s *Store) Touch(ctx context.Context, hash []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSessionClientIdentity remembers the last non-empty identity supplied by a TV.
|
||||
// Headerless image requests can then still be attributed to the correct app build.
|
||||
func (s *Store) UpdateSessionClientIdentity(
|
||||
ctx context.Context,
|
||||
hash []byte,
|
||||
version, protocol string,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions
|
||||
SET client_version = CASE WHEN $2 <> '' THEN $2 ELSE client_version END,
|
||||
client_protocol = CASE WHEN $3 <> '' THEN $3 ELSE client_protocol END,
|
||||
last_seen_at = now()
|
||||
WHERE token_hash = $1`,
|
||||
hash, version, protocol)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash)
|
||||
return err
|
||||
@@ -176,10 +252,11 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess
|
||||
AND ranked.device_rank > $1
|
||||
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
|
||||
current.username, current.server_id, current.device_id,
|
||||
current.device_name, current.last_seen_at
|
||||
current.device_name, current.client_version, current.client_protocol,
|
||||
current.last_seen_at
|
||||
)
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id,
|
||||
device_id, device_name, last_seen_at
|
||||
device_id, device_name, client_version, client_protocol, last_seen_at
|
||||
FROM retired`,
|
||||
maxClients,
|
||||
)
|
||||
@@ -193,7 +270,8 @@ func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Sess
|
||||
var sess Session
|
||||
if err := rows.Scan(
|
||||
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
|
||||
&sess.ClientProtocol, &sess.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user