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
+62 -5
View File
@@ -22,6 +22,7 @@ import (
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
@@ -43,21 +44,35 @@ type Server struct {
forYou *foryou.Service
sonarr *sonarr.Client
radarr *radarr.Client
bazarr *bazarr.Client
mdblist *mdblist.Client
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
sonarrMu sync.Mutex
radarrMu sync.Mutex
bazarrMu sync.Mutex
mdblistMu sync.Mutex
// mdblistSettingsCache spares every row and keystroke a settings read.
mdblistSettingsCache mdblistSettingsCache
// ratingsWarm fills and renews the durable rating cache behind the viewer, so a row
// never waits on MDBList and the operator's daily allowance is spent once per title.
ratingsWarm ratingsWarmer
// alertMu serialises the read-modify-write of the shared alert list. Its producers
// are events — a webhook, a finished sync, a health probe — none of them paced by
// this server, so two can land at once.
alertMu sync.Mutex
// playbackTitles lets a progress or stop report, which carries only an item id, be
// logged by name.
playbackTitles playbackTitles
recommendationBuilds recommendationBuilds
maintenance maintenanceState
updatePolicy updatePolicyCache
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
@@ -70,6 +85,7 @@ type Deps struct {
ForYou *foryou.Service
Sonarr *sonarr.Client
Radarr *radarr.Client
Bazarr *bazarr.Client
MDBList *mdblist.Client
Syncer syncerHandle
Log *slog.Logger
@@ -86,6 +102,7 @@ func New(cfg config.Config, deps Deps) *Server {
forYou: deps.ForYou,
sonarr: deps.Sonarr,
radarr: deps.Radarr,
bazarr: deps.Bazarr,
mdblist: deps.MDBList,
syncer: deps.Syncer,
log: deps.Log,
@@ -126,6 +143,10 @@ func (s *Server) Routes() http.Handler {
v1.Handle("PUT /v1/notifications", s.authed(s.handleNotifications))
v1.Handle("POST /v1/notifications/{id}/{action}", s.authed(s.handleNotificationAction))
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
// A viewer's settings follow the person, not the television. Both verbs land on one
// handler because a write answers with the stored document, not the submitted one.
v1.Handle("GET /v1/preferences", s.authed(s.handlePreferences))
v1.Handle("PUT /v1/preferences", s.authed(s.handlePreferences))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/items/{id}/ratings", s.authed(s.handleMovieRatings))
@@ -136,6 +157,8 @@ func (s *Server) Routes() http.Handler {
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
v1.Handle("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch))
v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
@@ -196,6 +219,7 @@ func (s *Server) authed(h authedFunc) http.Handler {
return
}
sess = s.captureClientIdentity(r, sess)
identify(r.Context(), sess)
h(w, r, sess)
})
}
@@ -204,6 +228,7 @@ func (s *Server) authed(h authedFunc) http.Handler {
// calls refresh it from headers; authenticated artwork requests, which can only carry a
// query token, inherit the last identity reported by that same TV.
func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) store.Session {
previousVersion := sess.ClientVersion
changed := mergeClientIdentity(r, &sess)
if changed {
if err := s.store.UpdateSessionClientIdentity(
@@ -214,6 +239,18 @@ func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) stor
} else {
s.cacheSession(r.Context(), sess)
}
// A television that updates itself never signs in again, so this is the only
// place the new build would otherwise be seen. Guarded on the version actually
// having moved: every request reaches here, and all but the first after an
// update would be a write of what is already stored.
if sess.ClientVersion != previousVersion {
if err := s.store.RecordDeviceVersion(
r.Context(), sess.DeviceID, sess.ClientVersion,
); err != nil {
s.log.Warn("device version record failed",
"device_id", sess.DeviceID, "error", err)
}
}
}
return sess
}
@@ -247,6 +284,7 @@ func mergeClientIdentity(r *http.Request, sess *store.Session) bool {
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
r, identity := withRequestIdentity(r)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Polling the live-log endpoint must not create another live-log record and
@@ -254,16 +292,31 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
if r.URL.Path == "/admin/api/events" {
return
}
// The request line is a transcript of one exchange, not the record of what the
// viewer did — that is what the events the handlers log are for. It stays terse
// and identical in shape for every route so it can be scanned in a column.
//
// Path only: query strings can carry image tokens.
level := requestLogLevel(r.URL.Path, rec.status)
s.log.Log(r.Context(), level, "HTTP request",
fields := []any{"component", identity.component}
fields = append(fields, identity.viewerAttrs()...)
// The app build keeps its placeholder where the viewer does not, because "which
// build made this call" always has an answer worth seeing, including "it did
// not say".
fields = append(fields,
"client", clientLogValue(identity.client),
"protocol", clientLogValue(identity.protocol),
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start).Round(time.Millisecond),
"client_version", clientLogValue(clientVersion(r)),
"client_protocol", clientLogValue(clientProtocol(r)),
)
// Whether an answer came from cache is the first thing anyone asks of a slow
// screen, and only the handler knows.
if cached := rec.Header().Get("X-Memby-Cache"); cached != "" {
fields = append(fields, "cache", cached)
}
s.log.Log(r.Context(), level, "request", fields...)
})
}
@@ -284,6 +337,7 @@ func requestLogLevel(path string, status int) slog.Level {
case status >= http.StatusBadRequest:
return slog.LevelWarn
case path == "/healthz", path == "/readyz", path == "/v1/status",
path == "/admin/api/status",
strings.HasPrefix(path, "/v1/images/"):
return slog.LevelDebug
default:
@@ -400,6 +454,7 @@ func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion,
}
}
@@ -426,7 +481,9 @@ func writeError(w http.ResponseWriter, status int, message string) {
// writeUpstreamError mirrors Emby's status so the TV can tell "signed out" (401) from
// "server is unwell" (5xx) without parsing strings.
func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message string) {
func (s *Server) writeUpstreamError(
ctx context.Context, w http.ResponseWriter, err error, message string,
) {
var apiErr *emby.APIError
if errors.As(err, &apiErr) {
switch {
@@ -438,7 +495,7 @@ func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message st
return
}
}
s.log.Error(message, "error", err)
s.loggerFor(ctx).Error(message, "error", err)
writeError(w, http.StatusBadGateway, message)
}