Skip Intro from Emby's own chapter markers, trickplay seek previews from BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and review scores, and My Alerts as its own page behind the user picker. Related titles now degrade at every step instead of returning empty, and the "+" is back on Manage users so a second viewer can be added from the launcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
837 lines
28 KiB
Go
837 lines
28 KiB
Go
package api
|
|
|
|
// The home hero — the four cards above the launcher's rows, and the one place the server
|
|
// says "this, tonight" rather than "here is a shelf".
|
|
//
|
|
// It used to be chosen on the television: take the first movies off whichever rows looked
|
|
// new or popular, rotate the starting point once a day. That was as good as the evidence
|
|
// the client had, which is very little. Emby's PremiereDate is frequently whatever a
|
|
// metadata agent guessed, nothing on the wire said whether a title was any good, and a
|
|
// series could only ever reach the hero as a random show off a shelf. So a poorly
|
|
// reviewed film imported last Tuesday led the launcher over the best-received release of
|
|
// the month, and the return of a household's favourite show passed unremarked.
|
|
//
|
|
// The gateway has the three pieces of evidence the television does not:
|
|
//
|
|
// - **Radarr knows when a film actually came out.** `digitalRelease` is the date the
|
|
// household could first have watched it, which is what a viewer means by "new". The
|
|
// schedule row already prefers it over Emby's; the hero reads the same answer over a
|
|
// backwards window instead of a forwards one.
|
|
// - **Sonarr knows a premiere from an ordinary episode.** S01E01 is a new show, S02E01
|
|
// is a returning one, and both are news in a way that the fourth episode of a show
|
|
// somebody is already halfway through is not.
|
|
// - **MDBList knows whether it is worth the evening.** By the point this runs those
|
|
// scores are already attached to the cards, so ranking by them costs nothing.
|
|
//
|
|
// Two properties are what stop this becoming a second recommendation engine, and both
|
|
// are easy to give away:
|
|
//
|
|
// - **It asks Emby for nothing.** The movie candidates are the rows already assembled
|
|
// and their ratings are already attached, so the expensive half of the launcher is
|
|
// reused rather than repeated. What it does read is the two *arr calendars, and those
|
|
// are cached for the day behind a shared lock like the schedule rows' — one household
|
|
// pays one miss each per day, and the three reads run together rather than in turn
|
|
// because this is the tail of a response every television is waiting on.
|
|
// - **Every card it produces is playable.** A premiere the household has not downloaded
|
|
// yet, or a film Radarr is still waiting on, is news for the schedule row — the hero
|
|
// exists to be pressed, and a lead card that does nothing is worse than no lead card.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
|
)
|
|
|
|
const (
|
|
heroRowID = "hero"
|
|
heroRowKind = "hero"
|
|
|
|
// How far back a release can be and still be the reason a card leads. Digital
|
|
// releases and premieres arrive in bursts, and a hero that empties out in a quiet
|
|
// fortnight is a hero that falls back to the library — three weeks keeps it
|
|
// populated without billing a six-week-old film as new.
|
|
heroWindowDays = 21
|
|
|
|
// The television draws four cards. The row carries a few more so one it cannot draw
|
|
// — no artwork, a type this build predates — costs a card rather than a gap.
|
|
heroRowLimit = 8
|
|
|
|
// Candidates are capped before scoring. A launcher is a few hundred cards; this is a
|
|
// guard against a future row type offering a thousand, not a limit anything reaches.
|
|
heroCandidateLimit = 240
|
|
)
|
|
|
|
// The ranking. Recency and quality are deliberately close in weight: the request this
|
|
// answers is that a well-received release should be able to beat a fresher one that
|
|
// nobody liked, which needs quality to be worth roughly as much as a fortnight of age.
|
|
const (
|
|
heroRecencyWeight = 0.55
|
|
heroRatingWeight = 0.45
|
|
|
|
// What a title nobody has rated is worth. Deliberately near the middle rather than
|
|
// zero: a good score is meant to *lift* a title above the merely recent, not to bury
|
|
// everything MDBList has never been asked about — which, on a household that has just
|
|
// turned ratings on, is the entire library.
|
|
heroUnratedScore = 0.55
|
|
|
|
// Radarr's cinema + 30 days is a guess (see effectiveRadarrRelease), and a guess
|
|
// should not outrank a date somebody published.
|
|
heroEstimatedPenalty = 0.12
|
|
|
|
// Where "well reviewed" starts, for the one label that claims it.
|
|
heroAcclaimedRating = 0.75
|
|
)
|
|
|
|
// Caption wording is the gateway's, like MembyAirLabel and MembyLifecycleText. The
|
|
// television renders the string it is handed, so a new kind of hero card reads correctly
|
|
// on a build that predates it.
|
|
const (
|
|
heroLabelSeriesPremiere = "SERIES PREMIERE"
|
|
heroLabelSeasonPremiere = "NEW SEASON"
|
|
heroLabelNewRelease = "NEW RELEASE"
|
|
heroLabelAcclaimed = "HIGHLY RATED"
|
|
heroLabelLibrary = "FROM YOUR LIBRARY"
|
|
)
|
|
|
|
// The fields the hero adds to an item payload. Emby's JSON is otherwise forwarded
|
|
// verbatim; these are injected the way MembyRatings is.
|
|
const (
|
|
heroLabelField = "MembyHeroLabel"
|
|
heroReasonField = "MembyHeroReason"
|
|
)
|
|
|
|
const heroReleasedCachePrefix = "radarr:released:v1:"
|
|
const heroPremiereCachePrefix = "sonarr:premieres:v1:"
|
|
|
|
type heroKind int
|
|
|
|
const (
|
|
heroMovie heroKind = iota
|
|
heroSeriesPremiere
|
|
heroSeasonPremiere
|
|
)
|
|
|
|
// heroCandidate is one title the hero could lead with, and everything the ranking needs
|
|
// to decide whether it should.
|
|
type heroCandidate struct {
|
|
ID string
|
|
Name string
|
|
Kind heroKind
|
|
Item json.RawMessage
|
|
|
|
// ReleasedAt is the *effective* release: Radarr's digital date for a film, the
|
|
// premiere's air date for a show, and Emby's PremiereDate only when nothing better
|
|
// is known. Zero means nothing is known at all, which is an answer — such a title
|
|
// ranks on quality alone rather than being excluded.
|
|
ReleasedAt time.Time
|
|
Estimated bool
|
|
|
|
// Rating is normalised onto 0..1 across whatever providers answered. Rated is false
|
|
// when none did, which is a different thing from a score of zero.
|
|
Rating float64
|
|
Rated bool
|
|
}
|
|
|
|
// heroRecency decays linearly across the window.
|
|
//
|
|
// A release in the future scores zero rather than more than one. The hero is a thing to
|
|
// be pressed, and a title that has not come out yet belongs to the schedule row — this
|
|
// only ever sees such a date because Radarr publishes a digital date before it arrives.
|
|
func heroRecency(released, now time.Time) float64 {
|
|
if released.IsZero() || released.After(now) {
|
|
return 0
|
|
}
|
|
age := now.Sub(released).Hours() / 24
|
|
if age >= heroWindowDays {
|
|
return 0
|
|
}
|
|
return 1 - age/heroWindowDays
|
|
}
|
|
|
|
func heroScore(candidate heroCandidate, now time.Time) float64 {
|
|
rating := heroUnratedScore
|
|
if candidate.Rated {
|
|
rating = candidate.Rating
|
|
}
|
|
score := heroRecencyWeight*heroRecency(candidate.ReleasedAt, now) + heroRatingWeight*rating
|
|
if candidate.Estimated {
|
|
score -= heroEstimatedPenalty
|
|
}
|
|
return score
|
|
}
|
|
|
|
// rankHeroCandidates orders the hero and is the whole of the feature that can be reasoned
|
|
// about without a network.
|
|
//
|
|
// The sort is stable and the tie-break is the order it was given, so the caller's own
|
|
// preference survives two titles the scorer cannot separate. Deduplication keeps the
|
|
// first appearance: a film that is both a Radarr release and a library card is the
|
|
// release, which is the more specific thing to say about it.
|
|
func rankHeroCandidates(candidates []heroCandidate, now time.Time, limit int) []heroCandidate {
|
|
if limit <= 0 {
|
|
return nil
|
|
}
|
|
type ranked struct {
|
|
candidate heroCandidate
|
|
position int
|
|
score float64
|
|
}
|
|
seen := make(map[string]bool, len(candidates))
|
|
scored := make([]ranked, 0, len(candidates))
|
|
for position, candidate := range candidates {
|
|
if candidate.ID == "" || seen[candidate.ID] || len(candidate.Item) == 0 {
|
|
continue
|
|
}
|
|
seen[candidate.ID] = true
|
|
scored = append(scored, ranked{
|
|
candidate: candidate,
|
|
position: position,
|
|
score: heroScore(candidate, now),
|
|
})
|
|
}
|
|
sort.SliceStable(scored, func(i, j int) bool {
|
|
if scored[i].score != scored[j].score {
|
|
return scored[i].score > scored[j].score
|
|
}
|
|
return scored[i].position < scored[j].position
|
|
})
|
|
if len(scored) > limit {
|
|
scored = scored[:limit]
|
|
}
|
|
out := make([]heroCandidate, 0, len(scored))
|
|
for _, entry := range scored {
|
|
out = append(out, entry.candidate)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// heroLabel is the caption the card wears, and it may only say what is actually known.
|
|
//
|
|
// The labels it replaced were the card's *position* — the first slot was captioned NEW
|
|
// RELEASE whatever was in it — which is how a 2019 film came to be announced as new. Each
|
|
// of these is a claim the candidate has already satisfied.
|
|
func heroLabel(candidate heroCandidate, now time.Time) string {
|
|
switch candidate.Kind {
|
|
case heroSeriesPremiere:
|
|
return heroLabelSeriesPremiere
|
|
case heroSeasonPremiere:
|
|
return heroLabelSeasonPremiere
|
|
}
|
|
if heroRecency(candidate.ReleasedAt, now) > 0 {
|
|
return heroLabelNewRelease
|
|
}
|
|
if candidate.Rated && candidate.Rating >= heroAcclaimedRating {
|
|
return heroLabelAcclaimed
|
|
}
|
|
return heroLabelLibrary
|
|
}
|
|
|
|
// heroReason is the second line: why this, over the rest of the library. It is allowed to
|
|
// be empty, and is empty precisely when there is nothing true to say — a card with no
|
|
// evidence behind it says nothing rather than inventing a reason.
|
|
func heroReason(candidate heroCandidate, now time.Time, location *time.Location) string {
|
|
acclaimed := candidate.Rated && candidate.Rating >= heroAcclaimedRating
|
|
fresh := heroRecency(candidate.ReleasedAt, now) > 0
|
|
switch {
|
|
case candidate.Kind == heroSeriesPremiere && acclaimed:
|
|
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 == heroSeasonPremiere:
|
|
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
|
|
case fresh && acclaimed && candidate.Estimated:
|
|
return "Well reviewed, and expected to have landed " +
|
|
heroWhen(candidate.ReleasedAt, now, location)
|
|
case fresh && acclaimed:
|
|
return "Well reviewed, released " + heroWhen(candidate.ReleasedAt, now, location)
|
|
case fresh && candidate.Estimated:
|
|
return "Expected to have landed " + heroWhen(candidate.ReleasedAt, now, location)
|
|
case fresh:
|
|
return "Released " + heroWhen(candidate.ReleasedAt, now, location)
|
|
case acclaimed:
|
|
return "One of the best-reviewed titles in your library"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func heroWhen(released, now time.Time, location *time.Location) string {
|
|
if location == nil {
|
|
location = time.UTC
|
|
}
|
|
released = released.In(location)
|
|
today := localDayStart(now, location)
|
|
day := localDayStart(released, location)
|
|
switch days := int(today.Sub(day).Hours() / 24); {
|
|
case days <= 0:
|
|
return "today"
|
|
case days == 1:
|
|
return "yesterday"
|
|
case days < 7:
|
|
return "on " + released.Format("Monday")
|
|
case days < 14:
|
|
return "last week"
|
|
default:
|
|
return "this month"
|
|
}
|
|
}
|
|
|
|
// heroRatingOf reads the scores already attached to the card.
|
|
//
|
|
// It is the *mean* of what the household's chosen providers said, normalised onto 0..1.
|
|
// The sources disagree about scale and about films — IMDb is generous, Rotten Tomatoes'
|
|
// critics are not — and averaging them is a better answer than nominating a favourite and
|
|
// letting one provider's blind spot decide what leads the launcher.
|
|
func heroRatingOf(raw json.RawMessage) (float64, bool) {
|
|
var payload struct {
|
|
Ratings []movieRating `json:"MembyRatings"`
|
|
CommunityRating *float64 `json:"CommunityRating"`
|
|
}
|
|
if json.Unmarshal(raw, &payload) != nil {
|
|
return 0, false
|
|
}
|
|
var sum float64
|
|
var count int
|
|
for _, rating := range payload.Ratings {
|
|
source, known := movieRatingSources[strings.ToLower(strings.TrimSpace(rating.Source))]
|
|
if !known || source.Maximum <= 0 {
|
|
continue
|
|
}
|
|
value, err := strconv.ParseFloat(strings.TrimSpace(rating.Score), 64)
|
|
if err != nil || value <= 0 || value > source.Maximum {
|
|
continue
|
|
}
|
|
sum += value / source.Maximum
|
|
count++
|
|
}
|
|
if count > 0 {
|
|
return sum / float64(count), true
|
|
}
|
|
// Emby's own CommunityRating is the fallback, and only here. The client is forbidden
|
|
// from *drawing* it (a card naming a provider that was never asked is a lie), but
|
|
// ordering four cards by it claims nothing to anybody — and it is what lets the hero
|
|
// rank sensibly on a household that has not configured MDBList at all.
|
|
if payload.CommunityRating != nil && *payload.CommunityRating > 0 && *payload.CommunityRating <= 10 {
|
|
return *payload.CommunityRating / 10, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// heroItemFacts pulls what the ranking needs out of an ordinary Emby item payload.
|
|
type heroItemFacts struct {
|
|
ID string
|
|
Name string
|
|
Type string
|
|
Premiere time.Time
|
|
Playable bool
|
|
}
|
|
|
|
func heroFactsOf(raw json.RawMessage) (heroItemFacts, bool) {
|
|
var payload struct {
|
|
ID string `json:"Id"`
|
|
Name string `json:"Name"`
|
|
Type string `json:"Type"`
|
|
PremiereDate string `json:"PremiereDate"`
|
|
Source string `json:"MembySource"`
|
|
Playable *bool `json:"MembyPlayable"`
|
|
}
|
|
if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.ID) == "" {
|
|
return heroItemFacts{}, false
|
|
}
|
|
facts := heroItemFacts{
|
|
ID: payload.ID,
|
|
Name: strings.TrimSpace(payload.Name),
|
|
Type: payload.Type,
|
|
// A synthetic schedule card carries MembySource and is explicitly not playable.
|
|
// Anything from Emby carries neither field, and is.
|
|
Playable: strings.TrimSpace(payload.Source) == "" &&
|
|
(payload.Playable == nil || *payload.Playable),
|
|
}
|
|
if parsed, err := parseEmbyDate(payload.PremiereDate); err == nil {
|
|
facts.Premiere = parsed
|
|
}
|
|
return facts, true
|
|
}
|
|
|
|
// parseEmbyDate accepts the shapes Emby writes a date in. A date it will not parse is
|
|
// simply unknown, which the ranking already has a behaviour for.
|
|
func parseEmbyDate(value string) (time.Time, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}, errNoDate
|
|
}
|
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
|
if parsed, err := time.Parse(layout, value); err == nil {
|
|
return parsed, nil
|
|
}
|
|
}
|
|
return time.Time{}, errNoDate
|
|
}
|
|
|
|
var errNoDate = errors.New("hero: unparsable date")
|
|
|
|
// injectHeroFields writes the caption and the reason onto one card, the way
|
|
// injectItemRatings writes the scores: through a map, so a field this build knows nothing
|
|
// about survives the round trip.
|
|
func injectHeroFields(raw json.RawMessage, label, reason string) json.RawMessage {
|
|
if label == "" && reason == "" {
|
|
return raw
|
|
}
|
|
var members map[string]json.RawMessage
|
|
if json.Unmarshal(raw, &members) != nil || members == nil {
|
|
return raw
|
|
}
|
|
if label != "" {
|
|
if encoded, err := json.Marshal(label); err == nil {
|
|
members[heroLabelField] = encoded
|
|
}
|
|
}
|
|
if reason != "" {
|
|
if encoded, err := json.Marshal(reason); err == nil {
|
|
members[heroReasonField] = encoded
|
|
}
|
|
}
|
|
out, err := json.Marshal(members)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
return out
|
|
}
|
|
|
|
// heroReleaseIndex answers "when did this film actually come out" for the candidates.
|
|
//
|
|
// It is keyed two ways because neither key is reliable on its own: a TMDB id is exact but
|
|
// only exists for a library item the import has resolved, and a normalised title/year is
|
|
// always available but can be wrong about a remake. The id is consulted first.
|
|
type heroReleaseIndex struct {
|
|
byTMDB map[string]radarrRelease
|
|
byTitle map[string]radarrRelease
|
|
}
|
|
|
|
func newHeroReleaseIndex(movies []radarr.Movie) heroReleaseIndex {
|
|
index := heroReleaseIndex{
|
|
byTMDB: make(map[string]radarrRelease, len(movies)),
|
|
byTitle: make(map[string]radarrRelease, len(movies)*2),
|
|
}
|
|
for _, movie := range movies {
|
|
release, ok := effectiveRadarrRelease(movie)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if movie.TMDBID > 0 {
|
|
index.byTMDB[strconv.Itoa(movie.TMDBID)] = release
|
|
}
|
|
title := normalizedShowTitle(movie.Title)
|
|
if title == "" {
|
|
continue
|
|
}
|
|
// Year-qualified first and never overwritten, so a remake cannot claim the
|
|
// original's release date — the same rule seriesIndex applies.
|
|
if movie.Year > 0 {
|
|
if _, seen := index.byTitle[seriesIndexKey(title, movie.Year)]; !seen {
|
|
index.byTitle[seriesIndexKey(title, movie.Year)] = release
|
|
}
|
|
}
|
|
if _, seen := index.byTitle[title]; !seen {
|
|
index.byTitle[title] = release
|
|
}
|
|
}
|
|
return index
|
|
}
|
|
|
|
func (index heroReleaseIndex) lookup(tmdbID, title string, year int) (radarrRelease, bool) {
|
|
if tmdbID != "" {
|
|
if release, ok := index.byTMDB[tmdbID]; ok {
|
|
return release, true
|
|
}
|
|
}
|
|
key := normalizedShowTitle(title)
|
|
if key == "" {
|
|
return radarrRelease{}, false
|
|
}
|
|
if year > 0 {
|
|
if release, ok := index.byTitle[seriesIndexKey(key, year)]; ok {
|
|
return release, true
|
|
}
|
|
}
|
|
release, ok := index.byTitle[key]
|
|
return release, ok
|
|
}
|
|
|
|
// heroPremiere is one thing Sonarr calls a premiere, resolved onto the Emby series the
|
|
// household can actually play.
|
|
type heroPremiere struct {
|
|
EmbySeriesID string
|
|
SeasonNumber int
|
|
AiredAt time.Time
|
|
}
|
|
|
|
// sonarrPremieres picks the premieres out of a calendar window.
|
|
//
|
|
// A premiere is the *first episode of a season* — S01E01 is a new show and S02E01 is a
|
|
// returning one, and the answered design question is that both are news where the fourth
|
|
// episode of something already in Continue Watching is not. Three filters do the work and
|
|
// each removes a card that would misfire:
|
|
//
|
|
// - Season 0 is specials. A Christmas special is not a premiere.
|
|
// - HasFile is required, because a hero card exists to be pressed.
|
|
// - The show must be one Emby holds, or the card has no page and no artwork.
|
|
//
|
|
// The most recent premiere per series wins: a show that premiered and then returned
|
|
// inside one window is one card about its newer season, not two.
|
|
func sonarrPremieres(
|
|
episodes []sonarr.Episode,
|
|
series seriesIndex,
|
|
from, until time.Time,
|
|
) []heroPremiere {
|
|
best := map[string]heroPremiere{}
|
|
order := make([]string, 0, len(episodes))
|
|
for _, episode := range episodes {
|
|
if episode.EpisodeNumber != 1 || episode.SeasonNumber < 1 || !episode.HasFile {
|
|
continue
|
|
}
|
|
if episode.AirDateUTC == nil {
|
|
continue
|
|
}
|
|
aired := *episode.AirDateUTC
|
|
if aired.Before(from) || aired.After(until) {
|
|
continue
|
|
}
|
|
embyID := series.lookup(episode.Series.Title, episode.Series.Year)
|
|
if embyID == "" {
|
|
continue
|
|
}
|
|
existing, seen := best[embyID]
|
|
if !seen {
|
|
order = append(order, embyID)
|
|
}
|
|
if seen && !aired.After(existing.AiredAt) {
|
|
continue
|
|
}
|
|
best[embyID] = heroPremiere{
|
|
EmbySeriesID: embyID,
|
|
SeasonNumber: episode.SeasonNumber,
|
|
AiredAt: aired,
|
|
}
|
|
}
|
|
out := make([]heroPremiere, 0, len(order))
|
|
for _, embyID := range order {
|
|
out = append(out, best[embyID])
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].AiredAt.After(out[j].AiredAt) })
|
|
return out
|
|
}
|
|
|
|
// heroRow composes the row. It is the only impure part of the feature, and every failure
|
|
// inside it costs a signal rather than the hero: a Radarr that will not answer means
|
|
// films fall back to Emby's premiere dates, a Sonarr that will not answer means no
|
|
// premieres, and neither means the launcher gets the ranking it had before.
|
|
func (s *Server) heroRow(
|
|
ctx context.Context,
|
|
rows []recommend.Row,
|
|
now time.Time,
|
|
) *recommend.Row {
|
|
location := s.cfg.RadarrLocation
|
|
if location == nil {
|
|
location = time.Local
|
|
}
|
|
candidates := s.heroCandidates(ctx, rows, now)
|
|
ranked := rankHeroCandidates(candidates, now, heroRowLimit)
|
|
if len(ranked) == 0 {
|
|
return nil
|
|
}
|
|
items := make([]json.RawMessage, 0, len(ranked))
|
|
for _, candidate := range ranked {
|
|
items = append(items, injectHeroFields(
|
|
candidate.Item,
|
|
heroLabel(candidate, now),
|
|
heroReason(candidate, now, location),
|
|
))
|
|
}
|
|
return &recommend.Row{
|
|
ID: heroRowID,
|
|
Title: "Featured",
|
|
Kind: heroRowKind,
|
|
Items: items,
|
|
}
|
|
}
|
|
|
|
// heroCandidates gathers everything eligible, premieres first.
|
|
//
|
|
// Premieres lead the input order so that they win a tie against a film of identical
|
|
// score — a returning show is the more time-sensitive piece of news, and the scorer
|
|
// cannot see that.
|
|
func (s *Server) heroCandidates(
|
|
ctx context.Context,
|
|
rows []recommend.Row,
|
|
now time.Time,
|
|
) []heroCandidate {
|
|
movies, facts := heroMovieCandidates(rows)
|
|
|
|
// Three independent reads, and on the one cache miss a day two of them are *arr round
|
|
// trips. They run together rather than in turn because this is the tail of the home
|
|
// response: every television in the house is waiting on it, and there is no reason for
|
|
// Sonarr's answer to be behind Radarr's.
|
|
var (
|
|
releases heroReleaseIndex
|
|
premieres []heroCandidate
|
|
providers map[string]string
|
|
wg sync.WaitGroup
|
|
)
|
|
wg.Add(3)
|
|
go func() { defer wg.Done(); releases = s.heroReleaseIndex(ctx, now) }()
|
|
go func() { defer wg.Done(); premieres = s.heroPremiereCandidates(ctx, now) }()
|
|
go func() { defer wg.Done(); providers = s.heroProviderIDs(ctx, facts) }()
|
|
wg.Wait()
|
|
|
|
for index := range movies {
|
|
fact := facts[movies[index].ID]
|
|
// Radarr's digital date is preferred over Emby's PremiereDate wherever there is
|
|
// one. That preference is the point of the feature: Emby's date is the
|
|
// theatrical release where it is right at all, and is a metadata agent's guess
|
|
// where it is not, so ranking "new releases" by it puts films in an order that
|
|
// has nothing to do with when the household could first watch them.
|
|
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 append(premieres, movies...)
|
|
}
|
|
|
|
// heroMovieCandidates reads the assembled rows. Nothing is fetched: these are the same
|
|
// payloads the launcher is about to be sent, ratings already attached.
|
|
func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]heroItemFacts) {
|
|
candidates := make([]heroCandidate, 0, heroCandidateLimit)
|
|
facts := make(map[string]heroItemFacts, heroCandidateLimit)
|
|
seen := make(map[string]bool, heroCandidateLimit)
|
|
for _, row := range rows {
|
|
// Continue Watching is what somebody is already in the middle of, which is the
|
|
// opposite of what a hero is for; the schedule rows are not playable at all.
|
|
if row.Kind == "continue" || row.Kind == "schedule" || row.Kind == "movie-schedule" {
|
|
continue
|
|
}
|
|
for _, raw := range row.Items {
|
|
if len(candidates) >= heroCandidateLimit {
|
|
return candidates, facts
|
|
}
|
|
fact, ok := heroFactsOf(raw)
|
|
if !ok || seen[fact.ID] || !fact.Playable ||
|
|
!strings.EqualFold(fact.Type, "Movie") {
|
|
continue
|
|
}
|
|
seen[fact.ID] = true
|
|
facts[fact.ID] = fact
|
|
rating, rated := heroRatingOf(raw)
|
|
candidates = append(candidates, heroCandidate{
|
|
ID: fact.ID,
|
|
Name: fact.Name,
|
|
Kind: heroMovie,
|
|
Item: raw,
|
|
ReleasedAt: fact.Premiere,
|
|
Rating: rating,
|
|
Rated: rated,
|
|
})
|
|
}
|
|
}
|
|
return candidates, facts
|
|
}
|
|
|
|
func heroYearOf(fact heroItemFacts) int {
|
|
if fact.Premiere.IsZero() {
|
|
return 0
|
|
}
|
|
return fact.Premiere.Year()
|
|
}
|
|
|
|
// heroProviderIDs resolves the candidates onto TMDB ids so Radarr can be matched exactly.
|
|
// A failure costs the exact match and leaves the title/year fallback.
|
|
func (s *Server) heroProviderIDs(
|
|
ctx context.Context,
|
|
facts map[string]heroItemFacts,
|
|
) map[string]string {
|
|
out := make(map[string]string, len(facts))
|
|
if s.store == nil || len(facts) == 0 {
|
|
return out
|
|
}
|
|
ids := make([]string, 0, len(facts))
|
|
for id := range facts {
|
|
ids = append(ids, id)
|
|
}
|
|
refs, err := s.store.LibraryProviderIDs(ctx, ids)
|
|
if err != nil {
|
|
s.loggerFor(ctx).Warn("hero provider ids unavailable", "error", err)
|
|
return out
|
|
}
|
|
for id, ref := range refs {
|
|
if tmdb := strings.TrimSpace(providerID(ref.ProviderIDs, "tmdb")); tmdb != "" {
|
|
out[id] = tmdb
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// heroReleaseIndex reads Radarr over the window that has already happened, cached for the
|
|
// day beside the schedule row's forward-looking one.
|
|
func (s *Server) heroReleaseIndex(ctx context.Context, now time.Time) heroReleaseIndex {
|
|
empty := heroReleaseIndex{
|
|
byTMDB: map[string]radarrRelease{},
|
|
byTitle: map[string]radarrRelease{},
|
|
}
|
|
if s.radarr == nil {
|
|
return empty
|
|
}
|
|
location := s.cfg.RadarrLocation
|
|
if location == nil {
|
|
location = time.Local
|
|
}
|
|
dayStart := localDayStart(now.In(location), location)
|
|
key := heroReleasedCachePrefix + dayStart.Format("2006-01-02")
|
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
|
var movies []radarr.Movie
|
|
if json.Unmarshal(raw, &movies) == nil {
|
|
return newHeroReleaseIndex(movies)
|
|
}
|
|
}
|
|
|
|
s.radarrMu.Lock()
|
|
defer s.radarrMu.Unlock()
|
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
|
var movies []radarr.Movie
|
|
if json.Unmarshal(raw, &movies) == nil {
|
|
return newHeroReleaseIndex(movies)
|
|
}
|
|
}
|
|
|
|
// The cinema fallback is cinema + 30 days, so a film whose digital date is unknown
|
|
// but which is inside the window had its cinema date up to 30 days before that.
|
|
movies, err := s.radarr.Calendar(
|
|
ctx,
|
|
dayStart.AddDate(0, 0, -(heroWindowDays+radarrTheatricalDelayDays)),
|
|
dayStart.AddDate(0, 0, 1),
|
|
)
|
|
if err != nil {
|
|
s.loggerFor(ctx).Warn("hero release calendar failed", "error", err)
|
|
return empty
|
|
}
|
|
if body, marshalErr := json.Marshal(movies); marshalErr == nil {
|
|
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.RadarrTTL); cacheErr != nil {
|
|
s.loggerFor(ctx).Warn("hero release cache write failed", "error", cacheErr)
|
|
}
|
|
}
|
|
return newHeroReleaseIndex(movies)
|
|
}
|
|
|
|
// heroPremiereCandidates reads Sonarr's recent calendar and turns each premiere into the
|
|
// Emby series card the household can play.
|
|
func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []heroCandidate {
|
|
if s.sonarr == nil || s.store == nil {
|
|
return nil
|
|
}
|
|
location := s.cfg.SonarrLocation
|
|
if location == nil {
|
|
location = time.Local
|
|
}
|
|
dayStart := localDayStart(now.In(location), location)
|
|
key := heroPremiereCachePrefix + dayStart.Format("2006-01-02")
|
|
|
|
var episodes []sonarr.Episode
|
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
|
_ = json.Unmarshal(raw, &episodes)
|
|
}
|
|
if episodes == nil {
|
|
s.sonarrMu.Lock()
|
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
|
_ = json.Unmarshal(raw, &episodes)
|
|
}
|
|
if episodes == nil {
|
|
fetched, err := s.sonarr.Calendar(
|
|
ctx,
|
|
dayStart.AddDate(0, 0, -heroWindowDays),
|
|
dayStart.AddDate(0, 0, 1),
|
|
)
|
|
if err != nil {
|
|
s.sonarrMu.Unlock()
|
|
s.loggerFor(ctx).Warn("hero premiere calendar failed", "error", err)
|
|
return nil
|
|
}
|
|
episodes = fetched
|
|
if body, marshalErr := json.Marshal(episodes); marshalErr == nil {
|
|
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
|
|
s.loggerFor(ctx).Warn("hero premiere cache write failed", "error", cacheErr)
|
|
}
|
|
}
|
|
}
|
|
s.sonarrMu.Unlock()
|
|
}
|
|
|
|
premieres := sonarrPremieres(
|
|
episodes,
|
|
s.embySeriesIndex(ctx),
|
|
now.AddDate(0, 0, -heroWindowDays),
|
|
now,
|
|
)
|
|
if len(premieres) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(premieres))
|
|
for _, premiere := range premieres {
|
|
ids = append(ids, premiere.EmbySeriesID)
|
|
}
|
|
payloads, err := s.store.LibraryItemsByID(ctx, ids)
|
|
if err != nil {
|
|
s.loggerFor(ctx).Warn("hero premiere series unavailable", "error", err)
|
|
return nil
|
|
}
|
|
// The imported catalogue is shared by the household and deliberately carries no user
|
|
// data, so these cards arrive without ratings. Decorating them is one indexed read
|
|
// and is what lets a premiere be ranked on the same terms as a film.
|
|
s.decorateItemRatings(ctx, payloads)
|
|
|
|
byID := make(map[string]json.RawMessage, len(payloads))
|
|
for _, raw := range payloads {
|
|
if id := itemIDOf(raw); id != "" {
|
|
byID[id] = raw
|
|
}
|
|
}
|
|
candidates := make([]heroCandidate, 0, len(premieres))
|
|
for _, premiere := range premieres {
|
|
raw, ok := byID[premiere.EmbySeriesID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
fact, ok := heroFactsOf(raw)
|
|
if !ok {
|
|
continue
|
|
}
|
|
kind := heroSeasonPremiere
|
|
if premiere.SeasonNumber == 1 {
|
|
kind = heroSeriesPremiere
|
|
}
|
|
rating, rated := heroRatingOf(raw)
|
|
candidates = append(candidates, heroCandidate{
|
|
ID: fact.ID,
|
|
Name: fact.Name,
|
|
Kind: kind,
|
|
Item: raw,
|
|
ReleasedAt: premiere.AiredAt,
|
|
Rating: rating,
|
|
Rated: rated,
|
|
})
|
|
}
|
|
return candidates
|
|
}
|