This commit is contained in:
ponzischeme89
2026-08-17 07:34:23 +12:00
parent 93fb0fd728
commit 36cb1324fd
56 changed files with 1177 additions and 168 deletions
+50
View File
@@ -57,6 +57,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/quiet-time", s.adminAuth(s.handleAdminQuietTime))
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus))
@@ -233,6 +234,7 @@ type adminStatus struct {
ServerVersion string `json:"serverVersion"`
CurrentUser string `json:"currentUser,omitempty"`
Maintenance store.Maintenance `json:"maintenance"`
QuietTime quietTimeStatus `json:"quietTime"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
@@ -301,6 +303,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
return username
}(),
Maintenance: s.maintenance.get(),
QuietTime: s.quietTimeStatus(time.Now()),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
@@ -605,6 +608,9 @@ type syncRequest struct {
// 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) {
if s.rejectWorkDuringQuietTime(w) {
return
}
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")
@@ -640,6 +646,9 @@ type forYouAdminRequest struct {
// 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.rejectWorkDuringQuietTime(w) {
return
}
if s.forYou == nil {
writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured")
return
@@ -731,6 +740,47 @@ func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, s.maintenance.get())
}
type quietTimeRequest struct {
Enabled bool `json:"enabled"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Message string `json:"message"`
}
func (s *Server) handleAdminQuietTime(w http.ResponseWriter, r *http.Request) {
var req quietTimeRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
start, startErr := time.Parse("15:04", strings.TrimSpace(req.StartTime))
end, endErr := time.Parse("15:04", strings.TrimSpace(req.EndTime))
if startErr != nil || endErr != nil {
writeError(w, http.StatusBadRequest, "quiet time must use valid 24-hour start and end times")
return
}
if start.Equal(end) {
writeError(w, http.StatusBadRequest, "quiet time start and end must be different")
return
}
policy := store.QuietTime{
Enabled: req.Enabled, StartTime: start.Format("15:04"), EndTime: end.Format("15:04"),
Message: strings.TrimSpace(req.Message),
}
if err := s.store.SetQuietTime(r.Context(), policy); err != nil {
s.loggerFor(r.Context()).Error("quiet-time write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not update quiet time")
return
}
if err := s.LoadQuietTime(r.Context()); err != nil {
s.loggerFor(r.Context()).Warn("quiet-time reload failed", "error", err)
}
status := s.quietTimeStatus(time.Now())
s.loggerFor(r.Context()).Info("quiet time changed", "enabled", status.Enabled,
"active", status.Active, "start", status.StartTime, "end", status.EndTime)
writeJSON(w, http.StatusOK, status)
}
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
days := queryInt(r, "days", 7, 90)
since := time.Now().UTC().AddDate(0, 0, -days)
@@ -214,6 +214,9 @@ func (s *Server) handleAdminDeleteIntegration(w http.ResponseWriter, r *http.Req
// Synchronous on purpose: a test is a question, and an operator who pressed it needs the
// answer here rather than on a delivery history they would have to go and refresh.
func (s *Server) handleAdminTestIntegration(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
id := strings.TrimSpace(r.PathValue("integrationID"))
if err := s.integrations.Test(r.Context(), id); err != nil {
s.loggerFor(r.Context()).Warn("integration test failed",
+3
View File
@@ -159,6 +159,9 @@ func (s *Server) handleAdminSubtitleSettings(w http.ResponseWriter, r *http.Requ
// the search comes back empty. One button that says "the key is rejected" is the whole
// difference between a five-minute fix and an evening of guessing.
func (s *Server) handleAdminSubtitleTest(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
ctx := r.Context()
type probe struct {
Provider string `json:"provider"`
+25
View File
@@ -90,6 +90,31 @@ func TestMaintenanceGateFallsBackToADefaultMessage(t *testing.T) {
}
}
func TestQuietTimeGateBlocksClientWorkWithTheConfiguredMessage(t *testing.T) {
server := testServer(config.Config{})
now := time.Now()
server.quietTime.set(store.QuietTime{
Enabled: true, StartTime: now.Add(-time.Minute).Format("15:04"),
EndTime: now.Add(time.Minute).Format("15:04"), Message: "Sleeping until morning",
})
rec := httptest.NewRecorder()
server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("client handler ran during quiet time")
})).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("quiet-time response = %d, want 503", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body: %v", err)
}
if body["quietTime"] != true || body["message"] != "Sleeping until morning" {
t.Fatalf("quiet-time response = %#v", body)
}
}
func TestBareAdminURLRedirectsToTheConsoleRoot(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
rec := httptest.NewRecorder()
+2 -1
View File
@@ -98,6 +98,7 @@ type Server struct {
recommendationBuilds recommendationBuilds
maintenance maintenanceState
quietTime quietTimeState
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.
@@ -293,7 +294,7 @@ func (s *Server) Routes() http.Handler {
mux.Handle("/v1/", s.maintenanceGate(v1))
// Radarr pushes here when an import finishes. Outside the gate on purpose: an event
// arriving during maintenance would otherwise be lost rather than delayed.
mux.HandleFunc("POST /hooks/radarr", s.handleRadarrWebhook)
mux.Handle("POST /hooks/radarr", s.quietTimeGate(http.HandlerFunc(s.handleRadarrWebhook)))
// State the canonical console URL explicitly. The console and its assets live below
// /admin/, while a bare /admin is routinely typed and some reverse proxies do not
// preserve ServeMux's implicit trailing-slash redirect for a mounted subtree.
+1 -1
View File
@@ -83,7 +83,7 @@ func (s *Server) RegisterCreditsTasks(sched *scheduler.Scheduler) {
ID: "credits-candidates",
Name: "Credits candidate refresh",
Group: "Library",
Description: "Rebuilds the credits-detection queue from what the household has " +
Description: "Rebuilds the credits-detection queue from what viewers have " +
"recently been watching. Only episodes viewers are about to reach are queued.",
Interval: 10 * time.Minute,
Timeout: 2 * time.Minute,
+2 -2
View File
@@ -89,8 +89,8 @@ var featureCatalogue = []featureDefinition{
Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback",
Description: "Shrink the picture and run the closing credits at double speed with " +
"the next episode beside them. Emby's own marker is preferred where it has one, " +
"and where it has none the position discovered for the episodes the household " +
"is about to watch is used instead. It is read from the same chapter list as the " +
"and where it has none the position discovered from episodes viewers are about to watch " +
"in your library is used instead. It is read from the same chapter list as the " +
"title sequence, so turning this off saves no request unless that is off too.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
+1 -1
View File
@@ -86,7 +86,7 @@
<div class="mark">M</div>
<h1>Install Memby</h1>
{{if .Ready}}
<p class="intro">The private Android TV client for this households Emby library.</p>
<p class="intro">The private Android TV client for your Emby library.</p>
<a class="download" href="{{.DownloadURL}}">Download Memby {{.Version}}</a>
<p class="meta">Signed Android APK · {{.Size}}</p>
<ol>
+10
View File
@@ -67,6 +67,10 @@ func (s *Server) WatchMaintenance(ctx context.Context, interval time.Duration) {
// deliberately outside this gate — you need them most while the app is down.
func (s *Server) maintenanceGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.quietTimeActive() {
s.quietTimeUnavailable(w)
return
}
state := s.maintenance.get()
if !state.Enabled {
next.ServeHTTP(w, r)
@@ -95,6 +99,11 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler {
// already listening to — a push channel would be a second connection for a banner.
func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, sess store.Session) {
state := s.maintenance.get()
quiet := s.quietTimeStatus(time.Now())
if quiet.Active {
state.Enabled = true
state.Message = quiet.Message
}
message := state.Message
if state.Enabled && message == "" {
message = store.DefaultMaintenanceMessage
@@ -123,6 +132,7 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
featurePolicy := s.currentFeaturePolicy(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled,
"quietTime": quiet.Active,
"message": message,
"alerts": alerts,
"compatible": compatible,
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"context"
"net/http"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// quietTimeState keeps the daily policy on the hot path without turning every television
// request or scheduler tick into a database read.
type quietTimeState struct {
mu sync.RWMutex
policy store.QuietTime
}
func (q *quietTimeState) get() store.QuietTime {
q.mu.RLock()
defer q.mu.RUnlock()
return q.policy
}
func (q *quietTimeState) set(policy store.QuietTime) {
q.mu.Lock()
defer q.mu.Unlock()
q.policy = policy
}
type quietTimeStatus struct {
store.QuietTime
Active bool `json:"active"`
TimeZone string `json:"timeZone"`
}
func (s *Server) quietTimeStatus(now time.Time) quietTimeStatus {
policy := s.quietTime.get()
return quietTimeStatus{
QuietTime: policy,
Active: store.QuietTimeActive(policy, now, s.sonarrLocation()),
TimeZone: s.sonarrLocation().String(),
}
}
func (s *Server) quietTimeActive() bool {
return s.quietTimeStatus(time.Now()).Active
}
// ActivityPaused is the shared gate used by workers constructed outside the API package.
func (s *Server) ActivityPaused() bool { return s.quietTimeActive() }
func (s *Server) LoadQuietTime(ctx context.Context) error {
policy, err := s.store.QuietTime(ctx)
if err != nil {
return err
}
s.quietTime.set(policy)
return nil
}
// WatchQuietTime lets another gateway instance or a direct database edit take effect
// without a restart. It is control-plane work and therefore continues during quiet time.
func (s *Server) WatchQuietTime(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.LoadQuietTime(ctx); err != nil {
s.log.Warn("quiet-time refresh failed", "component", "quiet-time", "error", err)
}
}
}
}
func (s *Server) quietTimeUnavailable(w http.ResponseWriter) {
status := s.quietTimeStatus(time.Now())
message := status.Message
if message == "" {
message = store.DefaultQuietTimeMessage
}
w.Header().Set("Retry-After", "300")
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"error": message, "maintenance": true, "quietTime": true, "message": message,
})
}
func (s *Server) rejectWorkDuringQuietTime(w http.ResponseWriter) bool {
if !s.quietTimeActive() {
return false
}
s.quietTimeUnavailable(w)
return true
}
func (s *Server) quietTimeGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
next.ServeHTTP(w, r)
})
}
+3
View File
@@ -23,6 +23,9 @@ func (s *Server) handleAdminReleaseBuilderStatus(w http.ResponseWriter, r *http.
}
func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
var request releaseBuilderRequest
decoder := json.NewDecoder(r.Body)
+3
View File
@@ -132,6 +132,9 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
case <-ctx.Done():
return
case <-ticker.C:
if s.quietTimeActive() {
continue
}
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := s.emby.Ping(probeCtx)
cancel()
+3
View File
@@ -19,6 +19,9 @@ func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duratio
return
}
scan := func() {
if s.quietTimeActive() {
return
}
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
s.log.Warn("Sonarr lifecycle scan failed", "error", err)
}