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" 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"` 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 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+":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 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) }() 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() location := s.householdLocation() window := homeForYouWindowAt(time.Now().In(location)) prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.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, window) 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) } // 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) 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.cfg.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) key := cache.UserKey(viewerKeyOf(ctx, sess), "search:"+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)}, }, fieldsRow)) 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 } // 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) }