App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
+121
-19
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
@@ -22,9 +23,16 @@ import (
|
||||
// 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"
|
||||
fieldsRow = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
|
||||
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName"
|
||||
// Resume and Next Up contain episodes as well as movies. Keep their descriptive and
|
||||
// rating fields in the row payload: an episode otherwise reaches the TV with only a
|
||||
// title and progress, and the first ratings request can cache that incomplete state
|
||||
// before the richer focus lookup finishes.
|
||||
fieldsContinue = "Overview,Genres,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio"
|
||||
fieldsRow = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
|
||||
// Status is a series' production state ("Continuing"/"Ended"). The television needs it
|
||||
// to decide whether it is estimating a finish or a catch-up, and the detail call is
|
||||
// where a field like this belongs — adding it to a home query is a startup cost.
|
||||
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
|
||||
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
|
||||
|
||||
rowImageTypes = "Backdrop,Primary,Logo"
|
||||
@@ -61,7 +69,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
|
||||
key := cache.UserKey(
|
||||
sess.EmbyUserID,
|
||||
"home:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||
"home:v3:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||
":r"+strconv.FormatBool(radarrSchedule)+":d"+sess.DeviceID,
|
||||
)
|
||||
|
||||
@@ -80,6 +88,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
radarrRow *recommend.Row
|
||||
forYouRows []recommend.Row
|
||||
forYouRowStale bool
|
||||
seriesPlayed map[string]time.Time
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
@@ -92,7 +101,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
failures++
|
||||
s.log.Warn("home row failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("home row failed", "error", err)
|
||||
return
|
||||
}
|
||||
*dest = result.Items
|
||||
@@ -121,6 +130,21 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
"Limit": {itoa(limit)},
|
||||
}, fieldsContinue))
|
||||
})
|
||||
// What orders the Next Up half of Continue Watching. A failure here is not a failed
|
||||
// row: the merge falls back to putting the resume items first, which is the order
|
||||
// the launcher had before the two rows became one.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
played, err := s.recentlyPlayedSeries(ctx, cred)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
seriesPlayed = played
|
||||
mu.Unlock()
|
||||
}()
|
||||
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
|
||||
newReleaseDays := s.weightedConfig().NewReleaseDays
|
||||
if newReleaseDays < 1 {
|
||||
@@ -143,7 +167,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
defer wg.Done()
|
||||
row, err := s.sonarrAiringTodayRow(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("sonarr calendar row failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("sonarr calendar row failed", "error", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
@@ -157,7 +181,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
defer wg.Done()
|
||||
row, err := s.radarrUpcomingMoviesRow(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("radarr calendar row failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("radarr calendar row failed", "error", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
@@ -179,7 +203,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
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)
|
||||
s.loggerFor(ctx).Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
|
||||
return
|
||||
}
|
||||
if !hit || len(prepared) == 0 {
|
||||
@@ -204,6 +228,14 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
}
|
||||
out.Partial = failures > 0
|
||||
ensureSlices(&out)
|
||||
// One row, not two: an episode finished a minute ago should be followed by the next
|
||||
// one at the front of Continue Watching rather than moving the show to a different
|
||||
// row. NextUp stays on the wire for televisions that predate the merge.
|
||||
out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed)
|
||||
// A current show with an episode today is more immediately useful than a long-lived
|
||||
// resume from an ended rewatch. Keep Emby's order inside both groups; this is a
|
||||
// promotion, not a replacement ranking for Continue Watching.
|
||||
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
|
||||
|
||||
// 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
|
||||
@@ -239,30 +271,95 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
sess.EmbyUserID,
|
||||
time.Now().Add(-45*24*time.Hour),
|
||||
); err != nil {
|
||||
s.log.Warn("home row preferences unavailable", "user", sess.EmbyUserID, "error", err)
|
||||
s.loggerFor(ctx).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)
|
||||
// Ratings ride on the cards themselves. Only what is already stored is attached, so
|
||||
// the launcher pays one indexed read rather than a request per poster, and a card
|
||||
// shows its scores as it is drawn instead of when focus reaches it.
|
||||
s.decorateHomeRatings(ctx, &out)
|
||||
|
||||
body, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
s.log.Error("home encode failed", "error", err)
|
||||
s.loggerFor(ctx).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)
|
||||
s.loggerFor(ctx).Warn("home cache write failed", "error", err)
|
||||
}
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func prioritizeAiringTodayContinue(
|
||||
items []json.RawMessage,
|
||||
schedule *recommend.Row,
|
||||
) []json.RawMessage {
|
||||
if len(items) < 2 || schedule == nil {
|
||||
return items
|
||||
}
|
||||
today := map[string]bool{}
|
||||
for _, raw := range schedule.Items {
|
||||
var entry struct {
|
||||
Name string `json:"Name"`
|
||||
Day string `json:"MembyAirDayLabel"`
|
||||
}
|
||||
if json.Unmarshal(raw, &entry) == nil && strings.EqualFold(entry.Day, "Today") {
|
||||
if key := normalizedShowKey(entry.Name); key != "" {
|
||||
today[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(today) == 0 {
|
||||
return items
|
||||
}
|
||||
promoted := make([]json.RawMessage, 0, len(items))
|
||||
rest := make([]json.RawMessage, 0, len(items))
|
||||
for _, raw := range items {
|
||||
var item struct {
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
}
|
||||
if json.Unmarshal(raw, &item) != nil {
|
||||
rest = append(rest, raw)
|
||||
continue
|
||||
}
|
||||
name := item.Name
|
||||
if strings.EqualFold(item.Type, "Episode") && strings.TrimSpace(item.SeriesName) != "" {
|
||||
name = item.SeriesName
|
||||
}
|
||||
if !strings.EqualFold(item.Type, "Episode") && !strings.EqualFold(item.Type, "Series") {
|
||||
rest = append(rest, raw)
|
||||
continue
|
||||
}
|
||||
if today[normalizedShowKey(name)] {
|
||||
promoted = append(promoted, raw)
|
||||
} else {
|
||||
rest = append(rest, raw)
|
||||
}
|
||||
}
|
||||
return append(promoted, rest...)
|
||||
}
|
||||
|
||||
func normalizedShowKey(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(value) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -291,11 +388,8 @@ func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommen
|
||||
float64(stat.DwellMs)/30_000
|
||||
score = (engagement + 2) / (float64(stat.Impressions) + 2)
|
||||
}
|
||||
switch row.ID {
|
||||
case "continue":
|
||||
if row.ID == "continue" {
|
||||
score = 1_000
|
||||
case "next-up":
|
||||
score += 0.35
|
||||
}
|
||||
ranked = append(ranked, rankedRow{row: row, position: position, score: score})
|
||||
}
|
||||
@@ -376,7 +470,7 @@ func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "could not load screensaver items")
|
||||
s.writeUpstreamError(ctx, w, err, "could not load screensaver items")
|
||||
return
|
||||
}
|
||||
items = result.Items
|
||||
@@ -418,10 +512,15 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
"Limit": {itoa(limit)},
|
||||
}, fieldsRow))
|
||||
if err != nil {
|
||||
s.writeUpstreamError(w, err, "search failed")
|
||||
s.writeUpstreamError(ctx, w, err, "search failed")
|
||||
return
|
||||
}
|
||||
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
|
||||
s.decorateItemRatings(ctx, items)
|
||||
// Instant search fires a request per keystroke past the second character, so this is
|
||||
// DEBUG: it is the record of what somebody was looking for when nothing was found,
|
||||
// not something to carry in the normal log.
|
||||
s.loggerFor(ctx).Debug("search", "query", term, "results", len(items))
|
||||
|
||||
body, err := json.Marshal(map[string]any{"items": nonNil(items)})
|
||||
if err != nil {
|
||||
@@ -429,7 +528,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
return
|
||||
}
|
||||
if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil {
|
||||
s.log.Warn("search cache write failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("search cache write failed", "error", err)
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
@@ -493,10 +592,13 @@ func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, ses
|
||||
// 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.
|
||||
//
|
||||
// There is deliberately no Next Up row: those episodes are merged into Continue Watching
|
||||
// by mergeContinueWatching, which is where somebody looks for them the moment an episode
|
||||
// ends.
|
||||
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: "Recent New Releases", Kind: "latest", Items: h.LatestMovies},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user