Files
memby/server/internal/api/home.go
2026-08-28 23:00:02 +12:00

970 lines
36 KiB
Go

package api
import (
"context"
"encoding/json"
"math/rand/v2"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/timing"
)
// Field sets mirror what each TV row actually renders. Asking Emby for less is the
// single biggest lever on home-screen latency, so keep these tight.
const (
// Resume and Next Up contain episodes as well as movies. Keep their descriptive and
// rating fields in the row payload: an episode otherwise reaches the TV with only a
// title and progress, and the first ratings request can cache that incomplete state
// before the richer focus lookup finishes.
fieldsContinue = "Overview,Genres,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio"
fieldsRow = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
// Status is a series' production state ("Continuing"/"Ended"). The television needs it
// to decide whether it is estimating a finish or a catch-up, and the detail call is
// where a field like this belongs — adding it to a home query is a startup cost.
//
// Studios, Taglines, PremiereDate, OriginalTitle and ProductionLocations are the Details
// tab's vocabulary and are read nowhere else. They belong here for the same reason: this
// call is made once, after D-pad focus has settled on a card, and every one of them
// would be a per-card cost on a home row.
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ProviderIds,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
// Thumb rides every row, not only Continue Watching's: rowParams is the one query
// shape every row shares, and Emby only reports ParentThumbItemId/ParentThumbImageTag
// on an item when Thumb is among the requested types.
rowImageTypes = "Backdrop,Primary,Logo,Thumb"
screensaverImageTypes = "Backdrop,Logo"
)
type homeResponse struct {
// Rows is the home screen as the server wants it drawn: order, titles and kinds all
// decided here, so a new row (a recommendation strip, a seasonal collection) ships
// without touching the TV app. The client renders whatever arrives.
Rows []recommend.Row `json:"rows"`
RowRelevance []homeRowRelevance `json:"rowRelevance,omitempty"`
// The fixed rows are also sent flat. They are what the client caches for an
// instant cold start, and what the direct-to-Emby path still produces.
ContinueWatching []json.RawMessage `json:"continueWatching"`
NextUp []json.RawMessage `json:"nextUp"`
Favorites []json.RawMessage `json:"favorites"`
LatestMovies []json.RawMessage `json:"latestMovies"`
// Partial is true when at least one row failed upstream. The TV shows what arrived
// and flags a refresh error rather than blanking the screen.
Partial bool `json:"partial"`
// Deliberately no update verdict here: this payload is cached per user, and the
// verdict depends on the *client's* version, so a cached body would hand one TV's
// answer to another running a different build. The client asks /v1/update instead.
}
type homeRowRelevance struct {
RowID string `json:"rowId"`
Score float64 `json:"score"`
Reason string `json:"reason,omitempty"`
}
// handleHome answers the entire launcher in one round trip.
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
s.noteDailyFirstUse(ctx, sess)
limit := queryInt(r, "limit", 24, 100)
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
hero := supportsHomeHero(r)
now := time.Now()
featurePolicy := s.currentFeaturePolicy(ctx)
continueDefinition, _ := knownFeature(featureContinueWatching)
continueWatching := evaluateFeature(
featurePolicy,
continueDefinition,
ProtocolVersion,
).Enabled
// The For You window is the viewer's own device clock, not the household's: the
// greeting above this row already draws from the TV's local time, and a gateway
// hosted (or configured) in a different zone must not tell somebody "Good morning"
// while leading with "Late-night picks for you". See clientLocalNow.
forYouWindow := homeForYouWindowAt(clientLocalNow(r, now.In(s.householdLocation())))
// The hero revision is part of the key rather than something to invalidate. An
// operator's change therefore makes the entries built under the old policy simply
// unreachable, and they age out on their own TTL — where the sweep it replaced dropped
// every cached answer this household had, artwork and item lookups included, to change
// four cards. See heroRevision.
heroRev := heroRevision(s.currentHeroPolicy(ctx), sess.EmbyUserID, now, s.heroLocation())
key := cache.UserKey(
viewerKeyOf(ctx, sess),
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
":c"+strconv.FormatBool(continueWatching)+
":f"+strconv.FormatInt(featurePolicy.Revision, 10)+
":hr"+heroRev+":w"+forYouWindow.ID+":d"+sess.DeviceID,
)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
cred := credentials(sess)
// The fan-out's wall clock, which is a different number from the summed Emby time
// beside it and the more useful of the two: the sum says how much work Emby did, this
// says how long the launcher waited for it. They diverge exactly when the calls stop
// running concurrently, which is the failure this instrumentation exists to catch.
upstream := timing.Start(ctx, "fanout")
var (
mu sync.Mutex
failures int
rowAttempts int
out homeResponse
sonarrRow *recommend.Row
radarrRow *recommend.Row
forYouRows []recommend.Row
forYouRowStale bool
seriesPlayed map[string]time.Time
surfacedReady []store.SurfacedReadyRequest
ranking rankingInputs
rowStats []store.RowStat
rowStatsOK bool
wg sync.WaitGroup
)
// Each row names its own stage, so a slow launcher says which query it was waiting
// on rather than only that it was waiting on Emby. They are concurrent, so a single
// summed `emby=` would be the one number that could not answer that.
run := func(name string, dest *[]json.RawMessage, fetch func(context.Context) (*emby.ItemsResult, error)) {
rowAttempts++
wg.Add(1)
go func() {
defer wg.Done()
result, err := fetch(timing.WithLabel(ctx, "emby."+name))
mu.Lock()
defer mu.Unlock()
if err != nil {
failures++
s.loggerFor(ctx).Warn("home row failed", "row", name, "error", err)
return
}
*dest = result.Items
}()
}
// The three rows that are answers about a *person* rather than about the library.
// For the account's own viewer they are Emby's, exactly as they always were; for a
// shadow viewer they are built from that viewer's own state, and Emby is asked only to
// describe the titles. The fan-out, the failure counting and the merge below are
// unchanged either way — this is a substitution of one fetch for another, not a second
// code path through the launcher.
viewer := viewerOf(ctx, sess)
shadow := !viewer.IsMain() && s.store != nil
if continueWatching {
run("resume", &out.ContinueWatching, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerContinueRow(ctx, cred, viewer.ID, limit)
}
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
"Recursive": {"true"},
"MediaTypes": {"Video"},
"Limit": {itoa(limit)},
}, fieldsContinue))
})
}
run("favourites", &out.Favorites, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerFavouritesRow(ctx, cred, viewer.ID, limit)
}
return s.emby.Items(ctx, cred, rowParams(url.Values{
"Filters": {"IsFavorite"},
"IncludeItemTypes": {"Movie,Series"},
"Recursive": {"true"},
"SortBy": {"SortName"},
"SortOrder": {"Ascending"},
"Limit": {itoa(limit)},
}, fieldsRow))
})
if continueWatching {
run("nextup", &out.NextUp, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerNextUpRow(ctx, cred, viewer.ID, limit)
}
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsContinue))
})
}
// What orders the Next Up half of Continue Watching. A failure here is not a failed
// row: the merge falls back to putting the resume items first, which is the order
// the launcher had before the two rows became one.
if continueWatching {
wg.Add(1)
go func() {
defer wg.Done()
// What orders the two halves of the merge. For a shadow viewer it is one grouped
// query over their own state rather than a lookback over the account's plays —
// the same question, asked of the system that holds the answer.
var played map[string]time.Time
var err error
if shadow {
played, err = s.store.ViewerWatchedSeries(ctx, viewer.ID, continuePlayLookback)
} else {
played, err = s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred)
}
if err != nil {
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
return
}
mu.Lock()
seriesPlayed = played
mu.Unlock()
}()
}
run("latest", &out.LatestMovies, func(ctx context.Context) (*emby.ItemsResult, error) {
newReleaseDays := s.weightedConfig().NewReleaseDays
if newReleaseDays < 1 {
newReleaseDays = recommend.DefaultWeightedConfig().NewReleaseDays
}
return s.emby.Items(ctx, cred, rowParams(url.Values{
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
// Eligibility precedes personalization: this row means a real recent
// release, not merely an old film imported into the library yesterday.
"MinPremiereDate": {time.Now().AddDate(0, 0, -newReleaseDays).UTC().Format(time.RFC3339)},
"SortBy": {"PremiereDate"},
"SortOrder": {"Descending"},
"Limit": {itoa(limit)},
}, fieldsRow))
})
if sonarrSchedule {
wg.Add(1)
go func() {
defer wg.Done()
row, err := s.sonarrAiringTodayRow(ctx)
if err != nil {
s.loggerFor(ctx).Warn("sonarr calendar row failed", "error", err)
return
}
mu.Lock()
sonarrRow = row
mu.Unlock()
}()
}
if radarrSchedule {
wg.Add(1)
go func() {
defer wg.Done()
row, err := s.radarrUpcomingMoviesRow(ctx)
if err != nil {
s.loggerFor(ctx).Warn("radarr calendar row failed", "error", err)
return
}
mu.Lock()
radarrRow = row
mu.Unlock()
}()
}
// The ranking evidence and the row-order preferences depend on the viewer and on
// nothing that is being fetched around them, so they are read *beside* the Emby
// fan-out rather than after it. They used to be the first two things the response did
// once every row had arrived — eight Postgres reads with nothing else in flight,
// entirely on the critical path, for answers that were available before the request
// asked Emby anything.
wg.Add(1)
go func() {
defer wg.Done()
ranking = s.rankingInputs(ctx, sess.EmbyUserID)
}()
// The requests this viewer has asked for that have just become watchable, to be pinned
// to the front of Continue Watching. Read beside the Emby fan-out, not after it: it is a
// function of the viewer's id alone, and the overwhelmingly common answer is none.
if continueWatching && s.store != nil {
wg.Add(1)
go func() {
defer wg.Done()
ready, err := s.store.SurfacedReadyRequests(ctx, sess.EmbyUserID)
if err != nil {
s.loggerFor(ctx).Warn("surfaced request arrivals unavailable",
"user", sess.EmbyUserID, "error", err)
return
}
mu.Lock()
surfacedReady = ready
mu.Unlock()
}()
}
if s.store != nil {
wg.Add(1)
go func() {
defer wg.Done()
stats, err := s.store.UserRowStats(ctx, sess.EmbyUserID, time.Now().Add(-45*24*time.Hour))
if err != nil {
s.loggerFor(ctx).Warn("home row preferences unavailable",
"user", sess.EmbyUserID, "error", err)
return
}
rowStats, rowStatsOK = stats, true
}()
}
if s.forYou != nil {
// The prepared pool is one indexed PostgreSQL read. It runs beside the Emby
// calls and deliberately has no live-engine fallback, so Home can never inherit
// Tracearr fan-out or recommendation rebuild latency.
wg.Add(1)
go func() {
defer wg.Done()
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, forYouWindow.Minutes)
if err != nil {
s.loggerFor(ctx).Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
return
}
if !hit || len(prepared) == 0 {
return
}
homeRows := preparedHomeForYouRows(prepared, forYouWindow)
if len(homeRows) == 0 {
return
}
mu.Lock()
forYouRows = homeRows
forYouRowStale = stale
mu.Unlock()
}()
}
wg.Wait()
upstream()
if rowAttempts > 0 && failures == rowAttempts {
writeError(w, http.StatusBadGateway, "could not reach the emby server")
return
}
out.Partial = failures > 0
ensureSlices(&out)
// One row, not two: an episode finished a minute ago should be followed by the next
// one at the front of Continue Watching rather than moving the show to a different
// row. NextUp stays on the wire for televisions that predate the merge.
if continueWatching {
out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed)
}
// A current show with an episode today is more immediately useful than a long-lived
// resume from an ended rewatch. Keep Emby's order inside both groups; this is a
// promotion, not a replacement ranking for Continue Watching.
if continueWatching {
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
}
// A title this viewer requested that has just arrived is pinned to the front of the row
// (or tagged in place if it is already there), so the request journey ends where they
// look for something to watch rather than on the My Requests page.
if continueWatching && len(surfacedReady) > 0 {
out.ContinueWatching = s.injectRequestReady(ctx, cred, out.ContinueWatching, surfacedReady)
}
// Recommendations are read from their own long-lived cache. A miss means this
// response ships without them and a rebuild starts in the background — the home
// screen never waits on the engine.
recommendations := s.cachedRecommendations(ctx, sess.EmbyUserID)
if recommendations == nil {
s.refreshRecommendationsInBackground(sess)
}
assemble := timing.Start(ctx, timing.StageRows)
rows := visibleBaseRows(out, continueWatching)
nearContinue := make([]recommend.Row, 0, len(forYouRows)+2)
if len(forYouRows) > 0 {
nearContinue = append(nearContinue, forYouRows...)
if forYouRowStale {
s.forYou.MarkDirty(context.WithoutCancel(ctx), sess)
s.forYou.RefreshAsync(sess, false)
}
}
if sonarrRow != nil {
nearContinue = append(nearContinue, *sonarrRow)
}
if radarrRow != nil {
nearContinue = append(nearContinue, *radarrRow)
}
if len(nearContinue) > 0 {
// Personalised discovery and the upcoming schedule are most useful at the start of
// Home: after Continue Watching when it is present, before broader collections.
insertAt := 0
if continueWatching {
insertAt = 1
}
rows = append(rows[:insertAt], append(nearContinue, rows[insertAt:]...)...)
}
out.Rows = append(rows, recommendations...)
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
if rowStatsOK {
out.Rows, out.RowRelevance = rankHomeRows(out.Rows, rowStats, now)
} else {
out.Rows, out.RowRelevance = rankHomeRows(out.Rows, nil, now)
}
assemble()
rank := timing.Start(ctx, timing.StageRank)
out.Rows = s.personalizeTitlesWith(out.Rows, ranking)
out.Rows = personalizeRowsByTitleScores(selectPersonalizedRows(out.Rows))
out.Rows = deduplicateRows(out.Rows)
out.Rows = s.applyRowLayouts(ctx, out.Rows)
rank()
// Ratings ride on the cards themselves. Only what is already stored is attached, so
// the launcher pays one indexed read rather than a request per poster, and a card
// shows its scores as it is drawn instead of when focus reaches it.
decorate := timing.Start(ctx, timing.StageRatings)
s.decorateHomeRatings(ctx, &out)
decorate()
// The hero is composed last, from the finished rows, because that is the only point
// at which the ratings it ranks by are already attached. It is prepended rather than
// inserted: the television consumes this row instead of drawing it, so its position
// among the shelves means nothing, and being first is what lets an older reader that
// does draw it put it somewhere sensible.
if hero {
compose := timing.Start(ctx, timing.StageHero)
row := s.heroRow(ctx, out.Rows, sess.EmbyUserID, now)
compose()
if row != nil {
out.Rows = append([]recommend.Row{*row}, out.Rows...)
}
}
encode := timing.Start(ctx, timing.StageEncode)
body, err := json.Marshal(out)
encode()
if err != nil {
s.loggerFor(ctx).Error("home encode failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not build the home payload")
return
}
// A partial payload is served but never cached: the next request should retry.
if !out.Partial {
if err := s.cache.Set(ctx, key, body, s.homeTTL()); err != nil {
s.loggerFor(ctx).Warn("home cache write failed", "error", err)
}
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
func prioritizeAiringTodayContinue(
items []json.RawMessage,
schedule *recommend.Row,
) []json.RawMessage {
if len(items) < 2 || schedule == nil {
return items
}
today := map[string]bool{}
for _, raw := range schedule.Items {
var entry struct {
Name string `json:"Name"`
Day string `json:"MembyAirDayLabel"`
}
if json.Unmarshal(raw, &entry) == nil && strings.EqualFold(entry.Day, "Today") {
if key := normalizedShowKey(entry.Name); key != "" {
today[key] = true
}
}
}
if len(today) == 0 {
return items
}
promoted := make([]json.RawMessage, 0, len(items))
rest := make([]json.RawMessage, 0, len(items))
for _, raw := range items {
var item struct {
Name string `json:"Name"`
Type string `json:"Type"`
SeriesName string `json:"SeriesName"`
}
if json.Unmarshal(raw, &item) != nil {
rest = append(rest, raw)
continue
}
name := item.Name
if strings.EqualFold(item.Type, "Episode") && strings.TrimSpace(item.SeriesName) != "" {
name = item.SeriesName
}
if !strings.EqualFold(item.Type, "Episode") && !strings.EqualFold(item.Type, "Series") {
rest = append(rest, raw)
continue
}
if today[normalizedShowKey(name)] {
promoted = append(promoted, raw)
} else {
rest = append(rest, raw)
}
}
return append(promoted, rest...)
}
func normalizedShowKey(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// personalizeHomeRows applies a deliberately conservative engagement nudge. New and
// lightly sampled rows keep their authored position; only shelves repeatedly shown and
// ignored lose ground. Continue Watching remains the stable first landmark, while a
// selection or meaningful dwell quickly earns a shelf its position back.
func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommend.Row {
if len(rows) < 2 || len(stats) == 0 {
return rows
}
byID := make(map[string]store.RowStat, len(stats))
for _, stat := range stats {
byID[stat.RowID] = stat
}
type rankedRow struct {
row recommend.Row
position int
score float64
}
ranked := make([]rankedRow, 0, len(rows))
for position, row := range rows {
score := 1.0
if stat, ok := byID[row.ID]; ok && stat.Impressions >= 5 {
// Two neutral pseudo-impressions prevent a tiny sample from producing an
// extreme score. Dwell is capped by the ingestion endpoint.
engagement := float64(stat.Selects)*6 +
float64(stat.Focuses) +
float64(stat.DwellMs)/30_000
score = (engagement + 2) / (float64(stat.Impressions) + 2)
}
if row.ID == "continue" {
score = 1_000
}
// This recovery shelf is deliberately surfaced on Home. It may contain only one
// title, so its impression sample is naturally small and must not let the generic
// engagement sorter bury it below discovery rows.
if row.ID == "for-you:pick-up" {
score = 900
}
ranked = append(ranked, rankedRow{row: row, position: position, score: score})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
return ranked[i].position < ranked[j].position
})
out := make([]recommend.Row, 0, len(ranked))
for _, entry := range ranked {
out = append(out, entry.row)
}
return out
}
// rankHomeRows is the server-side contextual row engine. It deliberately keeps
// Continue Watching as the household's reliable first landmark, then scores discovery
// shelves using engagement, time context and the row's own data source. New row types can
// participate without a client release because only the row metadata is interpreted here.
func rankHomeRows(rows []recommend.Row, stats []store.RowStat, now time.Time) ([]recommend.Row, []homeRowRelevance) {
byID := make(map[string]store.RowStat, len(stats))
for _, stat := range stats {
byID[stat.RowID] = stat
}
type scored struct {
row recommend.Row
score float64
reason string
position int
}
ranked := make([]scored, 0, len(rows))
for position, row := range rows {
score := 1.0
reason := ""
id := strings.ToLower(row.ID + " " + row.Kind + " " + row.Title)
if stat, ok := byID[row.ID]; ok && stat.Impressions >= 3 {
engagement := float64(stat.Selects)*6 + float64(stat.Focuses) + float64(stat.DwellMs)/30_000
score += (engagement + 2) / (float64(stat.Impressions) + 2)
}
if strings.Contains(id, "continue") {
score += 1000
reason = "Continue Watching"
}
if now.Weekday() == time.Friday && now.Hour() >= 18 && (strings.Contains(id, "movie") || strings.Contains(id, "film")) {
score += 8
reason = "Friday night films"
}
if now.Weekday() == time.Sunday && now.Hour() < 18 && (strings.Contains(id, "easy") || strings.Contains(id, "comfort")) {
score += 7
reason = "Something easy for Sunday"
}
if now.Hour() >= 20 && strings.Contains(id, "episode") {
score += 6
reason = "One episode before bed"
}
if strings.Contains(id, "for-you") || strings.Contains(id, "recommend") {
score += 3
if reason == "" {
reason = "New for you"
}
}
if strings.Contains(id, "latest") || strings.Contains(id, "recent") {
score += 2
if reason == "" {
reason = "Recently added"
}
}
ranked = append(ranked, scored{row: row, score: score, reason: reason, position: position})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
return ranked[i].position < ranked[j].position
})
out := make([]recommend.Row, 0, len(ranked))
relevance := make([]homeRowRelevance, 0, len(ranked))
for _, item := range ranked {
out = append(out, item.row)
relevance = append(relevance, homeRowRelevance{RowID: item.row.ID, Score: item.score, Reason: item.reason})
}
return out, relevance
}
// preparedHomeForYouRows promotes the specific abandoned-show shelf as well as the
// time-aware general picks. Other For You shelves remain in the dedicated destination.
func preparedHomeForYouRows(
prepared []recommend.Row,
window homeForYouWindow,
) []recommend.Row {
rows := make([]recommend.Row, 0, 2)
for _, source := range prepared {
row := source
switch row.ID {
case "for-you:pick-up":
row.Title = "Pick this show up again"
case "for-you:picks":
row.ID = "for-you:home:" + window.ID
row.Title = window.Title
default:
continue
}
if len(row.Items) > 12 {
row.Items = row.Items[:12]
}
rows = append(rows, row)
}
return rows
}
// Older clients render unknown rows but do not understand MembyPlayable=false, so they
// could try to send a synthetic Sonarr id to Emby. The feature ships with 0.1.54.
func supportsSonarrSchedule(r *http.Request) bool {
version := clientVersion(r)
return version != "" && appupdate.CompareVersions(version, "0.1.54") >= 0
}
// Radarr movie schedules need the movie-specific row presentation introduced in 0.1.79.
func supportsRadarrSchedule(r *http.Request) bool {
version := clientVersion(r)
return version != "" && appupdate.CompareVersions(version, "0.1.79") >= 0
}
// The server-composed hero ships in 0.2.27. Gating it matters more than gating a shelf:
// a television that predates it has no idea the "hero" kind is meant to be consumed
// rather than drawn, so it renders the featured cards a second time as a "Featured" row
// of posters beneath the hero it picked for itself.
//
// Note that 0.2.27 is also the version the feature was *added to* rather than a version
// after it, so any 0.2.27 build already in the field is one of the televisions this is
// meant to exclude. That is a deliberate call by the operator; if it bites, moving this
// floor to the next version is the fix, not a client change.
func supportsHomeHero(r *http.Request) bool {
version := clientVersion(r)
return version != "" && appupdate.CompareVersions(version, "0.2.27") >= 0
}
// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
// request, so the Dream still looks random without re-querying Emby every few seconds.
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
limit := queryInt(r, "limit", 200, 400)
key := cache.UserKey(sess.EmbyUserID, "screensaver:"+itoa(limit))
var items []json.RawMessage
if raw, err := s.cache.Get(ctx, key); err == nil {
_ = json.Unmarshal(raw, &items)
}
if items == nil {
result, err := s.emby.Items(ctx, credentials(sess), url.Values{
"IncludeItemTypes": {"Movie,Series"},
"Recursive": {"true"},
"Filters": {"HasBackdrop"},
"SortBy": {"Random"},
"Limit": {itoa(limit)},
"Fields": {fieldsScreensaver},
"ImageTypeLimit": {"1"},
"EnableImageTypes": {screensaverImageTypes},
"EnableUserData": {"true"},
})
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load screensaver items")
return
}
items = result.Items
if raw, err := json.Marshal(items); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.ScreensaverTTL)
}
}
shuffled := make([]json.RawMessage, len(items))
copy(shuffled, items)
rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
writeJSON(w, http.StatusOK, map[string]any{"items": shuffled})
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
term := r.URL.Query().Get("q")
if len(term) < 2 {
writeJSON(w, http.StatusOK, map[string]any{"items": []json.RawMessage{}})
return
}
limit := queryInt(r, "limit", 40, 100)
// v2: the row now carries ProviderIds, so an entry written by the previous build
// cannot hide a field the deduplication against external discovery depends on.
key := cache.UserKey(viewerKeyOf(ctx, sess), "search:v2:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
// Every search the tab performs is recorded here, before the cache is consulted, so a
// query answered from Redis counts the same as one that reached Emby. The client also
// posts to /v1/search/history and an older APK is the only thing that records at all —
// recordSearchQuery's dedupe window is what stops the two writing the same query twice.
s.recordSearchQuery(ctx, sess, term)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
// Search always asks Emby with the signed-in user's credentials. The imported
// household catalogue may contain titles hidden by library permissions or parental
// controls and therefore cannot be an eligibility authority.
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
"SearchTerm": {term},
"IncludeItemTypes": {"Movie,Series,Episode"},
"Recursive": {"true"},
"Limit": {itoa(limit)},
// ProviderIds is what lets the television collapse a library result and an *arr
// discovery result for one title into a single card, on a stable id rather than
// on a title string.
}, fieldsRow+",ProviderIds"))
if err != nil {
s.writeUpstreamError(ctx, w, err, "search failed")
return
}
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
s.decorateItems(ctx, items)
// Instant search fires a request per keystroke past the second character, so this is
// DEBUG: it is the record of what somebody was looking for when nothing was found,
// not something to carry in the normal log.
s.loggerFor(ctx).Debug("search", "query", term, "results", len(items))
body, err := json.Marshal(map[string]any{"items": nonNil(items)})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not build search results")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil {
s.loggerFor(ctx).Warn("search cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
const (
// minSearchQueryRunes matches the client's own floor: one letter matches half a
// library, so the search tab does not ask below two and neither route records below it.
minSearchQueryRunes = 2
// maxSearchQueryRunes bounds what is written to search_history. The query arrives in a
// URL on one of the two routes, so the table's row size must not be the client's to
// choose. Runes rather than bytes, or a title in Japanese is rejected at a third of the
// length of one in English.
maxSearchQueryRunes = 200
)
// searchQueryRecordable is the one rule both routes apply, so a query the search handler
// records is exactly one the history endpoint would have accepted.
func searchQueryRecordable(term string) bool {
n := len([]rune(strings.TrimSpace(term)))
return n >= minSearchQueryRunes && n <= maxSearchQueryRunes
}
// recordSearchQuery writes a query the search tab performed, and never makes the viewer
// wait for it.
//
// Detached from the request context deliberately: instant search cancels the in-flight
// request on every keystroke (the client's collectLatest), so a write hung off r.Context()
// would be abandoned for precisely the searches somebody typed fastest — and the record is
// worth having whether or not they waited for the results.
func (s *Server) recordSearchQuery(ctx context.Context, sess store.Session, term string) {
if s.store == nil || !searchQueryRecordable(term) {
return
}
term = strings.TrimSpace(term)
log := s.loggerFor(ctx)
detached := context.WithoutCancel(ctx)
go func() {
ctx, cancel := context.WithTimeout(detached, 5*time.Second)
defer cancel()
if err := s.store.RecordSearch(ctx, sess.EmbyUserID, term); err != nil {
// Telemetry, not the answer: a search whose record failed still returns
// results, and this is DEBUG for the same reason the search line itself is.
log.Debug("search not recorded", "query", term, "error", err)
}
}()
}
type searchHistoryRequest struct {
Query string `json:"query"`
}
func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, sess store.Session) {
var req searchHistoryRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid search history request")
return
}
query := strings.TrimSpace(req.Query)
if !searchQueryRecordable(query) {
writeError(w, http.StatusBadRequest, "search query length is invalid")
return
}
if s.store == nil || s.store.RecordSearch(r.Context(), sess.EmbyUserID, query) != nil {
writeError(w, http.StatusInternalServerError, "could not record search")
return
}
w.WriteHeader(http.StatusNoContent)
}
// baseRows describes the three fixed rows.
//
// Titles live here rather than in the app so wording can change server-side. They are
// emitted even when empty: the client draws its own "Nothing in progress" message, and a
// row that vanishes as you watch things is more jarring than an empty one.
//
// There is deliberately no Next Up row: those episodes are merged into Continue Watching
// by mergeContinueWatching, which is where somebody looks for them the moment an episode
// ends.
func baseRows(h homeResponse) []recommend.Row {
return []recommend.Row{
{ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching},
{ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites},
{ID: "latest-movies", Title: "Recent New Releases", Kind: "latest", Items: h.LatestMovies},
}
}
func visibleBaseRows(h homeResponse, continueWatching bool) []recommend.Row {
rows := baseRows(h)
if continueWatching {
return rows
}
return rows[1:]
}
type homeForYouWindow struct {
ID string
Title string
Minutes int
}
// clientLocalHourHeader carries the viewer's own device clock, in hours, on every request.
// It exists so a time-sensitive row can agree with the greeting drawn above it: the
// greeting is the TV's own local time, and a household's configured timezone (or the
// gateway's host clock, when none is set) is a different fact that happens to usually
// agree with it — usually, not always, and the times it disagrees are exactly what "Good
// morning" beside "Late-night picks for you" looks like.
const clientLocalHourHeader = "X-Memby-Local-Hour"
// clientLocalNow substitutes the client's reported hour into householdFallback, so an
// older client (or one that failed to send the header) still gets an answer — the
// household's own idea of the time, which is what this row used before there was
// anything to disagree with it.
func clientLocalNow(r *http.Request, householdFallback time.Time) time.Time {
hour, ok := parseClientLocalHour(r.Header.Get(clientLocalHourHeader))
if !ok {
return householdFallback
}
return time.Date(
householdFallback.Year(), householdFallback.Month(), householdFallback.Day(),
hour, householdFallback.Minute(), householdFallback.Second(), 0,
householdFallback.Location(),
)
}
func parseClientLocalHour(value string) (int, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
hour, err := strconv.Atoi(value)
if err != nil || hour < 0 || hour > 23 {
return 0, false
}
return hour, true
}
// homeForYouWindowAt keeps Home useful without asking the viewer for a duration.
// These deliberately broad windows suit a household TV: short before lunch, an
// episode-sized pick in the afternoon/late evening, and film headroom at night.
func homeForYouWindowAt(now time.Time) homeForYouWindow {
switch hour := now.Hour(); {
case hour >= 5 && hour < 12:
return homeForYouWindow{ID: "morning", Title: "Quick morning picks for you", Minutes: 30}
case hour >= 12 && hour < 17:
return homeForYouWindow{ID: "afternoon", Title: "An hour for your afternoon", Minutes: 60}
case hour >= 17 && hour < 23:
return homeForYouWindow{ID: "evening", Title: "Tonight's picks for you", Minutes: 120}
default:
return homeForYouWindow{ID: "late-night", Title: "Late-night picks for you", Minutes: 60}
}
}
// rowParams applies the query shape every list endpoint shares.
func rowParams(params url.Values, fields string) url.Values {
params.Set("Fields", fields)
params.Set("ImageTypeLimit", "1")
params.Set("EnableImages", "true")
params.Set("EnableImageTypes", rowImageTypes)
params.Set("EnableTotalRecordCount", "false")
params.Set("EnableUserData", "true")
return params
}
// ensureSlices keeps empty rows as [] rather than null, so kotlinx.serialization can
// decode them into non-null List fields.
func ensureSlices(h *homeResponse) {
h.ContinueWatching = nonNil(h.ContinueWatching)
h.NextUp = nonNil(h.NextUp)
h.Favorites = nonNil(h.Favorites)
h.LatestMovies = nonNil(h.LatestMovies)
}
func nonNil(items []json.RawMessage) []json.RawMessage {
if items == nil {
return []json.RawMessage{}
}
return items
}
func itoa(v int) string { return strconv.Itoa(v) }