2026-07-27 08:16:20 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-02 22:10:19 +12:00
|
|
|
"context"
|
2026-07-27 08:16:20 +12:00
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
2026-08-10 20:24:22 +12:00
|
|
|
"strings"
|
2026-07-27 08:16:20 +12:00
|
|
|
"time"
|
2026-08-12 08:25:15 +12:00
|
|
|
"unicode"
|
|
|
|
|
"unicode/utf8"
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// maxAnalyticsBatch caps one upload. The TV batches events and flushes periodically, so
|
|
|
|
|
// a larger payload than this means something has gone wrong client-side.
|
|
|
|
|
const maxAnalyticsBatch = 200
|
|
|
|
|
|
|
|
|
|
// maxDwellMs discards implausible dwell times — a TV left on a row overnight says
|
|
|
|
|
// nothing about what anyone was looking at.
|
|
|
|
|
const maxDwellMs = 30 * 60 * 1000
|
|
|
|
|
|
2026-08-26 21:31:05 +12:00
|
|
|
// maxPositionMs clamps a pause/resume step's reported position. A title that long does
|
|
|
|
|
// not exist, so a wilder value says the field was misread rather than that playback was
|
|
|
|
|
// genuinely there.
|
|
|
|
|
const maxPositionMs = 24 * 60 * 60 * 1000
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
type rowEventPayload struct {
|
|
|
|
|
RowID string `json:"rowId"`
|
|
|
|
|
RowKind string `json:"rowKind"`
|
|
|
|
|
Event string `json:"event"`
|
|
|
|
|
ItemID string `json:"itemId"`
|
|
|
|
|
DwellMs int `json:"dwellMs"`
|
|
|
|
|
OccurredAt string `json:"occurredAt"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type analyticsRequest struct {
|
|
|
|
|
Events []rowEventPayload `json:"events"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 20:24:22 +12:00
|
|
|
type journeyEventPayload struct {
|
2026-08-19 06:57:59 +12:00
|
|
|
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"`
|
|
|
|
|
ItemName string `json:"itemName"`
|
|
|
|
|
ItemType string `json:"itemType"`
|
2026-08-18 08:41:48 +12:00
|
|
|
// The Emby play session a playback step belongs to. Validated like every other
|
|
|
|
|
// controlled field: it is Emby's string rather than ours, and an event carrying one this
|
|
|
|
|
// cannot read is dropped whole, so the television sanitises it before sending.
|
|
|
|
|
PlaySessionID string `json:"playSessionId"`
|
|
|
|
|
Outcome string `json:"outcome"`
|
|
|
|
|
OccurredAt string `json:"occurredAt"`
|
2026-08-26 21:31:05 +12:00
|
|
|
// PositionMs is where playback was, in the title, on a pause or resume step. Absent
|
|
|
|
|
// (zero) on every other kind of step.
|
|
|
|
|
PositionMs int64 `json:"positionMs"`
|
2026-08-10 20:24:22 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type journeyAnalyticsRequest struct {
|
|
|
|
|
Events []journeyEventPayload `json:"events"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var journeyCategories = allowedAnalyticsValues("session", "navigation", "content", "search", "playback", "settings", "recommendations", "library", "profile", "notifications")
|
|
|
|
|
var journeyActions = allowedAnalyticsValues(
|
2026-08-14 09:40:03 +12:00
|
|
|
"journey_start", "home_open", "journey_end", "screen_view", "open", "close", "select",
|
2026-08-10 20:24:22 +12:00
|
|
|
"submit", "request", "start", "stop", "complete", "abandon", "change",
|
|
|
|
|
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
|
2026-08-26 21:31:05 +12:00
|
|
|
"mark_unplayed", "retry", "dismiss", "switch", "pause", "resume",
|
2026-08-10 20:24:22 +12:00
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-08-18 08:41:48 +12:00
|
|
|
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target,
|
|
|
|
|
payload.ItemID, payload.ItemType, payload.PlaySessionID}
|
2026-08-10 20:24:22 +12:00
|
|
|
for _, field := range fields {
|
|
|
|
|
if !safeAnalyticsValue(field, 100) {
|
|
|
|
|
return store.JourneyEvent{}, false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-12 08:25:15 +12:00
|
|
|
if utf8.RuneCountInString(payload.ItemName) > 160 ||
|
|
|
|
|
strings.IndexFunc(payload.ItemName, unicode.IsControl) >= 0 {
|
|
|
|
|
return store.JourneyEvent{}, false
|
|
|
|
|
}
|
2026-08-10 20:24:22 +12:00
|
|
|
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,
|
2026-08-18 08:41:48 +12:00
|
|
|
Target: payload.Target, ItemID: payload.ItemID,
|
|
|
|
|
ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
|
2026-08-26 21:31:05 +12:00
|
|
|
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome,
|
|
|
|
|
PositionMs: clampPositionMs(payload.PositionMs)}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clampPositionMs(value int64) int64 {
|
|
|
|
|
if value < 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
if value > maxPositionMs {
|
|
|
|
|
return maxPositionMs
|
|
|
|
|
}
|
|
|
|
|
return value
|
2026-08-10 20:24:22 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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
|
|
|
|
|
// worth surfacing on screen. Bad events are dropped individually rather than failing the
|
|
|
|
|
// batch.
|
|
|
|
|
func (s *Server) handleRowAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
|
|
|
var req analyticsRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<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.RowEvent, 0, len(req.Events))
|
|
|
|
|
for _, payload := range req.Events {
|
|
|
|
|
event, ok := toRowEvent(payload, sess.EmbyUserID, now)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
events = append(events, event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.store.InsertRowEvents(r.Context(), events); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Warn("row analytics write failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
// Still a 204: telemetry must never make the TV think something is broken.
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
for _, event := range events {
|
|
|
|
|
if event.Event == store.RowEventSelect {
|
2026-08-20 15:06:00 +12:00
|
|
|
_ = s.cache.InvalidateUser(r.Context(), viewerKeyOf(r.Context(), sess))
|
2026-08-02 22:10:19 +12:00
|
|
|
if s.forYou != nil {
|
|
|
|
|
s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.RowEvent, bool) {
|
|
|
|
|
if payload.RowID == "" {
|
|
|
|
|
return store.RowEvent{}, false
|
|
|
|
|
}
|
2026-08-28 23:00:02 +12:00
|
|
|
// Row id, kind and item id are controlled vocabulary the television composes, never
|
|
|
|
|
// free text — so an event carrying anything else in them was misassembled, and a
|
|
|
|
|
// pathological value would distort the aggregates the console reads. Drop it whole,
|
|
|
|
|
// as the journey path does.
|
|
|
|
|
if !safeAnalyticsValue(payload.RowID, 100) ||
|
|
|
|
|
!safeAnalyticsValue(payload.RowKind, 40) ||
|
|
|
|
|
!safeAnalyticsValue(payload.ItemID, 100) {
|
|
|
|
|
return store.RowEvent{}, false
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
switch payload.Event {
|
|
|
|
|
case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect:
|
|
|
|
|
default:
|
|
|
|
|
return store.RowEvent{}, false
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 20:24:22 +12:00
|
|
|
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
dwell := payload.DwellMs
|
|
|
|
|
if dwell < 0 {
|
|
|
|
|
dwell = 0
|
|
|
|
|
}
|
|
|
|
|
if dwell > maxDwellMs {
|
|
|
|
|
dwell = maxDwellMs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return store.RowEvent{
|
|
|
|
|
OccurredAt: occurredAt,
|
|
|
|
|
UserID: userID,
|
|
|
|
|
RowID: payload.RowID,
|
|
|
|
|
RowKind: payload.RowKind,
|
|
|
|
|
Event: payload.Event,
|
|
|
|
|
ItemID: payload.ItemID,
|
|
|
|
|
DwellMs: dwell,
|
|
|
|
|
}, true
|
|
|
|
|
}
|