package api import ( "context" "encoding/json" "net/http" "strings" "time" "unicode" "unicode/utf8" "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 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"` } 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"` ItemName string `json:"itemName"` ItemType string `json:"itemType"` // 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"` } 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", "home_open", "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, payload.PlaySessionID} for _, field := range fields { if !safeAnalyticsValue(field, 100) { return store.JourneyEvent{}, false } } if utf8.RuneCountInString(payload.ItemName) > 160 || strings.IndexFunc(payload.ItemName, unicode.IsControl) >= 0 { 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, ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType, PlaySessionID: payload.PlaySessionID, 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 // 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 { s.loggerFor(r.Context()).Warn("row analytics write failed", "error", err) // Still a 204: telemetry must never make the TV think something is broken. } for _, event := range events { if event.Event == store.RowEventSelect { _ = s.cache.InvalidateUser(r.Context(), viewerKeyOf(r.Context(), sess)) if s.forYou != nil { s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess) } break } } w.WriteHeader(http.StatusNoContent) } func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.RowEvent, bool) { if payload.RowID == "" { return store.RowEvent{}, false } switch payload.Event { case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect: default: return store.RowEvent{}, false } occurredAt := analyticsOccurredAt(payload.OccurredAt, now) 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 }