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:
@@ -0,0 +1,171 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/library"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
//go:embed admin.html
|
||||
var adminPage []byte
|
||||
|
||||
// adminRoutes is the operator interface: library imports, the maintenance switch, and
|
||||
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
|
||||
// left exposed by accident.
|
||||
func (s *Server) adminRoutes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
|
||||
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
|
||||
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
|
||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
||||
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// adminAuth guards the admin API with a shared token, compared in constant time.
|
||||
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AdminToken == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 {
|
||||
writeError(w, http.StatusUnauthorized, "invalid admin token")
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AdminToken == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// The page holds no secrets; the token is entered by the operator and kept in the
|
||||
// browser's local storage.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(adminPage)
|
||||
}
|
||||
|
||||
type adminStatus struct {
|
||||
Maintenance store.Maintenance `json:"maintenance"`
|
||||
Library store.LibraryStats `json:"library"`
|
||||
SyncRunning bool `json:"syncRunning"`
|
||||
Runs []store.SyncRun `json:"runs"`
|
||||
SyncEvery string `json:"syncEvery"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
stats, err := s.store.LibraryStats(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("library stats failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read library stats")
|
||||
return
|
||||
}
|
||||
runs, err := s.store.RecentSyncRuns(ctx, 10)
|
||||
if err != nil {
|
||||
s.log.Error("sync history failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read sync history")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminStatus{
|
||||
Maintenance: s.maintenance.get(),
|
||||
Library: stats,
|
||||
SyncRunning: s.syncer.Running(),
|
||||
Runs: runs,
|
||||
SyncEvery: s.cfg.SyncInterval.String(),
|
||||
})
|
||||
}
|
||||
|
||||
type syncRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// handleAdminSync starts an import in the background and returns immediately. A full
|
||||
// import of a large library takes minutes; the page polls /admin/api/status for progress.
|
||||
func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
|
||||
var req syncRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if req.Kind != "full" && req.Kind != "incremental" {
|
||||
writeError(w, http.StatusBadRequest, `kind must be "full" or "incremental"`)
|
||||
return
|
||||
}
|
||||
if s.syncer.Running() {
|
||||
writeError(w, http.StatusConflict, "a sync is already running")
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout)
|
||||
defer cancel()
|
||||
if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil {
|
||||
s.log.Error("manual sync failed", "kind", req.Kind, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind})
|
||||
}
|
||||
|
||||
type maintenanceRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
var req maintenanceRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
|
||||
state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)}
|
||||
if err := s.store.SetMaintenance(r.Context(), state); err != nil {
|
||||
s.log.Error("maintenance write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not update maintenance mode")
|
||||
return
|
||||
}
|
||||
if err := s.LoadMaintenance(r.Context()); err != nil {
|
||||
s.log.Warn("maintenance reload failed", "error", err)
|
||||
}
|
||||
|
||||
s.log.Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
|
||||
writeJSON(w, http.StatusOK, s.maintenance.get())
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
days := queryInt(r, "days", 7, 90)
|
||||
since := time.Now().UTC().AddDate(0, 0, -days)
|
||||
|
||||
stats, err := s.store.RowStats(r.Context(), since)
|
||||
if err != nil {
|
||||
s.log.Error("row stats failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats})
|
||||
}
|
||||
|
||||
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
|
||||
// concrete type for testing.
|
||||
type syncerHandle interface {
|
||||
Running() bool
|
||||
Sync(ctx context.Context, kind, trigger string) (library.Result, error)
|
||||
}
|
||||
Reference in New Issue
Block a user