103 lines
2.9 KiB
Go
103 lines
2.9 KiB
Go
package api
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"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.log.Warn("row analytics write failed", "error", err)
|
||
|
|
// Still a 204: telemetry must never make the TV think something is broken.
|
||
|
|
}
|
||
|
|
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
|
||
|
|
}
|