This commit is contained in:
ponzischeme89
2026-08-24 22:56:46 +12:00
parent 4f95767e2f
commit 396d35e2f5
48 changed files with 1541 additions and 672 deletions
+25
View File
@@ -0,0 +1,25 @@
package store
import (
"context"
"fmt"
)
func (s *Store) UserEnabled(ctx context.Context, userID string) (bool, error) {
var enabled bool
err := s.pool.QueryRow(ctx, `SELECT COALESCE((SELECT enabled FROM user_controls WHERE emby_user_id = $1), true)`, userID).Scan(&enabled)
if err != nil {
return false, fmt.Errorf("store: read user access: %w", err)
}
return enabled, nil
}
func (s *Store) SetUserEnabled(ctx context.Context, userID string, enabled bool) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_controls (emby_user_id, enabled, updated_at) VALUES ($1, $2, now())
ON CONFLICT (emby_user_id) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now()`, userID, enabled)
if err != nil {
return fmt.Errorf("store: set user access: %w", err)
}
return nil
}
+92
View File
@@ -97,6 +97,30 @@ type FeatureStat struct {
LastUsedAt time.Time `json:"lastUsedAt"`
}
// JourneyFeatureStat is the server-derived usage summary for one feature. Users are
// distinct profiles, Uses are recorded feature events, and Journeys are distinct app
// visits containing the feature.
type JourneyFeatureStat struct {
Feature string `json:"feature"`
Users int64 `json:"users"`
Uses int64 `json:"uses"`
Journeys int64 `json:"journeys"`
ActiveUserRate float64 `json:"activeUserRate"`
LastUsedAt time.Time `json:"lastUsedAt"`
}
// JourneyBreakdownStat keeps the three telemetry dimensions separate. SubFeature is
// sourced from the structured entry/source/target context, never parsed from display text.
type JourneyBreakdownStat struct {
Feature string `json:"feature"`
SubFeature string `json:"subFeature"`
Action string `json:"action"`
Users int64 `json:"users"`
Uses int64 `json:"uses"`
Journeys int64 `json:"journeys"`
LastUsedAt time.Time `json:"lastUsedAt"`
}
type PathStat struct {
From string `json:"from"`
To string `json:"to"`
@@ -430,6 +454,74 @@ func (s *Store) UserFeatureStats(ctx context.Context, userID string, since time.
return out, rows.Err()
}
// JourneyFeatureUsage aggregates structured journey telemetry. Lifecycle markers are not
// feature usage: screen views and feature actions are retained so a feature can be viewed
// without being selected, requested or played.
func (s *Store) JourneyFeatureUsage(ctx context.Context, userID string, since time.Time) ([]JourneyFeatureStat, error) {
rows, err := s.pool.Query(ctx, `
WITH active AS (
SELECT count(DISTINCT emby_user_id) AS users
FROM journey_events WHERE occurred_at >= $1
), events AS (
SELECT coalesce(nullif(feature, ''), nullif(category, '')) AS feature,
emby_user_id, journey_id, occurred_at
FROM journey_events
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
AND action NOT IN ('journey_start', 'journey_end')
)
SELECT feature, count(DISTINCT emby_user_id), count(*), count(DISTINCT (emby_user_id, journey_id)),
coalesce(count(DISTINCT emby_user_id)::double precision /
nullif((SELECT users FROM active), 0), 0), max(occurred_at)
FROM events
WHERE feature IS NOT NULL AND feature <> ''
GROUP BY feature ORDER BY count(*) DESC, feature`, since, userID)
if err != nil {
return nil, fmt.Errorf("store: journey feature usage: %w", err)
}
defer rows.Close()
out := []JourneyFeatureStat{}
for rows.Next() {
var value JourneyFeatureStat
if err := rows.Scan(&value.Feature, &value.Users, &value.Uses, &value.Journeys,
&value.ActiveUserRate, &value.LastUsedAt); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
// JourneyFeatureBreakdown is the drill-down behind the feature summary. Source is the
// strongest sub-feature signal (for example text or voice search); target and screen are
// fallbacks for older structured events.
func (s *Store) JourneyFeatureBreakdown(ctx context.Context, userID string, since time.Time) ([]JourneyBreakdownStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT coalesce(nullif(feature, ''), nullif(category, '')) AS feature,
coalesce(nullif(source, ''), nullif(target, ''), nullif(screen, ''), '') AS sub_feature,
action, count(DISTINCT emby_user_id), count(*), count(DISTINCT (emby_user_id, journey_id)), max(occurred_at)
FROM journey_events
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
AND action NOT IN ('journey_start', 'journey_end')
AND coalesce(nullif(feature, ''), nullif(category, '')) IS NOT NULL
GROUP BY coalesce(nullif(feature, ''), nullif(category, '')),
coalesce(nullif(source, ''), nullif(target, ''), nullif(screen, ''), ''), action
ORDER BY count(*) DESC, feature, sub_feature, action`, since, userID)
if err != nil {
return nil, fmt.Errorf("store: journey feature breakdown: %w", err)
}
defer rows.Close()
out := []JourneyBreakdownStat{}
for rows.Next() {
var value JourneyBreakdownStat
if err := rows.Scan(&value.Feature, &value.SubFeature, &value.Action, &value.Users,
&value.Uses, &value.Journeys, &value.LastUsedAt); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) ([]PathStat, error) {
rows, err := s.pool.Query(ctx, `
WITH ordered AS (
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"context"
"fmt"
)
func (s *Store) SetForcedUpdate(ctx context.Context, userID, version string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO forced_updates (emby_user_id, version) VALUES ($1, $2)
ON CONFLICT (emby_user_id) DO UPDATE SET version = EXCLUDED.version, requested_at = now()`, userID, version)
if err != nil {
return fmt.Errorf("store: queue forced update: %w", err)
}
return nil
}
func (s *Store) ForcedUpdate(ctx context.Context, userID string) (string, error) {
var version string
err := s.pool.QueryRow(ctx, `SELECT version FROM forced_updates WHERE emby_user_id = $1`, userID).Scan(&version)
if isNoRows(err) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("store: read forced update: %w", err)
}
return version, nil
}
func (s *Store) ClearForcedUpdate(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM forced_updates WHERE emby_user_id = $1`, userID)
if err != nil {
return fmt.Errorf("store: clear forced update: %w", err)
}
return nil
}
+14
View File
@@ -23,6 +23,20 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAU
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 '{}';
-- Gateway access is separate from Emby's own account policy. This lets an operator
-- suspend Memby access without changing the upstream account or its other clients.
CREATE TABLE IF NOT EXISTS user_controls (
emby_user_id TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS forced_updates (
emby_user_id TEXT PRIMARY KEY,
version TEXT NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
DELETE FROM sessions older
+25 -2
View File
@@ -50,6 +50,7 @@ type KnownClient struct {
Protocol string `json:"protocol"`
Capabilities []string `json:"capabilities"`
LastSeen time.Time `json:"lastSeen"`
LastIP string `json:"lastIp"`
Versions []DeviceVersion `json:"versions"`
}
@@ -91,6 +92,8 @@ type MembyAccount struct {
LastSeen time.Time `json:"lastSeen"`
Devices []MembyDevice `json:"devices"`
RecommendationPreferences json.RawMessage `json:"-"`
Enabled bool `json:"enabled"`
LastIP string `json:"lastIp"`
}
type MembyDevice struct {
@@ -101,6 +104,7 @@ type MembyDevice struct {
Capabilities []string `json:"capabilities"`
SignedInAt time.Time `json:"signedInAt"`
LastSeen time.Time `json:"lastSeen"`
LastIP string `json:"lastIp"`
Versions []DeviceVersion `json:"versions"`
}
@@ -110,9 +114,21 @@ 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)
s.created_at, s.last_seen_at, COALESCE(o.preferences, '{}'::jsonb),
COALESCE(c.enabled, true), COALESCE(ip.address, ''), COALESCE(dip.address, '')
FROM sessions s
LEFT JOIN recommendation_onboarding o ON o.emby_user_id = s.emby_user_id
LEFT JOIN user_controls c ON c.emby_user_id = s.emby_user_id
LEFT JOIN LATERAL (
SELECT ip_address AS address FROM login_events
WHERE emby_user_id = s.emby_user_id AND success AND ip_address <> ''
ORDER BY occurred_at DESC, id DESC LIMIT 1
) ip ON true
LEFT JOIN LATERAL (
SELECT ip_address AS address FROM login_events
WHERE device_id = s.device_id AND success AND ip_address <> ''
ORDER BY occurred_at DESC, id DESC LIMIT 1
) dip ON true
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)
@@ -125,10 +141,12 @@ func (s *Store) MembyAccounts(ctx context.Context) ([]MembyAccount, error) {
var userID, username string
var device MembyDevice
var preferences []byte
var accountsEnabled bool
var lastIP string
if err := rows.Scan(
&userID, &username, &device.ID, &device.Name, &device.Version,
&device.Protocol, &device.Capabilities, &device.SignedInAt,
&device.LastSeen, &preferences,
&device.LastSeen, &preferences, &accountsEnabled, &lastIP, &device.LastIP,
); err != nil {
return nil, fmt.Errorf("store: scan Memby account: %w", err)
}
@@ -140,9 +158,14 @@ func (s *Store) MembyAccounts(ctx context.Context) ([]MembyAccount, error) {
ID: userID, Username: username, CreatedAt: device.SignedInAt,
LastSeen: device.LastSeen, Devices: []MembyDevice{},
RecommendationPreferences: json.RawMessage(preferences),
Enabled: accountsEnabled, LastIP: lastIP,
})
}
account := &accounts[index]
account.Enabled = accountsEnabled
if account.LastIP == "" {
account.LastIP = lastIP
}
if device.SignedInAt.Before(account.CreatedAt) {
account.CreatedAt = device.SignedInAt
}