Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+39
View File
@@ -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
+15
View File
@@ -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(
+117
View File
@@ -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);
+4 -2
View File
@@ -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
}
+94 -16
View File
@@ -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)
}