Release Memby 0.2.64
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
key := cache.UserKey(sess.EmbyUserID, "hero:active:v1:"+placement+":"+heroRotationSlot(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, err := s.store.HeroPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("section hero policy unavailable", "placement", placement, "error", err)
|
||||
policy = store.HeroPolicy{}
|
||||
}
|
||||
placementPolicy := policy.Placement(placement)
|
||||
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user