Publish current app and server
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
¬ification.ID, ¬ification.Kind, ¬ification.ItemID,
|
||||
¬ification.Title, ¬ification.Message, ¬ification.EventAt,
|
||||
¬ification.CreatedAt, ¬ification.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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤tRaw)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(currentRaw, ¤t); 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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user