0.3.21
This commit is contained in:
@@ -40,6 +40,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/notifications", s.adminAuth(s.handleAdminNotificationPreferences))
|
||||
mux.Handle("POST /admin/api/accounts/{userID}/force-update", s.adminAuth(s.handleAdminForceUpdate))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/enabled", s.adminAuth(s.handleAdminUserEnabled))
|
||||
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
|
||||
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
|
||||
s.adminAuth(s.handleAdminRestorePreferences))
|
||||
@@ -857,16 +859,22 @@ func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
stats, statsErr := s.store.JourneyStats(r.Context(), userID, since)
|
||||
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
|
||||
featureUsage, usageErr := s.store.JourneyFeatureUsage(r.Context(), userID, since)
|
||||
featureBreakdown, breakdownErr := s.store.JourneyFeatureBreakdown(r.Context(), userID, since)
|
||||
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
|
||||
actions, actionErr := s.store.JourneyActionStats(r.Context(), userID, since)
|
||||
if statsErr != nil || featureErr != nil || pathErr != nil || actionErr != nil {
|
||||
s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID)
|
||||
if statsErr != nil || featureErr != nil || usageErr != nil || breakdownErr != nil || pathErr != nil || actionErr != nil {
|
||||
s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID,
|
||||
"stats_error", statsErr, "feature_error", featureErr,
|
||||
"feature_usage_error", usageErr, "feature_breakdown_error", breakdownErr,
|
||||
"paths_error", pathErr, "actions_error", actionErr)
|
||||
writeError(w, http.StatusInternalServerError, "could not read journeys")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)),
|
||||
"users": users, "stats": stats, "features": features, "paths": paths, "actions": actions,
|
||||
"users": users, "stats": stats, "features": features, "featureUsage": featureUsage,
|
||||
"featureBreakdown": featureBreakdown, "paths": paths, "actions": actions,
|
||||
}
|
||||
if userID != "" {
|
||||
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -38,11 +37,12 @@ type adminMembyAccount struct {
|
||||
// ShortName is the friendly name the launcher greets this person by, and is blank far
|
||||
// more often than not — the directory reads it as "their account name" rather than as
|
||||
// something missing.
|
||||
ShortName string `json:"shortName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Devices []store.MembyDevice `json:"devices"`
|
||||
Recommendations adminOnboardingPreferences `json:"recommendations"`
|
||||
ShortName string `json:"shortName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Devices []store.MembyDevice `json:"devices"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastIP string `json:"lastIp"`
|
||||
// Settings is the same document the television reads, normalised the same way, so
|
||||
// the console is editing what the TV will actually receive rather than a projection
|
||||
// of it. Saved is false for someone who has never synced — the values shown are then
|
||||
@@ -77,23 +77,6 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
allRatingIDs := []string{}
|
||||
preferences := make(map[string]recommend.OnboardingPreferences, len(accounts))
|
||||
for _, account := range accounts {
|
||||
var pref recommend.OnboardingPreferences
|
||||
_ = json.Unmarshal(account.RecommendationPreferences, &pref)
|
||||
preferences[account.ID] = pref
|
||||
for id := range pref.Ratings {
|
||||
allRatingIDs = append(allRatingIDs, id)
|
||||
}
|
||||
}
|
||||
titles := map[string]string{}
|
||||
if raws, loadErr := s.store.LibraryItemsByID(r.Context(), allRatingIDs); loadErr == nil {
|
||||
for _, item := range recommend.Decode(raws) {
|
||||
titles[item.ID] = item.Name
|
||||
}
|
||||
}
|
||||
|
||||
settings, err := s.store.AllUserPreferences(r.Context())
|
||||
if err != nil {
|
||||
// A settings read failure must not cost the operator the account list; the
|
||||
@@ -126,21 +109,6 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result := make([]adminMembyAccount, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
pref := preferences[account.ID]
|
||||
ratings := make([]adminOnboardingRating, 0, len(pref.Ratings))
|
||||
for itemID, rating := range pref.Ratings {
|
||||
title := titles[itemID]
|
||||
if title == "" {
|
||||
title = itemID
|
||||
}
|
||||
ratings = append(ratings, adminOnboardingRating{ItemID: itemID, Title: title, Rating: rating})
|
||||
}
|
||||
sort.Slice(ratings, func(i, j int) bool {
|
||||
if ratings[i].Rating != ratings[j].Rating {
|
||||
return ratings[i].Rating > ratings[j].Rating
|
||||
}
|
||||
return strings.ToLower(ratings[i].Title) < strings.ToLower(ratings[j].Title)
|
||||
})
|
||||
stored, saved := settings[account.ID]
|
||||
accountSettings := adminAccountSettings{
|
||||
Saved: saved, Revision: stored.Revision, Source: stored.Source,
|
||||
@@ -162,14 +130,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Notifications: notificationPrefs,
|
||||
Recommendations: adminOnboardingPreferences{
|
||||
Completed: pref.Completed, Prompted: pref.Prompted,
|
||||
Updated: len(account.RecommendationPreferences) > 2,
|
||||
Ratings: ratings, Genres: nonNilStrings(pref.Genres),
|
||||
Studios: nonNilStrings(pref.Studios), Actors: nonNilStrings(pref.Actors),
|
||||
Actresses: nonNilStrings(pref.Actresses),
|
||||
Directors: nonNilStrings(pref.Directors), ContentTypes: nonNilStrings(pref.ContentTypes),
|
||||
},
|
||||
Enabled: account.Enabled, LastIP: account.LastIP,
|
||||
})
|
||||
}
|
||||
// The catalogue rides along so the console builds its editor from the server's own
|
||||
@@ -218,6 +179,57 @@ func (s *Server) handleAdminNotificationPreferences(w http.ResponseWriter, r *ht
|
||||
writeJSON(w, http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUserEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "user is required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil || req.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "enabled is required")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserEnabled(r.Context(), userID, *req.Enabled); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not change user access")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"enabled": *req.Enabled})
|
||||
}
|
||||
|
||||
// The gateway's update check is policy-driven. This is the operator-facing queue point;
|
||||
// the device sees the request on its next status poll.
|
||||
func (s *Server) handleAdminForceUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "user is required")
|
||||
return
|
||||
}
|
||||
accounts, err := s.store.MembyAccounts(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load Memby account")
|
||||
return
|
||||
}
|
||||
for _, account := range accounts {
|
||||
if account.ID == userID {
|
||||
version := strings.TrimSpace(s.updatePolicy.get().LatestVersion)
|
||||
if version == "" {
|
||||
writeError(w, http.StatusConflict, "no current release is configured")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetForcedUpdate(r.Context(), userID, version); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not queue update")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "Memby account not found")
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
|
||||
@@ -701,6 +701,9 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
var cs cachedSession
|
||||
if json.Unmarshal(raw, &cs) == nil {
|
||||
if enabled, err := s.store.UserEnabled(ctx, cs.EmbyUserID); err != nil || !enabled {
|
||||
return store.Session{}, store.ErrNotFound
|
||||
}
|
||||
return store.Session{
|
||||
TokenHash: hash,
|
||||
EmbyUserID: cs.EmbyUserID,
|
||||
|
||||
@@ -62,6 +62,7 @@ var configurationCatalogue = []configurationDefinition{
|
||||
{Key: "continueWatching.enabled", Name: "Continue Watching", Description: "Show the Continue Watching row.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||
{Key: "continueWatching.showNextUp", Name: "Continue Watching: Next Up", Description: "Include an unstarted next episode in Continue Watching.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||
{Key: "continueWatching.progressColour", Name: "Progress bar colour", Description: "Choose the progress bar treatment.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "emby", Options: []string{"emby", "white"}},
|
||||
{Key: "detailExperience", Name: "Detail page experience", Description: "Which detail-page layout a viewer sees.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "v1", Options: []string{"v1", "v2"}},
|
||||
{Key: "presentation.fontFamily", Name: "App font family", Description: "Choose the bundled font used in Memby’s typography trial areas.", Type: "enum", Scopes: []string{"global"}, Default: "system", Options: []string{"system", "inter"}},
|
||||
{Key: "ratings.enabled", Name: "Ratings", Description: "Show ratings throughout the catalogue.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||
{Key: "genres.enabled", Name: "Genres", Description: "Show genre browsing controls.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||
@@ -281,6 +282,22 @@ func configurationValue(policy store.FeaturePolicy, definition configurationDefi
|
||||
return definition.Default, "default"
|
||||
}
|
||||
|
||||
// detailExperienceFor resolves the "detailExperience" configuration value for a session,
|
||||
// validating the stored value rather than trusting its type: Values/UserValues/DeviceValues
|
||||
// are opaque JSON, and a value that is not exactly "v1" or "v2" must never reach the client
|
||||
// as something it has to guess how to handle.
|
||||
func detailExperienceFor(policy store.FeaturePolicy, sess store.Session) string {
|
||||
definition, ok := configurationDefinitionFor("detailExperience")
|
||||
if !ok {
|
||||
return "v1"
|
||||
}
|
||||
value, _ := configurationValue(policy, definition, sess)
|
||||
if text, ok := value.(string); ok && (text == "v1" || text == "v2") {
|
||||
return text
|
||||
}
|
||||
return "v1"
|
||||
}
|
||||
|
||||
func configurationPayload(policy store.FeaturePolicy, sessions ...store.Session) []evaluatedConfiguration {
|
||||
result := make([]evaluatedConfiguration, 0, len(configurationCatalogue))
|
||||
for _, definition := range configurationCatalogue {
|
||||
|
||||
@@ -89,6 +89,43 @@ func TestAppFontFamilyIsAValidatedGlobalConfiguration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailExperienceResolvesDeviceThenUserThenGlobalThenDefault(t *testing.T) {
|
||||
sess := store.Session{EmbyUserID: "user-1", DeviceID: "device-1"}
|
||||
if got := detailExperienceFor(store.DefaultFeaturePolicy(), sess); got != "v1" {
|
||||
t.Fatalf("default detail experience = %q, want v1", got)
|
||||
}
|
||||
global := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v2"`)}}
|
||||
if got := detailExperienceFor(global, sess); got != "v2" {
|
||||
t.Fatalf("global detail experience = %q, want v2", got)
|
||||
}
|
||||
perUser := store.FeaturePolicy{
|
||||
Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v2"`)},
|
||||
UserValues: map[string]map[string]json.RawMessage{"user-1": {"detailExperience": json.RawMessage(`"v1"`)}},
|
||||
}
|
||||
if got := detailExperienceFor(perUser, sess); got != "v1" {
|
||||
t.Fatalf("per-user detail experience = %q, want v1 to outrank the global v2", got)
|
||||
}
|
||||
perDevice := store.FeaturePolicy{
|
||||
UserValues: map[string]map[string]json.RawMessage{"user-1": {"detailExperience": json.RawMessage(`"v1"`)}},
|
||||
DeviceValues: map[string]map[string]json.RawMessage{"device-1": {"detailExperience": json.RawMessage(`"v2"`)}},
|
||||
}
|
||||
if got := detailExperienceFor(perDevice, sess); got != "v2" {
|
||||
t.Fatalf("per-device detail experience = %q, want v2 to outrank the per-user v1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailExperienceFallsBackToV1OnAnUnrecognisedStoredValue(t *testing.T) {
|
||||
sess := store.Session{EmbyUserID: "user-1", DeviceID: "device-1"}
|
||||
garbage := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v3"`)}}
|
||||
if got := detailExperienceFor(garbage, sess); got != "v1" {
|
||||
t.Fatalf("garbage detail experience = %q, want v1", got)
|
||||
}
|
||||
notAString := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`42`)}}
|
||||
if got := detailExperienceFor(notAString, sess); got != "v1" {
|
||||
t.Fatalf("non-string detail experience = %q, want v1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
||||
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
|
||||
|
||||
@@ -195,5 +195,11 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
// else — the handlers refuse regardless, but a menu item that only ever produces a
|
||||
// refusal is worse than no menu item.
|
||||
"requests": map[string]any{"allowed": s.requestAllowed(r, sess)},
|
||||
// The v2 detail-page experiment, as a plain string rather than a boolean feature
|
||||
// flag: it needs three legal values room to grow into (a later variant), and per-
|
||||
// user/per-device scope the way the theme does. It rides this poll rather than
|
||||
// /v1/config because that document is fetched unauthenticated, before sign-in, and
|
||||
// cannot resolve a session to scope by user or device.
|
||||
"detailExperience": detailExperienceFor(featurePolicy, sess),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,7 +119,17 @@ func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
|
||||
}
|
||||
|
||||
func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
|
||||
return appupdate.Decide(effectiveUpdatePolicy(s.updatePolicy.get()), clientVersion(r))
|
||||
policy := effectiveUpdatePolicy(s.updatePolicy.get())
|
||||
if identity := identityFrom(r.Context()); identity != nil && identity.userID != "" {
|
||||
if forced, err := s.store.ForcedUpdate(r.Context(), identity.userID); err == nil && forced != "" {
|
||||
if appupdate.CompareVersions(clientVersion(r), forced) >= 0 {
|
||||
_ = s.store.ClearForcedUpdate(r.Context(), identity.userID)
|
||||
} else if policy.Enabled && policy.DownloadURL != "" && (policy.MinimumVersion == "" || appupdate.CompareVersions(policy.MinimumVersion, forced) < 0) {
|
||||
policy.MinimumVersion = forced
|
||||
}
|
||||
}
|
||||
}
|
||||
return appupdate.Decide(policy, clientVersion(r))
|
||||
}
|
||||
|
||||
// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a
|
||||
|
||||
@@ -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