Publish current app and server
This commit is contained in:
+154
-47
@@ -6,6 +6,7 @@ import (
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -22,8 +23,8 @@ import (
|
||||
// single biggest lever on home-screen latency, so keep these tight.
|
||||
const (
|
||||
fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
|
||||
fieldsRow = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
|
||||
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio"
|
||||
fieldsRow = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
|
||||
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName"
|
||||
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
|
||||
|
||||
rowImageTypes = "Backdrop,Primary,Logo"
|
||||
@@ -56,7 +57,13 @@ type homeResponse struct {
|
||||
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))
|
||||
sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
|
||||
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
|
||||
key := cache.UserKey(
|
||||
sess.EmbyUserID,
|
||||
"home:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||
":r"+strconv.FormatBool(radarrSchedule)+":d"+sess.DeviceID,
|
||||
)
|
||||
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
@@ -70,7 +77,8 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
failures int
|
||||
out homeResponse
|
||||
sonarrRow *recommend.Row
|
||||
forYouRow *recommend.Row
|
||||
radarrRow *recommend.Row
|
||||
forYouRows []recommend.Row
|
||||
forYouRowStale bool
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
@@ -114,15 +122,22 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
}, fieldsContinue))
|
||||
})
|
||||
run(&out.LatestMovies, func() (*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"},
|
||||
"SortBy": {"DateCreated"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {itoa(limit)},
|
||||
// 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 s.sonarr != nil && supportsSonarrSchedule(r) {
|
||||
if sonarrSchedule {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -136,6 +151,20 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
if radarrSchedule {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
row, err := s.radarrUpcomingMoviesRow(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("radarr calendar row failed", "error", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
radarrRow = row
|
||||
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
|
||||
@@ -156,24 +185,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
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 {
|
||||
homeRows := preparedHomeForYouRows(prepared, window)
|
||||
if len(homeRows) == 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
|
||||
forYouRows = homeRows
|
||||
forYouRowStale = stale
|
||||
mu.Unlock()
|
||||
}()
|
||||
@@ -196,9 +213,9 @@ 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)
|
||||
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)
|
||||
@@ -207,12 +224,28 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
if sonarrRow != nil {
|
||||
nearContinue = append(nearContinue, *sonarrRow)
|
||||
}
|
||||
if radarrRow != nil {
|
||||
nearContinue = append(nearContinue, *radarrRow)
|
||||
}
|
||||
if len(nearContinue) > 0 {
|
||||
// Personalised discovery and today's schedule are most useful immediately
|
||||
// Personalised discovery and the upcoming 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...)
|
||||
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 {
|
||||
s.log.Warn("home row preferences unavailable", "user", sess.EmbyUserID, "error", err)
|
||||
} 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)
|
||||
|
||||
body, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
@@ -230,6 +263,81 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
switch row.ID {
|
||||
case "continue":
|
||||
score = 1_000
|
||||
case "next-up":
|
||||
score += 0.35
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -237,6 +345,12 @@ func supportsSonarrSchedule(r *http.Request) bool {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -286,7 +400,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", 40, 100)
|
||||
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term)
|
||||
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
|
||||
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
@@ -294,27 +408,20 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
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)
|
||||
// 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.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
|
||||
s.writeUpstreamError(w, err, "search failed")
|
||||
return
|
||||
}
|
||||
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
|
||||
|
||||
body, err := json.Marshal(map[string]any{"items": nonNil(items)})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user