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:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+121 -19
View File
@@ -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},
}