0.2.71
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 household’s 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ type Service struct {
|
||||
behaviour BehaviourSource
|
||||
load LoadGauge
|
||||
log *slog.Logger
|
||||
paused func() bool
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
|
||||
@@ -109,6 +110,9 @@ type Service struct {
|
||||
liveDelay time.Duration
|
||||
}
|
||||
|
||||
// SetPaused installs the server-wide quiet-time gate before Run starts.
|
||||
func (s *Service) SetPaused(paused func() bool) { s.paused = paused }
|
||||
|
||||
func New(deps Deps) *Service {
|
||||
cfg := NormaliseConfig(deps.Config)
|
||||
detector := deps.Detector
|
||||
@@ -312,6 +316,12 @@ func (s *Service) Run(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if s.paused != nil && s.paused() {
|
||||
if !sleep(ctx, idlePoll) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
candidate, found := s.queue.Claim()
|
||||
if !found {
|
||||
if !sleep(ctx, idlePoll) {
|
||||
|
||||
@@ -467,7 +467,7 @@ func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, err
|
||||
|
||||
embyUsers, err := s.emby.Users(ctx, s.serviceCred)
|
||||
if err != nil {
|
||||
s.log.Warn("Emby household users unavailable; using signed-in users", "error", err)
|
||||
s.log.Warn("Emby user catalogue unavailable; using signed-in users", "error", err)
|
||||
return active, nil
|
||||
}
|
||||
tracearrUsers, traceErr := s.allTracearrUsers(ctx)
|
||||
@@ -771,6 +771,7 @@ func (s *Service) Schedule(
|
||||
ctx context.Context,
|
||||
importEvery, fullEvery time.Duration,
|
||||
rebuildHour int,
|
||||
paused ...func() bool,
|
||||
) {
|
||||
// One ticker asks "is anything owed?"; the persisted stamps decide what and whether.
|
||||
// Two independent tickers measured process uptime, which is what let a restart reset
|
||||
@@ -798,12 +799,17 @@ func (s *Service) Schedule(
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-importC:
|
||||
if len(paused) > 0 && paused[0] != nil && paused[0]() {
|
||||
continue
|
||||
}
|
||||
if _, _, err := s.ImportIfDue(ctx, importEvery, fullEvery); err != nil {
|
||||
s.log.Warn("scheduled Tracearr import failed", "error", err)
|
||||
}
|
||||
case <-rebuildTimer.C:
|
||||
if err := s.RebuildAll(ctx, true); err != nil {
|
||||
s.log.Warn("scheduled daily For You rebuild failed", "error", err)
|
||||
if len(paused) == 0 || paused[0] == nil || !paused[0]() {
|
||||
if err := s.RebuildAll(ctx, true); err != nil {
|
||||
s.log.Warn("scheduled daily For You rebuild failed", "error", err)
|
||||
}
|
||||
}
|
||||
rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour)))
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ type Dispatcher struct {
|
||||
|
||||
transports map[string]Transport
|
||||
queue chan job
|
||||
paused func() bool
|
||||
|
||||
mu sync.Mutex
|
||||
cached store.IntegrationSettings
|
||||
@@ -76,6 +77,9 @@ type Dispatcher struct {
|
||||
dropped int64
|
||||
}
|
||||
|
||||
// SetPaused installs the server-wide quiet-time gate before Start is called.
|
||||
func (d *Dispatcher) SetPaused(paused func() bool) { d.paused = paused }
|
||||
|
||||
func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Dispatcher {
|
||||
dispatcher := &Dispatcher{
|
||||
store: st, log: log.With("component", "integrations"), events: events,
|
||||
@@ -101,6 +105,15 @@ func (d *Dispatcher) Start(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case work := <-d.queue:
|
||||
for d.paused != nil && d.paused() {
|
||||
timer := time.NewTimer(30 * time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
d.deliver(ctx, work)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
|
||||
//
|
||||
// New episodes tend to land through the day and films weekly; an hourly incremental pass
|
||||
// covers both without ever asking Emby for the whole catalogue again.
|
||||
func (s *Syncer) Schedule(ctx context.Context, interval time.Duration) {
|
||||
func (s *Syncer) Schedule(ctx context.Context, interval time.Duration, paused ...func() bool) {
|
||||
if interval <= 0 {
|
||||
s.log.Info("library auto-sync disabled")
|
||||
return
|
||||
@@ -291,6 +291,10 @@ func (s *Syncer) Schedule(ctx context.Context, interval time.Duration) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if len(paused) > 0 && paused[0] != nil && paused[0]() {
|
||||
s.log.Debug("skipping scheduled sync; quiet time is active")
|
||||
continue
|
||||
}
|
||||
if s.Running() {
|
||||
s.log.Info("skipping scheduled sync; one is already running")
|
||||
continue
|
||||
|
||||
@@ -93,6 +93,7 @@ type Scheduler struct {
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
events *adminevents.Bus
|
||||
paused func() bool
|
||||
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*registered
|
||||
@@ -101,6 +102,21 @@ type Scheduler struct {
|
||||
started bool
|
||||
}
|
||||
|
||||
// SetPaused installs the server-wide activity gate. The function is intentionally read at
|
||||
// execution time so an admin change takes effect without rebuilding the task registry.
|
||||
func (s *Scheduler) SetPaused(paused func() bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.paused = paused
|
||||
}
|
||||
|
||||
func (s *Scheduler) isPaused() bool {
|
||||
s.mu.RLock()
|
||||
paused := s.paused
|
||||
s.mu.RUnlock()
|
||||
return paused != nil && paused()
|
||||
}
|
||||
|
||||
func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Scheduler {
|
||||
return &Scheduler{
|
||||
store: st, log: log.With("component", "scheduler"), events: events,
|
||||
@@ -219,6 +235,9 @@ func (s *Scheduler) loop(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *Scheduler) runDue(ctx context.Context) {
|
||||
if s.isPaused() {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.RLock()
|
||||
entries := make([]*registered, 0, len(s.tasks))
|
||||
@@ -248,6 +267,9 @@ func (s *Scheduler) RunNow(ctx context.Context, id string) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("scheduler: no task %q", id)
|
||||
}
|
||||
if s.isPaused() {
|
||||
return fmt.Errorf("scheduler: server activity is paused for quiet time")
|
||||
}
|
||||
entry.mu.Lock()
|
||||
if entry.running {
|
||||
entry.mu.Unlock()
|
||||
@@ -261,6 +283,9 @@ func (s *Scheduler) RunNow(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger string) {
|
||||
if s.isPaused() {
|
||||
return
|
||||
}
|
||||
entry.mu.Lock()
|
||||
if entry.running {
|
||||
entry.mu.Unlock()
|
||||
|
||||
@@ -40,6 +40,22 @@ func TestATaskNeedsAnIDAndAFunction(t *testing.T) {
|
||||
sched.Register(Task{ID: "broken"})
|
||||
}
|
||||
|
||||
func TestQuietTimePausesManualTasks(t *testing.T) {
|
||||
sched := quietScheduler()
|
||||
ran := false
|
||||
sched.Register(Task{ID: "quiet", Name: "Quiet", Run: func(context.Context) (string, error) {
|
||||
ran = true
|
||||
return "", nil
|
||||
}})
|
||||
sched.SetPaused(func() bool { return true })
|
||||
if err := sched.RunNow(context.Background(), "quiet"); err == nil {
|
||||
t.Fatal("manual task started during quiet time")
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("quiet-time task function ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPanickingTaskBecomesAFailedRun(t *testing.T) {
|
||||
// A background job is the one place a panic takes the whole process down for a reason
|
||||
// nobody is watching for. One housekeeping job with a nil map must not be able to stop
|
||||
|
||||
@@ -39,7 +39,7 @@ func (s *Store) HouseholdCompletionScores(
|
||||
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
|
||||
GROUP BY item_id`, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: household completion scores: %w", err)
|
||||
return nil, fmt.Errorf("store: library-wide completion scores: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]float64{}
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
// MaintenanceKey is the app_settings row backing maintenance mode.
|
||||
const MaintenanceKey = "maintenance"
|
||||
|
||||
// QuietTimeKey is the app_settings row backing the daily server quiet-time window.
|
||||
const QuietTimeKey = "quiet_time"
|
||||
|
||||
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
|
||||
const RequestPolicyKey = "request_policy"
|
||||
|
||||
@@ -742,6 +745,101 @@ func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuietTime is a daily window in the household timezone during which Memby's data plane
|
||||
// and background work stand down. The admin control plane and health checks remain live so
|
||||
// an operator can change a bad schedule without restarting the container.
|
||||
type QuietTime struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
Message string `json:"message"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
const DefaultQuietTimeMessage = "Memby is in quiet time. Try again later."
|
||||
|
||||
func DefaultQuietTime() QuietTime {
|
||||
return QuietTime{StartTime: "23:00", EndTime: "07:00", Message: DefaultQuietTimeMessage}
|
||||
}
|
||||
|
||||
// QuietTimeActive reports whether now falls in the configured local-clock window. A
|
||||
// window crossing midnight includes late evening and the following morning. Equal or
|
||||
// malformed endpoints are treated as inactive; the admin handler refuses both.
|
||||
func QuietTimeActive(policy QuietTime, now time.Time, location *time.Location) bool {
|
||||
if !policy.Enabled {
|
||||
return false
|
||||
}
|
||||
start, startErr := time.Parse("15:04", policy.StartTime)
|
||||
end, endErr := time.Parse("15:04", policy.EndTime)
|
||||
if startErr != nil || endErr != nil || policy.StartTime == policy.EndTime {
|
||||
return false
|
||||
}
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
local := now.In(location)
|
||||
minute := local.Hour()*60 + local.Minute()
|
||||
startMinute := start.Hour()*60 + start.Minute()
|
||||
endMinute := end.Hour()*60 + end.Minute()
|
||||
if startMinute < endMinute {
|
||||
return minute >= startMinute && minute < endMinute
|
||||
}
|
||||
return minute >= startMinute || minute < endMinute
|
||||
}
|
||||
|
||||
func normaliseQuietTime(policy QuietTime) QuietTime {
|
||||
defaults := DefaultQuietTime()
|
||||
if parsed, err := time.Parse("15:04", strings.TrimSpace(policy.StartTime)); err == nil {
|
||||
policy.StartTime = parsed.Format("15:04")
|
||||
} else {
|
||||
policy.StartTime = defaults.StartTime
|
||||
}
|
||||
if parsed, err := time.Parse("15:04", strings.TrimSpace(policy.EndTime)); err == nil {
|
||||
policy.EndTime = parsed.Format("15:04")
|
||||
} else {
|
||||
policy.EndTime = defaults.EndTime
|
||||
}
|
||||
policy.Message = strings.TrimSpace(policy.Message)
|
||||
if policy.Message == "" {
|
||||
policy.Message = DefaultQuietTimeMessage
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (s *Store) QuietTime(ctx context.Context) (QuietTime, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, QuietTimeKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultQuietTime(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultQuietTime(), fmt.Errorf("store: read quiet time: %w", err)
|
||||
}
|
||||
var policy QuietTime
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return DefaultQuietTime(), fmt.Errorf("store: decode quiet time: %w", err)
|
||||
}
|
||||
return normaliseQuietTime(policy), nil
|
||||
}
|
||||
|
||||
func (s *Store) SetQuietTime(ctx context.Context, policy QuietTime) error {
|
||||
policy = normaliseQuietTime(policy)
|
||||
policy.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
QuietTimeKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write quiet time: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePolicyKey is the app_settings row backing the client update policy.
|
||||
const UpdatePolicyKey = "update_policy"
|
||||
|
||||
|
||||
@@ -5,6 +5,34 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestQuietTimeActiveHandlesDaytimeAndOvernightWindows(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
at := func(hour, minute int) time.Time {
|
||||
return time.Date(2026, time.August, 17, hour, minute, 0, 0, location)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
policy QuietTime
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{"disabled", QuietTime{StartTime: "23:00", EndTime: "07:00"}, at(23, 30), false},
|
||||
{"overnight evening", QuietTime{Enabled: true, StartTime: "23:00", EndTime: "07:00"}, at(23, 0), true},
|
||||
{"overnight morning", QuietTime{Enabled: true, StartTime: "23:00", EndTime: "07:00"}, at(6, 59), true},
|
||||
{"overnight end exclusive", QuietTime{Enabled: true, StartTime: "23:00", EndTime: "07:00"}, at(7, 0), false},
|
||||
{"daytime inside", QuietTime{Enabled: true, StartTime: "09:00", EndTime: "17:00"}, at(12, 0), true},
|
||||
{"daytime outside", QuietTime{Enabled: true, StartTime: "09:00", EndTime: "17:00"}, at(18, 0), false},
|
||||
{"equal endpoints are safe", QuietTime{Enabled: true, StartTime: "09:00", EndTime: "09:00"}, at(9, 0), false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := QuietTimeActive(test.policy, test.now, location); got != test.want {
|
||||
t.Fatalf("QuietTimeActive() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
|
||||
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
|
||||
if policy.Allows("user-1") {
|
||||
|
||||
Reference in New Issue
Block a user