Files

194 lines
7.5 KiB
Go
Raw Permalink Normal View History

2026-08-14 13:32:14 +12:00
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
type activeHeroResponse struct {
2026-08-22 12:38:26 +12:00
Placement string `json:"placement"`
Source string `json:"source"`
Candidates []activeHeroCandidate `json:"candidates,omitempty"`
Rows []recommend.Row `json:"rows"`
}
type activeHeroCandidate struct {
ItemID string `json:"itemId"`
Source string `json:"source"`
Score float64 `json:"score"`
Reason string `json:"reason,omitempty"`
Pinned bool `json:"pinned"`
2026-08-14 13:32:14 +12:00
}
// handleActiveHero gives every section the same server-owned resolver as Home. The client
// supplies only a placement; schedules, priorities, pins and ranking remain gateway data.
func (s *Server) handleActiveHero(w http.ResponseWriter, r *http.Request, sess store.Session) {
placement := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("placement")))
if !store.ValidHeroPlacement(placement) {
writeError(w, http.StatusBadRequest, "placement must be home, movies or tv_shows")
return
}
now := time.Now()
2026-08-20 07:54:03 +12:00
location := s.heroLocation()
// Keyed by the revision the status poll publishes, which already carries the rotation
// slot. That is what makes an operator's change reachable immediately without dropping
// anything else the household has cached: the old entry is not invalidated, it is
// simply no longer named. See heroRevision.
2026-08-20 15:06:00 +12:00
key := cache.UserKey(viewerKeyOf(r.Context(), sess), "hero:active:v2:"+placement+":"+
2026-08-20 07:54:03 +12:00
heroRevision(s.currentHeroPolicy(r.Context()), sess.EmbyUserID, now, location))
2026-08-14 13:32:14 +12:00
if raw, err := s.cache.Get(r.Context(), key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
response, err := s.resolveActiveHero(r.Context(), sess, placement, now)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not resolve the section hero")
return
}
raw, err := json.Marshal(response)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not build the section hero")
return
}
if err := s.cache.Set(r.Context(), key, raw, s.cfg.SearchTTL); err != nil {
s.loggerFor(r.Context()).Warn("section hero cache write failed", "placement", placement, "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, raw)
}
func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, placement string, now time.Time) (activeHeroResponse, error) {
itemType := "Movie"
if placement == store.HeroPlacementTVShows {
itemType = "Series"
}
if placement == store.HeroPlacementHome {
itemType = "Movie,Series"
}
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
"IncludeItemTypes": {itemType}, "Recursive": {"true"}, "Limit": {"64"},
"SortBy": {"DateCreated,PremiereDate,SortName"}, "SortOrder": {"Descending"},
}, fieldsRow))
if err != nil {
return activeHeroResponse{}, err
}
2026-08-20 15:06:00 +12:00
s.decorateItems(ctx, result.Items)
2026-08-14 13:32:14 +12:00
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
2026-08-20 07:54:03 +12:00
policy := s.currentHeroPolicy(ctx)
2026-08-14 13:32:14 +12:00
placementPolicy := policy.Placement(placement)
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
2026-08-20 07:54:03 +12:00
location := s.heroLocation()
2026-08-14 13:32:14 +12:00
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
2026-08-22 12:38:26 +12:00
for index := range scheduled {
scheduled[index].Source = "time_sensitive"
}
2026-08-14 13:32:14 +12:00
var candidates []heroCandidate
if placement == store.HeroPlacementTVShows {
candidates = append(s.heroPremiereCandidates(ctx, now), heroSeriesCandidates(rows)...)
candidates = filterHeroPlacement(candidates, placement)
} else if placement == store.HeroPlacementMovies {
candidates = s.heroMoviePlacementCandidates(ctx, rows, now)
} else {
candidates = s.heroCandidates(ctx, rows, now)
}
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
organic := rotateHeroCandidates(pool, heroVariationSeed(sess.EmbyUserID, heroRotationSlot(now, location)+":"+placement), heroRowLimit)
ranked := mergePinnedHeroCandidates(append(pinned, scheduled...), candidates, organic, heroRowLimit)
source := "automatic"
if len(pinned) > 0 {
source = "manual"
} else if len(scheduled) > 0 {
source = "scheduled"
}
response := activeHeroResponse{Placement: placement, Source: source, Rows: []recommend.Row{}}
if len(ranked) == 0 {
response.Source = "fallback"
return response, nil
}
items := make([]json.RawMessage, 0, len(ranked))
2026-08-22 12:38:26 +12:00
metadata := make([]activeHeroCandidate, 0, len(ranked))
2026-08-14 13:32:14 +12:00
for index, candidate := range ranked {
2026-08-22 12:38:26 +12:00
reason := heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location)
items = append(items, injectHeroFields(candidate.Item, heroLabel(candidate, now), reason))
metadata = append(metadata, activeHeroCandidate{ItemID: candidate.ID, Source: heroSource(candidate), Score: heroScore(candidate, now), Reason: reason, Pinned: candidate.Source == "admin_pinned"})
2026-08-14 13:32:14 +12:00
}
2026-08-22 12:38:26 +12:00
response.Candidates = metadata
2026-08-14 13:32:14 +12:00
response.Rows = append(response.Rows, recommend.Row{ID: "hero-" + placement, Title: "Featured", Kind: heroRowKind, Items: items})
s.loggerFor(ctx).Debug("section hero resolved", "placement", placement, "source", source, "items", len(items))
return response, nil
}
// Movies needs Radarr's real availability date, but never needs to wait for Sonarr. Home
// intentionally combines both; keeping this path narrow is what makes section preloading
// inexpensive even when one integration is unavailable.
func (s *Server) heroMoviePlacementCandidates(ctx context.Context, rows []recommend.Row, now time.Time) []heroCandidate {
movies, facts := heroMovieCandidates(rows)
var releases heroReleaseIndex
var providers map[string]string
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); releases = s.heroReleaseIndex(ctx, now) }()
go func() { defer wg.Done(); providers = s.heroProviderIDs(ctx, facts) }()
wg.Wait()
for index := range movies {
fact := facts[movies[index].ID]
if release, ok := releases.lookup(providers[movies[index].ID], fact.Name, heroYearOf(fact)); ok {
movies[index].ReleasedAt = release.at
movies[index].Estimated = release.estimated
}
}
return movies
}
func filterHeroPlacement(candidates []heroCandidate, placement string) []heroCandidate {
out := make([]heroCandidate, 0, len(candidates))
for _, candidate := range candidates {
isMovie := candidate.Kind == heroMovie
if placement == store.HeroPlacementMovies && isMovie {
out = append(out, candidate)
}
if placement == store.HeroPlacementTVShows && !isMovie {
out = append(out, candidate)
}
if placement == store.HeroPlacementHome {
out = append(out, candidate)
}
}
return out
}
func heroSeriesCandidates(rows []recommend.Row) []heroCandidate {
out := make([]heroCandidate, 0, heroCandidateLimit)
seen := map[string]bool{}
for _, row := range rows {
for _, raw := range row.Items {
fact, ok := heroFactsOf(raw)
if !ok || seen[fact.ID] || !fact.Playable || !strings.EqualFold(fact.Type, "Series") {
continue
}
seen[fact.ID] = true
rating, rated := heroRatingOf(raw)
out = append(out, heroCandidate{ID: fact.ID, Name: fact.Name, Kind: heroSeries, Item: raw, ReleasedAt: fact.Premiere, Rating: rating, Rated: rated})
if len(out) == heroCandidateLimit {
return out
}
}
}
return out
}