2026-07-27 08:16:20 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/subtle"
|
|
|
|
|
_ "embed"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
2026-08-02 22:10:19 +12:00
|
|
|
"os"
|
|
|
|
|
"runtime"
|
|
|
|
|
"runtime/debug"
|
2026-07-29 15:26:27 +12:00
|
|
|
"strconv"
|
2026-07-27 08:16:20 +12:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-07-27 08:34:04 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
2026-08-06 22:33:56 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
2026-07-27 08:16:20 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/library"
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
const adminCookieName = "memby_admin"
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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"
|
|
|
|
|
|
2026-08-10 20:39:26 +12:00
|
|
|
// adminRoutes is the operator interface: library imports, maintenance and analytics.
|
|
|
|
|
// Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
|
2026-07-27 08:16:20 +12:00
|
|
|
// left exposed by accident.
|
|
|
|
|
func (s *Server) adminRoutes() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
|
2026-08-10 08:37:08 +12:00
|
|
|
mux.HandleFunc("POST /admin/logout", s.handleAdminLogout)
|
2026-08-02 22:10:19 +12:00
|
|
|
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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)
|
2026-07-27 08:16:20 +12:00
|
|
|
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
|
2026-08-03 10:16:44 +12:00
|
|
|
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))
|
2026-08-06 22:33:56 +12:00
|
|
|
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))
|
2026-08-03 10:16:44 +12:00
|
|
|
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
|
2026-08-06 22:33:56 +12:00
|
|
|
mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations))
|
2026-08-09 08:25:50 +12:00
|
|
|
mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes))
|
2026-08-02 22:10:19 +12:00
|
|
|
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
|
2026-07-27 08:16:20 +12:00
|
|
|
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
|
2026-08-10 20:39:26 +12:00
|
|
|
mux.Handle("GET /admin/api/journeys", s.adminAuth(s.handleAdminJourneys))
|
2026-08-09 08:25:50 +12:00
|
|
|
mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
|
2026-07-29 15:26:27 +12:00
|
|
|
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
|
2026-08-02 22:10:19 +12:00
|
|
|
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
2026-07-27 08:16:20 +12:00
|
|
|
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
2026-07-29 15:26:27 +12:00
|
|
|
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
2026-07-27 08:16:20 +12:00
|
|
|
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
2026-08-06 22:33:56 +12:00
|
|
|
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
2026-07-27 08:34:04 +12:00
|
|
|
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
2026-08-02 22:10:19 +12:00
|
|
|
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
|
|
|
|
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
|
2026-08-11 23:41:10 +12:00
|
|
|
mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch))
|
|
|
|
|
mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy))
|
2026-08-03 08:52:55 +12:00
|
|
|
mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
|
2026-08-09 08:25:50 +12:00
|
|
|
mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
|
|
|
|
|
mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
|
2026-08-02 22:10:19 +12:00
|
|
|
mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy))
|
2026-07-27 21:06:51 +12:00
|
|
|
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.cfg.AdminToken == "" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
http.Redirect(w, r, "/admin/overview", http.StatusFound)
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type adminRuntimeStatus struct {
|
|
|
|
|
Goroutines int `json:"goroutines"`
|
|
|
|
|
GOMAXPROCS int `json:"gomaxprocs"`
|
|
|
|
|
HeapAlloc uint64 `json:"heapAlloc"`
|
|
|
|
|
HeapInuse uint64 `json:"heapInuse"`
|
|
|
|
|
HeapIdle uint64 `json:"heapIdle"`
|
|
|
|
|
HeapReleased uint64 `json:"heapReleased"`
|
|
|
|
|
StackInuse uint64 `json:"stackInuse"`
|
|
|
|
|
Sys uint64 `json:"sys"`
|
|
|
|
|
NextGC uint64 `json:"nextGc"`
|
|
|
|
|
NumGC uint32 `json:"numGc"`
|
|
|
|
|
MemoryLimit int64 `json:"memoryLimit"`
|
|
|
|
|
ConfiguredLim string `json:"configuredLimit,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
var memory runtime.MemStats
|
|
|
|
|
runtime.ReadMemStats(&memory)
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
|
writeJSON(w, http.StatusOK, adminRuntimeStatus{
|
|
|
|
|
Goroutines: runtime.NumGoroutine(), GOMAXPROCS: runtime.GOMAXPROCS(0),
|
|
|
|
|
HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse,
|
|
|
|
|
HeapIdle: memory.HeapIdle, HeapReleased: memory.HeapReleased,
|
|
|
|
|
StackInuse: memory.StackInuse, Sys: memory.Sys,
|
|
|
|
|
NextGC: memory.NextGC, NumGC: memory.NumGC,
|
|
|
|
|
MemoryLimit: debug.SetMemoryLimit(-1), ConfiguredLim: os.Getenv("GOMEMLIMIT"),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.events == nil {
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"events": []any{}, "next": 0, "oldest": 0, "latest": 0,
|
|
|
|
|
"dropped": 0, "hasMore": false,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
|
|
|
|
|
limit := queryInt(r, "limit", 500, 1000)
|
|
|
|
|
writeJSON(w, http.StatusOK, s.events.Events(after, limit))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
// adminAuth guards the admin API with the shared token. Browser requests need both the
|
|
|
|
|
// admin cookie and a current Emby-verified browser session. Automation can continue to
|
|
|
|
|
// send the admin token as a Bearer header without pretending to be a browser.
|
2026-07-27 08:16:20 +12:00
|
|
|
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.cfg.AdminToken == "" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
authorization := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
|
|
|
presented := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
|
|
|
|
|
browser := presented == ""
|
|
|
|
|
if browser {
|
2026-07-29 15:26:27 +12:00
|
|
|
if cookie, err := r.Cookie(adminCookieName); err == nil {
|
|
|
|
|
presented = cookie.Value
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 ||
|
|
|
|
|
(browser && !s.validInstallerSession(r)) {
|
2026-07-27 08:16:20 +12:00
|
|
|
writeError(w, http.StatusUnauthorized, "invalid admin token")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
if browser && operatorPresent(r) {
|
2026-08-10 08:37:08 +12:00
|
|
|
s.renewAdminSession(w, r)
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
h(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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.
|
2026-08-02 22:10:19 +12:00
|
|
|
page := strings.TrimSpace(r.PathValue("page"))
|
|
|
|
|
if !adminPages[page] {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
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)
|
2026-08-02 22:10:19 +12:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
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.
|
2026-08-10 08:37:08 +12:00
|
|
|
s.renewAdminSession(w, r)
|
2026-07-29 15:26:27 +12:00
|
|
|
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
|
|
|
Name: adminCookieName,
|
|
|
|
|
Value: s.cfg.AdminToken,
|
|
|
|
|
Path: "/admin",
|
|
|
|
|
MaxAge: 10 * 365 * 24 * 60 * 60,
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
Secure: secure,
|
|
|
|
|
SameSite: http.SameSiteStrictMode,
|
|
|
|
|
})
|
2026-07-27 08:16:20 +12:00
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
2026-08-02 22:10:19 +12:00
|
|
|
preventDiscovery(w)
|
2026-08-06 22:33:56 +12:00
|
|
|
_, _ = w.Write(body)
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
2026-08-10 08:37:08 +12:00
|
|
|
func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.cfg.AdminToken == "" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.clearInstallerCookie(w)
|
|
|
|
|
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
|
|
|
Name: adminCookieName,
|
|
|
|
|
Path: "/admin",
|
|
|
|
|
MaxAge: -1,
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
Secure: secure,
|
|
|
|
|
SameSite: http.SameSiteStrictMode,
|
|
|
|
|
})
|
|
|
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
type adminStatus struct {
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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.
|
2026-08-09 08:25:50 +12:00
|
|
|
ServerVersion string `json:"serverVersion"`
|
|
|
|
|
Maintenance store.Maintenance `json:"maintenance"`
|
|
|
|
|
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
|
|
|
|
Library store.LibraryStats `json:"library"`
|
|
|
|
|
SyncRunning bool `json:"syncRunning"`
|
|
|
|
|
Runs []store.SyncRun `json:"runs"`
|
|
|
|
|
SyncEvery string `json:"syncEvery"`
|
|
|
|
|
ForYou store.ForYouStats `json:"forYou"`
|
|
|
|
|
ForYouRunning bool `json:"forYouRunning"`
|
|
|
|
|
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
|
|
|
|
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
2026-08-11 23:41:10 +12:00
|
|
|
HeroPolicy heroAdminPolicy `json:"heroPolicy"`
|
2026-08-09 08:25:50 +12:00
|
|
|
MDBList mdblistAdminSettings `json:"mdblist"`
|
|
|
|
|
Subtitles subtitleAdminSettings `json:"subtitles"`
|
|
|
|
|
Features featureResponse `json:"features"`
|
|
|
|
|
RequestUsers []store.KnownUser `json:"requestUsers"`
|
|
|
|
|
Clients []store.KnownClient `json:"clients"`
|
|
|
|
|
SonarrReady bool `json:"sonarrReady"`
|
|
|
|
|
RadarrReady bool `json:"radarrReady"`
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
|
|
|
|
|
stats, err := s.store.LibraryStats(ctx)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Error("library stats failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not read library stats")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
runs, err := s.store.RecentSyncRuns(ctx, 10)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Error("sync history failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not read sync history")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
var forYouStats store.ForYouStats
|
|
|
|
|
forYouRunning := false
|
|
|
|
|
if s.forYou != nil {
|
|
|
|
|
forYouStats, err = s.forYou.Stats(ctx)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("For You stats failed", "error", err)
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
forYouRunning = s.forYou.Running()
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
requestPolicy, err := s.store.RequestPolicy(ctx)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("request policy read failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
requestUsers, err := s.store.KnownUsers(ctx)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("known users read failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
clients, err := s.store.KnownClients(ctx)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("known clients read failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
writeJSON(w, http.StatusOK, adminStatus{
|
2026-08-06 22:33:56 +12:00
|
|
|
ServerVersion: buildinfo.Version(),
|
2026-07-29 15:26:27 +12:00
|
|
|
Maintenance: s.maintenance.get(),
|
|
|
|
|
UpdatePolicy: s.updatePolicy.get(),
|
|
|
|
|
Library: stats,
|
|
|
|
|
SyncRunning: s.syncer.Running(),
|
|
|
|
|
Runs: runs,
|
|
|
|
|
SyncEvery: s.cfg.SyncInterval.String(),
|
|
|
|
|
ForYou: forYouStats,
|
|
|
|
|
ForYouRunning: forYouRunning,
|
2026-08-02 22:10:19 +12:00
|
|
|
RequestPolicy: requestPolicy,
|
|
|
|
|
PlaybackPolicy: func() store.PlaybackPolicy {
|
|
|
|
|
policy, policyErr := s.store.PlaybackPolicy(ctx)
|
|
|
|
|
if policyErr != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("playback policy read failed", "error", policyErr)
|
2026-08-02 22:10:19 +12:00
|
|
|
return store.DefaultPlaybackPolicy()
|
|
|
|
|
}
|
|
|
|
|
return policy
|
|
|
|
|
}(),
|
2026-08-11 23:41:10 +12:00
|
|
|
HeroPolicy: s.heroAdminPolicy(ctx),
|
2026-08-09 08:25:50 +12:00
|
|
|
Subtitles: s.subtitleAdminSettings(ctx),
|
2026-08-06 22:33:56 +12:00
|
|
|
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
|
2026-08-02 22:10:19 +12:00
|
|
|
RequestUsers: requestUsers,
|
|
|
|
|
Clients: clients,
|
2026-08-03 08:52:55 +12:00
|
|
|
MDBList: func() mdblistAdminSettings {
|
|
|
|
|
settings, settingsErr := s.store.MDBListSettings(ctx)
|
|
|
|
|
if settingsErr != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(ctx).Warn("MDBList settings read failed", "error", settingsErr)
|
2026-08-03 08:52:55 +12:00
|
|
|
settings = store.DefaultMDBListSettings()
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
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
|
2026-08-03 08:52:55 +12:00
|
|
|
}(),
|
|
|
|
|
SonarrReady: s.sonarr != nil,
|
|
|
|
|
RadarrReady: s.radarr != nil,
|
2026-07-27 08:16:20 +12:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 08:52:55 +12:00
|
|
|
type mdblistAdminSettings struct {
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
APIKeyConfigured bool `json:"apiKeyConfigured"`
|
|
|
|
|
Sources []string `json:"sources"`
|
|
|
|
|
AvailableSources []string `json:"availableSources"`
|
2026-08-06 22:33:56 +12:00
|
|
|
CachedTitles int `json:"cachedTitles"`
|
|
|
|
|
StaleTitles int `json:"staleTitles"`
|
2026-08-03 08:52:55 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type mdblistSettingsRequest struct {
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
APIKey string `json:"apiKey"`
|
|
|
|
|
ClearAPIKey bool `json:"clearApiKey"`
|
|
|
|
|
Sources []string `json:"sources"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func publicMDBListSettings(settings store.MDBListSettings) mdblistAdminSettings {
|
|
|
|
|
return mdblistAdminSettings{
|
|
|
|
|
Enabled: settings.Enabled, APIKeyConfigured: settings.APIKey != "",
|
|
|
|
|
Sources: settings.Sources, AvailableSources: store.MDBListSources(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminMDBListSettings(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req mdblistSettingsRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
current, err := s.store.MDBListSettings(r.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read MDBList settings")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
apiKey := current.APIKey
|
|
|
|
|
if req.ClearAPIKey {
|
|
|
|
|
apiKey = ""
|
|
|
|
|
} else if replacement := strings.TrimSpace(req.APIKey); replacement != "" {
|
|
|
|
|
apiKey = replacement
|
|
|
|
|
}
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
sources := make([]string, 0, len(req.Sources))
|
|
|
|
|
for _, source := range req.Sources {
|
|
|
|
|
source = strings.ToLower(strings.TrimSpace(source))
|
|
|
|
|
if source == "" || seen[source] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !store.ValidMDBListSource(source) {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "unknown MDBList rating source")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
seen[source] = true
|
|
|
|
|
sources = append(sources, source)
|
|
|
|
|
}
|
|
|
|
|
if req.Enabled && apiKey == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "set an MDBList API key before enabling ratings")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Enabled && len(sources) == 0 {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "select at least one MDBList rating source")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if len(sources) == 0 {
|
|
|
|
|
sources = current.Sources
|
|
|
|
|
}
|
|
|
|
|
next := store.MDBListSettings{Enabled: req.Enabled, APIKey: apiKey, Sources: sources}
|
|
|
|
|
if err := s.store.SetMDBListSettings(r.Context(), next); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("MDBList settings write failed", "error", err)
|
2026-08-03 08:52:55 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not save MDBList settings")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
s.forgetMDBListSettings()
|
2026-08-03 08:52:55 +12:00
|
|
|
stored, err := s.store.MDBListSettings(r.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not reload MDBList settings")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
|
2026-08-03 08:52:55 +12:00
|
|
|
"api_key_configured", stored.APIKey != "")
|
|
|
|
|
writeJSON(w, http.StatusOK, publicMDBListSettings(stored))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
type playbackPolicyRequest struct {
|
|
|
|
|
PrerollEnabled bool `json:"prerollEnabled"`
|
|
|
|
|
PrerollDurationMs int64 `json:"prerollDurationMs"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req playbackPolicyRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.PrerollDurationMs < 1_000 || req.PrerollDurationMs > 30_000 {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "preroll duration must be between 1 and 30 seconds")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
policy := store.PlaybackPolicy{
|
|
|
|
|
PrerollEnabled: req.PrerollEnabled, PrerollDurationMs: req.PrerollDurationMs,
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.SetPlaybackPolicy(r.Context(), policy); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("playback policy write failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not save playback policy")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stored, err := s.store.PlaybackPolicy(r.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not reload playback policy")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
|
2026-08-02 22:10:19 +12:00
|
|
|
"preroll_duration_ms", stored.PrerollDurationMs)
|
|
|
|
|
writeJSON(w, http.StatusOK, stored)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type requestPolicyRequest struct {
|
|
|
|
|
AllowedUserIDs []string `json:"allowedUserIds"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req requestPolicyRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
known, err := s.store.KnownUsers(r.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not validate users")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
valid := make(map[string]bool, len(known))
|
|
|
|
|
for _, user := range known {
|
|
|
|
|
valid[user.ID] = true
|
|
|
|
|
}
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
allowed := make([]string, 0, len(req.AllowedUserIDs))
|
|
|
|
|
for _, id := range req.AllowedUserIDs {
|
|
|
|
|
id = strings.TrimSpace(id)
|
|
|
|
|
if id == "" || seen[id] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !valid[id] {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "unknown Emby user")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
seen[id] = true
|
|
|
|
|
allowed = append(allowed, id)
|
|
|
|
|
}
|
|
|
|
|
policy := store.RequestPolicy{AllowedUserIDs: allowed}
|
|
|
|
|
if err := s.store.SetRequestPolicy(r.Context(), policy); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("request policy write failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not save request access")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed))
|
2026-08-02 22:10:19 +12:00
|
|
|
writeJSON(w, http.StatusOK, policy)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:34:04 +12:00
|
|
|
type updatePolicyRequest struct {
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
LatestVersion string `json:"latestVersion"`
|
|
|
|
|
DownloadURL string `json:"downloadUrl"`
|
|
|
|
|
Notes string `json:"notes"`
|
|
|
|
|
// Required makes this release mandatory for everyone below it. The page offers a
|
|
|
|
|
// toggle rather than exposing "minimum version" directly, because "force this
|
|
|
|
|
// update" is the decision an operator actually wants to make.
|
|
|
|
|
Required bool `json:"required"`
|
2026-08-10 20:54:00 +12:00
|
|
|
// Destructive removes sessions for clients below this release. It implies Required,
|
|
|
|
|
// but remains separate so a required update can keep viewers signed in.
|
|
|
|
|
Destructive bool `json:"destructive"`
|
|
|
|
|
// RetireBelowVersion exposes the exact destructive compatibility floor for releases
|
|
|
|
|
// where the operator needs to retire only part of the installed fleet.
|
|
|
|
|
RetireBelowVersion string `json:"retireBelowVersion"`
|
2026-07-27 08:34:04 +12:00
|
|
|
// MinimumVersion is honoured when set explicitly, for staged rollouts where the
|
|
|
|
|
// forced floor is older than the latest build.
|
|
|
|
|
MinimumVersion string `json:"minimumVersion"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req updatePolicyRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
current := s.updatePolicy.get()
|
2026-08-10 20:54:00 +12:00
|
|
|
policy := appupdate.Policy{
|
|
|
|
|
Enabled: req.Enabled,
|
|
|
|
|
LatestVersion: strings.TrimSpace(req.LatestVersion),
|
|
|
|
|
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
|
|
|
|
|
RetireBelowVersion: strings.TrimSpace(req.RetireBelowVersion),
|
|
|
|
|
DownloadURL: strings.TrimSpace(req.DownloadURL),
|
|
|
|
|
Notes: strings.TrimSpace(req.Notes),
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL {
|
|
|
|
|
// Changing "required" or release notes must not silently discard integrity
|
|
|
|
|
// metadata added by the signed release publisher.
|
|
|
|
|
policy.SHA256 = current.SHA256
|
|
|
|
|
policy.SizeBytes = current.SizeBytes
|
|
|
|
|
}
|
2026-07-27 08:34:04 +12:00
|
|
|
if req.Required {
|
|
|
|
|
// Forcing means "nobody below the current build", so the floor is the latest.
|
|
|
|
|
policy.MinimumVersion = policy.LatestVersion
|
|
|
|
|
} else if policy.MinimumVersion == policy.LatestVersion {
|
|
|
|
|
// Un-ticking the box must actually release the floor.
|
|
|
|
|
policy.MinimumVersion = ""
|
|
|
|
|
}
|
2026-08-10 20:54:00 +12:00
|
|
|
if req.Destructive {
|
|
|
|
|
policy.MinimumVersion = policy.LatestVersion
|
|
|
|
|
policy.RetireBelowVersion = policy.LatestVersion
|
|
|
|
|
}
|
2026-07-27 08:34:04 +12:00
|
|
|
|
|
|
|
|
if policy.Enabled && policy.LatestVersion == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if policy.Enabled && policy.DownloadURL == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-10 20:54:00 +12:00
|
|
|
if policy.RetireBelowVersion != "" && !releaseVersionPattern.MatchString(policy.RetireBelowVersion) {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "the destructive update floor must look like 0.2.44")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if policy.Enabled && policy.RetireBelowVersion != "" &&
|
|
|
|
|
appupdate.CompareVersions(policy.RetireBelowVersion, policy.LatestVersion) > 0 {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "the destructive update floor cannot be newer than the latest version")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-27 08:34:04 +12:00
|
|
|
|
|
|
|
|
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("update policy write failed", "error", err)
|
2026-07-27 08:34:04 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not save the update policy")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Warn("update policy reload failed", "error", err)
|
2026-07-27 08:34:04 +12:00
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Info("update policy changed",
|
2026-07-27 08:34:04 +12:00
|
|
|
"enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion)
|
|
|
|
|
writeJSON(w, http.StatusOK, s.updatePolicy.get())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
type syncRequest struct {
|
|
|
|
|
Kind string `json:"kind"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleAdminSync starts an import in the background and returns immediately. A full
|
|
|
|
|
// import of a large library takes minutes; the page polls /admin/api/status for progress.
|
|
|
|
|
func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req syncRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Kind != "full" && req.Kind != "incremental" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, `kind must be "full" or "incremental"`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if s.syncer.Running() {
|
|
|
|
|
writeError(w, http.StatusConflict, "a sync is already running")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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)
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
type forYouAdminRequest struct {
|
|
|
|
|
Action string `json:"action"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleAdminForYou provides the recovery controls needed for an idempotent backfill:
|
|
|
|
|
// import all Tracearr sessions again, or rebuild every active user's derived pool.
|
|
|
|
|
func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.forYou == nil {
|
|
|
|
|
writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var req forYouAdminRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch req.Action {
|
|
|
|
|
case "incremental-import", "full-import", "rebuild-all":
|
|
|
|
|
default:
|
|
|
|
|
writeError(w, http.StatusBadRequest,
|
|
|
|
|
`action must be "incremental-import", "full-import", or "rebuild-all"`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if s.forYou.Running() {
|
|
|
|
|
writeError(w, http.StatusConflict, "For You maintenance is already running")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
go func() {
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), s.cfg.SyncTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if req.Action != "rebuild-all" {
|
|
|
|
|
_, err := s.forYou.Import(ctx, req.Action == "full-import")
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.log.Error("manual Tracearr import failed",
|
|
|
|
|
"component", "admin", "action", req.Action, "error", err)
|
2026-07-29 15:26:27 +12:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := s.forYou.RebuildAll(ctx, true); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.log.Error("manual For You rebuild failed",
|
|
|
|
|
"component", "admin", "action", req.Action, "error", err)
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
writeJSON(w, http.StatusAccepted, map[string]string{
|
|
|
|
|
"status": "started", "action": req.Action,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
type maintenanceRequest struct {
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req maintenanceRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)}
|
|
|
|
|
if err := s.store.SetMaintenance(r.Context(), state); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("maintenance write failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not update maintenance mode")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.LoadMaintenance(r.Context()); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Warn("maintenance reload failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
|
2026-07-27 08:16:20 +12:00
|
|
|
writeJSON(w, http.StatusOK, s.maintenance.get())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
days := queryInt(r, "days", 7, 90)
|
|
|
|
|
since := time.Now().UTC().AddDate(0, 0, -days)
|
|
|
|
|
|
|
|
|
|
stats, err := s.store.RowStats(r.Context(), since)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("row stats failed", "error", err)
|
2026-07-27 08:16:20 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-10 20:39:26 +12:00
|
|
|
// Keep the journey fields on this older endpoint for scripts written before journeys
|
|
|
|
|
// gained their own page and endpoint. The console itself only reads rows from here.
|
2026-08-10 20:24:22 +12:00
|
|
|
users, err := s.store.AnalyticsUsers(r.Context(), since)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("user analytics failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload := map[string]any{"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)), "rows": stats, "users": users}
|
|
|
|
|
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
|
|
|
|
|
if userID != "" {
|
|
|
|
|
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
|
|
|
|
|
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
|
|
|
|
|
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
|
|
|
|
|
if featureErr != nil || pathErr != nil || eventErr != nil {
|
2026-08-10 20:39:26 +12:00
|
|
|
s.loggerFor(r.Context()).Error("legacy user journey read failed", "user_id", userID)
|
2026-08-10 20:24:22 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not read user journey")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload["userId"] = userID
|
|
|
|
|
payload["features"] = features
|
|
|
|
|
payload["paths"] = paths
|
|
|
|
|
payload["events"] = events
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, payload)
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
2026-08-10 20:39:26 +12:00
|
|
|
func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
days := queryInt(r, "days", 30, 90)
|
|
|
|
|
since := time.Now().UTC().AddDate(0, 0, -days)
|
|
|
|
|
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
|
|
|
|
|
users, err := s.store.AnalyticsUsers(r.Context(), since)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("journey users failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read journeys")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stats, statsErr := s.store.JourneyStats(r.Context(), userID, since)
|
|
|
|
|
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
|
|
|
|
|
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
|
|
|
|
|
actions, actionErr := s.store.JourneyActionStats(r.Context(), userID, since)
|
|
|
|
|
if statsErr != nil || featureErr != nil || pathErr != nil || actionErr != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read journeys")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)),
|
|
|
|
|
"users": users, "stats": stats, "features": features, "paths": paths, "actions": actions,
|
|
|
|
|
}
|
|
|
|
|
if userID != "" {
|
|
|
|
|
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
|
|
|
|
|
if eventErr != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read user journey")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload["userId"] = userID
|
|
|
|
|
payload["events"] = events
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, payload)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
|
|
|
|
|
// concrete type for testing.
|
|
|
|
|
type syncerHandle interface {
|
|
|
|
|
Running() bool
|
|
|
|
|
Sync(ctx context.Context, kind, trigger string) (library.Result, error)
|
|
|
|
|
}
|