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,132 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// recommendationBuilds tracks in-flight rebuilds per user.
|
||||
//
|
||||
// Without this, four TVs waking up together would each kick off the same handful of Emby
|
||||
// queries. The first one through does the work; the rest skip it and pick the rows up on
|
||||
// their next home load.
|
||||
type recommendationBuilds struct {
|
||||
mu sync.Mutex
|
||||
running map[string]bool
|
||||
}
|
||||
|
||||
func (b *recommendationBuilds) begin(userID string) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.running == nil {
|
||||
b.running = map[string]bool{}
|
||||
}
|
||||
if b.running[userID] {
|
||||
return false
|
||||
}
|
||||
b.running[userID] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *recommendationBuilds) done(userID string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.running, userID)
|
||||
}
|
||||
|
||||
// cachedRecommendations returns the stored rows, or nil on a miss.
|
||||
func (s *Server) cachedRecommendations(ctx context.Context, userID string) []recommend.Row {
|
||||
raw, err := s.cache.Get(ctx, cache.RecommendationsKey(userID))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var rows []recommend.Row
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
return nil
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildRecommendations computes and caches rows for one user.
|
||||
func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) ([]recommend.Row, error) {
|
||||
rows, err := s.recommender.BuildRows(ctx, credentials(sess))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// An empty result is cached too: a user with no history should not trigger a full
|
||||
// rebuild on every single home load.
|
||||
if raw, err := json.Marshal(rows); err == nil {
|
||||
if err := s.cache.Set(ctx, cache.RecommendationsKey(sess.EmbyUserID), raw, s.cfg.RecommendTTL); err != nil {
|
||||
s.log.Warn("recommendation cache write failed", "error", err)
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// refreshRecommendationsInBackground rebuilds without holding up the caller.
|
||||
//
|
||||
// The home screen must stay fast, so a cold cache means "no recommendation rows this
|
||||
// time" rather than "wait several seconds for Emby". The rows appear on the next load.
|
||||
func (s *Server) refreshRecommendationsInBackground(sess store.Session) {
|
||||
if !s.recommendationBuilds.begin(sess.EmbyUserID) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer s.recommendationBuilds.done(sess.EmbyUserID)
|
||||
|
||||
// Detached from the request: the TV's connection is long gone by the time this
|
||||
// finishes, but the work is still worth completing.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.RecommendTimeout)
|
||||
defer cancel()
|
||||
|
||||
started := time.Now()
|
||||
rows, err := s.buildRecommendations(ctx, sess)
|
||||
if err != nil {
|
||||
s.log.Warn("recommendation build failed", "user", sess.EmbyUserID, "error", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("recommendations rebuilt",
|
||||
"user", sess.EmbyUserID, "rows", len(rows), "ms", time.Since(started).Milliseconds())
|
||||
}()
|
||||
}
|
||||
|
||||
// handleRecommendations serves the rows on their own, building synchronously when the
|
||||
// cache is cold. `?refresh=1` forces a rebuild — useful for testing the engine without
|
||||
// waiting out the TTL.
|
||||
func (s *Server) handleRecommendations(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
forceRefresh := r.URL.Query().Get("refresh") == "1"
|
||||
|
||||
if !forceRefresh {
|
||||
if rows := s.cachedRecommendations(ctx, sess.EmbyUserID); rows != nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
buildCtx, cancel := context.WithTimeout(ctx, s.cfg.RecommendTimeout)
|
||||
defer cancel()
|
||||
|
||||
rows, err := s.buildRecommendations(buildCtx, sess)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "could not build recommendations")
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
|
||||
}
|
||||
|
||||
func nonNilRows(rows []recommend.Row) []recommend.Row {
|
||||
if rows == nil {
|
||||
return []recommend.Row{}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
Reference in New Issue
Block a user