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
+147 -43
View File
@@ -14,15 +14,18 @@ import (
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/buildinfo"
"github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/store"
)
//go:embed admin.html
var adminPage []byte
const adminCookieName = "memby_admin"
// adminActivityHeader is how the console distinguishes its own poll from a request an
// operator caused. It is advice, not authority: a page that sets it always already holds
// a valid session, so the only thing it can extend is its own sign-in.
const adminActivityHeader = "X-Memby-Admin-Active"
// adminRoutes is the operator interface: library imports, the maintenance switch, and
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// left exposed by accident.
@@ -31,12 +34,25 @@ func (s *Server) adminRoutes() http.Handler {
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
// One person's own page. It is a path rather than a query string so it can be linked,
// bookmarked and returned to after a sign-in, like every other page here.
mux.HandleFunc("GET /admin/accounts/{userID}", s.handleAdminAccountPage)
// The settings history is its own page rather than a fifth card on the account: it is
// a table with a row per change and an action per row, and it is read when something
// has gone wrong rather than as part of ordinary account admin.
mux.HandleFunc("GET /admin/accounts/{userID}/settings", s.handleAdminSettingsHistoryPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/accounts", s.adminAuth(s.handleAdminAccounts))
mux.Handle("PUT /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminRenameDevice))
mux.Handle("DELETE /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminDeleteDevice))
mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount))
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
s.adminAuth(s.handleAdminRestorePreferences))
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations))
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
@@ -44,6 +60,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
@@ -54,18 +71,12 @@ func (s *Server) adminRoutes() http.Handler {
return mux
}
var adminPages = map[string]bool{
"accounts": true, "library": true, "recommendations": true, "requests": true,
"features": true, "playback": true, "maintenance": true, "updates": true, "engagement": true,
"ratings": true, "imports": true, "logs": true,
}
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/admin/features", http.StatusFound)
http.Redirect(w, r, "/admin/overview", http.StatusFound)
}
type adminRuntimeStatus struct {
@@ -132,24 +143,73 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
writeError(w, http.StatusUnauthorized, "invalid admin token")
return
}
if browser && operatorPresent(r) {
s.renewInstallerSession(w, r)
}
h(w, r)
})
}
// operatorPresent reports whether a request came from somebody at the keyboard rather
// than from the console's own status poll. Only these extend the sign-in: the dashboard
// refreshes itself on a timer, so renewing on any request at all would leave a tab
// abandoned on a second monitor signed in forever. A mutation is a click by definition;
// for reads the page says so itself, setting the header only while there has been recent
// interaction with it.
func operatorPresent(r *http.Request) bool {
return r.Method != http.MethodGet ||
strings.TrimSpace(r.Header.Get(adminActivityHeader)) == "1"
}
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
// A hidden page is reachable only through the route that knows how to address it: an
// account page with nobody to be about is not a page.
page := strings.TrimSpace(r.PathValue("page"))
if !adminPages[page] {
http.NotFound(w, r)
return
}
if !s.validInstallerSession(r) {
s.renderAccessLogin(w, r, "", http.StatusOK, "/admin/"+page)
s.serveAdminPage(w, r, page, "/admin/"+page)
}
// The account page is served from its own route because the identity is in the path. The
// sign-in returns to /admin/accounts rather than to this URL: cleanInstallerDestination only
// admits the pages it can name, and a person's id is not one of them.
func (s *Server) handleAdminAccountPage(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.PathValue("userID")) == "" {
http.NotFound(w, r)
return
}
s.serveAdminPage(w, r, "account", "/admin/accounts")
}
// Hidden for the same reason the account page is: a settings history with nobody to be
// about is not a page, so the identity is in the path and a sign-in returns to the account
// list rather than here.
func (s *Server) handleAdminSettingsHistoryPage(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.PathValue("userID")) == "" {
http.NotFound(w, r)
return
}
s.serveAdminPage(w, r, "settings-history", "/admin/accounts")
}
func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, next string) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
body, ok := adminRendered[page]
if !ok {
http.NotFound(w, r)
return
}
if !s.validInstallerSession(r) {
s.renderAccessLogin(w, r, "", http.StatusOK, next)
return
}
// Opening a page is somebody at the keyboard, so it starts the clock again.
s.renewInstallerSession(w, r)
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
@@ -163,10 +223,13 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
preventDiscovery(w)
_, _ = w.Write(adminPage)
_, _ = w.Write(body)
}
type adminStatus struct {
// ServerVersion is what the page's footer reports. An operator reading the live log
// needs to know which build wrote it, and the page is the one place that is asked.
ServerVersion string `json:"serverVersion"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
@@ -190,13 +253,13 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.LibraryStats(ctx)
if err != nil {
s.log.Error("library stats failed", "error", err)
s.loggerFor(ctx).Error("library stats failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read library stats")
return
}
runs, err := s.store.RecentSyncRuns(ctx, 10)
if err != nil {
s.log.Error("sync history failed", "error", err)
s.loggerFor(ctx).Error("sync history failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read sync history")
return
}
@@ -206,23 +269,24 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
if s.forYou != nil {
forYouStats, err = s.forYou.Stats(ctx)
if err != nil {
s.log.Warn("For You stats failed", "error", err)
s.loggerFor(ctx).Warn("For You stats failed", "error", err)
}
forYouRunning = s.forYou.Running()
}
requestPolicy, err := s.store.RequestPolicy(ctx)
if err != nil {
s.log.Warn("request policy read failed", "error", err)
s.loggerFor(ctx).Warn("request policy read failed", "error", err)
}
requestUsers, err := s.store.KnownUsers(ctx)
if err != nil {
s.log.Warn("known users read failed", "error", err)
s.loggerFor(ctx).Warn("known users read failed", "error", err)
}
clients, err := s.store.KnownClients(ctx)
if err != nil {
s.log.Warn("known clients read failed", "error", err)
s.loggerFor(ctx).Warn("known clients read failed", "error", err)
}
writeJSON(w, http.StatusOK, adminStatus{
ServerVersion: buildinfo.Version(),
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
@@ -235,21 +299,29 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
PlaybackPolicy: func() store.PlaybackPolicy {
policy, policyErr := s.store.PlaybackPolicy(ctx)
if policyErr != nil {
s.log.Warn("playback policy read failed", "error", policyErr)
s.loggerFor(ctx).Warn("playback policy read failed", "error", policyErr)
return store.DefaultPlaybackPolicy()
}
return policy
}(),
Features: featurePayload(s.currentFeaturePolicy(ctx), membyProtocolVersion),
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
RequestUsers: requestUsers,
Clients: clients,
MDBList: func() mdblistAdminSettings {
settings, settingsErr := s.store.MDBListSettings(ctx)
if settingsErr != nil {
s.log.Warn("MDBList settings read failed", "error", settingsErr)
s.loggerFor(ctx).Warn("MDBList settings read failed", "error", settingsErr)
settings = store.DefaultMDBListSettings()
}
return publicMDBListSettings(settings)
public := publicMDBListSettings(settings)
// How much of the catalogue is already stored is the only way to see whether
// the integration is still spending the operator's daily allowance.
if total, stale, statsErr := s.store.MediaRatingsStats(
ctx, time.Now().Add(-ratingsRefreshInterval),
); statsErr == nil {
public.CachedTitles, public.StaleTitles = total, stale
}
return public
}(),
SonarrReady: s.sonarr != nil,
RadarrReady: s.radarr != nil,
@@ -261,6 +333,8 @@ type mdblistAdminSettings struct {
APIKeyConfigured bool `json:"apiKeyConfigured"`
Sources []string `json:"sources"`
AvailableSources []string `json:"availableSources"`
CachedTitles int `json:"cachedTitles"`
StaleTitles int `json:"staleTitles"`
}
type mdblistSettingsRequest struct {
@@ -321,16 +395,17 @@ func (s *Server) handleAdminMDBListSettings(w http.ResponseWriter, r *http.Reque
}
next := store.MDBListSettings{Enabled: req.Enabled, APIKey: apiKey, Sources: sources}
if err := s.store.SetMDBListSettings(r.Context(), next); err != nil {
s.log.Error("MDBList settings write failed", "error", err)
s.loggerFor(r.Context()).Error("MDBList settings write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save MDBList settings")
return
}
s.forgetMDBListSettings()
stored, err := s.store.MDBListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not reload MDBList settings")
return
}
s.log.Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
s.loggerFor(r.Context()).Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
"api_key_configured", stored.APIKey != "")
writeJSON(w, http.StatusOK, publicMDBListSettings(stored))
}
@@ -354,7 +429,7 @@ func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Reques
PrerollEnabled: req.PrerollEnabled, PrerollDurationMs: req.PrerollDurationMs,
}
if err := s.store.SetPlaybackPolicy(r.Context(), policy); err != nil {
s.log.Error("playback policy write failed", "error", err)
s.loggerFor(r.Context()).Error("playback policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save playback policy")
return
}
@@ -363,7 +438,7 @@ func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusInternalServerError, "could not reload playback policy")
return
}
s.log.Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
s.loggerFor(r.Context()).Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
"preroll_duration_ms", stored.PrerollDurationMs)
writeJSON(w, http.StatusOK, stored)
}
@@ -403,11 +478,11 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
}
policy := store.RequestPolicy{AllowedUserIDs: allowed}
if err := s.store.SetRequestPolicy(r.Context(), policy); err != nil {
s.log.Error("request policy write failed", "error", err)
s.loggerFor(r.Context()).Error("request policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save request access")
return
}
s.log.Info("media request access changed", "users", len(allowed))
s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed))
writeJSON(w, http.StatusOK, policy)
}
@@ -464,15 +539,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
s.log.Error("update policy write failed", "error", err)
s.loggerFor(r.Context()).Error("update policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save the update policy")
return
}
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
s.log.Warn("update policy reload failed", "error", err)
s.loggerFor(r.Context()).Warn("update policy reload failed", "error", err)
}
s.log.Info("update policy changed",
s.loggerFor(r.Context()).Info("update policy changed",
"enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion)
writeJSON(w, http.StatusOK, s.updatePolicy.get())
}
@@ -502,7 +577,10 @@ func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout)
defer cancel()
if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil {
s.log.Error("manual sync failed", "kind", req.Kind, "error", err)
// Detached from the request on purpose, so the identity in the request
// context is gone by now; name the area explicitly instead.
s.log.Error("manual sync failed",
"component", "admin", "kind", req.Kind, "error", err)
}
}()
@@ -542,12 +620,14 @@ func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
if req.Action != "rebuild-all" {
_, err := s.forYou.Import(ctx, req.Action == "full-import")
if err != nil {
s.log.Error("manual Tracearr import failed", "action", req.Action, "error", err)
s.log.Error("manual Tracearr import failed",
"component", "admin", "action", req.Action, "error", err)
return
}
}
if err := s.forYou.RebuildAll(ctx, true); err != nil {
s.log.Error("manual For You rebuild failed", "action", req.Action, "error", err)
s.log.Error("manual For You rebuild failed",
"component", "admin", "action", req.Action, "error", err)
}
}()
writeJSON(w, http.StatusAccepted, map[string]string{
@@ -555,6 +635,30 @@ func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
})
}
// handleAdminDeploymentAlert announces that this gateway is about to be replaced.
//
// It takes no body and says the same thing every time: the caller is `deploy-server.ps1`,
// which runs before the old container is stopped, and a deployment script has no business
// deciding what appears over somebody's film. It is deliberately outside the maintenance
// switch — a deployment is not maintenance mode, and the point is to say so while the app
// is still working.
func (s *Server) handleAdminDeploymentAlert(w http.ResponseWriter, r *http.Request) {
if s.cache == nil {
// Alerts live in Redis; with none there is nowhere to publish to, and the deploy
// must be told rather than believing every TV was warned.
writeError(w, http.StatusServiceUnavailable, "alerts are unavailable without Redis")
return
}
s.AnnounceDeployment(r.Context())
s.loggerFor(r.Context()).Warn("deployment announced to clients",
"component", "admin", "window", deploymentAlertWindow.String())
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "announced",
"title": deploymentAlertTitle,
"message": deploymentAlertMessage,
})
}
type maintenanceRequest struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`
@@ -569,15 +673,15 @@ func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request)
state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)}
if err := s.store.SetMaintenance(r.Context(), state); err != nil {
s.log.Error("maintenance write failed", "error", err)
s.loggerFor(r.Context()).Error("maintenance write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not update maintenance mode")
return
}
if err := s.LoadMaintenance(r.Context()); err != nil {
s.log.Warn("maintenance reload failed", "error", err)
s.loggerFor(r.Context()).Warn("maintenance reload failed", "error", err)
}
s.log.Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
s.loggerFor(r.Context()).Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
writeJSON(w, http.StatusOK, s.maintenance.get())
}
@@ -587,7 +691,7 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.RowStats(r.Context(), since)
if err != nil {
s.log.Error("row stats failed", "error", err)
s.loggerFor(r.Context()).Error("row stats failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read analytics")
return
}