This commit is contained in:
ponzischeme89
2026-08-11 23:41:10 +12:00
parent 0e183cf560
commit 5fed3360c2
42 changed files with 1340 additions and 130 deletions
+100 -3
View File
@@ -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