Add Memby account management

This commit is contained in:
ponzischeme89
2026-08-03 10:16:44 +12:00
parent 666da9c5d3
commit 2675e6d82b
7 changed files with 467 additions and 5 deletions
+6
View File
@@ -161,6 +161,12 @@ func (s *Store) SetRecommendationOnboarding(
return err
}
func (s *Store) ClearRecommendationOnboarding(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM recommendation_onboarding WHERE emby_user_id = $1`, userID)
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(
+103
View File
@@ -4,6 +4,7 @@ package store
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"time"
@@ -49,6 +50,82 @@ type KnownClient struct {
LastSeen time.Time `json:"lastSeen"`
}
// MembyAccount is an account known to Memby, as opposed to an arbitrary user that
// exists only in Emby. An account exists here once it has at least one gateway session.
// Devices deliberately omit both gateway and upstream credentials.
type MembyAccount struct {
ID string `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"createdAt"`
LastSeen time.Time `json:"lastSeen"`
Devices []MembyDevice `json:"devices"`
RecommendationPreferences json.RawMessage `json:"-"`
}
type MembyDevice struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Protocol string `json:"protocol"`
Capabilities []string `json:"capabilities"`
SignedInAt time.Time `json:"signedInAt"`
LastSeen time.Time `json:"lastSeen"`
}
// MembyAccounts returns only people who have signed in through Memby. It must not be
// confused with an Emby user directory: users that exist solely in Emby are absent.
func (s *Store) MembyAccounts(ctx context.Context) ([]MembyAccount, error) {
rows, err := s.pool.Query(ctx, `
SELECT s.emby_user_id, s.username, s.device_id, s.device_name,
s.client_version, s.client_protocol, s.client_capabilities,
s.created_at, s.last_seen_at, COALESCE(o.preferences, '{}'::jsonb)
FROM sessions s
LEFT JOIN recommendation_onboarding o ON o.emby_user_id = s.emby_user_id
ORDER BY s.last_seen_at DESC, s.emby_user_id, s.device_name`)
if err != nil {
return nil, fmt.Errorf("store: list Memby accounts: %w", err)
}
defer rows.Close()
accounts := []MembyAccount{}
byID := map[string]int{}
for rows.Next() {
var userID, username string
var device MembyDevice
var preferences []byte
if err := rows.Scan(
&userID, &username, &device.ID, &device.Name, &device.Version,
&device.Protocol, &device.Capabilities, &device.SignedInAt,
&device.LastSeen, &preferences,
); err != nil {
return nil, fmt.Errorf("store: scan Memby account: %w", err)
}
index, ok := byID[userID]
if !ok {
index = len(accounts)
byID[userID] = index
accounts = append(accounts, MembyAccount{
ID: userID, Username: username, CreatedAt: device.SignedInAt,
LastSeen: device.LastSeen, Devices: []MembyDevice{},
RecommendationPreferences: json.RawMessage(preferences),
})
}
account := &accounts[index]
if device.SignedInAt.Before(account.CreatedAt) {
account.CreatedAt = device.SignedInAt
}
if device.LastSeen.After(account.LastSeen) {
account.LastSeen = device.LastSeen
account.Username = username
}
account.Devices = append(account.Devices, device)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: read Memby accounts: %w", err)
}
return accounts, nil
}
// 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) {
@@ -318,6 +395,32 @@ func (s *Store) DeleteUserDevice(ctx context.Context, userID, deviceID string) (
return tokenHash, nil
}
// DeleteUserSessions removes the Memby account's active gateway access while leaving
// the upstream Emby user untouched.
func (s *Store) DeleteUserSessions(ctx context.Context, userID string) ([][]byte, error) {
rows, err := s.pool.Query(ctx, `
DELETE FROM sessions WHERE emby_user_id = $1 RETURNING token_hash`, userID)
if err != nil {
return nil, fmt.Errorf("store: delete user sessions: %w", err)
}
defer rows.Close()
hashes := [][]byte{}
for rows.Next() {
var hash []byte
if err := rows.Scan(&hash); err != nil {
return nil, fmt.Errorf("store: scan deleted session: %w", err)
}
hashes = append(hashes, hash)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: read deleted sessions: %w", err)
}
if len(hashes) == 0 {
return nil, ErrNotFound
}
return hashes, 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, `