Big changes
This commit is contained in:
+154
-24
@@ -1,12 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
@@ -19,9 +22,8 @@ import (
|
||||
// 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"
|
||||
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio"
|
||||
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
|
||||
|
||||
rowImageTypes = "Backdrop,Primary,Logo"
|
||||
@@ -34,7 +36,7 @@ type homeResponse struct {
|
||||
// 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
|
||||
// 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"`
|
||||
@@ -64,11 +66,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
|
||||
cred := credentials(sess)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
out homeResponse
|
||||
sonarrRow *recommend.Row
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
out homeResponse
|
||||
sonarrRow *recommend.Row
|
||||
forYouRow *recommend.Row
|
||||
forYouRowStale bool
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
|
||||
@@ -88,20 +92,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
}
|
||||
|
||||
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)},
|
||||
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
|
||||
"Recursive": {"true"},
|
||||
"MediaTypes": {"Video"},
|
||||
"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"},
|
||||
@@ -112,6 +108,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
"Limit": {itoa(limit)},
|
||||
}, fieldsRow))
|
||||
})
|
||||
run(&out.NextUp, func() (*emby.ItemsResult, error) {
|
||||
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
|
||||
"Limit": {itoa(limit)},
|
||||
}, fieldsContinue))
|
||||
})
|
||||
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
|
||||
return s.emby.Items(ctx, cred, rowParams(url.Values{
|
||||
"IncludeItemTypes": {"Movie"},
|
||||
@@ -135,6 +136,48 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
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 {
|
||||
s.log.Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
|
||||
return
|
||||
}
|
||||
if !hit || len(prepared) == 0 {
|
||||
return
|
||||
}
|
||||
rowIndex := -1
|
||||
for i := range prepared {
|
||||
if prepared[i].ID == "for-you:picks" {
|
||||
rowIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if rowIndex < 0 {
|
||||
return
|
||||
}
|
||||
row := prepared[rowIndex]
|
||||
row.ID = "for-you:home:" + window.ID
|
||||
row.Title = window.Title
|
||||
if len(row.Items) > 12 {
|
||||
row.Items = row.Items[:12]
|
||||
}
|
||||
mu.Lock()
|
||||
forYouRow = &row
|
||||
forYouRowStale = stale
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
@@ -153,9 +196,21 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
s.refreshRecommendationsInBackground(sess)
|
||||
}
|
||||
rows := baseRows(out)
|
||||
nearContinue := make([]recommend.Row, 0, 2)
|
||||
if forYouRow != nil {
|
||||
nearContinue = append(nearContinue, *forYouRow)
|
||||
if forYouRowStale {
|
||||
s.forYou.MarkDirty(context.WithoutCancel(ctx), sess)
|
||||
s.forYou.RefreshAsync(sess, false)
|
||||
}
|
||||
}
|
||||
if sonarrRow != nil {
|
||||
// The schedule is most useful beside Next Up, before personal collections.
|
||||
rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...)
|
||||
nearContinue = append(nearContinue, *sonarrRow)
|
||||
}
|
||||
if len(nearContinue) > 0 {
|
||||
// Personalised discovery and today's schedule are most useful immediately
|
||||
// after Continue Watching, before the broader library collections.
|
||||
rows = append(rows[:1], append(nearContinue, rows[1:]...)...)
|
||||
}
|
||||
out.Rows = append(rows, recommendations...)
|
||||
|
||||
@@ -273,7 +328,60 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// baseRows describes the four fixed rows.
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
@@ -283,7 +391,29 @@ func baseRows(h homeResponse) []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},
|
||||
{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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user