This commit is contained in:
ponzischeme89
2026-08-19 14:25:44 +12:00
parent 2b43b9ef12
commit 590e069366
83 changed files with 8948 additions and 1266 deletions
+50 -39
View File
@@ -32,6 +32,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/notify"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/runtimestats"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -299,11 +300,17 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
return err
}
sched.SetPaused(server.ActivityPaused)
// The ranker calls Tracearr directly on the rebuild path, so it needs the operator's
// switch too — read at call time, because an integration switched off in the console
// must stop being called without a restart. It is wired here rather than where the
// engine is built because the switch belongs to the server, which does not exist yet
// at that point.
recommender.TracearrAllowed = server.TracearrEnabled
dispatcher.SetPaused(server.ActivityPaused)
dispatcher.Start(ctx)
if creditsService != nil {
creditsService.SetPaused(server.ActivityPaused)
go creditsService.Run(ctx)
runtimestats.Go("Credits detection", "Credits detection", func() { creditsService.Run(ctx) })
}
if ingester != nil {
// Quiet time is honoured here rather than at the hook: the webhook is recorded
@@ -313,7 +320,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
// is there rather than that it is coming. Installed here for the same reason
// SetAfterSync is: library stays ignorant of what an alert is.
ingester.Announce = server.AnnounceLibraryIngest
go ingester.Run(ctx)
runtimestats.Go("Library ingest", "Library sync", func() { ingester.Run(ctx) })
}
// Registration is separate from construction so the task list reads as a declaration
@@ -321,6 +328,12 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
server.RegisterHousekeeping(sched)
server.RegisterCreditsTasks(sched)
server.RegisterWatchTimeTasks(sched)
server.RegisterRequestTasks(sched)
// Sonarr's daily catalogue reading, Radarr's film refresh, the Tracearr import and the
// For You rebuild all used to be goroutines with tickers of their own here. They are
// scheduler tasks now, which is what gives the console's integrations area a run
// history without a second store — and what lets an operator run any of them by hand.
server.RegisterIntegrationTasks(sched)
sched.Start(ctx)
// Installed after the server exists, because both halves of a finished import are
@@ -344,62 +357,60 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
if err := server.LoadGatewaySettings(ctx); err != nil {
return err
}
go server.WatchGatewaySettings(ctx, 30*time.Second)
go server.WatchMaintenance(ctx, 30*time.Second)
go server.WatchQuietTime(ctx, 30*time.Second)
// Every long-running worker below is started through runtimestats.Go rather than with
// a bare `go`, so the admin console can name what is running instead of reporting a
// count of goroutines nobody can interpret. The name is the worker's identity across
// restarts and appears verbatim in the console, so it is wording rather than a symbol.
runtimestats.Go("Gateway settings watcher", "Gateway settings", func() {
server.WatchGatewaySettings(ctx, 30*time.Second)
})
runtimestats.Go("Maintenance watcher", "Gateway settings", func() {
server.WatchMaintenance(ctx, 30*time.Second)
})
runtimestats.Go("Quiet time watcher", "Gateway settings", func() {
server.WatchQuietTime(ctx, 30*time.Second)
})
// One probe per gateway, not per TV: the answer is the same for the whole house.
go server.WatchEmbyReachability(ctx)
// One Sonarr catalogue reading per day records lifecycle changes for the household and
// materialises cancellation notifications for every known viewer.
go server.WatchSonarrLifecycle(ctx, 24*time.Hour)
runtimestats.Go("Emby health probe", "Emby", func() { server.WatchEmbyReachability(ctx) })
// The trend ring behind the console's runtime page. A single instantaneous goroutine
// or heap figure cannot show a leak; this is what makes one visible.
runtimestats.Go("Runtime sampler", "Gateway API", func() { runtimestats.StartSampling(ctx) })
if err := server.LoadUpdatePolicy(ctx); err != nil {
return err
}
go server.WatchUpdatePolicy(ctx, 60*time.Second)
runtimestats.Go("App update policy watcher", "Gateway settings", func() {
server.WatchUpdatePolicy(ctx, 60*time.Second)
})
go syncer.Schedule(ctx, server.LibrarySyncInterval, server.ActivityPaused)
runtimestats.Go("Library sync schedule", "Library sync", func() {
syncer.Schedule(ctx, server.LibrarySyncInterval, server.ActivityPaused)
})
if cfg.SyncOnStart {
go func() {
runtimestats.Go("Startup library sync", "Library sync", func() {
if server.ActivityPaused() {
return
}
if _, err := syncer.Sync(ctx, "incremental", "startup"); err != nil {
log.Warn("startup sync failed", "error", err)
}
}()
})
}
if forYouService != nil {
go forYouService.Schedule(
ctx,
cfg.TracearrSyncInterval,
cfg.TracearrFullInterval,
cfg.ForYouRebuildHour,
server.ActivityPaused,
)
go func() {
// The import and the daily rebuild are scheduler tasks now — see
// RegisterIntegrationTasks — so what is left here is the one piece of start-up work
// neither of them is: profiles produced by an older algorithm. Only a version change
// warrants it, and normal dirty profiles still wait for the daily off-peak rebuild.
runtimestats.Go("Startup For You migration", "Recommendations", func() {
if server.ActivityPaused() {
return
}
importCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout)
// Only if one is actually owed. An unconditional startup import made every
// redeploy or container bounce a fresh pass over Tracearr's history.
if _, _, err := forYouService.ImportIfDue(
importCtx, cfg.TracearrSyncInterval, cfg.TracearrFullInterval,
); err != nil {
cancel()
log.Warn("startup Tracearr import failed", "error", err)
return
}
cancel()
// Only an algorithm-version change warrants startup work. Normal dirty
// profiles wait for the daily off-peak rebuild.
rebuildCtx, rebuildCancel := context.WithTimeout(ctx, cfg.SyncTimeout)
defer rebuildCancel()
rebuildCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout)
defer cancel()
if err := forYouService.RebuildOutdated(rebuildCtx); err != nil {
log.Warn("startup outdated For You rebuild failed", "error", err)
}
}()
})
}
httpServer := &http.Server{
Addr: cfg.ListenAddr,
@@ -410,7 +421,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
}
errCh := make(chan error, 1)
go func() {
runtimestats.Go("HTTP listener", "HTTP server", func() {
log.Info("gateway ready",
"listen", cfg.ListenAddr,
"emby", cfg.EmbyURL,
@@ -423,7 +434,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
})
select {
case err := <-errCh: