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() 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)) 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}} policy := s.currentHeroPolicy(ctx) placementPolicy := policy.Placement(placement) pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement) location := s.heroLocation() 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 }