package api import ( "context" "encoding/json" "net/http" "time" "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"` } // 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(), sess.EmbyUserID) 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 := 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() } } } 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 }