App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
+212
-25
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -42,12 +43,42 @@ type KnownUser struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
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"`
|
||||
Versions []DeviceVersion `json:"versions"`
|
||||
}
|
||||
|
||||
// DefaultDeviceName is what a television is called when its build predates device naming.
|
||||
// It is a placeholder rather than a name, which is why supersedeDevices refuses to treat
|
||||
// two sets wearing it as the same television.
|
||||
const DefaultDeviceName = "Memby TV"
|
||||
|
||||
// DeviceVersion is one app build a television has been seen running, newest first when
|
||||
// read back. It is deliberately per device rather than per session: the question it
|
||||
// answers — what has this set been running — is about the television.
|
||||
type DeviceVersion struct {
|
||||
Version string `json:"version"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// SupersededDevice is a session a sign-in retired: the same television, under a device id
|
||||
// it no longer uses. The token hash comes back so its cache entry can go with it.
|
||||
type SupersededDevice struct {
|
||||
DeviceID string
|
||||
TokenHash []byte
|
||||
}
|
||||
|
||||
// SessionCreated reports what a sign-in displaced. ReplacedHash is the token this same
|
||||
// device id already held; Superseded is the rest of the same television's past lives.
|
||||
type SessionCreated struct {
|
||||
ReplacedHash []byte
|
||||
Superseded []SupersededDevice
|
||||
}
|
||||
|
||||
// MembyAccount is an account known to Memby, as opposed to an arbitrary user that
|
||||
@@ -63,13 +94,14 @@ type MembyAccount struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
Versions []DeviceVersion `json:"versions"`
|
||||
}
|
||||
|
||||
// MembyAccounts returns only people who have signed in through Memby. It must not be
|
||||
@@ -123,14 +155,91 @@ func (s *Store) MembyAccounts(ctx context.Context) ([]MembyAccount, error) {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read Memby accounts: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := []string{}
|
||||
for _, account := range accounts {
|
||||
for _, device := range account.Devices {
|
||||
deviceIDs = append(deviceIDs, device.ID)
|
||||
}
|
||||
}
|
||||
// A history read failure costs the build list and nothing else — the devices and the
|
||||
// sign-out buttons are what this list is for.
|
||||
if history, err := s.DeviceVersions(ctx, deviceIDs); err == nil {
|
||||
for i := range accounts {
|
||||
for j := range accounts[i].Devices {
|
||||
accounts[i].Devices[j].Versions = history[accounts[i].Devices[j].ID]
|
||||
}
|
||||
}
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
// DeviceVersions returns every build each of these televisions has been seen running,
|
||||
// most recently seen first. One query for a whole household: the console draws this
|
||||
// beside every device row and a query per device would grow with the house.
|
||||
func (s *Store) DeviceVersions(ctx context.Context, deviceIDs []string) (map[string][]DeviceVersion, error) {
|
||||
history := map[string][]DeviceVersion{}
|
||||
if len(deviceIDs) == 0 {
|
||||
return history, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT device_id, client_version, first_seen_at, last_seen_at
|
||||
FROM device_versions
|
||||
WHERE device_id = ANY($1::text[])
|
||||
ORDER BY device_id, last_seen_at DESC`, deviceIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list device versions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var deviceID string
|
||||
var version DeviceVersion
|
||||
if err := rows.Scan(&deviceID, &version.Version, &version.FirstSeen, &version.LastSeen); err != nil {
|
||||
return nil, fmt.Errorf("store: scan device version: %w", err)
|
||||
}
|
||||
history[deviceID] = append(history[deviceID], version)
|
||||
}
|
||||
return history, rows.Err()
|
||||
}
|
||||
|
||||
// RecordDeviceVersion notes that a television is running this build. Called from the
|
||||
// sign-in and from the identity refresh on the auth path, so it must stay one statement:
|
||||
// every request a TV makes can reach it, and all but the first are a no-op write.
|
||||
func (s *Store) RecordDeviceVersion(ctx context.Context, deviceID, version string) error {
|
||||
if deviceID == "" || version == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO device_versions (device_id, client_version)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (device_id, client_version) DO UPDATE SET last_seen_at = now()`,
|
||||
deviceID, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: record device version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDeviceVersions retires a television's build history along with the television.
|
||||
// It is called wherever a device row is removed, so a set that is gone does not leave a
|
||||
// list of builds behind it with nothing to attach them to.
|
||||
func (s *Store) DeleteDeviceVersions(ctx context.Context, deviceIDs ...string) error {
|
||||
if len(deviceIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM device_versions WHERE device_id = ANY($1::text[])`, deviceIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete device versions: %w", err)
|
||||
}
|
||||
return 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) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT device_name, username, client_version, client_protocol,
|
||||
SELECT device_id, 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 {
|
||||
@@ -138,15 +247,26 @@ func (s *Store) KnownClients(ctx context.Context) ([]KnownClient, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
clients := []KnownClient{}
|
||||
deviceIDs := []string{}
|
||||
for rows.Next() {
|
||||
var client KnownClient
|
||||
if err := rows.Scan(&client.DeviceName, &client.Username, &client.Version,
|
||||
&client.Protocol, &client.Capabilities, &client.LastSeen); err != nil {
|
||||
if err := rows.Scan(&client.DeviceID, &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)
|
||||
deviceIDs = append(deviceIDs, client.DeviceID)
|
||||
}
|
||||
return clients, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read known clients: %w", err)
|
||||
}
|
||||
if history, err := s.DeviceVersions(ctx, deviceIDs); err == nil {
|
||||
for i := range clients {
|
||||
clients[i].Versions = history[clients[i].DeviceID]
|
||||
}
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// KnownUsers returns one entry per Emby user that has signed in to the gateway.
|
||||
@@ -252,16 +372,25 @@ func (s *Store) Migrate(ctx context.Context) error {
|
||||
|
||||
// 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) ([]byte, error) {
|
||||
//
|
||||
// It also retires the same television's earlier identities. Sessions are unique per
|
||||
// (user, device id), so a second row for one set can only mean its device id changed —
|
||||
// a reinstall on a build that generated a random id, or an install predating the derived
|
||||
// one. Left alone, each of those keeps a row, an Emby device record and a build history of
|
||||
// its own, and one television reads as several. See supersedeDevices for the match rule.
|
||||
//
|
||||
// The replaced hash and the superseded rows come back so their Redis entries, their Emby
|
||||
// records and their build histories can be retired with them.
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session) (SessionCreated, error) {
|
||||
created := SessionCreated{}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: begin session: %w", err)
|
||||
return created, 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, fmt.Errorf("store: lock user sessions: %w", err)
|
||||
return created, fmt.Errorf("store: lock user sessions: %w", err)
|
||||
}
|
||||
|
||||
var previousHash []byte
|
||||
@@ -270,7 +399,13 @@ func (s *Store) CreateSession(ctx context.Context, sess Session) ([]byte, error)
|
||||
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, fmt.Errorf("store: find device session: %w", err)
|
||||
return created, fmt.Errorf("store: find device session: %w", err)
|
||||
}
|
||||
created.ReplacedHash = previousHash
|
||||
|
||||
created.Superseded, err = supersedeDevices(ctx, tx, sess)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
@@ -293,12 +428,64 @@ func (s *Store) CreateSession(ctx context.Context, sess Session) ([]byte, error)
|
||||
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol,
|
||||
sess.ClientCapabilities)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: create session: %w", err)
|
||||
return created, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("store: commit session: %w", err)
|
||||
return created, fmt.Errorf("store: commit session: %w", err)
|
||||
}
|
||||
return previousHash, nil
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// supersedeDevices deletes the rows this sign-in makes redundant: the same person, the
|
||||
// same television name, a device id the set no longer uses.
|
||||
//
|
||||
// The name is the only evidence there is. A television that changes its device id has
|
||||
// nothing else in common with its previous row — the token is new, the session is new, and
|
||||
// Emby has issued it a second device record. What it does keep is what somebody typed into
|
||||
// Settings → Devices, which is the name of a physical set in a house.
|
||||
//
|
||||
// DefaultDeviceName is exempt, and that exemption is the whole safety of the rule: a build
|
||||
// predating device naming calls itself "Memby TV", so matching on it would let the second
|
||||
// such set in a household delete the first every time it signed in.
|
||||
func supersedeDevices(ctx context.Context, tx pgx.Tx, sess Session) ([]SupersededDevice, error) {
|
||||
name := supersedeName(sess.DeviceName)
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
DELETE FROM sessions
|
||||
WHERE emby_user_id = $1 AND device_id <> $2 AND lower(device_name) = lower($3)
|
||||
RETURNING device_id, token_hash`,
|
||||
sess.EmbyUserID, sess.DeviceID, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: supersede devices: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var superseded []SupersededDevice
|
||||
for rows.Next() {
|
||||
var device SupersededDevice
|
||||
if err := rows.Scan(&device.DeviceID, &device.TokenHash); err != nil {
|
||||
return nil, fmt.Errorf("store: scan superseded device: %w", err)
|
||||
}
|
||||
superseded = append(superseded, device)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read superseded devices: %w", err)
|
||||
}
|
||||
return superseded, nil
|
||||
}
|
||||
|
||||
// supersedeName is the whole decision, kept apart from the query so it can be tested: the
|
||||
// name to match earlier rows on, or "" for a sign-in that must displace nothing. A blank
|
||||
// name and the compatibility default are both "this set did not say", which is no evidence
|
||||
// of identity at all — and acting on it would let one unnamed television in a household
|
||||
// sign the others out.
|
||||
func supersedeName(deviceName string) string {
|
||||
name := strings.TrimSpace(deviceName)
|
||||
if name == "" || strings.EqualFold(name, DefaultDeviceName) {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
|
||||
|
||||
Reference in New Issue
Block a user