0.3.21
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user