Memby v0.1.53: Android TV client plus gateway

Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway
(Go, Postgres, Redis) that fronts it.

Client:
- Setup, profiles, home rows, Media3 playback, system screensaver (Dream)
- Backend chosen at build time: gateway when memby.gatewayUrl is set,
  otherwise direct to Emby. Both paths stay working.
- Server-composed home rows, rendered verbatim so new row types ship
  without an app release
- Full-screen animated maintenance state, row engagement telemetry

Gateway:
- One request per TV screen; auth, caching, search and row shaping
- Library import from Emby into Postgres (manual, then hourly incremental)
- Recommendations from viewing history (recency-weighted genre affinity)
- Admin page for imports, an offline switch, and per-row analytics
- Video always direct-plays from Emby; only metadata passes through

Identity is com.ponzischeme89.memby throughout, replacing
com.mattcohen.embyclientsname. A changed applicationId installs as a new
app: TVs need a fresh sign-in and the old package uninstalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-07-27 08:16:20 +12:00
co-authored by Claude Opus 5
commit 2ce405c540
99 changed files with 14433 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
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
}