0.2.52
This commit is contained in:
+100
-3
@@ -57,6 +57,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -134,6 +135,7 @@ type heroKind int
|
||||
|
||||
const (
|
||||
heroMovie heroKind = iota
|
||||
heroSeries
|
||||
heroSeriesPremiere
|
||||
heroSeasonPremiere
|
||||
)
|
||||
@@ -345,6 +347,8 @@ func heroReason(candidate heroCandidate, now time.Time, location *time.Location)
|
||||
return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case candidate.Kind == heroSeriesPremiere:
|
||||
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case candidate.Kind == heroSeries && fresh:
|
||||
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case candidate.Kind == heroSeasonPremiere:
|
||||
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case fresh && acclaimed && candidate.Estimated:
|
||||
@@ -363,6 +367,19 @@ func heroReason(candidate heroCandidate, now time.Time, location *time.Location)
|
||||
}
|
||||
}
|
||||
|
||||
func heroReasonForPosition(
|
||||
candidate heroCandidate,
|
||||
position int,
|
||||
primeSubtitle string,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
) string {
|
||||
if position == 0 && strings.TrimSpace(primeSubtitle) != "" {
|
||||
return strings.TrimSpace(primeSubtitle)
|
||||
}
|
||||
return heroReason(candidate, now, location)
|
||||
}
|
||||
|
||||
// heroWhen words a date the way somebody would say it out loud. Nothing here is more
|
||||
// precise than the evidence: a digital release date carries no time of day, so a card
|
||||
// never claims an hour.
|
||||
@@ -648,23 +665,30 @@ func (s *Server) heroRow(
|
||||
location = time.Local
|
||||
}
|
||||
candidates := s.heroCandidates(ctx, rows, now)
|
||||
policy, err := s.store.HeroPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
|
||||
policy = store.HeroPolicy{}
|
||||
}
|
||||
pinned := s.pinnedHeroCandidates(ctx, policy.PinnedItemIDs)
|
||||
// Rank a pool, then draw the row out of it. Ranking straight to the row's length is
|
||||
// what made the hero the same four cards for a week — see rotateHeroCandidates.
|
||||
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
|
||||
ranked := rotateHeroCandidates(
|
||||
organic := rotateHeroCandidates(
|
||||
pool,
|
||||
heroVariationSeed(userID, heroRotationSlot(now, location)),
|
||||
heroRowLimit,
|
||||
)
|
||||
ranked := mergePinnedHeroCandidates(pinned, candidates, organic, heroRowLimit)
|
||||
if len(ranked) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]json.RawMessage, 0, len(ranked))
|
||||
for _, candidate := range ranked {
|
||||
for index, candidate := range ranked {
|
||||
items = append(items, injectHeroFields(
|
||||
candidate.Item,
|
||||
heroLabel(candidate, now),
|
||||
heroReason(candidate, now, location),
|
||||
heroReasonForPosition(candidate, index, policy.PrimeSubtitle, now, location),
|
||||
))
|
||||
}
|
||||
return &recommend.Row{
|
||||
@@ -675,6 +699,79 @@ func (s *Server) heroRow(
|
||||
}
|
||||
}
|
||||
|
||||
// mergePinnedHeroCandidates places explicit operator choices in the visible grid first,
|
||||
// then fills it with the normal release-aware rotation. When a pin also qualified
|
||||
// organically, its release or premiere evidence is retained so pinning changes placement,
|
||||
// never presentation.
|
||||
func mergePinnedHeroCandidates(
|
||||
pinned, evidence, organic []heroCandidate,
|
||||
limit int,
|
||||
) []heroCandidate {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]heroCandidate, 0, limit)
|
||||
seen := map[string]bool{}
|
||||
evidenceByID := make(map[string]heroCandidate, len(evidence))
|
||||
for _, candidate := range evidence {
|
||||
evidenceByID[candidate.ID] = candidate
|
||||
}
|
||||
for index, candidate := range pinned {
|
||||
if natural, ok := evidenceByID[candidate.ID]; ok {
|
||||
pinned[index] = natural
|
||||
}
|
||||
}
|
||||
appendUnique := func(candidates []heroCandidate) {
|
||||
for _, candidate := range candidates {
|
||||
if len(out) == limit {
|
||||
return
|
||||
}
|
||||
if candidate.ID == "" || len(candidate.Item) == 0 || seen[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
appendUnique(pinned)
|
||||
appendUnique(organic)
|
||||
return out
|
||||
}
|
||||
|
||||
// pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or
|
||||
// unsupported id quietly drops out, so an old admin choice can never make Home fail.
|
||||
func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate {
|
||||
items, err := s.store.LibraryItemsByID(ctx, ids)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("pinned hero titles unavailable", "error", err)
|
||||
return nil
|
||||
}
|
||||
byID := make(map[string]heroCandidate, len(items))
|
||||
for _, raw := range items {
|
||||
fact, ok := heroFactsOf(raw)
|
||||
if !ok || !fact.Playable ||
|
||||
(!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) {
|
||||
continue
|
||||
}
|
||||
rating, rated := heroRatingOf(raw)
|
||||
kind := heroMovie
|
||||
if strings.EqualFold(fact.Type, "Series") {
|
||||
kind = heroSeries
|
||||
}
|
||||
byID[fact.ID] = heroCandidate{
|
||||
ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw,
|
||||
ReleasedAt: fact.Premiere, Rating: rating, Rated: rated,
|
||||
}
|
||||
}
|
||||
out := make([]heroCandidate, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if candidate, ok := byID[id]; ok {
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heroCandidates gathers everything eligible, premieres first.
|
||||
//
|
||||
// Premieres lead the input order so that they win a tie against a film of identical
|
||||
|
||||
Reference in New Issue
Block a user