0.2.45 - Advanced analytics, logout old versions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user