Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+40
View File
@@ -78,6 +78,46 @@ type RowStat struct {
SelectRate float64 `json:"selectRate"`
}
// UserRowStats is the per-profile counterpart to the admin aggregate. It gives the
// home composer enough evidence to gently demote shelves that a viewer repeatedly
// passes over without turning a couple of accidental focus moves into a preference.
func (s *Store) UserRowStats(
ctx context.Context,
userID string,
since time.Time,
) ([]RowStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT row_id,
(array_agg(row_kind ORDER BY occurred_at DESC))[1] AS row_kind,
count(*) FILTER (WHERE event = 'impression') AS impressions,
count(*) FILTER (WHERE event = 'focus') AS focuses,
count(*) FILTER (WHERE event = 'select') AS selects,
coalesce(sum(dwell_ms), 0) AS dwell_ms
FROM row_events
WHERE emby_user_id = $1 AND occurred_at >= $2
GROUP BY row_id`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user row stats: %w", err)
}
defer rows.Close()
stats := []RowStat{}
for rows.Next() {
var stat RowStat
if err := rows.Scan(
&stat.RowID, &stat.RowKind, &stat.Impressions, &stat.Focuses,
&stat.Selects, &stat.DwellMs,
); err != nil {
return nil, err
}
stat.Viewers = 1
if stat.Impressions > 0 {
stat.SelectRate = float64(stat.Selects) / float64(stat.Impressions)
}
stats = append(stats, stat)
}
return stats, rows.Err()
}
func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
if len(events) == 0 {
return nil
+116 -17
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -17,6 +18,16 @@ type TracearrSessionKey struct {
SessionID string
}
type TracearrSessionSignal struct {
Fingerprint []byte
Terminal bool
}
type RecommendationIdentity struct {
TracearrUserID string
Username string
}
type TracearrSession struct {
ServerID string
SessionID string
@@ -65,7 +76,10 @@ type RecommendationProfile struct {
GenreAffinity json.RawMessage
TitleAffinity json.RawMessage
StudioAffinity json.RawMessage
ContextAffinity json.RawMessage
CodecOutcomes json.RawMessage
WeightedProfile json.RawMessage
AlgorithmVersion string
SignalsThrough *time.Time
BuiltAt time.Time
}
@@ -89,6 +103,8 @@ type ForYouCandidate struct {
type PreparedForYouItem struct {
ItemID string
BaseRank int
BaseScore float64
AffinityScore float64
Payload json.RawMessage
RuntimeMinutes int
CompatibilityScore float64
@@ -98,6 +114,7 @@ type PreparedForYouItem struct {
ReasonGenre string
ReasonSourceItemID string
ReasonSourceTitle string
ContextAffinity json.RawMessage
}
type ForYouStats struct {
@@ -151,6 +168,46 @@ func (s *Store) TracearrFingerprints(
return out, rows.Err()
}
func (s *Store) TracearrSessionSignals(
ctx context.Context,
keys []TracearrSessionKey,
) (map[TracearrSessionKey]TracearrSessionSignal, error) {
out := make(map[TracearrSessionKey]TracearrSessionSignal, len(keys))
if len(keys) == 0 {
return out, nil
}
servers := make([]string, 0, len(keys))
ids := make([]string, 0, len(keys))
for _, key := range keys {
servers = append(servers, key.ServerID)
ids = append(ids, key.SessionID)
}
rows, err := s.pool.Query(ctx, `
SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint,
(current.watched OR current.stopped_at IS NOT NULL OR
lower(current.state) IN ('stopped', 'completed', 'complete', 'ended'))
FROM tracearr_sessions current
JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
ON current.server_id = wanted.server_id
AND current.tracearr_session_id = wanted.session_id`,
servers, ids)
if err != nil {
return nil, fmt.Errorf("store: tracearr session signals: %w", err)
}
defer rows.Close()
for rows.Next() {
var key TracearrSessionKey
var signal TracearrSessionSignal
if err := rows.Scan(
&key.ServerID, &key.SessionID, &signal.Fingerprint, &signal.Terminal,
); err != nil {
return nil, err
}
out[key] = signal
}
return out, rows.Err()
}
func (s *Store) UpsertTracearrSessions(
ctx context.Context,
sessions []TracearrSession,
@@ -354,7 +411,7 @@ func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (emby_user_id)
token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, last_seen_at
device_name, client_version, client_protocol, client_capabilities, last_seen_at
FROM sessions
ORDER BY emby_user_id, last_seen_at DESC`)
if err != nil {
@@ -367,7 +424,7 @@ func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error
if err := rows.Scan(
&session.TokenHash, &session.EmbyUserID, &session.EmbyToken, &session.Username,
&session.ServerID, &session.DeviceID, &session.DeviceName, &session.ClientVersion,
&session.ClientProtocol, &session.LastSeenAt,
&session.ClientProtocol, &session.ClientCapabilities, &session.LastSeenAt,
); err != nil {
return nil, err
}
@@ -400,6 +457,35 @@ func (s *Store) MarkAllForYouProfilesDirty(ctx context.Context) error {
return nil
}
func (s *Store) MarkForYouProfilesDirtyByTracearrIdentity(
ctx context.Context,
identities []RecommendationIdentity,
) (int64, error) {
if len(identities) == 0 {
return 0, nil
}
userIDs := make([]string, 0, len(identities))
usernames := make([]string, 0, len(identities))
for _, identity := range identities {
if identity.TracearrUserID != "" {
userIDs = append(userIDs, identity.TracearrUserID)
}
if identity.Username != "" {
usernames = append(usernames, strings.ToLower(identity.Username))
}
}
tag, err := s.pool.Exec(ctx, `
UPDATE recommendation_user_profiles
SET dirty_since = coalesce(dirty_since, now())
WHERE tracearr_user_id = ANY($1)
OR lower(tracearr_username) = ANY($2)`,
userIDs, usernames)
if err != nil {
return 0, fmt.Errorf("store: mark affected For You profiles dirty: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *Store) MatchRecommendationUser(
ctx context.Context,
embyUserID, embyUsername, tracearrUserID, tracearrUsername string,
@@ -431,18 +517,18 @@ func (s *Store) MatchRecommendationUser(
func (s *Store) ForYouProfileTimes(
ctx context.Context,
userID string,
) (builtAt, poolBuiltAt, dirtySince *time.Time, err error) {
) (builtAt, poolBuiltAt, dirtySince *time.Time, algorithmVersion string, err error) {
err = s.pool.QueryRow(ctx, `
SELECT built_at, pool_built_at, dirty_since
SELECT built_at, pool_built_at, dirty_since, algorithm_version
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
Scan(&builtAt, &poolBuiltAt, &dirtySince)
Scan(&builtAt, &poolBuiltAt, &dirtySince, &algorithmVersion)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil, nil
return nil, nil, nil, "", nil
}
if err != nil {
return nil, nil, nil, fmt.Errorf("store: For You freshness: %w", err)
return nil, nil, nil, "", fmt.Errorf("store: For You freshness: %w", err)
}
return builtAt, poolBuiltAt, dirtySince, nil
return builtAt, poolBuiltAt, dirtySince, algorithmVersion, nil
}
func (s *Store) SetForYouError(ctx context.Context, userID string, buildErr error) error {
@@ -474,10 +560,11 @@ func (s *Store) ReplaceForYouPool(
INSERT INTO recommendation_user_profiles (
emby_user_id, tracearr_user_id, tracearr_username, source_session_count,
mean_completion_ratio, typical_session_minutes, genre_affinity,
title_affinity, studio_affinity, codec_outcomes, signals_through,
built_at, pool_built_at, dirty_since, last_error
title_affinity, studio_affinity, context_affinity, codec_outcomes,
weighted_profile, algorithm_version, signals_through, built_at, pool_built_at, dirty_since, last_error
) VALUES (
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11,$12,$12,NULL,''
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11::jsonb,
$12::jsonb,$13,$14,$15,$15,NULL,''
)
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
@@ -488,7 +575,10 @@ func (s *Store) ReplaceForYouPool(
genre_affinity = EXCLUDED.genre_affinity,
title_affinity = EXCLUDED.title_affinity,
studio_affinity = EXCLUDED.studio_affinity,
context_affinity = EXCLUDED.context_affinity,
codec_outcomes = EXCLUDED.codec_outcomes,
weighted_profile = EXCLUDED.weighted_profile,
algorithm_version = EXCLUDED.algorithm_version,
signals_through = EXCLUDED.signals_through,
built_at = EXCLUDED.built_at,
pool_built_at = EXCLUDED.pool_built_at,
@@ -498,7 +588,9 @@ func (s *Store) ReplaceForYouPool(
profile.SourceSessionCount, profile.MeanCompletionRatio,
profile.TypicalSessionMinutes, string(profile.GenreAffinity),
string(profile.TitleAffinity), string(profile.StudioAffinity),
string(profile.CodecOutcomes), profile.SignalsThrough, profile.BuiltAt)
string(profile.ContextAffinity), string(profile.CodecOutcomes),
string(profile.WeightedProfile), profile.AlgorithmVersion,
profile.SignalsThrough, profile.BuiltAt)
if err != nil {
return fmt.Errorf("store: write For You profile: %w", err)
}
@@ -544,15 +636,20 @@ func (s *Store) PreparedForYou(
minutes, limit int,
) ([]PreparedForYouItem, *time.Time, error) {
rows, err := s.pool.Query(ctx, `
SELECT fc.item_id, fc.base_rank, li.payload, fc.runtime_minutes,
SELECT fc.item_id, fc.base_rank, fc.base_score, fc.affinity_score,
li.payload, fc.runtime_minutes,
fc.compatibility_score, fc.compatibility_label,
fc.recommendation_reason, fc.reason_kind, fc.reason_genre,
fc.reason_source_item_id, fc.reason_source_title, p.pool_built_at
fc.reason_source_item_id, fc.reason_source_title, p.context_affinity,
p.pool_built_at
FROM for_you_candidates fc
JOIN library_items li ON li.id = fc.item_id
JOIN recommendation_user_profiles p ON p.emby_user_id = fc.emby_user_id
WHERE fc.emby_user_id = $1
AND ($2 = 0 OR (fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2))
AND (
$2 = 0 OR fc.reason_kind = 'pick-up' OR
(fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2)
)
ORDER BY fc.base_rank
LIMIT $3`,
userID, minutes, limit)
@@ -567,10 +664,12 @@ func (s *Store) PreparedForYou(
var item PreparedForYouItem
var rowBuiltAt *time.Time
if err := rows.Scan(
&item.ItemID, &item.BaseRank, &payload, &item.RuntimeMinutes,
&item.ItemID, &item.BaseRank, &item.BaseScore, &item.AffinityScore,
&payload, &item.RuntimeMinutes,
&item.CompatibilityScore, &item.CompatibilityLabel,
&item.RecommendationReason, &item.ReasonKind, &item.ReasonGenre,
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &rowBuiltAt,
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &item.ContextAffinity,
&rowBuiltAt,
); err != nil {
return nil, nil, err
}
+109 -24
View File
@@ -34,7 +34,8 @@ type LibraryStats struct {
LastSynced *time.Time `json:"lastSynced"`
}
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches.
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches, and
// returns how many recommendation-relevant payloads were inserted or actually changed.
//
// synced_at doubles as the mark-and-sweep marker: a full import stamps everything it
// sees, then deletes whatever kept an older stamp.
@@ -46,23 +47,48 @@ func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syn
batch := &pgx.Batch{}
for _, item := range items {
batch.Queue(`
INSERT INTO library_items (
id, type, name, series_id, series_name, production_year, community_rating,
genres, studios, date_created, search_text, payload, synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
ON CONFLICT (id) DO UPDATE SET
type = EXCLUDED.type,
name = EXCLUDED.name,
series_id = EXCLUDED.series_id,
series_name = EXCLUDED.series_name,
production_year = EXCLUDED.production_year,
community_rating = EXCLUDED.community_rating,
genres = EXCLUDED.genres,
studios = EXCLUDED.studios,
date_created = EXCLUDED.date_created,
search_text = EXCLUDED.search_text,
payload = EXCLUDED.payload,
synced_at = EXCLUDED.synced_at`,
WITH previous AS MATERIALIZED (
SELECT type, name, series_id, series_name, production_year,
community_rating, genres, studios, date_created,
payload->'RunTimeTicks' AS runtime_ticks,
payload->'MediaStreams' AS media_streams,
payload->'Container' AS container
FROM library_items WHERE id = $1
), upserted AS (
INSERT INTO library_items (
id, type, name, series_id, series_name, production_year, community_rating,
genres, studios, date_created, search_text, payload, synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
ON CONFLICT (id) DO UPDATE SET
type = EXCLUDED.type,
name = EXCLUDED.name,
series_id = EXCLUDED.series_id,
series_name = EXCLUDED.series_name,
production_year = EXCLUDED.production_year,
community_rating = EXCLUDED.community_rating,
genres = EXCLUDED.genres,
studios = EXCLUDED.studios,
date_created = EXCLUDED.date_created,
search_text = EXCLUDED.search_text,
payload = EXCLUDED.payload,
synced_at = EXCLUDED.synced_at
RETURNING 1
)
SELECT NOT EXISTS (SELECT 1 FROM previous)
OR EXISTS (
SELECT 1 FROM previous
WHERE ROW(
type, name, series_id, series_name, production_year,
community_rating, genres, studios, date_created,
runtime_ticks, media_streams, container
) IS DISTINCT FROM ROW(
$2::text, $3::text, $4::text, $5::text, $6::int,
$7::real, $8::text[], $9::text[], $10::timestamptz,
$12::jsonb->'RunTimeTicks', $12::jsonb->'MediaStreams',
$12::jsonb->'Container'
)
)
FROM upserted`,
item.ID, item.Type, item.Name, item.SeriesID, item.SeriesName,
item.ProductionYear, item.CommunityRating, item.Genres, item.Studios,
item.DateCreated, item.SearchText, string(item.Payload), syncedAt)
@@ -71,15 +97,17 @@ func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syn
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
var written int64
var changed int64
for range items {
tag, err := results.Exec()
if err != nil {
return written, fmt.Errorf("store: upsert library items: %w", err)
var recommendationChanged bool
if err := results.QueryRow().Scan(&recommendationChanged); err != nil {
return changed, fmt.Errorf("store: upsert library items: %w", err)
}
if recommendationChanged {
changed++
}
written += tag.RowsAffected()
}
return written, nil
return changed, nil
}
// DeleteLibraryItemsBefore removes anything a full import did not touch — items deleted
@@ -153,6 +181,23 @@ func (s *Store) AllRecommendationCandidates(ctx context.Context) ([]json.RawMess
return collectPayloads(rows)
}
func (s *Store) LibraryItemsByID(
ctx context.Context,
ids []string,
) ([]json.RawMessage, error) {
if len(ids) == 0 {
return []json.RawMessage{}, nil
}
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE id = ANY($1)`, ids)
if err != nil {
return nil, fmt.Errorf("store: library items by id: %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(
@@ -190,6 +235,46 @@ func (s *Store) CuratedCandidates(
return collectPayloads(rows)
}
// LibraryGenres returns every genre with enough catalogue depth to make a shelf feel
// intentional. The recommendation engine still applies per-user seen filtering and may
// drop a shelf afterwards when too few unseen titles remain.
func (s *Store) LibraryGenres(
ctx context.Context,
itemTypes []string,
minItems int,
) ([]string, error) {
if len(itemTypes) == 0 {
return nil, nil
}
if minItems < 1 {
minItems = 1
}
rows, err := s.pool.Query(ctx, `
SELECT genre, count(DISTINCT item.id) AS item_count
FROM library_items AS item
CROSS JOIN LATERAL unnest(item.genres) AS genre
WHERE item.type = ANY($1)
AND btrim(genre) <> ''
GROUP BY genre
HAVING count(DISTINCT item.id) >= $2
ORDER BY item_count DESC, lower(genre) ASC`,
itemTypes, minItems)
if err != nil {
return nil, fmt.Errorf("store: library genres: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var genre string
var count int
if err := rows.Scan(&genre, &count); err != nil {
return nil, err
}
out = append(out, genre)
}
return out, rows.Err()
}
func lowerStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
+175
View File
@@ -0,0 +1,175 @@
package store
import (
"context"
"fmt"
"time"
)
type UserShow struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
ImageTag string `json:"imageTag,omitempty"`
AddedAt time.Time `json:"addedAt"`
}
type NotificationPreferences struct {
Enabled bool `json:"enabled"`
ShowReturnAlerts bool `json:"showReturnAlerts"`
LeadDays int `json:"leadDays"`
}
type UserNotification struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
ItemID string `json:"itemId,omitempty"`
Title string `json:"title"`
Message string `json:"message"`
EventAt *time.Time `json:"eventAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ReadAt *time.Time `json:"readAt,omitempty"`
}
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (emby_user_id, item_id) DO UPDATE
SET title = EXCLUDED.title, year = EXCLUDED.year, image_tag = EXCLUDED.image_tag`,
userID, show.ItemID, show.Title, show.Year, show.ImageTag)
return err
}
// SaveUserShowIfAbsent is the auto-follow path. Manual saves intentionally refresh
// metadata, while playback must only announce a show when it actually added it.
func (s *Store) SaveUserShowIfAbsent(ctx context.Context, userID string, show UserShow) (bool, error) {
result, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (emby_user_id, item_id) DO NOTHING`,
userID, show.ItemID, show.Title, show.Year, show.ImageTag)
if err != nil {
return false, err
}
return result.RowsAffected() == 1, nil
}
func (s *Store) DeleteUserShow(ctx context.Context, userID, itemID string) error {
_, err := s.pool.Exec(ctx, `
WITH removed AS (
DELETE FROM user_shows
WHERE emby_user_id = $1 AND item_id = $2
RETURNING item_id
)
UPDATE user_notifications SET dismissed_at = now()
WHERE emby_user_id = $1
AND item_id IN (SELECT item_id FROM removed)
AND dismissed_at IS NULL`,
userID, itemID)
return err
}
func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error) {
rows, err := s.pool.Query(ctx, `
SELECT item_id, title, year, image_tag, added_at
FROM user_shows WHERE emby_user_id = $1 ORDER BY added_at, lower(title)`, userID)
if err != nil {
return nil, fmt.Errorf("store: list user shows: %w", err)
}
defer rows.Close()
shows := []UserShow{}
for rows.Next() {
var show UserShow
if err := rows.Scan(&show.ItemID, &show.Title, &show.Year, &show.ImageTag, &show.AddedAt); err != nil {
return nil, fmt.Errorf("store: scan user show: %w", err)
}
shows = append(shows, show)
}
return shows, rows.Err()
}
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
err := s.pool.QueryRow(ctx, `
SELECT enabled, show_return_alerts, lead_days
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.LeadDays)
if err != nil && !isNoRows(err) {
return prefs, fmt.Errorf("store: notification preferences: %w", err)
}
return prefs, nil
}
func (s *Store) SetNotificationPreferences(
ctx context.Context, userID string, prefs NotificationPreferences,
) error {
if prefs.LeadDays < 1 {
prefs.LeadDays = 1
}
if prefs.LeadDays > 30 {
prefs.LeadDays = 30
}
_, err := s.pool.Exec(ctx, `
INSERT INTO user_notification_preferences
(emby_user_id, enabled, show_return_alerts, lead_days)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emby_user_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
show_return_alerts = EXCLUDED.show_return_alerts,
lead_days = EXCLUDED.lead_days,
updated_at = now()`,
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.LeadDays)
return err
}
func (s *Store) UpsertNotification(
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_notifications
(emby_user_id, source_key, kind, item_id, title, message, event_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (emby_user_id, source_key) DO NOTHING`,
userID, sourceKey, kind, itemID, title, message, eventAt)
return err
}
func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, kind, item_id, title, message, event_at, created_at, read_at
FROM user_notifications
WHERE emby_user_id = $1 AND dismissed_at IS NULL
ORDER BY created_at DESC LIMIT 100`, userID)
if err != nil {
return nil, fmt.Errorf("store: list notifications: %w", err)
}
defer rows.Close()
notifications := []UserNotification{}
for rows.Next() {
var notification UserNotification
if err := rows.Scan(
&notification.ID, &notification.Kind, &notification.ItemID,
&notification.Title, &notification.Message, &notification.EventAt,
&notification.CreatedAt, &notification.ReadAt,
); err != nil {
return nil, fmt.Errorf("store: scan notification: %w", err)
}
notifications = append(notifications, notification)
}
return notifications, rows.Err()
}
func (s *Store) MarkNotificationRead(ctx context.Context, userID string, id int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE user_notifications SET read_at = COALESCE(read_at, now())
WHERE id = $1 AND emby_user_id = $2`, id, userID)
return err
}
func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE user_notifications SET dismissed_at = now()
WHERE id = $1 AND emby_user_id = $2`, id, userID)
return err
}
+196
View File
@@ -0,0 +1,196 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type RecommendationAction struct {
ItemID string `json:"itemId"`
Action string `json:"action"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ItemExposureStat struct {
ItemID string
Impressions int
Focuses int
Selects int
LastShown time.Time
}
func (s *Store) HouseholdCompletionScores(
ctx context.Context,
since time.Time,
) (map[string]float64, error) {
rows, err := s.pool.Query(ctx, `
SELECT coalesce(nullif(emby_series_id, ''), emby_item_id) AS item_id,
count(DISTINCT lower(username))::float8
FROM tracearr_sessions
WHERE started_at >= $1
AND (watched OR (
total_duration_ms > 0 AND progress_ms::float8 / total_duration_ms >= 0.9
))
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
GROUP BY item_id`, since)
if err != nil {
return nil, fmt.Errorf("store: household completion scores: %w", err)
}
defer rows.Close()
out := map[string]float64{}
maxScore := 0.0
for rows.Next() {
var id string
var score float64
if err := rows.Scan(&id, &score); err != nil {
return nil, err
}
out[id] = score
if score > maxScore {
maxScore = score
}
}
if maxScore > 0 {
for id, score := range out {
out[id] = score / maxScore
}
}
return out, rows.Err()
}
func (s *Store) WeightedRecommendationProfile(
ctx context.Context,
userID string,
) (json.RawMessage, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `
SELECT weighted_profile
FROM recommendation_user_profiles
WHERE emby_user_id = $1`, userID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return json.RawMessage(`{}`), nil
}
if err != nil {
return nil, fmt.Errorf("store: weighted recommendation profile: %w", err)
}
return json.RawMessage(raw), nil
}
func (s *Store) SetRecommendationAction(
ctx context.Context,
userID, itemID, action string,
) error {
if action != "more_like_this" && action != "not_for_me" {
return fmt.Errorf("store: invalid recommendation action %q", action)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_actions (emby_user_id, item_id, action, updated_at)
VALUES ($1,$2,$3,now())
ON CONFLICT (emby_user_id, item_id) DO UPDATE
SET action = EXCLUDED.action, updated_at = now()`,
userID, itemID, action)
if err != nil {
return fmt.Errorf("store: set recommendation action: %w", err)
}
return nil
}
func (s *Store) ClearRecommendationAction(
ctx context.Context,
userID, itemID string,
) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM recommendation_actions
WHERE emby_user_id = $1 AND item_id = $2`, userID, itemID)
return err
}
func (s *Store) RecommendationActions(
ctx context.Context,
userID string,
) ([]RecommendationAction, error) {
rows, err := s.pool.Query(ctx, `
SELECT item_id, action, updated_at
FROM recommendation_actions
WHERE emby_user_id = $1`, userID)
if err != nil {
return nil, fmt.Errorf("store: recommendation actions: %w", err)
}
defer rows.Close()
out := []RecommendationAction{}
for rows.Next() {
var value RecommendationAction
if err := rows.Scan(&value.ItemID, &value.Action, &value.UpdatedAt); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
func (s *Store) RecommendationOnboarding(
ctx context.Context,
userID string,
) (json.RawMessage, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `
SELECT preferences FROM recommendation_onboarding WHERE emby_user_id = $1`,
userID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return json.RawMessage(`{}`), nil
}
return json.RawMessage(raw), err
}
func (s *Store) SetRecommendationOnboarding(
ctx context.Context,
userID string,
preferences json.RawMessage,
) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_onboarding (emby_user_id, preferences, updated_at)
VALUES ($1,$2::jsonb,now())
ON CONFLICT (emby_user_id) DO UPDATE
SET preferences = EXCLUDED.preferences, updated_at = now()`,
userID, string(preferences))
return err
}
// UserItemExposures is deliberately item-scoped. A row impression with no item id is
// useful for row ordering but cannot be used to claim a particular poster was ignored.
func (s *Store) UserItemExposures(
ctx context.Context,
userID string,
since time.Time,
) ([]ItemExposureStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT item_id,
count(*) FILTER (WHERE event = 'impression'),
count(*) FILTER (WHERE event = 'focus'),
count(*) FILTER (WHERE event = 'select'),
max(occurred_at)
FROM row_events
WHERE emby_user_id = $1 AND occurred_at >= $2 AND item_id <> ''
GROUP BY item_id`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user item exposures: %w", err)
}
defer rows.Close()
out := []ItemExposureStat{}
for rows.Next() {
var value ItemExposureStat
if err := rows.Scan(
&value.ItemID, &value.Impressions, &value.Focuses,
&value.Selects, &value.LastShown,
); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
+75
View File
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS sessions (
device_name TEXT NOT NULL DEFAULT 'Memby TV',
client_version TEXT NOT NULL DEFAULT '',
client_protocol TEXT NOT NULL DEFAULT '',
client_capabilities TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -20,6 +21,7 @@ CREATE TABLE IF NOT EXISTS sessions (
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 '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_capabilities 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.
@@ -119,6 +121,50 @@ CREATE TABLE IF NOT EXISTS search_history (
CREATE INDEX IF NOT EXISTS search_history_user_time_idx
ON search_history (emby_user_id, occurred_at DESC);
-- 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,
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 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);
-- 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.
@@ -180,7 +226,10 @@ CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
genre_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
title_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
studio_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
context_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
weighted_profile JSONB NOT NULL DEFAULT '{}'::jsonb,
algorithm_version TEXT NOT NULL DEFAULT '',
signals_through TIMESTAMPTZ,
built_at TIMESTAMPTZ,
pool_built_at TIMESTAMPTZ,
@@ -188,6 +237,32 @@ CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
last_error TEXT NOT NULL DEFAULT ''
);
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()
);
CREATE INDEX IF NOT EXISTS recommendation_profiles_dirty_idx
ON recommendation_user_profiles (dirty_since)
WHERE dirty_since IS NOT NULL;
+214 -2
View File
@@ -14,6 +14,218 @@ import (
// MaintenanceKey is the app_settings row backing maintenance mode.
const MaintenanceKey = "maintenance"
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
const RequestPolicyKey = "request_policy"
// PlaybackPolicyKey controls presentation behavior that should be adjustable without
// shipping a new TV build.
const PlaybackPolicyKey = "playback_policy"
// FeaturePolicyKey is the durable operator control plane for optional behaviour.
// The catalogue of valid flags lives in the API; the store only persists overrides so
// removing or renaming a feature does not strand an unreadable database row.
const FeaturePolicyKey = "feature_policy"
type FeaturePolicySnapshot struct {
Overrides map[string]bool `json:"overrides"`
SafeMode bool `json:"safeMode"`
Revision int64 `json:"revision"`
UpdatedAt time.Time `json:"updatedAt"`
}
type FeaturePolicy struct {
Overrides map[string]bool `json:"overrides"`
SafeMode bool `json:"safeMode"`
Revision int64 `json:"revision"`
UpdatedAt time.Time `json:"updatedAt"`
Previous *FeaturePolicySnapshot `json:"previous,omitempty"`
}
var ErrFeaturePolicyConflict = errors.New("store: feature policy revision conflict")
func DefaultFeaturePolicy() FeaturePolicy {
return FeaturePolicy{Overrides: map[string]bool{}}
}
func normalizeFeaturePolicy(policy FeaturePolicy) FeaturePolicy {
if policy.Overrides == nil {
policy.Overrides = map[string]bool{}
}
return policy
}
func (s *Store) FeaturePolicy(ctx context.Context) (FeaturePolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, FeaturePolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultFeaturePolicy(), nil
}
if err != nil {
return DefaultFeaturePolicy(), fmt.Errorf("store: read feature policy: %w", err)
}
var policy FeaturePolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return DefaultFeaturePolicy(), fmt.Errorf("store: decode feature policy: %w", err)
}
return normalizeFeaturePolicy(policy), nil
}
// SetFeaturePolicy preserves the prior revision inside the same durable document. This
// gives the operator a recovery button without requiring a matching client release.
func (s *Store) SetFeaturePolicy(
ctx context.Context, next FeaturePolicy, expectedRevision int64,
) (FeaturePolicy, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return FeaturePolicy{}, fmt.Errorf("store: begin feature policy write: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, FeaturePolicyKey); err != nil {
return FeaturePolicy{}, fmt.Errorf("store: lock feature policy: %w", err)
}
current := DefaultFeaturePolicy()
var currentRaw []byte
err = tx.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, FeaturePolicyKey).Scan(&currentRaw)
if err == nil {
if err := json.Unmarshal(currentRaw, &current); err != nil {
return FeaturePolicy{}, fmt.Errorf("store: decode current feature policy: %w", err)
}
current = normalizeFeaturePolicy(current)
} else if !errors.Is(err, pgx.ErrNoRows) {
return FeaturePolicy{}, fmt.Errorf("store: read current feature policy: %w", err)
}
if current.Revision != expectedRevision {
return FeaturePolicy{}, ErrFeaturePolicyConflict
}
next = normalizeFeaturePolicy(next)
next.Revision = current.Revision + 1
next.UpdatedAt = time.Now().UTC()
next.Previous = &FeaturePolicySnapshot{
Overrides: current.Overrides, SafeMode: current.SafeMode,
Revision: current.Revision, UpdatedAt: current.UpdatedAt,
}
raw, err := json.Marshal(next)
if err != nil {
return FeaturePolicy{}, err
}
_, err = tx.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
FeaturePolicyKey, string(raw))
if err != nil {
return FeaturePolicy{}, fmt.Errorf("store: write feature policy: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return FeaturePolicy{}, fmt.Errorf("store: commit feature policy: %w", err)
}
return next, nil
}
const DefaultPrerollDurationMs int64 = 6_500
type PlaybackPolicy struct {
PrerollEnabled bool `json:"prerollEnabled"`
PrerollDurationMs int64 `json:"prerollDurationMs"`
UpdatedAt time.Time `json:"updatedAt"`
}
func DefaultPlaybackPolicy() PlaybackPolicy {
return PlaybackPolicy{PrerollEnabled: true, PrerollDurationMs: DefaultPrerollDurationMs}
}
func normalizePlaybackPolicy(policy PlaybackPolicy) PlaybackPolicy {
if policy.PrerollDurationMs == 0 {
policy.PrerollDurationMs = DefaultPrerollDurationMs
}
policy.PrerollDurationMs = max(int64(1_000), min(policy.PrerollDurationMs, int64(30_000)))
return policy
}
func (s *Store) PlaybackPolicy(ctx context.Context) (PlaybackPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, PlaybackPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultPlaybackPolicy(), nil
}
if err != nil {
return DefaultPlaybackPolicy(), fmt.Errorf("store: read playback policy: %w", err)
}
var policy PlaybackPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return DefaultPlaybackPolicy(), fmt.Errorf("store: decode playback policy: %w", err)
}
return normalizePlaybackPolicy(policy), nil
}
func (s *Store) SetPlaybackPolicy(ctx context.Context, policy PlaybackPolicy) error {
policy = normalizePlaybackPolicy(policy)
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
PlaybackPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write playback policy: %w", err)
}
return nil
}
type RequestPolicy struct {
AllowedUserIDs []string `json:"allowedUserIds"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (p RequestPolicy) Allows(userID string) bool {
for _, allowed := range p.AllowedUserIDs {
if allowed == userID {
return true
}
}
return false
}
func (s *Store) RequestPolicy(ctx context.Context) (RequestPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, RequestPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return RequestPolicy{AllowedUserIDs: []string{}}, nil
}
if err != nil {
return RequestPolicy{}, fmt.Errorf("store: read request policy: %w", err)
}
var policy RequestPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return RequestPolicy{}, fmt.Errorf("store: decode request policy: %w", err)
}
if policy.AllowedUserIDs == nil {
policy.AllowedUserIDs = []string{}
}
return policy, nil
}
func (s *Store) SetRequestPolicy(ctx context.Context, policy RequestPolicy) error {
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
RequestPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write request policy: %w", err)
}
return nil
}
// Maintenance is the operator switch that takes Memby down independently of Emby.
//
// Deliberately durable: a restart must not quietly bring the app back up while someone
@@ -105,11 +317,11 @@ 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,
device_name, client_version, client_protocol, last_seen_at
device_name, client_version, client_protocol, client_capabilities, 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.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt)
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
+37
View File
@@ -0,0 +1,37 @@
package store
import "testing"
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
if policy.Allows("user-1") {
t.Fatal("unlisted user was allowed")
}
if !policy.Allows("user-2") {
t.Fatal("listed user was denied")
}
}
func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
defaults := DefaultPlaybackPolicy()
if !defaults.PrerollEnabled || defaults.PrerollDurationMs != 6_500 {
t.Fatalf("default playback policy = %+v", defaults)
}
if got := normalizePlaybackPolicy(PlaybackPolicy{PrerollDurationMs: 500}); got.PrerollDurationMs != 1_000 {
t.Fatalf("short duration = %d, want 1000", got.PrerollDurationMs)
}
if got := normalizePlaybackPolicy(PlaybackPolicy{PrerollDurationMs: 60_000}); got.PrerollDurationMs != 30_000 {
t.Fatalf("long duration = %d, want 30000", got.PrerollDurationMs)
}
}
func TestFeaturePolicyDefaultsAreRecoverable(t *testing.T) {
policy := normalizeFeaturePolicy(FeaturePolicy{})
if policy.Overrides == nil || policy.SafeMode || policy.Revision != 0 {
t.Fatalf("default feature policy = %+v", policy)
}
policy.Overrides["sonarr_preroll"] = false
if policy.Overrides["sonarr_preroll"] {
t.Fatal("explicit off override was not retained")
}
}
+151 -91
View File
@@ -17,19 +17,80 @@ var schema string
// ErrNotFound is returned when a token does not match a live session.
var ErrNotFound = errors.New("store: session not found")
var ErrDeviceLimit = errors.New("store: device limit reached")
func isNoRows(err error) bool { return errors.Is(err, pgx.ErrNoRows) }
type Session struct {
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
DeviceName string
ClientVersion string
ClientProtocol string
LastSeenAt time.Time
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
DeviceName string
ClientVersion string
ClientProtocol string
ClientCapabilities []string
LastSeenAt time.Time
}
type KnownUser struct {
ID string `json:"id"`
Username string `json:"username"`
LastSeen time.Time `json:"lastSeen"`
}
type KnownClient struct {
DeviceName string `json:"deviceName"`
Username string `json:"username"`
Version string `json:"version"`
Protocol string `json:"protocol"`
Capabilities []string `json:"capabilities"`
LastSeen time.Time `json:"lastSeen"`
}
// KnownClients gives the feature console compatibility evidence without exposing
// gateway or Emby credentials. Stale sessions remain useful rollout information.
func (s *Store) KnownClients(ctx context.Context) ([]KnownClient, error) {
rows, err := s.pool.Query(ctx, `
SELECT device_name, username, client_version, client_protocol,
client_capabilities, last_seen_at
FROM sessions ORDER BY last_seen_at DESC LIMIT 100`)
if err != nil {
return nil, fmt.Errorf("store: list known clients: %w", err)
}
defer rows.Close()
clients := []KnownClient{}
for rows.Next() {
var client KnownClient
if err := rows.Scan(&client.DeviceName, &client.Username, &client.Version,
&client.Protocol, &client.Capabilities, &client.LastSeen); err != nil {
return nil, fmt.Errorf("store: scan known client: %w", err)
}
clients = append(clients, client)
}
return clients, rows.Err()
}
// KnownUsers returns one entry per Emby user that has signed in to the gateway.
func (s *Store) KnownUsers(ctx context.Context) ([]KnownUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (emby_user_id) emby_user_id, username, last_seen_at
FROM sessions
ORDER BY emby_user_id, last_seen_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list known users: %w", err)
}
defer rows.Close()
users := []KnownUser{}
for rows.Next() {
var user KnownUser
if err := rows.Scan(&user.ID, &user.Username, &user.LastSeen); err != nil {
return nil, fmt.Errorf("store: scan known user: %w", err)
}
users = append(users, user)
}
return users, rows.Err()
}
type Store struct {
@@ -112,18 +173,18 @@ func (s *Store) Migrate(ctx context.Context) error {
return nil
}
// CreateSession enforces a user's device allowance under a per-user transaction lock.
// Re-authenticating the same stable device replaces its token and never consumes a slot.
// CreateSession records every signed-in TV without an account-level device cap.
// Re-authenticating the same stable device replaces its token.
// The replaced hash is returned so its Redis entry can be invalidated immediately.
func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) ([]byte, int, error) {
func (s *Store) CreateSession(ctx context.Context, sess Session) ([]byte, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, 0, fmt.Errorf("store: begin session: %w", err)
return nil, fmt.Errorf("store: begin session: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, sess.EmbyUserID); err != nil {
return nil, 0, fmt.Errorf("store: lock user sessions: %w", err)
return nil, fmt.Errorf("store: lock user sessions: %w", err)
}
var previousHash []byte
@@ -132,26 +193,15 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
WHERE emby_user_id = $1 AND device_id = $2`,
sess.EmbyUserID, sess.DeviceID).Scan(&previousHash)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, fmt.Errorf("store: find device session: %w", err)
}
existingDevice := err == nil
var activeClients int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM sessions WHERE emby_user_id = $1`,
sess.EmbyUserID).Scan(&activeClients); err != nil {
return nil, 0, fmt.Errorf("store: count user sessions: %w", err)
}
if !existingDevice && activeClients >= maxClients {
return nil, activeClients, ErrDeviceLimit
return nil, fmt.Errorf("store: find device session: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO sessions (
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name,
client_version, client_protocol
client_version, client_protocol, client_capabilities
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
token_hash = EXCLUDED.token_hash,
emby_token = EXCLUDED.emby_token,
@@ -160,30 +210,29 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
device_name = EXCLUDED.device_name,
client_version = EXCLUDED.client_version,
client_protocol = EXCLUDED.client_protocol,
client_capabilities = EXCLUDED.client_capabilities,
last_seen_at = now()`,
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol)
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol,
sess.ClientCapabilities)
if err != nil {
return nil, 0, fmt.Errorf("store: create session: %w", err)
}
if !existingDevice {
activeClients++
return nil, fmt.Errorf("store: create session: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, 0, fmt.Errorf("store: commit session: %w", err)
return nil, fmt.Errorf("store: commit session: %w", err)
}
return previousHash, activeClients, nil
return previousHash, nil
}
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, client_version, client_protocol, last_seen_at
device_name, client_version, client_protocol, client_capabilities, 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.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt)
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
@@ -205,15 +254,16 @@ func (s *Store) Touch(ctx context.Context, hash []byte) error {
func (s *Store) UpdateSessionClientIdentity(
ctx context.Context,
hash []byte,
version, protocol string,
version, protocol string, capabilities []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,
client_capabilities = CASE WHEN cardinality($4::text[]) > 0 THEN $4 ELSE client_capabilities END,
last_seen_at = now()
WHERE token_hash = $1`,
hash, version, protocol)
hash, version, protocol, capabilities)
return err
}
@@ -222,6 +272,66 @@ func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
return err
}
// SessionsForUser returns the TVs whose gateway tokens are still active for a user.
func (s *Store) SessionsForUser(ctx context.Context, userID string) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, client_capabilities, last_seen_at
FROM sessions
WHERE emby_user_id = $1
ORDER BY last_seen_at DESC, device_name`, userID)
if err != nil {
return nil, fmt.Errorf("store: list user sessions: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
var sess Session
if err := rows.Scan(
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("store: scan user session: %w", err)
}
sessions = append(sessions, sess)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list user session rows: %w", err)
}
return sessions, nil
}
// DeleteUserDevice revokes one device while scoping the delete to the authenticated user.
func (s *Store) DeleteUserDevice(ctx context.Context, userID, deviceID string) ([]byte, error) {
var tokenHash []byte
err := s.pool.QueryRow(ctx, `
DELETE FROM sessions
WHERE emby_user_id = $1 AND device_id = $2
RETURNING token_hash`, userID, deviceID).Scan(&tokenHash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: delete user device: %w", err)
}
return tokenHash, nil
}
// RenameUserDevice changes only the display name and keeps the session/token intact.
func (s *Store) RenameUserDevice(ctx context.Context, userID, deviceID, deviceName string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE sessions SET device_name = $3
WHERE emby_user_id = $1 AND device_id = $2`, userID, deviceID, deviceName)
if err != nil {
return fmt.Errorf("store: rename user device: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// DeleteIdleSessions retires tokens unused for longer than idle, returning how many went.
func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int64, error) {
tag, err := s.pool.Exec(ctx,
@@ -232,53 +342,3 @@ func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int
}
return tag.RowsAffected(), nil
}
// TrimSessionsToLimit brings data created under an older, more generous policy back
// within the current allowance. The most recently active devices survive.
func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
WITH ranked AS (
SELECT token_hash,
row_number() OVER (
PARTITION BY emby_user_id
ORDER BY last_seen_at DESC, created_at DESC, token_hash DESC
) AS device_rank
FROM sessions
),
retired AS (
DELETE FROM sessions current
USING ranked
WHERE current.token_hash = ranked.token_hash
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.client_version, current.client_protocol,
current.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 retired`,
maxClients,
)
if err != nil {
return nil, fmt.Errorf("store: trim sessions: %w", err)
}
defer rows.Close()
var retired []Session
for rows.Next() {
var sess Session
if err := rows.Scan(
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
}
retired = append(retired, sess)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: trim sessions rows: %w", err)
}
return retired, nil
}