2026-07-27 08:16:20 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-29 15:26:27 +12:00
|
|
|
"context"
|
2026-07-27 08:16:20 +12:00
|
|
|
"encoding/json"
|
|
|
|
|
"math/rand/v2"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
2026-08-02 22:10:19 +12:00
|
|
|
"sort"
|
2026-07-27 08:16:20 +12:00
|
|
|
"strconv"
|
2026-07-29 15:26:27 +12:00
|
|
|
"strings"
|
2026-07-27 08:16:20 +12:00
|
|
|
"sync"
|
2026-07-29 15:26:27 +12:00
|
|
|
"time"
|
2026-08-06 22:33:56 +12:00
|
|
|
"unicode"
|
2026-07-27 08:16:20 +12:00
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
2026-07-27 08:16:20 +12:00
|
|
|
"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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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 (
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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.
|
|
|
|
|
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
|
2026-07-27 08:16:20 +12:00
|
|
|
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
|
|
|
|
|
|
|
|
|
|
rowImageTypes = "Backdrop,Primary,Logo"
|
|
|
|
|
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"`
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
// The fixed rows are also sent flat. They are what the client caches for an
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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"`
|
2026-07-27 08:34:04 +12:00
|
|
|
|
|
|
|
|
// 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.
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
limit := queryInt(r, "limit", 24, 100)
|
2026-08-02 22:10:19 +12:00
|
|
|
sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
|
|
|
|
|
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
|
2026-08-07 10:44:17 +12:00
|
|
|
hero := supportsHomeHero(r)
|
2026-08-02 22:10:19 +12:00
|
|
|
key := cache.UserKey(
|
|
|
|
|
sess.EmbyUserID,
|
2026-08-07 10:44:17 +12:00
|
|
|
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
|
|
|
|
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
|
|
|
|
|
":d"+sess.DeviceID,
|
2026-08-02 22:10:19 +12:00
|
|
|
)
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
var (
|
2026-07-29 15:26:27 +12:00
|
|
|
mu sync.Mutex
|
|
|
|
|
failures int
|
|
|
|
|
out homeResponse
|
|
|
|
|
sonarrRow *recommend.Row
|
2026-08-02 22:10:19 +12:00
|
|
|
radarrRow *recommend.Row
|
|
|
|
|
forYouRows []recommend.Row
|
2026-07-29 15:26:27 +12:00
|
|
|
forYouRowStale bool
|
2026-08-06 22:33:56 +12:00
|
|
|
seriesPlayed map[string]time.Time
|
2026-07-29 15:26:27 +12:00
|
|
|
wg sync.WaitGroup
|
2026-07-27 08:16:20 +12:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func() {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
result, err := fetch()
|
|
|
|
|
mu.Lock()
|
|
|
|
|
defer mu.Unlock()
|
|
|
|
|
if err != nil {
|
|
|
|
|
failures++
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("home row failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
*dest = result.Items
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
run(&out.ContinueWatching, func() (*emby.ItemsResult, error) {
|
2026-07-29 15:26:27 +12:00
|
|
|
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
|
|
|
|
|
"Recursive": {"true"},
|
|
|
|
|
"MediaTypes": {"Video"},
|
|
|
|
|
"Limit": {itoa(limit)},
|
2026-07-27 08:16:20 +12:00
|
|
|
}, fieldsContinue))
|
|
|
|
|
})
|
|
|
|
|
run(&out.Favorites, func() (*emby.ItemsResult, error) {
|
|
|
|
|
return s.emby.Items(ctx, cred, rowParams(url.Values{
|
|
|
|
|
"Filters": {"IsFavorite"},
|
|
|
|
|
"IncludeItemTypes": {"Movie,Series"},
|
|
|
|
|
"Recursive": {"true"},
|
|
|
|
|
"SortBy": {"SortName"},
|
|
|
|
|
"SortOrder": {"Ascending"},
|
|
|
|
|
"Limit": {itoa(limit)},
|
|
|
|
|
}, fieldsRow))
|
|
|
|
|
})
|
2026-07-29 15:26:27 +12:00
|
|
|
run(&out.NextUp, func() (*emby.ItemsResult, error) {
|
|
|
|
|
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
|
|
|
|
|
"Limit": {itoa(limit)},
|
|
|
|
|
}, fieldsContinue))
|
|
|
|
|
})
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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.
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func() {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
played, err := s.recentlyPlayedSeries(ctx, cred)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
mu.Lock()
|
|
|
|
|
seriesPlayed = played
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
}()
|
2026-07-27 08:16:20 +12:00
|
|
|
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
|
2026-08-02 22:10:19 +12:00
|
|
|
newReleaseDays := s.weightedConfig().NewReleaseDays
|
|
|
|
|
if newReleaseDays < 1 {
|
|
|
|
|
newReleaseDays = recommend.DefaultWeightedConfig().NewReleaseDays
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
return s.emby.Items(ctx, cred, rowParams(url.Values{
|
|
|
|
|
"IncludeItemTypes": {"Movie"},
|
|
|
|
|
"Recursive": {"true"},
|
2026-08-02 22:10:19 +12:00
|
|
|
// 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)},
|
2026-07-27 08:16:20 +12:00
|
|
|
}, fieldsRow))
|
|
|
|
|
})
|
2026-08-02 22:10:19 +12:00
|
|
|
if sonarrSchedule {
|
2026-07-27 21:06:51 +12:00
|
|
|
wg.Add(1)
|
|
|
|
|
go func() {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
row, err := s.sonarrAiringTodayRow(ctx)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("sonarr calendar row failed", "error", err)
|
2026-07-27 21:06:51 +12:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
mu.Lock()
|
|
|
|
|
sonarrRow = row
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
}()
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
if radarrSchedule {
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func() {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
row, err := s.radarrUpcomingMoviesRow(ctx)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("radarr calendar row failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
mu.Lock()
|
|
|
|
|
radarrRow = row
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
}()
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
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()
|
|
|
|
|
location := s.cfg.SonarrLocation
|
|
|
|
|
if location == nil {
|
|
|
|
|
location = time.UTC
|
|
|
|
|
}
|
|
|
|
|
window := homeForYouWindowAt(time.Now().In(location))
|
|
|
|
|
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
|
2026-07-29 15:26:27 +12:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !hit || len(prepared) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
homeRows := preparedHomeForYouRows(prepared, window)
|
|
|
|
|
if len(homeRows) == 0 {
|
2026-07-29 15:26:27 +12:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
mu.Lock()
|
2026-08-02 22:10:19 +12:00
|
|
|
forYouRows = homeRows
|
2026-07-29 15:26:27 +12:00
|
|
|
forYouRowStale = stale
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
}()
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
if failures == 4 {
|
|
|
|
|
writeError(w, http.StatusBadGateway, "could not reach the emby server")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
out.Partial = failures > 0
|
|
|
|
|
ensureSlices(&out)
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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.
|
|
|
|
|
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.
|
|
|
|
|
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
2026-07-27 21:06:51 +12:00
|
|
|
rows := baseRows(out)
|
2026-08-02 22:10:19 +12:00
|
|
|
nearContinue := make([]recommend.Row, 0, len(forYouRows)+2)
|
|
|
|
|
if len(forYouRows) > 0 {
|
|
|
|
|
nearContinue = append(nearContinue, forYouRows...)
|
2026-07-29 15:26:27 +12:00
|
|
|
if forYouRowStale {
|
|
|
|
|
s.forYou.MarkDirty(context.WithoutCancel(ctx), sess)
|
|
|
|
|
s.forYou.RefreshAsync(sess, false)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 21:06:51 +12:00
|
|
|
if sonarrRow != nil {
|
2026-07-29 15:26:27 +12:00
|
|
|
nearContinue = append(nearContinue, *sonarrRow)
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
if radarrRow != nil {
|
|
|
|
|
nearContinue = append(nearContinue, *radarrRow)
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
if len(nearContinue) > 0 {
|
2026-08-02 22:10:19 +12:00
|
|
|
// Personalised discovery and the upcoming schedule are most useful immediately
|
2026-07-29 15:26:27 +12:00
|
|
|
// after Continue Watching, before the broader library collections.
|
|
|
|
|
rows = append(rows[:1], append(nearContinue, rows[1:]...)...)
|
2026-07-27 21:06:51 +12:00
|
|
|
}
|
|
|
|
|
out.Rows = append(rows, recommendations...)
|
2026-08-02 22:10:19 +12:00
|
|
|
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
|
|
|
|
|
if stats, err := s.store.UserRowStats(
|
|
|
|
|
ctx,
|
|
|
|
|
sess.EmbyUserID,
|
|
|
|
|
time.Now().Add(-45*24*time.Hour),
|
|
|
|
|
); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("home row preferences unavailable", "user", sess.EmbyUserID, "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
} else {
|
|
|
|
|
out.Rows = personalizeHomeRows(out.Rows, stats)
|
|
|
|
|
}
|
|
|
|
|
out.Rows = s.personalizeTitles(ctx, sess, out.Rows)
|
|
|
|
|
out.Rows = personalizeRowsByTitleScores(selectPersonalizedRows(out.Rows))
|
|
|
|
|
out.Rows = deduplicateRows(out.Rows)
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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.
|
|
|
|
|
s.decorateHomeRatings(ctx, &out)
|
2026-08-07 10:44:17 +12:00
|
|
|
// 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 {
|
|
|
|
|
if row := s.heroRow(ctx, out.Rows, time.Now()); row != nil {
|
|
|
|
|
out.Rows = append([]recommend.Row{*row}, out.Rows...)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
body, err := json.Marshal(out)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Error("home encode failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
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.cfg.HomeTTL); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("home cache write failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("X-Memby-Cache", "miss")
|
|
|
|
|
writeRaw(w, http.StatusOK, body)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
if row.ID == "continue" {
|
2026-08-02 22:10:19 +12:00
|
|
|
score = 1_000
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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 {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.writeUpstreamError(ctx, w, err, "could not load screensaver items")
|
2026-07-27 08:16:20 +12:00
|
|
|
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)
|
2026-08-02 22:10:19 +12:00
|
|
|
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
|
|
|
|
w.Header().Set("X-Memby-Cache", "hit")
|
|
|
|
|
writeRaw(w, http.StatusOK, raw)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
// 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)},
|
|
|
|
|
}, fieldsRow))
|
2026-07-27 08:16:20 +12:00
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.writeUpstreamError(ctx, w, err, "search failed")
|
2026-08-02 22:10:19 +12:00
|
|
|
return
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
|
2026-08-06 22:33:56 +12:00
|
|
|
s.decorateItemRatings(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))
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
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 {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("search cache write failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
w.Header().Set("X-Memby-Cache", "miss")
|
|
|
|
|
writeRaw(w, http.StatusOK, body)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
type searchHistoryRequest struct {
|
|
|
|
|
Query string `json:"query"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type searchHistoryResponse struct {
|
|
|
|
|
Queries []string `json:"queries"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
recentSearchDays = 30
|
|
|
|
|
recentSearchLimit = 10
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleRecentSearches(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
|
|
|
if s.store == nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not load recent searches")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
since := time.Now().Add(-recentSearchDays * 24 * time.Hour)
|
|
|
|
|
queries, err := s.store.RecentSearches(
|
|
|
|
|
r.Context(),
|
|
|
|
|
sess.EmbyUserID,
|
|
|
|
|
since,
|
|
|
|
|
recentSearchLimit,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not load recent searches")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if queries == nil {
|
|
|
|
|
queries = []string{}
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, searchHistoryResponse{Queries: queries})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 len([]rune(query)) < 2 || len([]rune(query)) > 200 {
|
|
|
|
|
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.
|
2026-07-27 08:16:20 +12:00
|
|
|
//
|
|
|
|
|
// 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.
|
2026-08-06 22:33:56 +12:00
|
|
|
//
|
|
|
|
|
// 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.
|
2026-07-27 08:16:20 +12:00
|
|
|
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},
|
2026-07-29 15:26:27 +12:00
|
|
|
{ID: "latest-movies", Title: "Recent New Releases", Kind: "latest", Items: h.LatestMovies},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type homeForYouWindow struct {
|
|
|
|
|
ID string
|
|
|
|
|
Title string
|
|
|
|
|
Minutes int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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}
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) }
|