Add Memby account management
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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, `
|
||||
|
||||
Reference in New Issue
Block a user