318 lines
11 KiB
Go
318 lines
11 KiB
Go
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) }
|