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 {
|
|
|
|
|
Placement string `json:"placement"`
|
|
|
|
|
Source string `json:"source"`
|
|
|
|
|
Rows []recommend.Row `json:"rows"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
key := cache.UserKey(sess.EmbyUserID, "hero:active:v2:"+placement+":"+
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
s.decorateItemRatings(ctx, result.Items)
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
for index, candidate := range ranked {
|
|
|
|
|
items = append(items, injectHeroFields(candidate.Item, heroLabel(candidate, now), heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location)))
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|