0.2.45 - Advanced analytics, logout old versions

This commit is contained in:
ponzischeme89
2026-08-10 20:24:22 +12:00
parent 63f0768507
commit 56c1167382
32 changed files with 1125 additions and 452 deletions
+8
View File
@@ -532,6 +532,14 @@ landing page serves), release notes, and a **Require this update** toggle.
`minimumVersion` can also be set directly for a staged rollout where the forced floor is
older than the newest build.
Builds below 0.2.44 are permanently retired once the enabled policy points at an
actionable 0.2.44-or-newer release. On their next authenticated request the gateway
deletes the session and returns 401, which makes the TV remove the rejected local profile;
the public update check continues to return the mandatory update screen. The gateway also
refuses a new login from a retired build, so signing in again cannot bypass the update.
This floor remains dormant when the policy has no download URL or its latest release is
older than 0.2.44.
Two deliberate safeguards, both tested in `internal/appupdate`:
- A client that cannot report a version is **never** forced. It would otherwise be stuck
+1 -3
View File
@@ -309,9 +309,7 @@ func openStore(ctx context.Context, databaseURL string, log *slog.Logger) (*stor
return nil, lastErr
}
// pruneAnalytics keeps raw row events inside their retention window. The admin page
// aggregates at read time, so nothing survives the prune — deliberately, since this is
// tuning telemetry rather than a permanent record of what anyone watched.
// pruneAnalytics keeps raw engagement and journey events inside their retention window.
func pruneAnalytics(ctx context.Context, st *store.Store, retention time.Duration, log *slog.Logger) {
if retention <= 0 {
return
+23 -1
View File
@@ -720,7 +720,29 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "could not read analytics")
return
}
writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats})
users, err := s.store.AnalyticsUsers(r.Context(), since)
if err != nil {
s.loggerFor(r.Context()).Error("user analytics failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read analytics")
return
}
payload := map[string]any{"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)), "rows": stats, "users": users}
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
if userID != "" {
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
if featureErr != nil || pathErr != nil || eventErr != nil {
s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID)
writeError(w, http.StatusInternalServerError, "could not read user journey")
return
}
payload["userId"] = userID
payload["features"] = features
payload["paths"] = paths
payload["events"] = events
}
writeJSON(w, http.StatusOK, payload)
}
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
@@ -8,8 +8,9 @@
<label class="field narrow"><span>Window</span>
<select id="engagement-days">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
</select></label>
</div>
<div class="table-wrap">
@@ -24,3 +25,53 @@
</table>
</div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="people" data-icon-tone="info">User journeys</h2>
<p class="card-note">Choose an Emby profile to review feature use, common paths and
significant actions in time order. Search text, content titles and setting values
are not stored in journey analytics.</p>
</div>
<label class="field narrow"><span>User</span>
<select id="engagement-user"><option value="">Choose a user</option></select></label>
</div>
<div class="tiles" id="engagement-user-tiles"></div>
</section>
<section class="card" id="engagement-feature-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
<p class="card-note">Rare and unused features are shown explicitly against Memby's
major feature catalogue.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
<tbody id="engagement-features"></tbody>
</table></div>
</section>
<section class="card" id="engagement-path-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
<p class="card-note">Repeated transitions reveal routes into playback and places a
viewer commonly leaves a flow.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
<tbody id="engagement-paths"></tbody>
</table></div>
</section>
<section class="card" id="engagement-journey-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journey</h2>
<p class="card-note">Newest journeys first; actions within each journey run from start
to finish. A journey without an end event indicates an interruption or abandonment.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
<tbody id="engagement-events"></tbody>
</table></div>
</section>
+81 -3
View File
@@ -1,11 +1,81 @@
const { fmt, ui, $ } = Admin;
const featureCatalogue = [
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
];
const label = (value) => {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
};
function renderUser(payload) {
const selected = $('engagement-user').value;
const users = payload.users || [];
const current = users.find((user) => user.userId === selected);
$('engagement-user-tiles').innerHTML = current ? ui.tiles([
['events', fmt.number(current.events), { icon: 'pulse', tone: 'info' }],
['journeys', fmt.number(current.journeys), { icon: 'list', tone: 'data' }],
['last active', fmt.when(current.lastActiveAt), { icon: 'clock', tone: 'note', small: true }],
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
]) : '';
['feature', 'path', 'journey'].forEach((name) => {
$('engagement-' + name + '-card').hidden = !current;
});
if (!current) return;
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
const features = [...new Set([...featureCatalogue, ...used.keys()])];
$('engagement-features').innerHTML = features.map((name) => {
const stat = used.get(name);
const uses = stat?.uses || 0;
const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok');
return '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
}).join('');
const paths = payload.paths || [];
$('engagement-paths').innerHTML = paths.length ? paths.map((path) =>
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
: ui.emptyRow(3, 'No repeated paths in this window.');
const grouped = new Map();
(payload.events || []).forEach((event) => {
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
grouped.get(event.journeyId).push(event);
});
let journeyNumber = grouped.size;
const rows = [];
grouped.forEach((events) => {
events.sort((a, b) => a.sequence - b.sequence);
const number = journeyNumber--;
events.forEach((event) => {
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
: label(event.target || event.screen);
const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—';
rows.push('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
});
});
$('engagement-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.');
}
Admin.onRefresh(async () => {
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value);
const selected = $('engagement-user').value;
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value +
(selected ? '&userId=' + encodeURIComponent(selected) : ''));
const rows = payload.rows || [];
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
'<tr><td>' + fmt.escape(row.rowId) + '</td>' +
'<td class="muted">' + fmt.escape(row.rowKind || '—') + '</td>' +
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
'<td class="muted">' + fmt.escape(label(row.rowKind)) + '</td>' +
'<td class="num">' + fmt.duration(row.dwellMs) + '</td>' +
'<td class="num">' + fmt.number(row.impressions) + '</td>' +
'<td class="num">' + fmt.number(row.focuses) + '</td>' +
@@ -13,6 +83,14 @@ Admin.onRefresh(async () => {
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
: ui.emptyRow(8, 'No events in this window.');
const users = payload.users || [];
const existing = $('engagement-user').value;
$('engagement-user').innerHTML = '<option value="">Choose a user</option>' + users.map((user) =>
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').join('');
if (users.some((user) => user.userId === existing)) $('engagement-user').value = existing;
renderUser(payload);
});
Admin.ready(() => $('engagement-days').addEventListener('change', Admin.refresh));
Admin.ready(() => $('engagement-user').addEventListener('change', Admin.refresh));
+104 -10
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -30,6 +31,108 @@ type analyticsRequest struct {
Events []rowEventPayload `json:"events"`
}
type journeyEventPayload struct {
UserID string `json:"userId"`
JourneyID string `json:"journeyId"`
Sequence int `json:"sequence"`
Category string `json:"category"`
Action string `json:"action"`
Screen string `json:"screen"`
Feature string `json:"feature"`
Source string `json:"source"`
Target string `json:"target"`
ItemID string `json:"itemId"`
ItemType string `json:"itemType"`
Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"`
}
type journeyAnalyticsRequest struct {
Events []journeyEventPayload `json:"events"`
}
var journeyCategories = allowedAnalyticsValues("session", "navigation", "content", "search", "playback", "settings", "recommendations", "library", "profile", "notifications")
var journeyActions = allowedAnalyticsValues(
"journey_start", "journey_end", "screen_view", "open", "close", "select",
"submit", "request", "start", "stop", "complete", "abandon", "change",
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
"mark_unplayed", "retry", "dismiss", "switch",
)
var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned")
func allowedAnalyticsValues(values ...string) map[string]bool {
out := make(map[string]bool, len(values))
for _, value := range values {
out[value] = true
}
return out
}
// handleJourneyAnalytics accepts privacy-bounded journey steps. The payload's user id is
// only a profile-switch guard: authority always comes from the bearer session.
func (s *Server) handleJourneyAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) {
var req journeyAnalyticsRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if len(req.Events) > maxAnalyticsBatch {
req.Events = req.Events[:maxAnalyticsBatch]
}
now := time.Now().UTC()
events := make([]store.JourneyEvent, 0, len(req.Events))
for _, payload := range req.Events {
if event, ok := toJourneyEvent(payload, sess.EmbyUserID, now); ok {
events = append(events, event)
}
}
if err := s.store.InsertJourneyEvents(r.Context(), events); err != nil {
s.loggerFor(r.Context()).Warn("journey analytics write failed", "error", err)
}
w.WriteHeader(http.StatusNoContent)
}
func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (store.JourneyEvent, bool) {
if payload.UserID != userID || !safeAnalyticsValue(payload.JourneyID, 80) ||
payload.Sequence < 0 || !journeyCategories[payload.Category] || !journeyActions[payload.Action] ||
!journeyOutcomes[payload.Outcome] {
return store.JourneyEvent{}, false
}
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemID, payload.ItemType}
for _, field := range fields {
if !safeAnalyticsValue(field, 100) {
return store.JourneyEvent{}, false
}
}
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
Target: payload.Target, ItemID: payload.ItemID, ItemType: payload.ItemType,
Outcome: payload.Outcome}, true
}
func safeAnalyticsValue(value string, max int) bool {
if len(value) > max {
return false
}
for _, char := range value {
if !(char == '-' || char == '_' || char == '.' || char == ':' ||
char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return false
}
}
return true
}
func analyticsOccurredAt(value string, now time.Time) time.Time {
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil &&
parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
return parsed.UTC()
}
return now
}
// handleRowAnalytics accepts a batch of row engagement events from a TV.
//
// Fire-and-forget by design: the client does not retry, and a rejected event is never
@@ -81,16 +184,7 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro
return store.RowEvent{}, false
}
occurredAt := now
if payload.OccurredAt != "" {
if parsed, err := time.Parse(time.RFC3339, payload.OccurredAt); err == nil {
// Trust the device's clock only within a sane window; TVs are notorious for
// waking up in 1970.
if parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
occurredAt = parsed.UTC()
}
}
}
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
dwell := payload.DwellMs
if dwell < 0 {
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"testing"
"time"
)
func TestJourneyEventUsesAuthenticatedUser(t *testing.T) {
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
payload := journeyEventPayload{UserID: "user-1", JourneyID: "journey-1", Sequence: 3,
Category: "navigation", Action: "open", Screen: "home", Feature: "search", Target: "search"}
event, ok := toJourneyEvent(payload, "user-1", now)
if !ok {
t.Fatal("valid event was rejected")
}
if event.UserID != "user-1" || event.Sequence != 3 {
t.Fatalf("unexpected event: %+v", event)
}
if _, ok := toJourneyEvent(payload, "user-2", now); ok {
t.Fatal("an event buffered under another profile was accepted")
}
}
func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) {
now := time.Now().UTC()
base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content", Action: "open"}
withTitle := base
withTitle.Feature = "A Film Title"
if _, ok := toJourneyEvent(withTitle, "u1", now); ok {
t.Fatal("free text feature was accepted")
}
unknown := base
unknown.Action = "typed_query"
if _, ok := toJourneyEvent(unknown, "u1", now); ok {
t.Fatal("unknown action was accepted")
}
}
+39 -3
View File
@@ -126,7 +126,7 @@ func (s *Server) Routes() http.Handler {
// once, without the gate ever touching health checks or the admin page.
v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
v1.HandleFunc("POST /v1/auth/login", s.requireSupportedClient(s.handleLogin))
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
v1.Handle("GET /v1/auth/devices", s.authed(s.handleDevices))
@@ -196,6 +196,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
v1.Handle("POST /v1/analytics/events", s.authed(s.handleJourneyAnalytics))
v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage))
@@ -204,8 +205,8 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /readyz", s.handleReady)
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
// whether the server requires an update without touching a viewer session.
mux.HandleFunc("GET /v1/update", s.handleUpdate)
// whether the server requires an update. A valid session enriches only its log context.
mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate)))
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
@@ -230,6 +231,21 @@ func (s *Server) Routes() http.Handler {
type authedFunc func(http.ResponseWriter, *http.Request, store.Session)
// identifyOptionalSession gives public routes the viewer and television attached to a
// valid bearer token without turning authentication into a condition of access. The
// update check must remain reachable before sign-in, but an offer made to a signed-in
// client should still say whose session is affected in the logs.
func (s *Server) identifyOptionalSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token := bearerToken(r); token != "" {
if sess, err := s.sessionFor(r.Context(), token); err == nil {
identify(r.Context(), sess)
}
}
next.ServeHTTP(w, r)
})
}
// authed resolves the bearer token to a session before running h.
//
// Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the
@@ -253,6 +269,26 @@ func (s *Server) authed(h authedFunc) http.Handler {
}
sess = s.captureClientIdentity(r, sess)
identify(r.Context(), sess)
decision := s.updateDecision(r)
if mustRetireForUpdate(decision, clientVersion(r)) {
// Mirror an ordinary sign-out closely enough that this token cannot be restored
// from either database or Redis. The 401 is intentional: every supported client
// treats it as authoritative and removes the rejected local profile.
if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil {
s.loggerFor(r.Context()).Error("required-update session delete failed", "error", err)
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.loggerFor(r.Context()).Info("signed out for required update",
"device_id", sess.DeviceID,
"from", clientLogValue(clientVersion(r)),
"minimum", forcedUpdateFloor,
"to", decision.Version,
)
w.Header().Set("X-Memby-Update-Required", decision.Version)
writeError(w, http.StatusUnauthorized, "Memby must be updated before signing in again")
return
}
h(w, r, sess)
})
}
+50 -3
View File
@@ -17,6 +17,12 @@ import (
// speaks, next to the build's own version.
const ProtocolVersion = 1
// forcedUpdateFloor retires builds whose update behaviour is no longer reliable enough
// to leave optional. The floor only takes effect once an enabled policy points at this
// version (or a newer one) and carries a download URL, so deploying the gateway before
// publishing the APK cannot lock televisions out.
const forcedUpdateFloor = "0.2.44"
// updatePolicyCache keeps the policy in memory. It is read on every home request, and a
// database round trip per home load to answer "nothing to say" would be wasteful.
type updatePolicyCache struct {
@@ -90,13 +96,54 @@ func compatibilityFor(r *http.Request) (bool, string) {
return true, ""
}
// effectiveUpdatePolicy applies the server-owned emergency floor without weakening a
// higher minimum the operator has already selected.
func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" ||
appupdate.CompareVersions(policy.LatestVersion, forcedUpdateFloor) < 0 {
return policy
}
if strings.TrimSpace(policy.MinimumVersion) == "" ||
appupdate.CompareVersions(policy.MinimumVersion, forcedUpdateFloor) < 0 {
policy.MinimumVersion = forcedUpdateFloor
}
return policy
}
func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
return appupdate.Decide(effectiveUpdatePolicy(s.updatePolicy.get()), clientVersion(r))
}
// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a
// newer release without wanting every otherwise supported session destroyed. Only builds
// below the permanent compatibility floor are signed out.
func mustRetireForUpdate(decision appupdate.Decision, version string) bool {
return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" &&
appupdate.CompareVersions(version, forcedUpdateFloor) < 0
}
// requireSupportedClient prevents a retired build from signing straight back in after
// the authenticated gate has removed its old session. Its public update check remains
// available and will keep returning the actionable mandatory verdict.
func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
decision := s.updateDecision(r)
if mustRetireForUpdate(decision, clientVersion(r)) {
w.Header().Set("X-Memby-Update-Required", decision.Version)
writeJSON(w, http.StatusUpgradeRequired, decision)
return
}
next(w, r)
}
}
// handleUpdate answers the client's version check.
//
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
// app build, not a viewer or login. The only client input is its build-version header and
// the answer comes from memory, so checking it never reads or mutates a user session.
// app build, not a viewer or login. The verdict comes from memory; when a bearer token is
// present the route resolves it only to attribute an offered update to the affected viewer.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
decision := appupdate.Decide(s.updatePolicy.get(), clientVersion(r))
decision := s.updateDecision(r)
// Only a verdict that asks a television to do something is worth a line. Every TV
// checks on every launch, and "nothing to say" logged each time would bury the
// launch where an update was actually offered — or forced.
+105
View File
@@ -2,12 +2,15 @@ package api
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/config"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -38,3 +41,105 @@ func TestUpdateStatusIsPublicAndAvailableDuringMaintenance(t *testing.T) {
t.Fatalf("status = %q, want mandatory", decision.Status)
}
}
func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) {
logger, events := serverlogging.NewBuffered(io.Discard, slog.LevelInfo, 10, serverlogging.FormatConsole)
server := New(config.Config{}, Deps{Log: logger, Events: events})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk",
})
req := httptest.NewRequest(http.MethodGet, "/v1/update", nil)
req.Header.Set("X-Memby-Version", "0.2.38")
req, _ = withRequestIdentity(req)
identify(req.Context(), store.Session{Username: "matt", DeviceName: "Living room"})
server.handleUpdate(httptest.NewRecorder(), req)
page := events.Events(0, 10)
if len(page.Events) != 1 {
t.Fatalf("events = %d, want 1", len(page.Events))
}
event := page.Events[0]
if event.Message != "update offered" || event.Attributes["user"] != "matt" {
t.Fatalf("update event was not attributed to the viewer: %+v", event)
}
}
func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) {
server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
})
for version, want := range map[string]string{
"0.2.43": appupdate.StatusMandatory,
"0.2.44": appupdate.StatusNone,
"0.2.45": appupdate.StatusNone,
} {
req := httptest.NewRequest(http.MethodGet, "/v1/update", nil)
req.Header.Set("X-Memby-Version", version)
rec := httptest.NewRecorder()
server.handleUpdate(rec, req)
var decision appupdate.Decision
if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil {
t.Fatalf("%s: decode decision: %v", version, err)
}
if decision.Status != want {
t.Errorf("%s: status = %q, want %q", version, decision.Status, want)
}
}
}
func TestEmergencyFloorWaitsForAnActionableRelease(t *testing.T) {
for name, policy := range map[string]appupdate.Policy{
"disabled": {
LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk",
},
"missing download": {
Enabled: true, LatestVersion: "0.2.44",
},
"release too old": {
Enabled: true, LatestVersion: "0.2.43", DownloadURL: "/updates/memby-0.2.43.apk",
},
} {
t.Run(name, func(t *testing.T) {
decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43")
if mustRetireForUpdate(decision, "0.2.43") {
t.Fatal("client would be retired without an actionable 0.2.44-or-newer release")
}
})
}
}
func TestRetiredClientCannotSignBackIn(t *testing.T) {
server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
})
reached := false
handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) {
reached = true
})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
req.Header.Set("X-Memby-Version", "0.2.43")
rec := httptest.NewRecorder()
handler(rec, req)
if reached {
t.Fatal("retired client reached the login handler")
}
if rec.Code != http.StatusUpgradeRequired {
t.Fatalf("status = %d, want 426", rec.Code)
}
if rec.Header().Get("X-Memby-Update-Required") != "0.2.44" {
t.Fatalf("required update header = %q", rec.Header().Get("X-Memby-Update-Required"))
}
}
+5 -1
View File
@@ -75,7 +75,8 @@ type Config struct {
SyncUserID string
SyncAPIKey string
// AnalyticsRetention is how long raw row events are kept before being pruned.
// AnalyticsRetention is how long raw row and journey events are kept before pruning.
// Load enforces a 30-day floor so the per-user history promise cannot be configured away.
AnalyticsRetention time.Duration
// Sonarr is optional. When configured, its local calendar supplies the informational
@@ -192,6 +193,9 @@ func Load() (Config, error) {
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
}
if c.AnalyticsRetention < 30*24*time.Hour {
c.AnalyticsRetention = 30 * 24 * time.Hour
}
if c.EmbyURL == "" {
return c, fmt.Errorf("MEMBY_EMBY_URL is required")
+14
View File
@@ -63,3 +63,17 @@ func TestRecommendationWeightsMustBeJSON(t *testing.T) {
t.Fatal("expected invalid recommendation weights to fail")
}
}
func TestAnalyticsRetentionCannotDropBelowThirtyDays(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_ANALYTICS_RETENTION", "168h")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.AnalyticsRetention != 30*24*time.Hour {
t.Fatalf("analytics retention = %v, want 30 days", cfg.AnalyticsRetention)
}
}
+177 -4
View File
@@ -58,6 +58,45 @@ type RowEvent struct {
DwellMs int
}
// JourneyEvent is one significant step through the app. All descriptive fields are
// controlled vocabulary; ItemID is the only content identity retained.
type JourneyEvent struct {
ID int64 `json:"id"`
OccurredAt time.Time `json:"occurredAt"`
UserID string `json:"userId"`
JourneyID string `json:"journeyId"`
Sequence int `json:"sequence"`
Category string `json:"category"`
Action string `json:"action"`
Screen string `json:"screen"`
Feature string `json:"feature"`
Source string `json:"source"`
Target string `json:"target"`
ItemID string `json:"itemId,omitempty"`
ItemType string `json:"itemType,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
type AnalyticsUser struct {
UserID string `json:"userId"`
Username string `json:"username"`
Events int64 `json:"events"`
Journeys int64 `json:"journeys"`
LastActiveAt time.Time `json:"lastActiveAt"`
}
type FeatureStat struct {
Feature string `json:"feature"`
Uses int64 `json:"uses"`
LastUsedAt time.Time `json:"lastUsedAt"`
}
type PathStat struct {
From string `json:"from"`
To string `json:"to"`
Count int64 `json:"count"`
}
// Event kinds. Impressions say a row was drawn; focus says the remote actually landed
// on it and for how long; select says something was opened from it.
const (
@@ -141,6 +180,137 @@ func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
return nil
}
func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent) error {
if len(events) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, event := range events {
batch.Queue(`
INSERT INTO journey_events
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
feature, source, target, item_id, item_type, outcome)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
event.Category, event.Action, event.Screen, event.Feature, event.Source,
event.Target, event.ItemID, event.ItemType, event.Outcome)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
for range events {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("store: insert journey events: %w", err)
}
}
return nil
}
func (s *Store) AnalyticsUsers(ctx context.Context, since time.Time) ([]AnalyticsUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT je.emby_user_id,
coalesce((array_agg(s.username ORDER BY s.last_seen_at DESC)
FILTER (WHERE s.username IS NOT NULL))[1], ''),
count(DISTINCT je.id), count(DISTINCT je.journey_id), max(je.occurred_at)
FROM journey_events je
LEFT JOIN sessions s ON s.emby_user_id = je.emby_user_id
WHERE je.occurred_at >= $1
GROUP BY je.emby_user_id ORDER BY max(je.occurred_at) DESC`, since)
if err != nil {
return nil, fmt.Errorf("store: analytics users: %w", err)
}
defer rows.Close()
out := []AnalyticsUser{}
for rows.Next() {
var value AnalyticsUser
if err := rows.Scan(&value.UserID, &value.Username, &value.Events, &value.Journeys, &value.LastActiveAt); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
func (s *Store) UserFeatureStats(ctx context.Context, userID string, since time.Time) ([]FeatureStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT feature, count(*), max(occurred_at) FROM journey_events
WHERE emby_user_id=$1 AND occurred_at >= $2 AND feature <> ''
AND action NOT IN ('screen_view', 'journey_start', 'journey_end')
GROUP BY feature ORDER BY count(*) DESC, feature`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user feature stats: %w", err)
}
defer rows.Close()
out := []FeatureStat{}
for rows.Next() {
var v FeatureStat
if err := rows.Scan(&v.Feature, &v.Uses, &v.LastUsedAt); err != nil {
return nil, err
}
out = append(out, v)
}
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 (
SELECT id, journey_id, sequence, action, occurred_at,
coalesce(nullif(target,''), nullif(screen,''), feature) AS node,
lag(coalesce(nullif(target,''), nullif(screen,''), feature)) OVER
(PARTITION BY journey_id ORDER BY sequence, occurred_at, id) AS previous
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
), path_steps AS (
SELECT previous AS from_node, node AS to_node FROM ordered
WHERE previous IS NOT NULL AND node IS NOT NULL AND previous <> node
), last_steps AS (
SELECT DISTINCT ON (journey_id) journey_id, node, action, occurred_at
FROM ordered ORDER BY journey_id, sequence DESC, occurred_at DESC, id DESC
), all_steps AS (
SELECT from_node, to_node FROM path_steps
UNION ALL
SELECT node, 'abandoned' FROM last_steps
WHERE action <> 'journey_end' AND node <> ''
AND occurred_at < now() - interval '30 minutes'
)
SELECT from_node, to_node, count(*) FROM all_steps
GROUP BY from_node, to_node ORDER BY count(*) DESC, from_node, to_node LIMIT 20`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user paths: %w", err)
}
defer rows.Close()
out := []PathStat{}
for rows.Next() {
var v PathStat
if err := rows.Scan(&v.From, &v.To, &v.Count); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
screen, feature, source, target, item_id, item_type, outcome
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
if err != nil {
return nil, fmt.Errorf("store: user journey events: %w", err)
}
defer rows.Close()
out := []JourneyEvent{}
for rows.Next() {
var v JourneyEvent
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemType, &v.Outcome); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// RowStats aggregates engagement since a point in time, busiest row first.
//
// Dwell is the interesting number: impressions only say a row was on screen, whereas
@@ -182,11 +352,14 @@ func (s *Store) RowStats(ctx context.Context, since time.Time) ([]RowStat, error
// at read time, so nothing is preserved once the events go — which is the point: this is
// engagement telemetry for tuning rows, not a permanent record of what people watched.
func (s *Store) PruneRowEvents(ctx context.Context, olderThan time.Duration) (int64, error) {
tag, err := s.pool.Exec(ctx,
`DELETE FROM row_events WHERE occurred_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(olderThan.Seconds())))
interval := fmt.Sprintf("%d seconds", int64(olderThan.Seconds()))
rows, err := s.pool.Exec(ctx, `DELETE FROM row_events WHERE occurred_at < now() - $1::interval`, interval)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
journeys, err := s.pool.Exec(ctx, `DELETE FROM journey_events WHERE occurred_at < now() - $1::interval`, interval)
if err != nil {
return rows.RowsAffected(), err
}
return rows.RowsAffected() + journeys.RowsAffected(), nil
}
+28
View File
@@ -155,6 +155,34 @@ CREATE TABLE IF NOT EXISTS row_events (
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
-- Significant, user-scoped app journeys. Values are deliberately categorical: content
-- names, search terms, setting values and other free text do not belong in this table.
-- journey_id is generated by the client for one foreground visit; emby_user_id is always
-- taken from the authenticated gateway session rather than trusted from the payload.
CREATE TABLE IF NOT EXISTS journey_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL,
journey_id TEXT NOT NULL,
sequence INT NOT NULL DEFAULT 0,
category TEXT NOT NULL,
action TEXT NOT NULL,
screen TEXT NOT NULL DEFAULT '',
feature TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL DEFAULT '',
item_type TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
ON journey_events (emby_user_id, journey_id, sequence);
CREATE INDEX IF NOT EXISTS journey_events_user_time_idx
ON journey_events (emby_user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS journey_events_feature_time_idx
ON journey_events (feature, occurred_at DESC);
-- Search terms are retained separately from row engagement so they can inform future
-- ranking/recommendation work without coupling that analysis to rendered rows.
CREATE TABLE IF NOT EXISTS search_history (