Files
memby/server/internal/api/recommend.go
T
2026-07-29 15:26:27 +12:00

173 lines
5.4 KiB
Go

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)})
}
// handleForYou is deliberately separate from /home. Tracearr and the richer scoring
// path may take longer than a launcher request, and the chosen time budget is local to
// this visit. The TV only calls this when the viewer enters the dedicated area.
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store.Session) {
minutes := queryInt(r, "minutes", 0, 360)
if s.forYou != nil && r.URL.Query().Get("refresh") != "1" {
rows, hit, stale, err := s.forYou.PreparedRows(r.Context(), sess, minutes)
if err != nil {
s.log.Warn("prepared For You read failed; using live fallback",
"user", sess.EmbyUserID, "error", err)
} else if hit {
w.Header().Set("X-Memby-For-You", "prepared")
if stale {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
return
} else {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
}
buildCtx, cancel := context.WithTimeout(r.Context(), s.cfg.RecommendTimeout)
defer cancel()
rows, err := s.recommender.BuildForYou(
buildCtx,
credentials(sess),
sess.Username,
recommend.ForYouOptions{AvailableMinutes: minutes},
)
if err != nil {
s.writeUpstreamError(w, err, "could not build For You recommendations")
return
}
w.Header().Set("X-Memby-For-You", "live-fallback")
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
}