Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+154 -47
View File
@@ -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 {