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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user