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.BuildRowsForUser(ctx, credentials(sess), sess.Username) 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 { rows = s.filterRecommendationPermissions(r.Context(), sess, rows) rows = s.personalizeTitles(r.Context(), sess, rows) rows = deduplicateRows(personalizeRowsByTitleScores(selectPersonalizedRows(rows))) 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 } rows = s.filterRecommendationPermissions(r.Context(), sess, rows) rows = s.personalizeTitles(r.Context(), sess, rows) rows = deduplicateRows(personalizeRowsByTitleScores(selectPersonalizedRows(rows))) 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 }