0.2.45 - Server side git commits

This commit is contained in:
ponzischeme89
2026-08-10 20:39:26 +12:00
parent 56c1167382
commit 4c47a5f8a0
12 changed files with 321 additions and 132 deletions
+6
View File
@@ -1,3 +1,9 @@
## 0.2.45 - 2026-08-10
- Bug fixes
## Server 0.1.31 — 2026-08-10
- Added: The admin console now has a Journeys page for visit health, feature use, significant actions, common paths and per-profile event history.
## 0.2.43 — 2026-08-10
- Fixed: App no longer crashes.
- Fixed: Manual surround-sound choices now reliably override the automatically detected audio output.
+10 -4
View File
@@ -319,8 +319,8 @@ leaves the server. `MEMBY_RADARR_TTL` controls the shared calendar cache lifetim
## Admin interface
`https://mserver.sublogue.com/admin/` — a single self-contained page for library imports, the
maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every
`https://mserver.sublogue.com/admin/` — a self-contained console for library imports,
maintenance, engagement and journeys. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every
`/admin` route 404s so it cannot be left exposed by accident. The page first uses the same
discreet Emby login gate as the private installer. After successful verification it
establishes the HttpOnly admin cookie, but browser API requests require both that cookie
@@ -349,6 +349,7 @@ the sign-in form with `next` pointing back at the page rather than as an error b
| POST | `/admin/api/features` | Publish feature overrides, enter safe mode, reset defaults, or roll back one revision |
| POST | `/admin/api/mdblist-settings` | Enable MDBList, replace/clear its API key, and select visible rating sources |
| GET | `/admin/api/analytics?days=7` | Row engagement |
| GET | `/admin/api/journeys?days=30&userId=…` | Journey health, feature use, paths and event history |
The **Features** admin page is the recovery surface for optional TV behaviour. Flags are
registered in the server catalogue and persist only explicit overrides; clearing an
@@ -567,6 +568,13 @@ Raw events are pruned after `MEMBY_ANALYTICS_RETENTION` (90 days) and aggregates
computed at read time, so nothing survives the prune. This is tuning telemetry, not a
record of what anyone watched.
Journey events are ingested separately at `POST /v1/analytics/events` and have their own
**Journeys** admin page. The gateway derives completion, abandonment, active visits,
feature use, significant actions and common paths from the retained events. An unfinished
visit remains active for 30 minutes before it counts as abandoned. Selecting one profile
reveals its ordered event history; search text, content titles and setting values are never
accepted into this stream.
## Caching
Redis holds everything user-scoped under `u:<embyUserId>:*`, plus session lookups under
@@ -648,5 +656,3 @@ can review and revoke signed-in TVs from the app's Settings screen.
- **Cache warming.** Rows go cold after `MEMBY_HOME_TTL`; the first TV to ask pays for the
refresh. A background refresher per active session would hide that.
- **Rate limiting** on `/v1/auth/login`.
- **Per-user analytics breakdown.** Events carry a user id, but the admin page only shows
totals per row.
+42 -3
View File
@@ -26,8 +26,8 @@ const adminCookieName = "memby_admin"
// a valid session, so the only thing it can extend is its own sign-in.
const adminActivityHeader = "X-Memby-Admin-Active"
// adminRoutes is the operator interface: library imports, the maintenance switch, and
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// adminRoutes is the operator interface: library imports, maintenance and analytics.
// Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// left exposed by accident.
func (s *Server) adminRoutes() http.Handler {
mux := http.NewServeMux()
@@ -57,6 +57,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes))
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("GET /admin/api/journeys", s.adminAuth(s.handleAdminJourneys))
mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
@@ -720,6 +721,8 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "could not read analytics")
return
}
// Keep the journey fields on this older endpoint for scripts written before journeys
// gained their own page and endpoint. The console itself only reads rows from here.
users, err := s.store.AnalyticsUsers(r.Context(), since)
if err != nil {
s.loggerFor(r.Context()).Error("user analytics failed", "error", err)
@@ -733,7 +736,7 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
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)
s.loggerFor(r.Context()).Error("legacy user journey read failed", "user_id", userID)
writeError(w, http.StatusInternalServerError, "could not read user journey")
return
}
@@ -745,6 +748,42 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, payload)
}
func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) {
days := queryInt(r, "days", 30, 90)
since := time.Now().UTC().AddDate(0, 0, -days)
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
users, err := s.store.AnalyticsUsers(r.Context(), since)
if err != nil {
s.loggerFor(r.Context()).Error("journey users failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read journeys")
return
}
stats, statsErr := s.store.JourneyStats(r.Context(), userID, since)
features, featureErr := s.store.UserFeatureStats(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)
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,
}
if userID != "" {
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
if 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["events"] = events
}
writeJSON(w, http.StatusOK, payload)
}
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
// concrete type for testing.
type syncerHandle interface {
@@ -25,53 +25,3 @@
</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 -71
View File
@@ -1,11 +1,5 @@
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';
@@ -13,65 +7,8 @@ const label = (value) => {
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 selected = $('engagement-user').value;
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value +
(selected ? '&userId=' + encodeURIComponent(selected) : ''));
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value);
const rows = payload.rows || [];
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
@@ -84,13 +21,6 @@ Admin.onRefresh(async () => {
'<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));
@@ -0,0 +1,66 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="people" data-icon-tone="info">Journey health</h2>
<p class="card-note">Server-derived foreground visits, completion and interruption.
Search text, content titles and setting values are never stored.</p>
</div>
<div class="row bottom">
<label class="field narrow"><span>Window</span>
<select id="journeys-days">
<option value="1">24 hours</option><option value="7">7 days</option>
<option value="30" selected>30 days</option><option value="90">90 days</option>
</select></label>
<label class="field narrow"><span>User</span>
<select id="journeys-user"><option value="">All users</option></select></label>
</div>
</div>
<div class="tiles" id="journeys-tiles"></div>
</section>
<section class="card">
<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 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="journeys-features"></tbody>
</table></div>
</section>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="chart" data-icon-tone="info">Significant actions</h2>
<p class="card-note">Actions are counted by event and by distinct journey, so repeated use
inside one visit remains visible without looking like more visits.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Category</th><th>Action</th><th class="num">Events</th><th class="num">Journeys</th></tr></thead>
<tbody id="journeys-actions"></tbody>
</table></div>
</section>
<section class="card">
<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 viewers
commonly leave a flow. A quiet unfinished journey becomes abandoned after 30 minutes.</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="journeys-paths"></tbody>
</table></div>
</section>
<section class="card" id="journeys-events-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journeys</h2>
<p class="card-note">Choose one user to inspect visits. Newest visits appear first;
actions within each visit run from start to finish.</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="journeys-events"></tbody>
</table></div>
</section>
@@ -0,0 +1,89 @@
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 renderEvents(events) {
const grouped = new Map();
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((steps) => {
steps.sort((a, b) => a.sequence - b.sequence);
const number = journeyNumber--;
steps.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>');
});
});
$('journeys-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.');
}
Admin.onRefresh(async () => {
const selected = $('journeys-user').value;
const payload = await Admin.api('/admin/api/journeys?days=' + $('journeys-days').value +
(selected ? '&userId=' + encodeURIComponent(selected) : ''));
const users = payload.users || [];
const existing = $('journeys-user').value;
$('journeys-user').innerHTML = '<option value="">All users</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)) $('journeys-user').value = existing;
const stats = payload.stats || {};
$('journeys-tiles').innerHTML = ui.tiles([
['journeys', fmt.number(stats.journeys || 0), { icon: 'list', tone: 'data' }],
['viewers', fmt.number(stats.viewers || 0), { icon: 'people', tone: 'info' }],
['completion', Math.round((stats.completionRate || 0) * 100) + '%', { icon: 'check', tone: 'ok' }],
['abandoned', fmt.number(stats.abandoned || 0), { icon: 'alert', tone: 'note' }],
['active now', fmt.number(stats.active || 0), { icon: 'pulse', tone: 'info' }],
['average steps', Number(stats.averageSteps || 0).toFixed(1), { icon: 'chart' }],
['average visit', fmt.duration(stats.averageTimeMs || 0), { icon: 'clock', small: true }],
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
]);
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
const features = [...new Set([...featureCatalogue, ...used.keys()])];
$('journeys-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 actions = payload.actions || [];
$('journeys-actions').innerHTML = actions.length ? actions.map((action) =>
'<tr><td>' + fmt.escape(label(action.category)) + '</td><td>' + fmt.escape(label(action.action)) +
'</td><td class="num">' + fmt.number(action.events) + '</td><td class="num">' +
fmt.number(action.journeys) + '</td></tr>').join('') : ui.emptyRow(4, 'No significant actions in this window.');
const paths = payload.paths || [];
$('journeys-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.');
$('journeys-events-card').hidden = !selected;
if (selected) renderEvents(payload.events || []);
});
Admin.ready(() => $('journeys-days').addEventListener('change', Admin.refresh));
Admin.ready(() => $('journeys-user').addEventListener('change', Admin.refresh));
+5
View File
@@ -137,6 +137,11 @@ var adminNav = []adminNavGroup{
{
Label: "Operations",
Items: []adminNavItem{
{
ID: "journeys", Label: "Journeys", Title: "User journeys",
Intro: "How viewers move through Memby, use features and complete flows.",
Icon: "M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3",
},
{
ID: "maintenance", Label: "Maintenance", Title: "Maintenance",
Intro: "Take Memby offline for every television.",
+22
View File
@@ -166,6 +166,28 @@ func adminPreviewData() map[string]any {
map[string]any{"rowId": "favorites", "title": "Favourites", "impressions": 1610,
"focuses": 300, "selections": 74, "averageDwellMs": 1800},
}},
"/admin/api/journeys": map[string]any{
"days": 30, "retentionDays": 90,
"stats": map[string]any{"events": 184, "journeys": 28, "viewers": 2,
"completed": 21, "abandoned": 5, "active": 2, "averageSteps": 6.6,
"averageTimeMs": 1140000, "completionRate": 0.81},
"users": []any{
map[string]any{"userId": "u-1", "username": "matt", "events": 120, "journeys": 18, "lastActiveAt": stamp(9 * time.Minute)},
map[string]any{"userId": "u-2", "username": "sam", "events": 64, "journeys": 10, "lastActiveAt": stamp(3 * time.Hour)},
},
"features": []any{
map[string]any{"feature": "playback", "uses": 31, "lastUsedAt": stamp(9 * time.Minute)},
map[string]any{"feature": "search", "uses": 12, "lastUsedAt": stamp(3 * time.Hour)},
},
"actions": []any{
map[string]any{"category": "content", "action": "open", "events": 44, "journeys": 22},
map[string]any{"category": "playback", "action": "start", "events": 24, "journeys": 18},
},
"paths": []any{
map[string]any{"from": "home", "to": "details", "count": 31},
map[string]any{"from": "details", "to": "playback", "count": 18},
},
},
"/admin/api/requests": map[string]any{"requests": []any{}},
// A prefix among the terms and an unattributed row in the log, because both are
// ordinary here and a preview showing neither would not be a preview of this page.
+1 -1
View File
@@ -576,7 +576,7 @@ func TestAdminPagesUseRealRoutes(t *testing.T) {
for _, page := range []string{
"overview", "accounts", "clients", "library", "recommendations", "inspector",
"requests", "ratings", "features", "playback", "maintenance", "updates",
"engagement", "imports", "logs",
"journeys", "engagement", "imports", "logs",
} {
req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil)
addInstallerSession(t, server, req)
+1 -1
View File
@@ -1 +1 @@
0.1.30
0.1.31
+78 -2
View File
@@ -97,6 +97,28 @@ type PathStat struct {
Count int64 `json:"count"`
}
// JourneyStats is the server-derived health of foreground visits in a reporting window.
// An unfinished visit is only abandoned once it has been quiet for thirty minutes; until
// then it is active, so an open television does not immediately look like a failed flow.
type JourneyStats struct {
Events int64 `json:"events"`
Journeys int64 `json:"journeys"`
Viewers int64 `json:"viewers"`
Completed int64 `json:"completed"`
Abandoned int64 `json:"abandoned"`
Active int64 `json:"active"`
AverageSteps float64 `json:"averageSteps"`
AverageTimeMs int64 `json:"averageTimeMs"`
CompletionRate float64 `json:"completionRate"`
}
type JourneyActionStat struct {
Category string `json:"category"`
Action string `json:"action"`
Events int64 `json:"events"`
Journeys int64 `json:"journeys"`
}
// 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 (
@@ -231,10 +253,64 @@ func (s *Store) AnalyticsUsers(ctx context.Context, since time.Time) ([]Analytic
return out, rows.Err()
}
func (s *Store) JourneyStats(ctx context.Context, userID string, since time.Time) (JourneyStats, error) {
var value JourneyStats
err := s.pool.QueryRow(ctx, `
WITH visits AS (
SELECT emby_user_id, journey_id, count(*) AS steps,
min(occurred_at) AS started_at, max(occurred_at) AS last_at,
bool_or(action = 'journey_end') AS completed
FROM journey_events
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
GROUP BY emby_user_id, journey_id
)
SELECT coalesce(sum(steps), 0), count(*), count(DISTINCT emby_user_id),
count(*) FILTER (WHERE completed),
count(*) FILTER (WHERE NOT completed AND last_at < now() - interval '30 minutes'),
count(*) FILTER (WHERE NOT completed AND last_at >= now() - interval '30 minutes'),
coalesce(avg(steps), 0)::double precision,
round(coalesce(avg(extract(epoch FROM (last_at - started_at)) * 1000)
FILTER (WHERE completed), 0))::bigint
FROM visits`, since, userID).Scan(
&value.Events, &value.Journeys, &value.Viewers, &value.Completed,
&value.Abandoned, &value.Active, &value.AverageSteps, &value.AverageTimeMs,
)
if err != nil {
return JourneyStats{}, fmt.Errorf("store: journey stats: %w", err)
}
finished := value.Completed + value.Abandoned
if finished > 0 {
value.CompletionRate = float64(value.Completed) / float64(finished)
}
return value, nil
}
func (s *Store) JourneyActionStats(ctx context.Context, userID string, since time.Time) ([]JourneyActionStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT category, action, count(*), count(DISTINCT journey_id)
FROM journey_events
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
AND action NOT IN ('journey_start', 'journey_end')
GROUP BY category, action ORDER BY count(*) DESC, category, action`, since, userID)
if err != nil {
return nil, fmt.Errorf("store: journey action stats: %w", err)
}
defer rows.Close()
out := []JourneyActionStat{}
for rows.Next() {
var value JourneyActionStat
if err := rows.Scan(&value.Category, &value.Action, &value.Events, &value.Journeys); 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 <> ''
WHERE ($1 = '' OR 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 {
@@ -259,7 +335,7 @@ func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) (
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
FROM journey_events WHERE ($1 = '' OR 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