package api import ( "encoding/json" "math/rand/v2" "net/http" "net/url" "strconv" "sync" "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" ) // 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 ( fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio" fieldsNextUp = "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio" fieldsRow = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio" fieldsDetail = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio" 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"` // The four 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. } // 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) key := cache.UserKey(sess.EmbyUserID, "home:"+itoa(limit)) 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 ( mu sync.Mutex failures int out homeResponse sonarrRow *recommend.Row wg sync.WaitGroup ) 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++ s.log.Warn("home row failed", "error", err) return } *dest = result.Items }() } run(&out.ContinueWatching, func() (*emby.ItemsResult, error) { return s.emby.Items(ctx, cred, rowParams(url.Values{ "Filters": {"IsResumable"}, "IncludeItemTypes": {"Movie,Episode"}, "Recursive": {"true"}, "SortBy": {"DatePlayed"}, "SortOrder": {"Descending"}, "Limit": {itoa(limit)}, }, fieldsContinue)) }) run(&out.NextUp, func() (*emby.ItemsResult, error) { return s.emby.NextUp(ctx, cred, rowParams(url.Values{ "Limit": {itoa(limit)}, }, fieldsNextUp)) }) 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)) }) run(&out.LatestMovies, func() (*emby.ItemsResult, error) { return s.emby.Items(ctx, cred, rowParams(url.Values{ "IncludeItemTypes": {"Movie"}, "Recursive": {"true"}, "SortBy": {"DateCreated"}, "SortOrder": {"Descending"}, "Limit": {itoa(limit)}, }, fieldsRow)) }) if s.sonarr != nil && supportsSonarrSchedule(r) { wg.Add(1) go func() { defer wg.Done() row, err := s.sonarrAiringTodayRow(ctx) if err != nil { s.log.Warn("sonarr calendar row failed", "error", err) return } mu.Lock() sonarrRow = row mu.Unlock() }() } wg.Wait() if failures == 4 { writeError(w, http.StatusBadGateway, "could not reach the emby server") return } out.Partial = failures > 0 ensureSlices(&out) // 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) } rows := baseRows(out) if sonarrRow != nil { // The schedule is most useful beside Next Up, before personal collections. rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...) } out.Rows = append(rows, recommendations...) body, err := json.Marshal(out) if err != nil { s.log.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.log.Warn("home cache write failed", "error", err) } } w.Header().Set("X-Memby-Cache", "miss") writeRaw(w, http.StatusOK, body) } // 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 } // 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(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(sess.EmbyUserID, "search:"+itoa(limit)+":"+term) if raw, err := s.cache.Get(ctx, key); err == nil { w.Header().Set("X-Memby-Cache", "hit") writeRaw(w, http.StatusOK, raw) return } // The imported library answers search from Postgres, which is the difference // between "instant" and "one round trip to Emby per keystroke". An empty result // falls through to Emby, so search still works before the first import completes. items, err := s.store.SearchLibrary(ctx, term, limit) if err != nil { s.log.Warn("library search failed; falling back to emby", "error", err) items = nil } if len(items) == 0 { 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(w, err, "search failed") return } items = result.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.log.Warn("search cache write failed", "error", err) } w.Header().Set("X-Memby-Cache", "miss") writeRaw(w, http.StatusOK, body) } // baseRows describes the four 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. func baseRows(h homeResponse) []recommend.Row { return []recommend.Row{ {ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching}, {ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp}, {ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites}, {ID: "latest-movies", Title: "Recently Added Movies", Kind: "latest", Items: h.LatestMovies}, } } // 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) }