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
+287
View File
@@ -0,0 +1,287 @@
// Package api exposes the gateway's HTTP surface.
//
// The API is shaped for one TV screen at a time rather than mirroring Emby: /v1/home
// returns everything the launcher renders in a single round trip, which is the whole
// point of putting a gateway in front of Emby.
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
type Server struct {
cfg config.Config
emby *emby.Client
store *store.Store
cache *cache.Cache
recommender *recommend.Engine
syncer syncerHandle
log *slog.Logger
recommendationBuilds recommendationBuilds
maintenance maintenanceState
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
// this list has grown three times already.
type Deps struct {
Emby *emby.Client
Store *store.Store
Cache *cache.Cache
Recommender *recommend.Engine
Syncer syncerHandle
Log *slog.Logger
}
func New(cfg config.Config, deps Deps) *Server {
return &Server{
cfg: cfg,
emby: deps.Emby,
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
syncer: deps.Syncer,
log: deps.Log,
}
}
func (s *Server) Routes() http.Handler {
// The client API lives on its own mux so maintenance mode can gate all of it at
// once, without the gate ever touching health checks or the admin page.
v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
v1.Handle("GET /v1/home", s.authed(s.handleHome))
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage))
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady)
mux.Handle("/v1/", s.maintenanceGate(v1))
mux.Handle("/admin/", s.adminRoutes())
return s.withLogging(mux)
}
// --- middleware -------------------------------------------------------------
type authedFunc func(http.ResponseWriter, *http.Request, store.Session)
// authed resolves the bearer token to a session before running h.
//
// Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the
// repository's helpers and cannot attach headers to them.
func (s *Server) authed(h authedFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
sess, err := s.sessionFor(r.Context(), token)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
s.log.Error("session lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "session lookup failed")
return
}
h(w, r, sess)
})
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Path only: query strings can carry image tokens.
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"ms", time.Since(start).Milliseconds(),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// --- sessions ---------------------------------------------------------------
func bearerToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
}
if h := r.Header.Get("X-Memby-Token"); h != "" {
return strings.TrimSpace(h)
}
return strings.TrimSpace(r.URL.Query().Get("t"))
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
func newToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
type cachedSession struct {
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
}
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, error) {
hash := hashToken(token)
key := cache.SessionKey(hex.EncodeToString(hash))
if raw, err := s.cache.Get(ctx, key); err == nil {
var cs cachedSession
if json.Unmarshal(raw, &cs) == nil {
return store.Session{
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
}, nil
}
}
sess, err := s.store.SessionByTokenHash(ctx, hash)
if err != nil {
return store.Session{}, err
}
// Constant-time confirmation that the stored hash matches the presented token.
if subtle.ConstantTimeCompare(sess.TokenHash, hash) != 1 {
return store.Session{}, store.ErrNotFound
}
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
}); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
}
// Best-effort activity stamp; a failure here must not fail the request.
if err := s.store.Touch(ctx, hash); err != nil {
s.log.Warn("touch session failed", "error", err)
}
return sess, nil
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID}
}
// --- responses --------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
// Headers are already out; nothing useful left to do but stop.
return
}
}
func writeRaw(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// writeUpstreamError mirrors Emby's status so the TV can tell "signed out" (401) from
// "server is unwell" (5xx) without parsing strings.
func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message string) {
var apiErr *emby.APIError
if errors.As(err, &apiErr) {
switch {
case apiErr.StatusCode == http.StatusUnauthorized, apiErr.StatusCode == http.StatusForbidden:
writeError(w, http.StatusUnauthorized, "emby rejected the session")
return
case apiErr.StatusCode == http.StatusNotFound:
writeError(w, http.StatusNotFound, "not found on the emby server")
return
}
}
s.log.Error(message, "error", err)
writeError(w, http.StatusBadGateway, message)
}
func queryInt(r *http.Request, key string, fallback, max int) int {
raw := r.URL.Query().Get(key)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return fallback
}
if v > max {
return max
}
return v
}