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
+43 -28
View File
@@ -5,10 +5,8 @@ import (
"crypto/subtle"
_ "embed"
"encoding/json"
"io"
"net/http"
"os"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -16,6 +14,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/buildinfo"
"github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/runtimestats"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -54,6 +53,9 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
// The breakdown is its own route because it costs its own money: walking every
// goroutine's stack stops the world, so it is asked for rather than polled.
mux.Handle("GET /admin/api/runtime/goroutines", s.adminAuth(s.handleAdminGoroutines))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("GET /admin/api/ingest", s.adminAuth(s.handleAdminIngest))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
@@ -101,6 +103,18 @@ func (s *Server) adminRoutes() http.Handler {
// abandoned tab's session alive.
mux.Handle("GET /admin/api/notifications/stream", s.adminAuth(s.handleAdminNotificationStream))
// The Integrations area. Deliberately a longer path than /admin/api/integrations,
// which is the older, narrower question — the webhook destinations administrative
// events are posted to. These are the external services Memby depends on.
mux.Handle("GET /admin/api/integrations/services",
s.adminAuth(s.handleAdminIntegrationServices))
mux.Handle("GET /admin/api/integrations/services/{integrationID}",
s.adminAuth(s.handleAdminIntegrationService))
mux.Handle("POST /admin/api/integrations/services/{integrationID}/enabled",
s.adminAuth(s.handleAdminIntegrationEnabled))
mux.Handle("POST /admin/api/integrations/services/{integrationID}/test",
s.adminAuth(s.handleAdminIntegrationTest))
mux.Handle("GET /admin/api/integrations", s.adminAuth(s.handleAdminIntegrations))
mux.Handle("POST /admin/api/integrations", s.adminAuth(s.handleAdminSaveIntegration))
mux.Handle("DELETE /admin/api/integrations/{integrationID}", s.adminAuth(s.handleAdminDeleteIntegration))
@@ -134,33 +148,34 @@ func (s *Server) adminRoutes() http.Handler {
return mux
}
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"`
// The console's Process card used to be handed a bare goroutine count, which is a number
// nobody can act on: it says nothing about what those goroutines are doing, which part of
// Memby they belong to, or whether it has been climbing all week. Everything behind these
// two routes lives in internal/runtimestats, including the health verdict, so the
// thresholds and the sentences explaining them sit beside each other and can be tested.
func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, runtimestats.Read())
}
func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) {
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
// handleAdminGoroutines is the on-demand snapshot. `?format=text` returns the raw dump for
// an operator who wants the whole thing — which is the alternative to leaving a profiling
// endpoint permanently mounted, and unlike one it is behind the admin guard and produces
// nothing until somebody asks.
func (s *Server) handleAdminGoroutines(w http.ResponseWriter, r *http.Request) {
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"),
})
log := s.loggerFor(r.Context())
if r.URL.Query().Get("format") == "text" {
log.Info("goroutine dump requested", "component", "admin")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=memby-goroutines.txt")
_, _ = io.WriteString(w, runtimestats.StackDump())
return
}
report := runtimestats.CollectGoroutines()
log.Info("goroutine breakdown collected",
"component", "admin", "goroutines", report.Total, "took_ms", report.CollectedInMs)
writeJSON(w, http.StatusOK, report)
}
func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
@@ -686,7 +701,7 @@ func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
return
}
}
if err := s.forYou.RebuildAll(ctx, true); err != nil {
if _, err := s.forYou.RebuildAll(ctx, true); err != nil {
s.log.Error("manual For You rebuild failed",
"component", "admin", "action", req.Action, "error", err)
}
@@ -0,0 +1,369 @@
package api
import (
"context"
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The Integrations area's API.
//
// One service is one row, and the row is assembled from three things the gateway already
// holds: the catalogue (what this service is and where its switch lives), the scheduler
// (what work belongs to it, when it last ran and when it is next due) and the run table
// read along the integration axis (what that work actually did). Nothing here is stored
// for the console's benefit.
//
// The status word is the gateway's, not the console's — the stance every label Memby
// prints takes. A console that decided for itself what "unhealthy" meant would have an
// older build disagreeing with a newer one about the same server, and the threshold and
// the sentence explaining it belong together.
// Integration status words, in the order they outrank each other. See integrationStatus.
const (
integrationStatusUnconfigured = "unconfigured"
integrationStatusDisabled = "disabled"
integrationStatusRunning = "running"
integrationStatusError = "error"
integrationStatusHealthy = "healthy"
integrationStatusIdle = "idle"
)
// integrationView is one service as the console reads it.
type integrationView struct {
ID string `json:"id"`
Name string `json:"name"`
Summary string `json:"summary"`
Address string `json:"address,omitempty"`
Configured bool `json:"configured"`
Enabled bool `json:"enabled"`
// Status is one word and StatusLabel is what to print; Detail is the sentence behind
// it — the failure reason, or what is running, or why there is nothing to say.
Status string `json:"status"`
StatusLabel string `json:"statusLabel"`
Detail string `json:"detail,omitempty"`
Running bool `json:"running"`
// Powers is what stops working when this is switched off, so a dependency is stated
// rather than discovered by something silently not happening.
Powers []string `json:"powers"`
LastRun *store.TaskRun `json:"lastRun,omitempty"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
LastFailureAt *time.Time `json:"lastFailureAt,omitempty"`
LastError string `json:"lastError,omitempty"`
NextRun *time.Time `json:"nextRun,omitempty"`
Runs int `json:"runs"`
Failures int `json:"failures"`
// Health is the last reachability probe, absent for a service that is not probed.
Health *integrationHealth `json:"health,omitempty"`
// Probed says whether this service can be probed at all, which is what stops the
// console drawing "never checked" beside MDBList for ever.
Probed bool `json:"probed"`
Tasks []scheduler.Status `json:"tasks"`
Facts []integrationFact `json:"facts,omitempty"`
}
// integrationStatus resolves one word per service, in priority order, because a status
// column with two answers in it is one nobody can scan.
//
// Not configured outranks everything: a service with no address has no switch, no work and
// no health, and drawing it as "off" would suggest turning it on is a thing an operator
// could do from this page. Switched off comes next for the same reason a disabled task
// does — yesterday's success beside a service nobody is running reads as one that is still
// working. A run in flight outranks its own history, and below those the service is
// described by whichever of its probe and its last run has something to say. Idle is the
// honest answer for a configured, enabled service that has not yet done anything: it is
// neither working nor broken, and claiming either would be a guess.
func integrationStatus(view integrationView) (string, string, string) {
switch {
case !view.Configured:
return integrationStatusUnconfigured, "Not configured",
"No address or credential is set for this service."
case !view.Enabled:
return integrationStatusDisabled, "Disabled",
"Switched off: Memby schedules no work for it and makes no requests to it."
case view.Running:
return integrationStatusRunning, "Running", runningDetail(view.Tasks)
}
if view.Health != nil && !view.Health.Reachable && !view.Health.CheckedAt.IsZero() {
return integrationStatusError, "Error", view.Health.Error
}
// A failure that has been followed by a success is history, not a verdict. Without
// that comparison one bad night would leave a service red until the retention job
// eventually removed the row.
if view.LastFailureAt != nil &&
(view.LastSuccessAt == nil || view.LastSuccessAt.Before(*view.LastFailureAt)) {
return integrationStatusError, "Error", view.LastError
}
if view.Health != nil && view.Health.Reachable {
return integrationStatusHealthy, "Healthy", ""
}
if view.LastSuccessAt != nil {
return integrationStatusHealthy, "Healthy", ""
}
return integrationStatusIdle, "Idle", "Nothing has run for this service yet."
}
func runningDetail(tasks []scheduler.Status) string {
for _, task := range tasks {
if task.Running {
return task.Name + " is running now."
}
}
return ""
}
// integrationViews assembles every service. One pass over the catalogue, one scheduler
// snapshot and one grouped query, whatever the catalogue's length.
func (s *Server) integrationViews(ctx context.Context) []integrationView {
summaries := map[string]store.IntegrationRunSummary{}
if s.store != nil {
if read, err := s.store.IntegrationRunSummaries(ctx); err == nil {
summaries = read
} else {
s.loggerFor(ctx).Warn("integration run summaries unavailable", "error", err)
}
}
tasksByIntegration := map[string][]scheduler.Status{}
if s.scheduler != nil {
for _, task := range s.scheduler.Snapshot() {
if task.Integration == "" {
continue
}
tasksByIntegration[task.Integration] = append(
tasksByIntegration[task.Integration], task)
}
}
views := make([]integrationView, 0, len(integrationCatalogue()))
for _, definition := range integrationCatalogue() {
view := integrationView{
ID: definition.ID,
Name: definition.Name,
Summary: definition.Summary,
Powers: definition.Powers,
Configured: definition.Configured(s),
Probed: definition.Probe != nil,
Tasks: tasksByIntegration[definition.ID],
}
if view.Tasks == nil {
view.Tasks = []scheduler.Status{}
}
if view.Configured {
view.Address = definition.Address(s)
view.Enabled = definition.Enabled(s, ctx)
}
if summary, ok := summaries[definition.ID]; ok {
view.LastSuccessAt = summary.LastSuccessAt
view.LastFailureAt = summary.LastFailureAt
view.LastError = summary.LastError
view.Runs, view.Failures = summary.Runs, summary.Failures
}
// The most recent run and the next due one come from the scheduler rather than
// from a query: it holds both, and the next run has no row anywhere to read.
for _, task := range view.Tasks {
if task.Running {
view.Running = true
}
if task.LastRun != nil &&
(view.LastRun == nil || task.LastRun.StartedAt.After(view.LastRun.StartedAt)) {
last := *task.LastRun
view.LastRun = &last
}
if task.NextRun != nil &&
(view.NextRun == nil || task.NextRun.Before(*view.NextRun)) {
next := *task.NextRun
view.NextRun = &next
}
}
// A switched-off service has no next run to promise, whatever the scheduler still
// holds: its tasks stand down as soon as they start.
if !view.Enabled {
view.NextRun = nil
}
if state, ok := s.integrationHealth.get(definition.ID); ok {
health := state
view.Health = &health
}
view.Status, view.StatusLabel, view.Detail = integrationStatus(view)
views = append(views, view)
}
// Anything wrong sorts to the top, the order the tasks table takes and for the same
// reason: a page read from the top down should not need a sort to find the one thing
// that is broken.
sort.SliceStable(views, func(i, j int) bool {
return integrationRank(views[i].Status) < integrationRank(views[j].Status)
})
return views
}
func integrationRank(status string) int {
switch status {
case integrationStatusError:
return 0
case integrationStatusRunning:
return 1
case integrationStatusIdle:
return 2
case integrationStatusHealthy:
return 3
case integrationStatusDisabled:
return 4
default:
return 5
}
}
func (s *Server) handleAdminIntegrationServices(w http.ResponseWriter, r *http.Request) {
views := s.integrationViews(r.Context())
runs, err := s.store.IntegrationRuns(r.Context(), "", integrationRunLimit(r))
if err != nil {
s.loggerFor(r.Context()).Warn("integration runs unavailable", "error", err)
runs = []store.TaskRun{}
}
writeJSON(w, http.StatusOK, map[string]any{
"services": views,
// Every service's runs interleaved, which is the view that shows two integrations
// getting in each other's way — the same reason the tasks page carries one.
"runs": runs,
})
}
func (s *Server) handleAdminIntegrationService(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("integrationID"))
definition, ok := integrationDefinitionFor(id)
if !ok {
writeError(w, http.StatusNotFound, "no such integration")
return
}
var view integrationView
for _, candidate := range s.integrationViews(r.Context()) {
if candidate.ID == id {
view = candidate
break
}
}
// Facts are read only on the detail page: several of them are database queries, and
// the overview draws four services at a poll.
if definition.Facts != nil && view.Configured {
view.Facts = definition.Facts(s, r.Context())
}
runs, err := s.store.IntegrationRuns(r.Context(), id, integrationRunLimit(r))
if err != nil {
s.loggerFor(r.Context()).Warn("integration runs unavailable",
"integration", id, "error", err)
runs = []store.TaskRun{}
}
writeJSON(w, http.StatusOK, map[string]any{"service": view, "runs": runs})
}
func integrationRunLimit(r *http.Request) int {
limit, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit")))
if err != nil || limit <= 0 {
return 50
}
return limit
}
func (s *Server) handleAdminIntegrationEnabled(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("integrationID"))
definition, ok := integrationDefinitionFor(id)
if !ok {
writeError(w, http.StatusNotFound, "no such integration")
return
}
if !definition.Configured(s) {
// A service with no address has nothing to switch, and recording a preference
// about one would leave a stored decision nothing reads.
writeError(w, http.StatusConflict,
definition.Name+" is not configured on this gateway")
return
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if err := definition.SetEnabled(s, r.Context(), req.Enabled); err != nil {
s.loggerFor(r.Context()).Error("could not change integration",
"integration", id, "error", err)
writeError(w, http.StatusInternalServerError, "could not save the integration setting")
return
}
if !req.Enabled {
// Its last health reading described a service Memby has stopped calling. Left in
// place it would sit under a disabled row claiming a verdict nothing is renewing.
s.integrationHealth.forget(id)
}
s.loggerFor(r.Context()).Info("integration switched",
"integration", id, "enabled", req.Enabled)
state := "disabled"
if req.Enabled {
state = "enabled"
}
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeSettingsChanged,
Severity: adminevents.SeverityInfo,
Title: definition.Name + " " + state,
Summary: integrationSwitchSummary(definition, req.Enabled),
Target: id,
Link: "/admin/integrations/" + id,
Metadata: adminevents.Meta(map[string]any{"integration": id, "enabled": req.Enabled}),
})
writeJSON(w, http.StatusOK, map[string]any{"services": s.integrationViews(r.Context())})
}
// integrationSwitchSummary spells out the consequence rather than repeating the switch.
// "Sonarr disabled" in an activity feed says nothing an operator did not just do; what
// they may not have thought about is the calendar going with it.
func integrationSwitchSummary(definition integrationDefinition, enabled bool) string {
if enabled {
return definition.Name + " is back on: its scheduled work resumes."
}
if len(definition.Powers) == 0 {
return definition.Name + " will no longer be called."
}
return "Memby will stop calling " + definition.Name +
". This also stops: " + strings.Join(definition.Powers, "; ") + "."
}
// handleAdminIntegrationTest probes one service now.
//
// It takes the same path the scheduled probe does, deliberately: a test that asked a
// different question could report a service as working while the row beside it stayed red.
func (s *Server) handleAdminIntegrationTest(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("integrationID"))
definition, ok := integrationDefinitionFor(id)
if !ok {
writeError(w, http.StatusNotFound, "no such integration")
return
}
if !definition.Configured(s) {
writeError(w, http.StatusConflict, definition.Name+" is not configured on this gateway")
return
}
if definition.Probe == nil {
// Stated rather than faked. MDBList is the case: a probe would spend a request of
// an allowance bought by the day, and answering "healthy" without asking would be
// the console making something up.
writeError(w, http.StatusNotImplemented,
definition.Name+" cannot be tested without spending part of its daily allowance")
return
}
state := s.probeIntegration(r.Context(), definition)
s.loggerFor(r.Context()).Info("integration probed",
"integration", id, "reachable", state.Reachable, "duration_ms", state.LatencyMS)
writeJSON(w, http.StatusOK, state)
}
+123 -3
View File
@@ -116,9 +116,9 @@ func adminPreviewData() map[string]any {
}
return map[string]any{
"/admin/api/status": status,
"/admin/api/runtime": map[string]any{"goroutines": 84, "heapInuse": 41943040,
"sys": 92274688, "numGc": 311, "nextGc": 62914560, "gomaxprocs": 4},
"/admin/api/status": status,
"/admin/api/runtime": adminPreviewRuntime(now),
"/admin/api/runtime/goroutines": adminPreviewGoroutines(now),
"/admin/api/accounts": map[string]any{
"accounts": []any{
map[string]any{"userId": "u-1", "username": "matt", "onboardingCompleted": true,
@@ -191,3 +191,123 @@ func adminPreviewData() map[string]any {
},
}
}
// A gateway with a mild, deliberate upward drift in both goroutines and heap, so the
// preview shows what a trend that has earned a "watch" actually looks like — the flat case
// is the one that draws itself.
func adminPreviewRuntime(now time.Time) map[string]any {
const points = 90
samples := make([]any, 0, points)
for index := 0; index < points; index++ {
at := now.Add(-time.Duration(points-1-index) * time.Minute)
samples = append(samples, map[string]any{
"at": at.Format(time.RFC3339),
"goroutines": 62 + index/4 + index%3,
"heapInuse": 33_000_000 + index*90_000,
"sys": 92_274_688,
"numGc": 220 + index,
})
}
trend := func(direction string, perHour, first, latest, low, high float64) map[string]any {
return map[string]any{
"direction": direction, "perHour": perHour, "first": first, "latest": latest,
"min": low, "max": high, "spanSeconds": float64((points - 1) * 60), "points": points,
}
}
return map[string]any{
"at": now.Format(time.RFC3339), "uptimeSeconds": 61_240.0,
"goroutines": 84, "gomaxprocs": 4, "threads": 17, "goVersion": "go1.26",
"memory": map[string]any{
"heapAlloc": 39_000_000, "heapInuse": 41_943_040, "heapIdle": 24_000_000,
"heapReleased": 8_000_000, "stackInuse": 1_800_000, "sys": 223_100_000,
"nextGc": 62_914_560, "numGc": 311, "pauseTotalMs": 812.4, "pauseRecentMs": 1.9,
"memoryLimit": 402_653_184, "configuredLimit": "384MiB",
"heapShare": 0.104, "gcPerHour": 60.0,
},
"workers": []any{
map[string]any{"name": "HTTP listener", "component": "HTTP server",
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
map[string]any{"name": "Emby health probe", "component": "Emby",
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
map[string]any{"name": "Library ingest", "component": "Library sync",
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
map[string]any{"name": "Task scheduler", "component": "Scheduled jobs",
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
map[string]any{"name": "Startup library sync", "component": "Library sync",
"state": "finished", "started": now.Add(-17 * time.Hour).Format(time.RFC3339),
"stopped": now.Add(-17 * time.Hour).Add(4 * time.Minute).Format(time.RFC3339), "starts": 1},
},
"process": map[string]any{"pid": 1, "cpuSeconds": 431.8, "cpuPercent": 3.4,
"cpuKnown": true, "openFiles": 41, "openSockets": 22, "fileLimit": 1048576,
"filesKnown": true},
"samples": samples,
"goroutineTrend": trend("rising", 14, 62, 84, 62, 86),
"heapTrend": trend("rising", 5_400_000, 33_000_000, 41_943_040, 33_000_000, 41_943_040),
"reservedTrend": trend("steady", 0, 92_274_688, 92_274_688, 92_274_688, 92_274_688),
"sampleEverySeconds": 60.0,
"health": map[string]any{
"level": "watch",
"summary": "2 things to look at, in Goroutines and Memory.",
"areas": []any{"Goroutines", "Memory"},
"notes": []any{
map[string]any{"level": "watch", "area": "Memory",
"message": "Heap in use has risen by about 5.1 MB an hour over the last 1.5 hours. If it does not fall after a collection, something is holding on to it."},
map[string]any{"level": "watch", "area": "Goroutines",
"message": "Goroutines have risen by about 14 an hour over the last 1.5 hours, from 62 to 84. Open the breakdown to see which component they belong to."},
},
},
}
}
func adminPreviewGoroutines(now time.Time) map[string]any {
category := func(key, label, description string, count int, states []any) map[string]any {
return map[string]any{"category": key, "label": label, "description": description,
"count": count, "states": states}
}
state := func(name string, count int) any {
return map[string]any{"state": name, "count": count}
}
return map[string]any{
"at": now.Format(time.RFC3339), "total": 84, "collectedInMs": 6.2, "dumpBytes": 98_304,
"categories": []any{
category("running", "Running", "On a processor now, or queued for one.", 3,
[]any{state("running", 2), state("runnable", 1)}),
category("io", "Network / I/O",
"Waiting on a network read or write — Emby, Postgres, Redis, or a television.", 26,
[]any{state("IO wait", 26)}),
category("waiting", "Waiting / idle",
"Parked waiting for work or for a lock. Idle, and costing almost nothing.", 41,
[]any{state("select", 29), state("chan receive", 9), state("semacquire", 3)}),
category("timers", "Timers / scheduled work", "Asleep until a scheduled time.", 8,
[]any{state("sleep", 8)}),
category("runtime", "Go runtime / collection",
"The Go runtime's own housekeeping. This handful is always present.", 6,
[]any{state("GC worker (idle)", 4), state("finalizer wait", 1), state("force gc (idle)", 1)}),
},
"components": []any{
map[string]any{"component": "HTTP server", "count": 24, "longestWaitMinutes": 3},
map[string]any{"component": "Database pool", "count": 18, "longestWaitMinutes": 940},
map[string]any{"component": "Emby", "count": 11, "longestWaitMinutes": 2},
map[string]any{"component": "Gateway API", "count": 9, "longestWaitMinutes": 0},
map[string]any{"component": "Redis", "count": 8, "longestWaitMinutes": 940},
map[string]any{"component": "Library sync", "count": 6, "longestWaitMinutes": 940},
map[string]any{"component": "Go runtime", "count": 6, "longestWaitMinutes": 0},
map[string]any{"component": "Scheduled jobs", "count": 2, "longestWaitMinutes": 940},
},
"groups": []any{
map[string]any{"count": 24, "component": "HTTP server", "category": "io",
"state": "IO wait", "function": "net/http.(*conn).serve",
"file": "/usr/local/go/src/net/http/server.go:2092",
"createdBy": "net/http.(*Server).Serve", "longestWaitMinutes": 3},
map[string]any{"count": 18, "component": "Database pool", "category": "waiting",
"state": "select", "function": "github.com/jackc/puddle/v2.(*Pool).acquire",
"file": "/root/go/pkg/mod/github.com/jackc/puddle/v2/pool.go:481",
"createdBy": "github.com/jackc/pgx/v5/pgxpool.NewWithConfig", "longestWaitMinutes": 940},
map[string]any{"count": 6, "component": "Library sync", "category": "waiting",
"state": "chan receive", "function": "memby/server/internal/library.(*Ingester).Run",
"file": "/app/internal/library/ingest.go:118",
"createdBy": "main.run", "longestWaitMinutes": 940},
},
"groupsTotal": 21,
}
}
+42 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/runtimestats"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -488,18 +489,57 @@ func TestAdminRuntimeMetricsAreProtectedAndReportHeap(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("runtime status = %d: %s", rec.Code, rec.Body.String())
}
var body adminRuntimeStatus
var body runtimestats.Snapshot
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Goroutines < 1 || body.HeapInuse == 0 || body.MemoryLimit <= 0 {
if body.Goroutines < 1 || body.Memory.HeapInuse == 0 || body.Memory.MemoryLimit <= 0 {
t.Fatalf("runtime metrics = %+v", body)
}
// The health verdict is the reason this route exists: a count with no verdict beside
// it is the number the console could not explain.
if body.Health.Level == "" || body.Health.Summary == "" {
t.Fatalf("runtime health = %+v", body.Health)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("cache control = %q", rec.Header().Get("Cache-Control"))
}
}
// The breakdown is the answer to "what are those goroutines doing", so the parts must add
// up to the whole: a category table that quietly drops a state is worse than no table.
func TestAdminGoroutineBreakdownPartitionsTheTotal(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
req := httptest.NewRequest(http.MethodGet, "/admin/api/runtime/goroutines", nil)
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("goroutine breakdown = %d: %s", rec.Code, rec.Body.String())
}
var report runtimestats.GoroutineReport
if err := json.Unmarshal(rec.Body.Bytes(), &report); err != nil {
t.Fatal(err)
}
if report.Total < 1 {
t.Fatalf("total = %d", report.Total)
}
counted := 0
for _, category := range report.Categories {
counted += category.Count
}
if counted != report.Total {
t.Fatalf("categories total %d, report total %d", counted, report.Total)
}
components := 0
for _, component := range report.Components {
components += component.Count
}
if components != report.Total {
t.Fatalf("components total %d, report total %d", components, report.Total)
}
}
func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
+4
View File
@@ -95,6 +95,10 @@ type Server struct {
// ratingsWarm fills and renews the durable rating cache behind the viewer, so a row
// never waits on MDBList and the operator's daily allowance is spent once per title.
ratingsWarm ratingsWarmer
// integrationHealth is what the last reachability probe found for each external
// service. Cached rather than probed on request because the console polls: every open
// tab would otherwise be its own request against somebody's Sonarr.
integrationHealth integrationHealthCache
// alertMu serialises the read-modify-write of the shared alert list. Its producers
// are events — a webhook, a finished sync, a health probe — none of them paced by
// this server, so two can land at once.
+9
View File
@@ -41,6 +41,15 @@ func (s *Server) handleSonarrWebhook(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "invalid webhook token")
return
}
// A switched-off integration records nothing. Answering 200 rather than refusing is
// deliberate: neither *arr re-delivers a rejection, so a failure here would look to the
// operator like Memby losing imports rather than like the switch they set. The library
// sweep is what reconciles whatever arrives while it is off.
if s.integrationSuppressed(r.Context(), integrationSonarr) {
s.loggerFor(r.Context()).Debug("sonarr webhook ignored: integration switched off")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ignored": true})
return
}
var payload library.SonarrWebhook
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil {
+425
View File
@@ -0,0 +1,425 @@
package api
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/mdblist"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The work each external service does on a schedule.
//
// These are ordinary scheduler tasks carrying an Integration id, which is the whole of how
// the integrations area gets a run history: a run is filed against the service as well as
// against the job, and the console reads the scheduler's own table along the other axis.
// There is no second store, no second retention job and no second place a piece of work
// can be recorded as having failed — which was the point of asking whether the existing
// infrastructure could answer before building anything.
//
// Every one of them begins by asking whether its integration is switched on, and reports
// a *skipped* run rather than an error when it is not. That distinction is the feature:
// "Memby did not run this" and "Memby ran this and it failed" look identical from a page
// that only records failures, and an operator who has switched something off is entitled
// to see the schedule quietly standing down rather than a red row every hour.
//
// The counters are the four in store.RunCounts and they mean the same thing everywhere:
// processed is what the run looked at, changed is what it wrote, skipped is what it
// deliberately passed over, failed is what went wrong without stopping the run.
// ratingsRefreshBatch bounds one MDBList refresh run.
//
// It is a cost decision rather than a throughput one: MDBList is bought by the day, this
// runs hourly, and the batch times the cadence is what the household spends on renewals
// before a single television has asked for anything. Forty an hour renews a thousand-title
// library about once a day while leaving most of the allowance for titles somebody is
// actually looking at.
const ratingsRefreshBatch = 40
// RegisterIntegrationTasks declares the background work belonging to external services.
func (s *Server) RegisterIntegrationTasks(sched *scheduler.Scheduler) {
if sched == nil {
return
}
sched.Register(scheduler.Task{
ID: "sonarr-lifecycle",
Name: "Series lifecycle scan",
Group: "Sonarr and Radarr",
Integration: integrationSonarr,
Description: "Reads Sonarr's catalogue and records which shows have been added, " +
"have returned or have been cancelled since the last reading.",
Interval: 24 * time.Hour,
Timeout: 10 * time.Minute,
// It ran on start-up before it was a task and still should: the history it keeps
// is a record of transitions, and a gateway that was down for a week has a week of
// catching up to do before it can tell one from a first sighting.
RunOnStart: true,
Work: s.runSonarrLifecycleScan,
})
sched.Register(scheduler.Task{
ID: "radarr-catalogue",
Name: "Film catalogue refresh",
Group: "Sonarr and Radarr",
Integration: integrationRadarr,
Description: "Re-reads Radarr's film catalogue, which is what the upcoming releases " +
"row, the request pages and the Radarr-only film pages are all served from.",
Interval: 6 * time.Hour,
Timeout: 5 * time.Minute,
Work: s.runRadarrCatalogueRefresh,
})
sched.Register(scheduler.Task{
ID: "tracearr-import",
Name: "Watch history import",
Group: "Recommendations",
Integration: integrationTracearr,
Description: "Brings the household's watch history across from Tracearr, which is " +
"what For You rows and watch-time summaries are built from.",
// The cadence here only decides how often the question is asked. What is actually
// due is decided by the persisted import stamps inside ImportIfDue, so a container
// restarted three times in an evening still imports on the configured schedule
// rather than three times over.
Interval: 15 * time.Minute,
Timeout: 30 * time.Minute,
Work: s.runTracearrImport,
})
sched.Register(scheduler.Task{
ID: "for-you-rebuild",
Name: "Recommendation rebuild",
Group: "Recommendations",
Integration: integrationTracearr,
Description: "Rebuilds every viewer's prepared For You rows from the imported " +
"watch history. Runs once a day at the household's configured hour.",
// Daily, and the hour is the operator's: the interval decides only how often the
// question is asked, and the rebuild is skipped unless the household's chosen hour
// has come round since the last one. Rebuilding at whatever time the container
// happened to start is what it did before, which on a redeploy meant the heaviest
// job in the gateway ran in the middle of the evening.
Interval: time.Hour,
Timeout: 30 * time.Minute,
Work: s.runForYouRebuild,
})
sched.Register(scheduler.Task{
ID: "mdblist-ratings-refresh",
Name: "Ratings refresh",
Group: "Ratings",
Integration: integrationMDBList,
Description: fmt.Sprintf(
"Renews up to %d of the oldest stored review scores. Titles nobody has looked "+
"at yet are fetched by the warmer on demand rather than here.",
ratingsRefreshBatch),
Interval: time.Hour,
Timeout: 10 * time.Minute,
Work: s.runRatingsRefresh,
})
// Not filed against any integration, deliberately. It touches all of them, so a run
// per probe cycle under each service's history would bury the runs that say what that
// service actually did under twelve reachability checks an hour.
sched.Register(scheduler.Task{
ID: "integration-health",
Name: "Integration health check",
Group: "System",
Description: "Asks each configured external service whether it is answering.",
Interval: integrationProbeInterval,
Timeout: 2 * time.Minute,
RunOnStart: true,
Work: s.runIntegrationHealthCheck,
})
}
// integrationOff is the outcome every integration task returns when its switch is off.
//
// A skipped run rather than an error or silence: silence is indistinguishable from a
// scheduler that has stopped, and an error would put a red row in the console for a state
// the operator chose. The sentence names the service so the row reads correctly in the
// all-tasks table, where the integration column is not necessarily beside it.
func integrationOff(name string) (scheduler.Outcome, error) {
return scheduler.Outcome{Detail: name + " is switched off"}, nil
}
func (s *Server) runSonarrLifecycleScan(ctx context.Context) (scheduler.Outcome, error) {
if !s.integrationEnabled(ctx, integrationSonarr) {
return integrationOff("Sonarr")
}
result, err := s.scanSonarrLifecycle(ctx)
if err != nil {
return scheduler.Outcome{}, err
}
outcome := scheduler.Outcome{RunCounts: store.RunCounts{
Processed: result.Series,
Changed: result.Changes,
}}
// Silent when nothing moved, which on a settled household is most days. See
// scheduler.announce: a job that reports itself every day is one nobody reads.
if result.Changes == 0 {
return outcome, nil
}
parts := []string{plural(result.Changes, "change", "changes")}
if result.Added > 0 {
parts = append(parts, fmt.Sprintf("%d added", result.Added))
}
if result.Cancelled > 0 {
parts = append(parts, fmt.Sprintf("%d cancelled", result.Cancelled))
}
if result.Notifications > 0 {
parts = append(parts, plural(result.Notifications, "notification", "notifications"))
}
outcome.Detail = fmt.Sprintf("%s checked · %s",
plural(result.Series, "series", "series"), strings.Join(parts, ", "))
return outcome, nil
}
func (s *Server) runRadarrCatalogueRefresh(ctx context.Context) (scheduler.Outcome, error) {
if !s.integrationEnabled(ctx, integrationRadarr) {
return integrationOff("Radarr")
}
// Straight to Radarr rather than through radarrMovieCatalogue: that reads the shared
// cache first, and a refresh whose whole job is to replace the cache must not be
// satisfied by it.
movies, err := s.radarr.Movies(ctx)
if err != nil {
return scheduler.Outcome{}, fmt.Errorf("read Radarr catalogue: %w", err)
}
held, upcoming := 0, 0
now := time.Now()
for _, movie := range movies {
if movie.HasFile {
held++
continue
}
// Upcoming is "monitored and not yet available", which is what the launcher's
// releases row draws from — an unmonitored film Radarr is not chasing is not on
// its way to anybody.
if movie.Monitored {
if movie.DigitalRelease == nil || movie.DigitalRelease.After(now) {
upcoming++
}
}
}
s.cacheRadarrMovies(ctx, movies)
return scheduler.Outcome{
Detail: fmt.Sprintf("%s checked · %d in the library, %d still to come",
plural(len(movies), "film", "films"), held, upcoming),
RunCounts: store.RunCounts{Processed: len(movies), Changed: held, Skipped: upcoming},
}, nil
}
func (s *Server) runTracearrImport(ctx context.Context) (scheduler.Outcome, error) {
if !s.integrationEnabled(ctx, integrationTracearr) {
return integrationOff("Tracearr")
}
if s.forYou.Running() {
// A rebuild started by an operator is already using it. Skipping is the right
// answer rather than queueing: the next tick is fifteen minutes away and the
// import is idempotent.
return scheduler.Outcome{Detail: "a For You rebuild is already running"}, nil
}
result, imported, err := s.forYou.ImportIfDue(
ctx, s.cfg.TracearrSyncInterval, s.cfg.TracearrFullInterval)
if err != nil {
return scheduler.Outcome{}, fmt.Errorf("Tracearr import: %w", err)
}
if !imported {
// Not due. Silent, because this is asked four times an hour and answered "no"
// almost every time.
return scheduler.Outcome{}, nil
}
outcome := scheduler.Outcome{RunCounts: store.RunCounts{
Processed: result.Seen,
Changed: result.Changed,
}}
if result.Seen == 0 && result.Changed == 0 {
return outcome, nil
}
outcome.Detail = fmt.Sprintf("%s import · %s read, %d changed",
result.Kind, plural(result.Seen, "session", "sessions"), result.Changed)
return outcome, nil
}
// forYouRebuildDue reports whether the household's daily rebuild hour has come round
// since the last one.
//
// Pure so the boundary can be tested: the two ways to get this wrong are both invisible
// from a log — never rebuilding, and rebuilding on every tick — and both look like a
// working scheduler from outside. A rebuild that has never happened is due, so a fresh
// household does not wait until tomorrow evening for its first rows.
func forYouRebuildDue(last *time.Time, now time.Time, hour int) bool {
if hour < 0 || hour > 23 {
hour = 0
}
if now.Hour() < hour {
return false
}
if last == nil {
return true
}
// The comparison is against the local calendar day rather than "24 hours ago", the
// rule every household-local boundary in Memby follows: a day containing a
// daylight-saving change is 23 or 25 hours long, and subtracting hours would skip or
// repeat a rebuild twice a year.
previous := last.In(now.Location())
sameDay := previous.Year() == now.Year() && previous.YearDay() == now.YearDay()
return !sameDay
}
func (s *Server) runForYouRebuild(ctx context.Context) (scheduler.Outcome, error) {
if !s.integrationEnabled(ctx, integrationTracearr) {
return integrationOff("Tracearr")
}
if s.forYou.Running() {
return scheduler.Outcome{Detail: "a For You rebuild is already running"}, nil
}
now := time.Now().In(s.householdLocation())
state, err := s.store.TracearrImportState(ctx)
if err != nil {
return scheduler.Outcome{}, fmt.Errorf("read For You rebuild state: %w", err)
}
if !forYouRebuildDue(state.LastRebuildAt, now, s.cfg.ForYouRebuildHour) {
return scheduler.Outcome{}, nil
}
result, err := s.forYou.RebuildAll(ctx, true)
if err != nil {
return scheduler.Outcome{}, fmt.Errorf("For You rebuild: %w", err)
}
if markErr := s.store.MarkForYouRebuild(ctx, now); markErr != nil {
// The rebuild happened; failing the run over the stamp would have the console
// report a failure for work that succeeded. It does mean the next tick tries
// again, which is the safe direction to be wrong in.
s.loggerFor(ctx).Warn("could not record For You rebuild", "error", markErr)
}
outcome := scheduler.Outcome{RunCounts: store.RunCounts{
Processed: result.Users, Changed: result.Built, Failed: result.Failed,
}}
if result.Users == 0 {
return outcome, nil
}
outcome.Detail = fmt.Sprintf("%s processed · %d rebuilt",
plural(result.Users, "viewer", "viewers"), result.Built)
if result.Failed > 0 {
outcome.Detail += fmt.Sprintf(", %d failed", result.Failed)
}
return outcome, nil
}
func (s *Server) runRatingsRefresh(ctx context.Context) (scheduler.Outcome, error) {
settings, enabled := s.mdblistSettings(ctx)
if !enabled || !s.integrationEnabled(ctx, integrationMDBList) {
return integrationOff("MDBList")
}
keys, err := s.store.StaleRatingKeys(
ctx, time.Now().Add(-ratingsRefreshInterval), ratingsRefreshBatch)
if err != nil {
return scheduler.Outcome{}, fmt.Errorf("read stale ratings: %w", err)
}
if len(keys) == 0 {
return scheduler.Outcome{}, nil
}
counts := store.RunCounts{}
var lastErr error
for _, key := range keys {
if ctx.Err() != nil {
break
}
// The same daily allowance the on-demand warmer spends from, claimed the same way.
// Two counters would let a quiet evening of browsing and a refresh run each spend a
// full day's worth between them.
if !s.claimRatingsBudget(time.Now()) {
counts.Skipped++
continue
}
counts.Processed++
ratings, fetchErr := s.fetchAndStoreRatings(ctx, settings.APIKey, key)
if fetchErr != nil {
counts.Failed++
lastErr = fetchErr
var apiErr *mdblist.APIError
if errors.As(fetchErr, &apiErr) &&
(apiErr.StatusCode == 429 || apiErr.StatusCode == 402) {
// The allowance is exhausted. Stopping the whole run is the point: the
// remaining titles are still stale and will be the oldest next hour, and
// hammering a provider that has just said no is how a key gets withdrawn.
s.blockRatingsWarming(time.Now().Add(ratingsWarmBackoff))
break
}
continue
}
if len(ratings) == 0 {
// A title MDBList has nothing for. Recorded rather than counted as changed:
// the stamp moved so it is not re-fetched immediately, but nothing on a
// television will look different.
counts.Skipped++
continue
}
counts.Changed++
}
outcome := scheduler.Outcome{RunCounts: counts}
if counts.Processed == 0 && counts.Skipped == 0 {
return outcome, nil
}
outcome.Detail = fmt.Sprintf("%s requested · %d updated, %d unavailable",
plural(counts.Processed, "title", "titles"), counts.Changed, counts.Skipped)
// A run that failed every title it asked for is a failed run, not a quiet one: that is
// a wrong key or a provider that is down, and it belongs in the notification feed. A
// run that lost a few titles among many is ordinary and stays a detail.
if counts.Failed > 0 && counts.Failed == counts.Processed && lastErr != nil {
return outcome, fmt.Errorf("every MDBList request failed: %w", lastErr)
}
if counts.Failed > 0 {
outcome.Detail += fmt.Sprintf(", %d failed", counts.Failed)
}
return outcome, nil
}
// runIntegrationHealthCheck probes every configured service.
//
// A probe failure is not this task's failure: the whole job is to find out, and a red row
// on the health check would say the gateway's own scheduler is broken when what is
// actually broken is somebody's Sonarr. The verdict lives on the integration's row
// instead, where it names the service.
func (s *Server) runIntegrationHealthCheck(ctx context.Context) (scheduler.Outcome, error) {
counts := store.RunCounts{}
unreachable := []string{}
for _, definition := range integrationCatalogue() {
if definition.Probe == nil || !definition.Configured(s) {
continue
}
if !definition.Enabled(s, ctx) {
// A switched-off service is not probed at all. Probing one would be the
// gateway continuing to call an API the operator has told it to stop calling,
// which is exactly what the switch promises not to do.
s.integrationHealth.forget(definition.ID)
counts.Skipped++
continue
}
counts.Processed++
if state := s.probeIntegration(ctx, definition); !state.Reachable {
counts.Failed++
unreachable = append(unreachable, definition.Name)
}
}
outcome := scheduler.Outcome{RunCounts: counts}
if len(unreachable) > 0 {
outcome.Detail = "not answering: " + strings.Join(unreachable, ", ")
}
return outcome, nil
}
// plural is the "3 films" / "1 film" the run details are written in. The console prints
// these sentences verbatim, so getting it wrong is visible on every row.
func plural(count int, singular, many string) string {
if count == 1 {
return "1 " + singular
}
return fmt.Sprintf("%d %s", count, many)
}
@@ -0,0 +1,427 @@
package api
import (
"context"
"fmt"
"net/url"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The gateway's external services, as the console's Integrations area reads them.
//
// One catalogue in Go, the shape featureCatalogue and preferenceCatalogue already take:
// what an integration *is* — what it powers, whether it can be probed, where its switch is
// stored — belongs to the code, and only the operator's choices belong in the database.
// Adding a fifth service is an entry here plus its scheduler task, and the console page
// needs no change at all.
//
// Two decisions in here are worth stating because they look like omissions otherwise.
//
// **The switch is stored where the integration's other configuration already lives.**
// Sonarr and Radarr keep theirs in the arr integration policy, MDBList keeps its own in
// the ratings settings, and Tracearr — which had nowhere — uses the integration policy
// document. Consolidating them would mean a migration and, for the length of it, two
// documents that could disagree about whether a service was on. What the console needs is
// one reader and one writer, which is what integrationEnabled and setIntegrationEnabled
// are; which document answers is nobody else's business.
//
// **MDBList has no live probe.** Every other service here is a machine in the house and
// pinging it costs nothing. MDBList is an allowance bought by the day, and spending a
// request of it to draw a green dot on a page that polls would be the console competing
// with the televisions for the thing it is reporting on. Its health comes from its own run
// history instead, which is the honest answer and the cheaper one.
const (
integrationSonarr = "sonarr"
integrationRadarr = "radarr"
integrationTracearr = "tracearr"
integrationMDBList = "mdblist"
)
// integrationDefinition is one external service.
type integrationDefinition struct {
ID string
Name string
// Summary is what the service is for, in one sentence, from Memby's point of view
// rather than the vendor's.
Summary string
// Powers names the parts of Memby that stop working when this is switched off. It is
// the "make the dependency clear rather than silently failing" half: an operator
// turning Sonarr off is entitled to know the television calendar goes with it.
Powers []string
// Configured reports whether this deployment has an address and a credential for the
// service at all. A service that is not configured has nothing to switch.
Configured func(s *Server) bool
// Address is the service's location, for the console to print. Never a credential —
// the API key is part of neither the URL nor this string.
Address func(s *Server) string
// Enabled and SetEnabled are the operator's global switch. See the note above about
// where each one is stored.
Enabled func(s *Server, ctx context.Context) bool
SetEnabled func(s *Server, ctx context.Context, enabled bool) error
// Probe asks the service whether it is answering. Nil where asking costs something
// that should not be spent on a status page.
Probe func(s *Server, ctx context.Context) error
// Facts are the configuration lines the detail page prints. Deliberately a function
// of the live server rather than stored text, so a page cannot describe a deployment
// that has since been reconfigured.
Facts func(s *Server, ctx context.Context) []integrationFact
}
// integrationFact is one label-and-value line of an integration's configuration.
type integrationFact struct {
Label string `json:"label"`
Value string `json:"value"`
// Tone lets a fact carry a verdict where it has one — an unset API key is not
// neutral. Empty is the ordinary, judgement-free case.
Tone string `json:"tone,omitempty"`
}
func integrationCatalogue() []integrationDefinition {
return []integrationDefinition{
{
ID: integrationSonarr,
Name: "Sonarr",
Summary: "Follows television: what has been imported, what is still to air, and which shows have ended.",
Powers: []string{
"The television calendar and the launcher's airing-soon row",
"Series requests from a television",
"Cancellation and returning-show notifications",
"Import announcements for newly downloaded episodes",
},
Configured: func(s *Server) bool { return s.sonarr != nil },
Address: func(s *Server) string { return serviceAddress(s.cfg.SonarrURL) },
Enabled: func(s *Server, ctx context.Context) bool {
policy, err := s.store.ArrIntegrationPolicy(ctx)
if err != nil {
return false
}
return policy.SonarrEnabled
},
SetEnabled: func(s *Server, ctx context.Context, enabled bool) error {
policy, err := s.store.ArrIntegrationPolicy(ctx)
if err != nil {
policy = store.DefaultArrIntegrationPolicy()
}
policy.SonarrEnabled = enabled
return s.store.SetArrIntegrationPolicy(ctx, policy)
},
Probe: func(s *Server, ctx context.Context) error {
// Quality profiles rather than the series list: it is a handful of rows on
// any household, where the catalogue is thousands and would make the
// health check the most expensive thing on the page.
_, err := s.sonarr.QualityProfiles(ctx)
return err
},
Facts: func(s *Server, ctx context.Context) []integrationFact {
return []integrationFact{
{Label: "Address", Value: serviceAddress(s.cfg.SonarrURL)},
{Label: "API key", Value: credentialState(s.cfg.SonarrAPIKey),
Tone: credentialTone(s.cfg.SonarrAPIKey)},
{Label: "Import webhook", Value: credentialState(s.cfg.SonarrWebhookToken),
Tone: credentialTone(s.cfg.SonarrWebhookToken)},
}
},
},
{
ID: integrationRadarr,
Name: "Radarr",
Summary: "Follows films: what the household holds, what is on the way, and when a release becomes watchable.",
Powers: []string{
"The launcher's upcoming releases row and the Radarr-only film pages",
"Film requests from a television",
"Digital release dates, which the home hero is ranked by",
"Import announcements for newly downloaded films",
},
Configured: func(s *Server) bool { return s.radarr != nil },
Address: func(s *Server) string { return serviceAddress(s.cfg.RadarrURL) },
Enabled: func(s *Server, ctx context.Context) bool {
policy, err := s.store.ArrIntegrationPolicy(ctx)
if err != nil {
return false
}
return policy.RadarrEnabled
},
SetEnabled: func(s *Server, ctx context.Context, enabled bool) error {
policy, err := s.store.ArrIntegrationPolicy(ctx)
if err != nil {
policy = store.DefaultArrIntegrationPolicy()
}
policy.RadarrEnabled = enabled
return s.store.SetArrIntegrationPolicy(ctx, policy)
},
Probe: func(s *Server, ctx context.Context) error {
_, err := s.radarr.QualityProfiles(ctx)
return err
},
Facts: func(s *Server, ctx context.Context) []integrationFact {
return []integrationFact{
{Label: "Address", Value: serviceAddress(s.cfg.RadarrURL)},
{Label: "API key", Value: credentialState(s.cfg.RadarrAPIKey),
Tone: credentialTone(s.cfg.RadarrAPIKey)},
{Label: "Import webhook", Value: credentialState(s.cfg.RadarrWebhookToken),
Tone: credentialTone(s.cfg.RadarrWebhookToken)},
}
},
},
{
ID: integrationTracearr,
Name: "Tracearr",
Summary: "The household's real watch history, which is what personalised rows and watch-time summaries are built from.",
Powers: []string{
"For You rows and the recommendation profiles behind them",
"Weekly and monthly watch-time summaries",
"Watch time on the console's user pages",
},
Configured: func(s *Server) bool { return s.forYou != nil },
Address: func(s *Server) string { return serviceAddress(s.cfg.TracearrURL) },
Enabled: func(s *Server, ctx context.Context) bool {
policy, err := s.store.IntegrationPolicy(ctx)
if err != nil {
return false
}
return policy.Enabled(integrationTracearr)
},
SetEnabled: func(s *Server, ctx context.Context, enabled bool) error {
return s.store.SetIntegrationEnabled(ctx, integrationTracearr, enabled)
},
Probe: func(s *Server, ctx context.Context) error { return s.forYou.Ping(ctx) },
Facts: func(s *Server, ctx context.Context) []integrationFact {
facts := []integrationFact{
{Label: "Address", Value: serviceAddress(s.cfg.TracearrURL)},
{Label: "API key", Value: credentialState(s.cfg.TracearrAPIKey),
Tone: credentialTone(s.cfg.TracearrAPIKey)},
}
if s.cfg.TracearrServerID != "" {
facts = append(facts,
integrationFact{Label: "Server", Value: s.cfg.TracearrServerID})
}
if state, err := s.store.TracearrImportState(ctx); err == nil {
facts = append(facts, integrationFact{
Label: "Last full import", Value: importStamp(state.LastFullAt),
}, integrationFact{
Label: "Last incremental import", Value: importStamp(state.LastIncrementalAt),
})
}
return facts
},
},
{
ID: integrationMDBList,
Name: "MDBList",
Summary: "External review scores. Bought by the day, so a title is fetched once and kept.",
Powers: []string{
"The ratings strip on detail pages and cards",
"Score weighting in the home hero's ranking",
},
Configured: func(s *Server) bool { return s.mdblist != nil },
Address: func(s *Server) string { return "mdblist.com" },
Enabled: func(s *Server, ctx context.Context) bool {
settings, err := s.store.MDBListSettings(ctx)
if err != nil {
return false
}
return settings.Enabled
},
SetEnabled: func(s *Server, ctx context.Context, enabled bool) error {
settings, err := s.store.MDBListSettings(ctx)
if err != nil {
settings = store.DefaultMDBListSettings()
}
settings.Enabled = enabled
if err := s.store.SetMDBListSettings(ctx, settings); err != nil {
return err
}
// The ratings path caches this document for thirty seconds; without this
// the switch appears not to have worked for half a minute.
s.forgetMDBListSettings()
return nil
},
// Deliberately no probe. See the note at the top of this file.
Facts: func(s *Server, ctx context.Context) []integrationFact {
settings, err := s.store.MDBListSettings(ctx)
if err != nil {
settings = store.DefaultMDBListSettings()
}
facts := []integrationFact{
{Label: "API key", Value: credentialState(settings.APIKey),
Tone: credentialTone(settings.APIKey)},
{Label: "Sources shown", Value: fmt.Sprint(len(settings.Sources))},
}
total, stale, statsErr := s.store.MediaRatingsStats(
ctx, time.Now().Add(-ratingsRefreshInterval))
if statsErr == nil {
facts = append(facts,
integrationFact{Label: "Titles stored", Value: fmt.Sprint(total)},
integrationFact{Label: "Due to be re-checked", Value: fmt.Sprint(stale)})
}
return facts
},
},
}
}
func integrationDefinitionFor(id string) (integrationDefinition, bool) {
for _, definition := range integrationCatalogue() {
if definition.ID == id {
return definition, true
}
}
return integrationDefinition{}, false
}
// integrationEnabled is the single reader of an integration's global switch.
//
// It answers false for a service this deployment has not configured, which is what every
// caller means by the question: "may I use Sonarr" and "is Sonarr switched on" are only
// different questions to somebody looking at the console.
func (s *Server) integrationEnabled(ctx context.Context, id string) bool {
definition, ok := integrationDefinitionFor(id)
if !ok || s.store == nil || !definition.Configured(s) {
return false
}
return definition.Enabled(s, ctx)
}
// integrationSuppressed reports that the operator has explicitly switched a service off.
//
// Deliberately not the negation of integrationEnabled, which is also false for a service
// this deployment never configured. The difference matters at the import webhooks: a
// household can perfectly well point Sonarr's notification at Memby without giving Memby
// Sonarr's API key, and reading "no API credentials" as "the operator turned this off"
// would silently stop recording their imports.
func (s *Server) integrationSuppressed(ctx context.Context, id string) bool {
definition, ok := integrationDefinitionFor(id)
if !ok || s.store == nil {
return false
}
return !definition.Enabled(s, ctx)
}
// TracearrEnabled is the Tracearr switch, exported for the pieces of the gateway that are
// built outside this package and still have to honour it — today the recommendation
// engine, which calls Tracearr on the rebuild path.
func (s *Server) TracearrEnabled(ctx context.Context) bool {
return s.integrationEnabled(ctx, integrationTracearr)
}
// serviceAddress is a service's location with anything credential-shaped removed.
//
// An *arr address is ordinarily a bare host and port, but nothing stops an operator
// putting one behind basic auth, and a console that printed the URL verbatim would put
// that password on a page. Scheme, host and path only.
func serviceAddress(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return raw
}
parsed.User = nil
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimSuffix(parsed.String(), "/")
}
// credentialState says whether a credential is set and never what it is. The console has
// no use for the value and every reason not to hold it — the stance the MDBList key and
// the OpenSubtitles login already take.
func credentialState(value string) string {
if strings.TrimSpace(value) == "" {
return "not set"
}
return "saved"
}
func credentialTone(value string) string {
if strings.TrimSpace(value) == "" {
return "warn"
}
return "ok"
}
func importStamp(at *time.Time) string {
if at == nil {
return "never"
}
return at.UTC().Format(time.RFC3339)
}
// --- health -----------------------------------------------------------------------
// integrationProbeInterval is how often the health task asks each service whether it is
// answering. Five minutes is chosen against what a probe is worth rather than against what
// it costs: a service that has just gone away is news, and being up to five minutes late
// with it is invisible next to the hourly jobs that would otherwise be the first thing to
// notice. It is also the reason the console does not probe on page load — every open tab
// would be its own request against somebody's Sonarr.
const integrationProbeInterval = 5 * time.Minute
// integrationHealth is what the last probe found.
type integrationHealth struct {
Reachable bool `json:"reachable"`
CheckedAt time.Time `json:"checkedAt"`
Error string `json:"error,omitempty"`
// LatencyMS is how long the service took to answer, which is the difference between
// "working" and "working, and it is why the launcher is slow".
LatencyMS int64 `json:"latencyMs"`
}
type integrationHealthCache struct {
mu sync.RWMutex
states map[string]integrationHealth
}
func (c *integrationHealthCache) get(id string) (integrationHealth, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
state, ok := c.states[id]
return state, ok
}
func (c *integrationHealthCache) set(id string, state integrationHealth) {
c.mu.Lock()
defer c.mu.Unlock()
if c.states == nil {
c.states = map[string]integrationHealth{}
}
c.states[id] = state
}
func (c *integrationHealthCache) forget(id string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.states, id)
}
// probeIntegration asks one service whether it is answering and records what it found.
//
// It is also what the console's Test button calls, which is deliberate: a test that took a
// different path from the scheduled probe could report a service as working while the page
// beside it stayed red.
func (s *Server) probeIntegration(
ctx context.Context, definition integrationDefinition,
) integrationHealth {
if definition.Probe == nil || !definition.Configured(s) {
return integrationHealth{}
}
probeCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
started := time.Now()
err := definition.Probe(s, probeCtx)
state := integrationHealth{
CheckedAt: time.Now().UTC(),
LatencyMS: time.Since(started).Milliseconds(),
Reachable: err == nil,
}
if err != nil {
state.Error = err.Error()
}
s.integrationHealth.set(definition.ID, state)
return state
}
@@ -0,0 +1,145 @@
package api
import (
"testing"
"time"
)
// The two rules in the integrations area that are pure, and therefore the two that can be
// pinned without a database, a Sonarr or a clock. Both are the kind of judgement whose
// failures are invisible from outside: a status word that is merely wrong still renders,
// and a rebuild that never becomes due looks exactly like a working scheduler.
func TestIntegrationStatusResolvesInPriorityOrder(t *testing.T) {
past := time.Now().Add(-time.Hour)
recent := time.Now().Add(-time.Minute)
cases := []struct {
name string
view integrationView
want string
}{
{
// Not configured outranks everything, including a switch left on from a
// deployment that used to have an address for it.
name: "unconfigured outranks enabled",
view: integrationView{Configured: false, Enabled: true},
want: integrationStatusUnconfigured,
},
{
// Switched off outranks its own history: yesterday's success beside a service
// nobody is running reads as one that is still working.
name: "disabled outranks a past failure",
view: integrationView{
Configured: true, Enabled: false, LastFailureAt: &past, LastError: "boom",
},
want: integrationStatusDisabled,
},
{
name: "running outranks a past failure",
view: integrationView{
Configured: true, Enabled: true, Running: true, LastFailureAt: &past,
},
want: integrationStatusRunning,
},
{
// A probe that could not reach the service is the most current evidence there
// is, and outranks a run that happened to succeed before it went away.
name: "an unreachable probe beats an older success",
view: integrationView{
Configured: true, Enabled: true, LastSuccessAt: &past,
Health: &integrationHealth{CheckedAt: recent, Error: "connection refused"},
},
want: integrationStatusError,
},
{
// The comparison that stops one bad night leaving a service red for a month.
name: "a failure followed by a success is history",
view: integrationView{
Configured: true, Enabled: true,
LastFailureAt: &past, LastSuccessAt: &recent,
},
want: integrationStatusHealthy,
},
{
name: "a failure with no success since is the verdict",
view: integrationView{
Configured: true, Enabled: true,
LastSuccessAt: &past, LastFailureAt: &recent, LastError: "401",
},
want: integrationStatusError,
},
{
// Neither working nor broken. Claiming either would be a guess, and "healthy"
// on a service that has never done anything is the more damaging guess.
name: "nothing has run yet",
view: integrationView{Configured: true, Enabled: true},
want: integrationStatusIdle,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
status, label, _ := integrationStatus(testCase.view)
if status != testCase.want {
t.Fatalf("status = %q, want %q", status, testCase.want)
}
if label == "" {
t.Fatal("every status must carry something to print")
}
})
}
}
func TestIntegrationStatusExplainsEveryUnhappyAnswer(t *testing.T) {
// The console prints this sentence and nothing else says why. An unconfigured or
// disabled row with no explanation is one an operator has to guess at.
for _, view := range []integrationView{
{Configured: false},
{Configured: true, Enabled: false},
{Configured: true, Enabled: true},
} {
if _, _, detail := integrationStatus(view); detail == "" {
t.Fatalf("no detail for %+v", view)
}
}
}
func TestForYouRebuildDue(t *testing.T) {
location := time.UTC
at := func(day, hour int) time.Time {
return time.Date(2026, time.August, day, hour, 30, 0, 0, location)
}
// A household that has never rebuilt does not wait until tomorrow evening for its
// first rows — but it does wait for the hour it was told to use.
if forYouRebuildDue(nil, at(19, 2), 4) {
t.Fatal("rebuilt before the household's hour")
}
if !forYouRebuildDue(nil, at(19, 4), 4) {
t.Fatal("a household that has never rebuilt is due at its hour")
}
// Once today's rebuild has happened it is not due again today, however many times the
// hourly task asks. This is the whole of what stops the heaviest job in the gateway
// running twenty times an evening.
today := at(19, 4)
if forYouRebuildDue(&today, at(19, 23), 4) {
t.Fatal("rebuilt twice in one day")
}
tomorrow := at(20, 4)
if !forYouRebuildDue(&today, tomorrow, 4) {
t.Fatal("not due on the next day")
}
// The boundary is the local calendar day, not "24 hours ago". A rebuild at 04:30 and
// a tick at 04:00 the next morning are 23.5 hours apart and are still two days.
if !forYouRebuildDue(&today, at(20, 4), 4) {
t.Fatal("an interval shorter than 24 hours must still be a new day")
}
// An hour outside the clock is read as midnight rather than as a reason never to run.
if !forYouRebuildDue(nil, at(19, 0), 99) {
t.Fatal("an out-of-range hour must not strand the rebuild")
}
}
+16 -5
View File
@@ -331,14 +331,25 @@ func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, erro
if err != nil {
return nil, err
}
if body, marshalErr := json.Marshal(movies); marshalErr == nil {
if cacheErr := s.cache.Set(ctx, radarrMovieCacheKey, body, s.cfg.RadarrTTL); cacheErr != nil {
s.loggerFor(ctx).Warn("radarr movie cache write failed", "error", cacheErr)
}
}
s.cacheRadarrMovies(ctx, movies)
return movies, nil
}
// cacheRadarrMovies stores the household's shared copy of Radarr's catalogue.
//
// Split out because the scheduled refresh writes it too: that task reads Radarr directly
// — a refresh satisfied by the cache it exists to replace would do nothing — and would
// otherwise need its own copy of the key, the TTL and the failure handling.
func (s *Server) cacheRadarrMovies(ctx context.Context, movies []radarr.Movie) {
body, err := json.Marshal(movies)
if err != nil {
return
}
if cacheErr := s.cache.Set(ctx, radarrMovieCacheKey, body, s.cfg.RadarrTTL); cacheErr != nil {
s.loggerFor(ctx).Warn("radarr movie cache write failed", "error", cacheErr)
}
}
func (s *Server) cachedRadarrMovies(ctx context.Context) []radarr.Movie {
raw, err := s.cache.Get(ctx, radarrMovieCacheKey)
if err != nil {
+9
View File
@@ -75,6 +75,15 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "invalid webhook token")
return
}
// A switched-off integration records nothing. Answering 200 rather than refusing is
// deliberate: neither *arr re-delivers a rejection, so a failure here would look to the
// operator like Memby losing imports rather than like the switch they set. The library
// sweep is what reconciles whatever arrives while it is off.
if s.integrationSuppressed(r.Context(), integrationRadarr) {
s.loggerFor(r.Context()).Debug("radarr webhook ignored: integration switched off")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ignored": true})
return
}
var payload radarrWebhookPayload
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil {
+30 -10
View File
@@ -259,7 +259,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
// is not the same as them never having asked for it, and their page is the
// only place that distinction is kept.
s.recordMediaRequest(r.Context(), sess, req, movie.Year,
radarrCoverURL(movie.Images, "poster"))
radarrCoverURL(movie.Images, "poster"), openingRequestStatus(movie.HasFile))
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
return
@@ -282,7 +282,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
req.Title = added.Title
s.logRadarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil)
s.recordMediaRequest(r.Context(), sess, req, added.Year,
radarrCoverURL(added.Images, "poster"))
radarrCoverURL(added.Images, "poster"), openingRequestStatus(false))
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
@@ -306,7 +306,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
if show.ID > 0 {
req.Title = show.Title
s.recordMediaRequest(r.Context(), sess, req, show.Year,
sonarrCoverURL(show.Images, "poster"))
sonarrCoverURL(show.Images, "poster"), openingRequestStatus(false))
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
return
@@ -329,7 +329,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
req.Title = added.Title
s.logSonarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil)
s.recordMediaRequest(r.Context(), sess, req, added.Year,
sonarrCoverURL(added.Images, "poster"))
sonarrCoverURL(added.Images, "poster"), openingRequestStatus(false))
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
@@ -349,17 +349,18 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
// did. The cost of a lost write is that the ask is missing from their own page, which is
// recoverable by asking again — the cost of the opposite is a viewer requesting it twice.
func (s *Server) recordMediaRequest(
ctx context.Context, sess store.Session, req requestPayload, year int, posterURL string,
ctx context.Context, sess store.Session, req requestPayload, year int, posterURL, opening string,
) {
if s.store == nil || sess.EmbyUserID == "" {
return
}
err := s.store.SaveMediaRequest(ctx, sess.EmbyUserID, store.MediaRequest{
MediaType: req.MediaType,
ForeignID: req.ForeignID,
Title: req.Title,
Year: year,
PosterURL: posterURL,
MediaType: req.MediaType,
ForeignID: req.ForeignID,
Title: req.Title,
Year: year,
PosterURL: posterURL,
LastStatus: opening,
})
if err != nil {
s.loggerFor(ctx).Warn("media request not recorded",
@@ -367,6 +368,25 @@ func (s *Server) recordMediaRequest(
}
}
// openingRequestStatus is the state a request is born in, and it exists so that the ready
// sweep has something to compare against on its very first pass.
//
// Leaving it blank and letting the first sweep fill it in would lose exactly the arrivals
// worth announcing: a film that downloads in the three minutes between the ask and the first
// sweep would have its arrival recorded as its opening state, and nobody would ever be told.
// So the ask itself records what was true at the moment somebody pressed the button — which
// is the one moment the handler knows for certain, having just asked the *arr.
//
// A series is never born available. Sonarr's series list carries no file information, so
// "the household has this show" is a claim only the library can make, and the sweep is where
// it gets made.
func openingRequestStatus(hasFile bool) string {
if hasFile {
return RequestStatusAvailable
}
return RequestStatusSearching
}
func (s *Server) logMediaRequest(
ctx context.Context, req requestPayload, outcome string, err error,
) {
+90 -8
View File
@@ -3,11 +3,14 @@ package api
import (
"context"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -25,6 +28,15 @@ type myRequest struct {
Status string `json:"status"`
StatusLabel string `json:"statusLabel"`
StatusDetail string `json:"statusDetail"`
// Progress is whole percent and rides only a downloading card. It is omitted rather
// than sent as zero everywhere else, so the television draws a bar when it is given a
// figure and nothing at all when it is not — there is no "0%" state to confuse with a
// download that has not started.
Progress int `json:"progress,omitempty"`
// EstimatedReadySeconds is how long the download client says the bytes will take, and is
// omitted whenever nothing could say. That omission is the whole of "never fabricate an
// ETA": a card with no number here prints the plain wording in StatusDetail instead.
EstimatedReadySeconds int `json:"estimatedReadySeconds,omitempty"`
// EmbyItemID is set once Emby has imported the title, so the card can open the ordinary
// detail page instead of being a dead end at the moment it finally becomes watchable.
EmbyItemID string `json:"embyItemId,omitempty"`
@@ -49,21 +61,36 @@ func (s *Server) handleMyRequests(w http.ResponseWriter, r *http.Request, sess s
return
}
writeJSON(w, http.StatusOK, myRequestsResponse{
Requests: s.decorateRequests(r.Context(), stored),
Requests: s.decorateRequests(r.Context(), stored, requestProgressSupported(r)),
Allowed: true,
})
}
// requestProgressSupported asks whether this television can draw the download states.
//
// A capability rather than a version floor, because that is what the client already declares
// and what the console reports against — and because the answer is about what the app can
// *draw*, which is exactly what a capability token says. An older build is sent the collapsed
// vocabulary it understands (collapseRequestStatus) rather than four words it would file
// under "Nothing happening" and a percentage it has nowhere to put.
func requestProgressSupported(r *http.Request) bool {
return slices.Contains(clientCapabilities(r), "request_progress_v1")
}
// decorateRequests turns stored asks into cards by asking the two catalogues and the
// library what has become of each.
//
// The three lookups run concurrently and every one of them is allowed to fail: a request
// whose state cannot be established falls back to "requested", which is the honest answer —
// The lookups run concurrently and every one of them is allowed to fail: a request whose
// state cannot be established falls back to "requested", which is the honest answer —
// somebody asked and we cannot currently say more. A page that errored because Radarr was
// restarting would be a page that is broken exactly when a viewer wants to know why their
// title has not arrived.
// title has not arrived. The download queues degrade one step further and more gently
// still: losing them costs the percentage and the estimate, and the card falls back to
// "Searching", which is what the page said before there was a queue reader at all.
//
// progressAware is what this television can draw; see requestProgressSupported.
func (s *Server) decorateRequests(
ctx context.Context, stored []store.MediaRequest,
ctx context.Context, stored []store.MediaRequest, progressAware bool,
) []myRequest {
if len(stored) == 0 {
return []myRequest{}
@@ -85,6 +112,14 @@ func (s *Server) decorateRequests(
seriesInLibrary = map[int]bool{}
movieItemIDs = map[int]string{}
seriesItemIDs = map[int]string{}
// The two halves of the queue join, gathered separately and put together after the
// wait: a queue row names the *arr's own internal id, and only the catalogue can say
// which TMDb or TVDb id that is. Fetching them concurrently and joining afterwards is
// what keeps the queue read off the catalogue's critical path.
tmdbByMovieID = map[int]int{}
tvdbBySeriesID = map[int]int{}
movieQueue []radarr.QueueItem
seriesQueue []sonarr.QueueItem
)
now := time.Now()
@@ -101,6 +136,7 @@ func (s *Server) decorateRequests(
if movie.TMDBID == 0 {
continue
}
tmdbByMovieID[movie.ID] = movie.TMDBID
movies[movie.TMDBID] = requestCatalogueEntry{
tracked: true,
hasFile: movie.HasFile,
@@ -110,6 +146,16 @@ func (s *Server) decorateRequests(
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
queue, err := s.radarrQueue(ctx)
if err != nil {
s.loggerFor(ctx).Warn("radarr queue unavailable for requests", "error", err)
return
}
movieQueue = queue
}()
}
if len(seriesIDs) > 0 && s.sonarrEnabled(ctx) {
wg.Add(1)
@@ -124,6 +170,7 @@ func (s *Server) decorateRequests(
if show.TVDBID == 0 {
continue
}
tvdbBySeriesID[show.ID] = show.TVDBID
series[show.TVDBID] = requestCatalogueEntry{
tracked: true,
released: seriesReleased(show.Status, show.NextAiring, now),
@@ -132,6 +179,16 @@ func (s *Server) decorateRequests(
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
queue, err := s.sonarrQueue(ctx)
if err != nil {
s.loggerFor(ctx).Warn("sonarr queue unavailable for requests", "error", err)
return
}
seriesQueue = queue
}()
}
wg.Add(1)
go func() {
@@ -148,12 +205,18 @@ func (s *Server) decorateRequests(
}()
wg.Wait()
movieWork := groupMovieWork(movieQueue, tmdbByMovieID)
seriesWork := groupSeriesWork(seriesQueue, tvdbBySeriesID)
cards := make([]myRequest, 0, len(stored))
for _, req := range stored {
entry, inLibrary, itemID := movies[req.ForeignID], moviesInLibrary[req.ForeignID], movieItemIDs[req.ForeignID]
work := movieWork[req.ForeignID]
if req.MediaType == "series" {
entry, inLibrary, itemID = series[req.ForeignID], seriesInLibrary[req.ForeignID], seriesItemIDs[req.ForeignID]
work = seriesWork[req.ForeignID]
}
progress := downloadProgress(work)
status := RequestStatusRequested
// Only claim a state when something actually answered. With every catalogue down,
// "requested" is all that is known and is what the card must say.
@@ -163,13 +226,14 @@ func (s *Server) decorateRequests(
HasFile: entry.hasFile,
InLibrary: inLibrary,
Released: entry.released,
Progress: progress,
})
}
poster := req.PosterURL
if poster == "" {
poster = entry.poster
}
cards = append(cards, myRequest{
card := myRequest{
MediaType: req.MediaType,
ForeignID: req.ForeignID,
Title: req.Title,
@@ -179,9 +243,27 @@ func (s *Server) decorateRequests(
RequestedAt: req.RequestedAt.UTC().Format(time.RFC3339),
Status: status,
StatusLabel: requestStatusLabel(status),
StatusDetail: requestStatusDetail(status, req.MediaType),
StatusDetail: requestStatusDetail(status, req.MediaType, progress.EstimatedReadySeconds),
EmbyItemID: itemID,
})
}
// The figures belong to a download and to nothing else. A title that is available,
// pending or unavailable may still have a row in the queue — an upgrade, a stale
// entry — and pinning a percentage to it would put a progress bar under a card
// somebody can already press Play on.
if status == RequestStatusDownloading {
card.Progress = progress.Progress
card.EstimatedReadySeconds = progress.EstimatedReadySeconds
}
if !progressAware {
// Narrowed on the way out rather than on the way in, so the label and the
// detail are still computed from the truth and only the *slug* is generalised.
// That is the better half of the trade: an older television files the card under
// "On the way" as it always did, and still reads "Downloading" on the chip and
// the honest sentence underneath it. Only the bar and the number go.
card.Status = collapseRequestStatus(card.Status)
card.Progress, card.EstimatedReadySeconds = 0, 0
}
cards = append(cards, card)
}
return cards
}
+262
View File
@@ -0,0 +1,262 @@
package api
import (
"math"
"strconv"
"strings"
"time"
)
// What the download client is doing with something somebody asked for, normalised into the
// few words a viewer can act on.
//
// The whole point of this file is that the television is never told about indexers, release
// profiles, trackers or import queues. Radarr and Sonarr describe one download in three
// overlapping vocabularies — see radarr.QueueItem — and between them they can produce
// something like twenty distinct states. A viewer standing in front of a card needs to know
// one of five things: nothing has been found yet, something has been found, it is coming
// down and how far through it is, it is being filed away, or it went wrong.
//
// Everything here is pure, because it is the half of the feature that has to be right and
// the half that is cheapest to be wrong about: an ETA is a promise, and a promise made from
// a misread field is worse than no promise at all.
// requestWork is one row of a download queue with the *arr it came from forgotten.
//
// Keeping it free of radarr and sonarr types is what lets one rule answer for both — a film
// and a season of a show are the same question about bytes — and lets the rule be tested
// without either service.
type requestWork struct {
// Size and SizeLeft are bytes. Both zero means the client has not said, which is
// different from a finished download and must not read as 100%.
Size float64
SizeLeft float64
// TimeLeft is the download client's own estimate as a .NET TimeSpan ("00:14:32",
// "1.02:03:04"), or empty when it will not say — which is the ordinary case for a
// queued or stalled item and exactly where an ETA must not be manufactured.
TimeLeft string
// Status is the download client's word, TrackedState is what the *arr will do with the
// bytes once they land, and TrackedStatus is the verdict over both.
Status string
TrackedState string
TrackedStatus string
}
// requestProgress is the normalised answer, and it is deliberately the shape of the wire.
//
// Progress and EstimatedReadySeconds are both zero when unknown, which is why they are
// omitempty on the response types that embed this: a card draws a figure it was given and
// says nothing at all when it was given none. Zero percent and "we have no idea" therefore
// look the same to the television, which is the correct conflation — neither is a number
// worth printing.
type requestProgress struct {
// Status is one of the request status slugs below, or empty when the queue had nothing
// to say about this title at all.
Status string
// Progress is whole percent, 0-100.
Progress int
// EstimatedReadySeconds is how long until the bytes have landed. It never includes the
// import that follows, because nothing measures that — see requestStatusDetail, where
// "a few minutes" is wording rather than an estimate.
EstimatedReadySeconds int
}
// Download-client states, in the order a request passes through them. These sit alongside
// the states in requests_status.go and share its vocabulary space; they are separate only
// because these four are the ones a queue can answer for.
const (
// RequestStatusSearching means monitored, released, nothing found yet — the honest
// reading of "the *arr holds this and the download client has never heard of it".
RequestStatusSearching = "searching"
// RequestStatusFound means a release has been grabbed and is waiting on the download
// client: queued, paused, held by a delay profile. There is something to wait for, but
// no bytes are moving.
RequestStatusFound = "found"
// RequestStatusDownloading means bytes are moving. This is the only state that carries
// a percentage.
RequestStatusDownloading = "downloading"
// RequestStatusFailed means the download failed and the *arr will look for another
// release. Deliberately not a dead end: it is the one state whose wording has to say
// that Memby is still trying.
RequestStatusFailed = "failed"
)
// workState is the per-row rule, and the order of its tests is the whole of it.
//
// A verdict of error outranks everything, because a row can look perfectly healthy —
// "completed", even — while the *arr has decided it cannot use what arrived. Importing
// outranks downloading next, since a row that has finished downloading is still reported by
// some clients with a downloading-ish status while the *arr moves the file. Only then does
// the download client's own word matter, and anything it says that is not "downloading" is
// a wait of some kind, which is what "found" means.
//
// A word this build has never seen falls through to found rather than to failed: a new
// vocabulary in a future Radarr must degrade to "something is happening" rather than
// telling a viewer their film is broken.
func workState(w requestWork) string {
status := strings.ToLower(strings.TrimSpace(w.Status))
tracked := strings.ToLower(strings.TrimSpace(w.TrackedState))
verdict := strings.ToLower(strings.TrimSpace(w.TrackedStatus))
switch {
case verdict == "error", status == "failed", tracked == "failed", tracked == "failedpending":
return RequestStatusFailed
case tracked == "importpending", tracked == "importing", tracked == "imported",
status == "completed":
return RequestStatusProcessing
case status == "downloading":
return RequestStatusDownloading
default:
return RequestStatusFound
}
}
// workRank orders the states by how much they deserve to be what a card says when one title
// has several rows — a season pack is a dozen episodes at a dozen different stages.
//
// Highest wins, and the ordering is "what is the most active thing happening to this": a
// show with one episode downloading and eleven already filed is downloading. Failed is
// lowest, so a single failed episode never overrides eleven healthy ones and a title only
// reads as failed when every row of it has.
func workRank(status string) int {
switch status {
case RequestStatusDownloading:
return 4
case RequestStatusFound:
return 3
case RequestStatusProcessing:
return 2
case RequestStatusFailed:
return 1
default:
return 0
}
}
// downloadProgress folds a title's queue rows into one answer.
//
// No rows is not an error and not a state: it is the caller's question to answer, since
// "nothing is downloading" means something different for a film that is already in the
// library, one nobody has released yet, and one the *arr has been searching for all
// afternoon. So this returns an empty status and requestStatusFor decides.
func downloadProgress(work []requestWork) requestProgress {
if len(work) == 0 {
return requestProgress{}
}
var (
best string
totalSize float64
totalLeft float64
// eta is the longest remaining time across the rows that have not landed yet: a
// season is ready when its slowest episode is, not its fastest.
eta time.Duration
// etaKnown starts true and is cleared by the first unfinished row that will not say.
// One silent row makes the total unknowable, and reporting the rest of the pack's
// time as the whole pack's would be an ETA that quietly expires and keeps going.
etaKnown = true
)
for _, row := range work {
state := workState(row)
if workRank(state) > workRank(best) {
best = state
}
if row.Size > 0 {
totalSize += row.Size
totalLeft += math.Min(math.Max(row.SizeLeft, 0), row.Size)
}
switch state {
case RequestStatusDownloading, RequestStatusFound:
remaining, ok := parseTimeLeft(row.TimeLeft)
if !ok {
etaKnown = false
continue
}
if remaining > eta {
eta = remaining
}
}
}
progress := requestProgress{Status: best}
if totalSize > 0 {
done := (totalSize - totalLeft) / totalSize * 100
progress.Progress = int(math.Round(math.Min(math.Max(done, 0), 100)))
}
// An estimate is only ever offered against moving bytes. A queue that is paused or
// waiting on a delay profile has a "time left" only in the sense that the download
// client is guessing, and an import has no measured duration at all.
if etaKnown && eta > 0 && best == RequestStatusDownloading {
progress.EstimatedReadySeconds = int(math.Round(eta.Seconds()))
}
return progress
}
// parseTimeLeft reads the .NET TimeSpan the *arrs send: "hh:mm:ss", with an optional
// "d." day part in front and an optional fractional-seconds part behind.
//
// It refuses anything it cannot read completely rather than salvaging a number from part of
// it. This is the one input that becomes a promise to a viewer, and a misparse here is how
// "ready in 14 minutes" becomes "ready in 14 hours".
func parseTimeLeft(value string) (time.Duration, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
days := 0
// A day part is separated by a full stop, and so is a fractional second — so a leading
// "d." only exists when what follows still holds two colons.
if dot := strings.Index(value, "."); dot > 0 && strings.Count(value[dot+1:], ":") == 2 {
parsed, err := strconv.Atoi(value[:dot])
if err != nil || parsed < 0 {
return 0, false
}
days = parsed
value = value[dot+1:]
}
if dot := strings.Index(value, "."); dot >= 0 {
value = value[:dot] // drop fractional seconds; nobody counts a film in milliseconds
}
parts := strings.Split(value, ":")
if len(parts) != 3 {
return 0, false
}
units := []time.Duration{time.Hour, time.Minute, time.Second}
total := time.Duration(days) * 24 * time.Hour
for index, part := range parts {
number, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || number < 0 {
return 0, false
}
total += time.Duration(number) * units[index]
}
return total, true
}
// estimatedReadyLabel is the second half of the download line: the sentence under
// "Downloading - 68%".
//
// It is worded in the coarsest unit that is still useful, because the precision the
// download client reports is not precision anybody has: a client that says 14 minutes 3
// seconds is guessing at the minutes, so printing the seconds claims an accuracy the number
// does not have. An estimate of zero is not an estimate and produces nothing at all, which
// is what makes "we will let you know when it is ready" reachable.
func estimatedReadyLabel(seconds int) string {
if seconds <= 0 {
return ""
}
remaining := time.Duration(seconds) * time.Second
switch {
case remaining < 90*time.Second:
return "Estimated ready in under a minute"
case remaining < time.Hour:
return "Estimated ready in ~" + strconv.Itoa(int(math.Round(remaining.Minutes()))) + " minutes"
case remaining < 2*time.Hour:
return "Estimated ready in about an hour"
case remaining < 24*time.Hour:
return "Estimated ready in ~" + strconv.Itoa(int(remaining.Hours())) + " hours"
default:
// Past a day the number stops being an estimate and starts being a warning that
// something is wrong with the release, so it is deliberately vague.
return "Estimated ready in over a day"
}
}
@@ -0,0 +1,255 @@
package api
import (
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
)
func TestWorkStateReadsTheThreeVocabulariesInOrder(t *testing.T) {
cases := []struct {
name string
work requestWork
want string
}{
{
// The case the ordering exists for: everything about this row looks finished,
// and the *arr has decided it cannot use what arrived.
name: "an error verdict outranks a completed download",
work: requestWork{Status: "completed", TrackedState: "importPending", TrackedStatus: "error"},
want: RequestStatusFailed,
},
{
name: "importing is processing even while the client still says downloading",
work: requestWork{Status: "downloading", TrackedState: "importing", TrackedStatus: "ok"},
want: RequestStatusProcessing,
},
{
name: "moving bytes",
work: requestWork{Status: "downloading", TrackedState: "downloading", TrackedStatus: "ok"},
want: RequestStatusDownloading,
},
{
name: "queued is a wait, not a download",
work: requestWork{Status: "queued", TrackedState: "downloading", TrackedStatus: "ok"},
want: RequestStatusFound,
},
{
name: "a delay profile is a wait",
work: requestWork{Status: "delay", TrackedStatus: "ok"},
want: RequestStatusFound,
},
{
// A future Radarr inventing a word must not tell a viewer their film is broken.
name: "an unknown word degrades to found",
work: requestWork{Status: "reticulatingSplines", TrackedState: "somethingNew"},
want: RequestStatusFound,
},
{
// A warning is not a failure. Radarr flags a stalled download this way and will
// carry on with it.
name: "a warning verdict is not a failure",
work: requestWork{Status: "downloading", TrackedStatus: "warning"},
want: RequestStatusDownloading,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := workState(tc.work); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestDownloadProgressSaysNothingAboutAnEmptyQueue(t *testing.T) {
// The absence of a queue row is the caller's question, not a state: it means something
// different for a film already on the shelf, one nobody has released, and one that has
// been searched for all afternoon.
got := downloadProgress(nil)
if got.Status != "" {
t.Fatalf("an empty queue must claim no state, got %q", got.Status)
}
if got.Progress != 0 || got.EstimatedReadySeconds != 0 {
t.Fatal("an empty queue must carry no figures")
}
}
func TestDownloadProgressReportsPercentAndEstimate(t *testing.T) {
got := downloadProgress([]requestWork{{
Size: 1000, SizeLeft: 320, TimeLeft: "00:14:00",
Status: "downloading", TrackedState: "downloading", TrackedStatus: "ok",
}})
if got.Status != RequestStatusDownloading {
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got.Status)
}
if got.Progress != 68 {
t.Fatalf("expected 68%%, got %d%%", got.Progress)
}
if got.EstimatedReadySeconds != 840 {
t.Fatalf("expected 840 seconds, got %d", got.EstimatedReadySeconds)
}
}
func TestASeasonIsOneAnswerOverManyRows(t *testing.T) {
// A season pack is one row per episode at a dozen different stages. What the card says
// is the most active of them, and the percentage is over the whole pack rather than
// whichever episode happens to be first.
work := []requestWork{
{Size: 100, SizeLeft: 0, Status: "completed", TrackedState: "imported"},
{Size: 100, SizeLeft: 50, TimeLeft: "00:05:00", Status: "downloading", TrackedState: "downloading"},
{Size: 100, SizeLeft: 100, TimeLeft: "00:20:00", Status: "queued", TrackedState: "downloading"},
}
got := downloadProgress(work)
if got.Status != RequestStatusDownloading {
t.Fatalf("a pack with one episode downloading is downloading, got %q", got.Status)
}
if got.Progress != 50 {
t.Fatalf("expected 50%% over the whole pack, got %d%%", got.Progress)
}
// The pack is ready when its slowest episode is, not its fastest.
if got.EstimatedReadySeconds != 1200 {
t.Fatalf("expected the longest remaining time, got %d", got.EstimatedReadySeconds)
}
}
func TestOneFailedEpisodeDoesNotFailTheWholeShow(t *testing.T) {
work := []requestWork{
{Size: 100, SizeLeft: 100, Status: "failed", TrackedStatus: "error"},
{Size: 100, SizeLeft: 40, TimeLeft: "00:03:00", Status: "downloading"},
}
if got := downloadProgress(work).Status; got != RequestStatusDownloading {
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got)
}
// With nothing else happening, though, failed is the honest answer.
only := []requestWork{{Size: 100, SizeLeft: 100, Status: "failed", TrackedStatus: "error"}}
if got := downloadProgress(only).Status; got != RequestStatusFailed {
t.Fatalf("expected %q, got %q", RequestStatusFailed, got)
}
}
func TestAnEstimateIsNeverManufactured(t *testing.T) {
// A download client that will not say how long it has left leaves the estimate absent.
// This is the acceptance criterion the feature is judged on, so it is asserted from
// several directions.
silent := downloadProgress([]requestWork{{Size: 100, SizeLeft: 60, Status: "downloading"}})
if silent.EstimatedReadySeconds != 0 {
t.Fatalf("no time left reported must produce no estimate, got %d", silent.EstimatedReadySeconds)
}
if silent.Progress != 40 {
t.Fatalf("a missing estimate must not cost the percentage, got %d%%", silent.Progress)
}
// One silent row makes the whole pack unknowable: reporting the rest of it as the whole
// would be an estimate that quietly expires while the download carries on.
partly := downloadProgress([]requestWork{
{Size: 100, SizeLeft: 50, TimeLeft: "00:05:00", Status: "downloading"},
{Size: 100, SizeLeft: 90, Status: "downloading"},
})
if partly.EstimatedReadySeconds != 0 {
t.Fatalf("one silent row must make the pack unknowable, got %d", partly.EstimatedReadySeconds)
}
// An import has no measured duration, so its "few minutes" is wording rather than a
// number — see requestStatusDetail.
importing := downloadProgress([]requestWork{
{Size: 100, SizeLeft: 0, TimeLeft: "00:00:30", Status: "completed", TrackedState: "importing"},
})
if importing.EstimatedReadySeconds != 0 {
t.Fatalf("an import must not carry an estimate, got %d", importing.EstimatedReadySeconds)
}
}
func TestSizeIsOnlyReadWhenTheClientGaveOne(t *testing.T) {
// Zero size and zero left is a client that has not said, and reading it as
// (0-0)/0 finished would draw a full bar over a download that has not started.
got := downloadProgress([]requestWork{{Status: "downloading"}})
if got.Progress != 0 {
t.Fatalf("an unsized download must report no percentage, got %d%%", got.Progress)
}
// A client reporting more left than the whole is clamped rather than trusted into a
// negative percentage.
odd := downloadProgress([]requestWork{{Size: 100, SizeLeft: 400, Status: "downloading"}})
if odd.Progress != 0 {
t.Fatalf("expected a clamped 0%%, got %d%%", odd.Progress)
}
}
func TestParseTimeLeftReadsTheArrsFormat(t *testing.T) {
cases := []struct {
in string
want time.Duration
ok bool
}{
{"00:14:32", 14*time.Minute + 32*time.Second, true},
{"01:00:00", time.Hour, true},
{"1.02:03:04", 26*time.Hour + 3*time.Minute + 4*time.Second, true},
{"00:00:30.5000000", 30 * time.Second, true},
// Everything it cannot read completely is refused rather than salvaged. This is the
// one input that becomes a promise to a viewer.
{"", 0, false},
{"14:32", 0, false},
{"soon", 0, false},
{"00:xx:32", 0, false},
{"-1.00:00:01", 0, false},
}
for _, tc := range cases {
got, ok := parseTimeLeft(tc.in)
if ok != tc.ok {
t.Fatalf("%q: expected ok=%v, got %v", tc.in, tc.ok, ok)
}
if ok && got != tc.want {
t.Fatalf("%q: expected %s, got %s", tc.in, tc.want, got)
}
}
}
func TestEstimatedReadyLabelIsCoarserThanTheNumberItWasGiven(t *testing.T) {
// The download client's seconds are not precision anybody has, so printing them would
// claim an accuracy the estimate does not carry.
cases := []struct {
seconds int
want string
}{
{0, ""},
{-5, ""},
{45, "Estimated ready in under a minute"},
{840, "Estimated ready in ~14 minutes"},
{4500, "Estimated ready in about an hour"},
{5 * 3600, "Estimated ready in ~5 hours"},
{50 * 3600, "Estimated ready in over a day"},
}
for _, tc := range cases {
if got := estimatedReadyLabel(tc.seconds); got != tc.want {
t.Fatalf("%d seconds: expected %q, got %q", tc.seconds, tc.want, got)
}
}
}
func TestQueueIsGroupedByTheIdTheRequestWasRecordedAgainst(t *testing.T) {
// A queue row names the *arr's own internal id, which nothing outside it has seen. The
// translation to TMDb is what lets a stored request find its own download.
work := groupMovieWork(
[]radarr.QueueItem{
{MovieID: 7, Size: 100, Sizeleft: 25, Status: "downloading"},
// Radarr id 99 is not in the catalogue map: a film added since it was cached.
{MovieID: 99, Size: 100, Sizeleft: 100, Status: "queued"},
},
map[int]int{7: 550, 8: 603},
)
if len(work[550]) != 1 {
t.Fatalf("expected one row against tmdb 550, got %d", len(work[550]))
}
if work[550][0].Status != "downloading" {
t.Fatalf("the row lost its status: %+v", work[550][0])
}
// A download for a film added since the catalogue was cached cannot be translated, and
// is dropped rather than guessed at.
if _, ok := work[0]; ok {
t.Fatal("an untranslatable row must not be filed under a zero id")
}
if len(work) != 1 {
t.Fatalf("expected one translated title, got %d", len(work))
}
}
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"context"
"encoding/json"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
// Reading the download queues, which is the one part of the request page that cannot be
// cached for long and is read by every television in the house at once.
//
// The two *arr catalogues beside this are cached for the day: what Radarr holds and whether
// it has a file changes a handful of times a day, and a viewer finding out at midnight costs
// nothing. A queue is the opposite — it is the number moving on the card — so it carries its
// own short cache instead. requestQueueTTL is what stops four televisions on the launcher
// turning a page refresh into four requests apiece: within the window they share one answer,
// and past it the figure is stale by at most that window, which at this cadence is invisible
// against a download measured in minutes.
const (
radarrQueueCacheKey = "radarr:queue:v1"
sonarrQueueCacheKey = "sonarr:queue:v1"
requestQueueTTL = 15 * time.Second
)
// radarrQueue is what Radarr's download client is working on.
//
// Single-flighted behind the same mutex the catalogue uses, so a burst of polls costs one
// upstream read. A miss is not an error the page can be failed over: see decorateRequests,
// where a queue that will not answer costs the percentage and never the card.
func (s *Server) radarrQueue(ctx context.Context) ([]radarr.QueueItem, error) {
if !s.radarrEnabled(ctx) {
return nil, nil
}
if queue, ok := cachedQueue[radarr.QueueItem](ctx, s, radarrQueueCacheKey); ok {
return queue, nil
}
s.radarrMu.Lock()
defer s.radarrMu.Unlock()
if queue, ok := cachedQueue[radarr.QueueItem](ctx, s, radarrQueueCacheKey); ok {
return queue, nil
}
queue, err := s.radarr.Queue(ctx)
if err != nil {
return nil, err
}
s.cacheQueue(ctx, radarrQueueCacheKey, queue)
return queue, nil
}
// sonarrQueue is what Sonarr's download client is working on.
func (s *Server) sonarrQueue(ctx context.Context) ([]sonarr.QueueItem, error) {
if !s.sonarrEnabled(ctx) {
return nil, nil
}
if queue, ok := cachedQueue[sonarr.QueueItem](ctx, s, sonarrQueueCacheKey); ok {
return queue, nil
}
s.sonarrSeriesMu.Lock()
defer s.sonarrSeriesMu.Unlock()
if queue, ok := cachedQueue[sonarr.QueueItem](ctx, s, sonarrQueueCacheKey); ok {
return queue, nil
}
queue, err := s.sonarr.Queue(ctx)
if err != nil {
return nil, err
}
s.cacheQueue(ctx, sonarrQueueCacheKey, queue)
return queue, nil
}
// cachedQueue reads a stored queue. An empty queue is a real and common answer — most of the
// time the household is downloading nothing — so the second return distinguishes "nothing is
// stored" from "nothing is downloading", which a nil slice could not.
func cachedQueue[T any](ctx context.Context, s *Server, key string) ([]T, bool) {
if s.cache == nil {
return nil, false
}
raw, err := s.cache.Get(ctx, key)
if err != nil {
return nil, false
}
var queue []T
if err := json.Unmarshal(raw, &queue); err != nil {
return nil, false
}
if queue == nil {
queue = []T{}
}
return queue, true
}
func (s *Server) cacheQueue(ctx context.Context, key string, queue any) {
if s.cache == nil {
return
}
body, err := json.Marshal(queue)
if err != nil {
return
}
if err := s.cache.Set(ctx, key, body, requestQueueTTL); err != nil {
s.loggerFor(ctx).Warn("download queue cache write failed", "key", key, "error", err)
}
}
// groupMovieWork turns Radarr's queue into work per *TMDb* id.
//
// The translation is the point. A queue row names Radarr's own movie id, which is an
// internal number nothing outside Radarr has ever seen; a request is recorded against the
// TMDb id, which is what the catalogue, the library and Emby all agree on. The map comes
// from the catalogue the caller already has, so this costs no extra request — and a row
// whose film is not in that map is dropped rather than guessed at, which is what a download
// for something added since the catalogue was cached looks like.
func groupMovieWork(queue []radarr.QueueItem, tmdbByMovieID map[int]int) map[int][]requestWork {
work := map[int][]requestWork{}
for _, row := range queue {
tmdbID, ok := tmdbByMovieID[row.MovieID]
if !ok || tmdbID == 0 {
continue
}
work[tmdbID] = append(work[tmdbID], requestWork{
Size: row.Size,
SizeLeft: row.Sizeleft,
TimeLeft: row.Timeleft,
Status: row.Status,
TrackedState: row.TrackedDownloadState,
TrackedStatus: row.TrackedDownloadStatus,
})
}
return work
}
// groupSeriesWork turns Sonarr's queue into work per TVDb id.
//
// A show legitimately has many rows — a season pack is one row per episode — and they are
// kept as a list rather than reduced here, because downloadProgress is what decides how a
// dozen episodes at a dozen stages become one sentence.
func groupSeriesWork(queue []sonarr.QueueItem, tvdbBySeriesID map[int]int) map[int][]requestWork {
work := map[int][]requestWork{}
for _, row := range queue {
tvdbID, ok := tvdbBySeriesID[row.SeriesID]
if !ok || tvdbID == 0 {
continue
}
work[tvdbID] = append(work[tvdbID], requestWork{
Size: row.Size,
SizeLeft: row.Sizeleft,
TimeLeft: row.Timeleft,
Status: row.Status,
TrackedState: row.TrackedDownloadState,
TrackedStatus: row.TrackedDownloadStatus,
})
}
return work
}
+192
View File
@@ -0,0 +1,192 @@
package api
import (
"context"
"fmt"
"strconv"
"time"
"github.com/ponzischeme89/memby/server/internal/notify"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Telling somebody the thing they asked for has arrived.
//
// This is the one part of the request feature that cannot be answered by looking: every
// other state on a card is derived per read from the *arrs and the library, but "it has just
// become ready" is a *difference between two observations*, and a viewer has to be told about
// it exactly once. media_requests.last_status is the memory that makes the difference
// visible; this sweep is what looks.
//
// It is a personal notification rather than a service alert, and that is the same distinction
// the watch-time digest makes: a service alert is the house being told something, and one
// person's request arriving is news for one person. It therefore lands in My Alerts and
// follows them to whichever television they sign into, rather than dropping a bar across
// somebody else's film.
const (
// requestReadyKind is what the television files the notification under. An app that
// predates it draws the fallback icon, which is why the wording has to stand on its own.
requestReadyKind = "request-ready"
// notifySourceRequestReady names this producer in the notification log, so an operator
// answering "did Memby tell them?" has one thing to filter on.
notifySourceRequestReady = "request-ready"
)
// RegisterRequestTasks declares the arrival sweep.
//
// Registered as an ordinary scheduler job for the reason the digest is: an operator can see
// when it last ran and can run one by hand. For a job whose whole output is silence on a
// quiet day, that is the difference between "nothing has arrived" and "it has not run".
func (s *Server) RegisterRequestTasks(sched *scheduler.Scheduler) {
if sched == nil {
return
}
sched.Register(scheduler.Task{
ID: "request-ready-scan",
Name: "Requested content arrivals",
Group: "Notifications",
Description: "Notices when something a viewer requested has become watchable and tells " +
"the person who asked for it. Needs Radarr or Sonarr.",
// Five minutes is chosen against what it is watching rather than against cost. An
// import is the last few seconds of a wait measured in tens of minutes, so being up
// to five minutes late with the news is invisible; being an hour late is the
// difference between a notification and a thing somebody had already found for
// themselves.
Interval: 5 * time.Minute,
Timeout: 2 * time.Minute,
Run: s.runRequestReadyScan,
})
}
func (s *Server) runRequestReadyScan(ctx context.Context) (string, error) {
if s.store == nil {
return "", nil
}
// Nothing to compare against with both catalogues gone: every card would read
// "requested", which is not a transition and must not be recorded as one.
if !s.radarrEnabled(ctx) && !s.sonarrEnabled(ctx) {
return "", nil
}
owned, err := s.store.AllMediaRequests(ctx, store.MediaRequestSweepLimit)
if err != nil {
return "", fmt.Errorf("request arrivals: read requests: %w", err)
}
if len(owned) == 0 {
return "", nil
}
stored := make([]store.MediaRequest, 0, len(owned))
for _, req := range owned {
stored = append(stored, req.MediaRequest)
}
// progressAware is true because nothing here is drawn: the sweep wants the truth, and
// collapsing it would make a download and a search indistinguishable in the memory this
// writes back.
cards := s.decorateRequests(ctx, stored, true)
if len(cards) != len(owned) {
// decorateRequests answers one card per stored ask, in order. If that ever stopped
// being true the zip below would attribute one person's arrival to another, which is
// the one mistake here that reaches somebody as a notification about a title they
// never asked for.
return "", fmt.Errorf("request arrivals: %d cards for %d requests", len(cards), len(owned))
}
announced, moved := 0, 0
for index, card := range cards {
req := owned[index]
if card.Status == req.LastStatus {
continue
}
// A state that could not be established is not a transition. With Radarr restarting,
// every card falls back to "requested", and recording that would throw away the
// memory of what each request was actually doing — so the next sweep, once Radarr is
// back, would read an arrival as a move from "requested" and announce titles that had
// been on the shelf for a week.
if card.Status == RequestStatusRequested {
continue
}
if card.Status == RequestStatusAvailable && req.LastStatus != RequestStatusAvailable {
if s.announceRequestReady(ctx, req, card) {
announced++
}
}
if err := s.store.SetMediaRequestStatus(
ctx, req.UserID, req.MediaType, req.ForeignID, card.Status,
); err != nil {
s.loggerFor(ctx).Warn("request status not recorded",
"user_id", req.UserID, "type", req.MediaType,
"foreign_id", req.ForeignID, "error", err)
continue
}
moved++
}
// An empty detail keeps a job that runs every five minutes out of the operator's feed
// every five minutes; see scheduler.announce. Movement without an arrival is real work
// and worth reporting, because it is the evidence that the sweep is watching anything
// at all on a household where nothing has finished downloading yet.
switch {
case announced == 1:
return "1 request ready", nil
case announced > 1:
return fmt.Sprintf("%d requests ready", announced), nil
case moved > 0:
return fmt.Sprintf("%d requests moved on", moved), nil
default:
return "", nil
}
}
// announceRequestReady tells one viewer their title has landed.
//
// It carries the Emby item id whenever the library has one, which is what lets pressing the
// notification open the title's own detail page rather than a page about nothing. A title the
// *arr reports a file for but Emby has not imported yet legitimately has none — the news is
// still true and still worth sending, and the notification simply is not a link.
//
// There is deliberately no preference check. The other personal notifications are things
// Memby decided to send somebody; this one is the answer to a question they asked by pressing
// a button, and a viewer who has requested a film has said as clearly as they can that they
// want to know when it arrives.
func (s *Server) announceRequestReady(
ctx context.Context, req store.OwnedMediaRequest, card myRequest,
) bool {
title := card.Title
if title == "" {
title = req.Title
}
thing := "film"
if req.MediaType == "series" {
thing = "series"
}
eventAt := time.Now()
sent := s.notifyUser(ctx, notify.Notification{
Kind: requestReadyKind,
Source: notifySourceRequestReady,
UserID: req.UserID,
Title: "Your " + thing + " is ready",
Body: title + " is now available to watch.",
ItemID: card.EmbyItemID,
// The key names the request rather than the moment, so the arrival is announced once
// however many times the sweep runs — and a viewer who deletes the request, asks
// again and waits through a second download is a second key only if the title left
// the library in between, which is the case where the news is genuinely new.
SourceKey: "request-ready:" + req.MediaType + ":" + strconv.Itoa(req.ForeignID) +
":" + req.UserID,
EventAt: &eventAt,
Metadata: map[string]any{
"mediaType": req.MediaType,
"foreignId": req.ForeignID,
},
})
if sent {
s.loggerFor(ctx).Info("requested title is ready",
"user_id", req.UserID, "type", req.MediaType,
"title", clientLogValue(title), "foreign_id", req.ForeignID,
"item_id", card.EmbyItemID)
}
return sent
}
+76 -4
View File
@@ -16,7 +16,11 @@ import (
const (
// The household has it. Either Emby has imported it or the *arr reports a file.
RequestStatusAvailable = "available"
// Accepted and being worked on: released, monitored, no file yet.
// Being filed away: the bytes have landed and the *arr is importing them. This used to
// mean "released, monitored, no file yet" — everything between the ask and the arrival —
// and now means only the last step of it, with searching, found and downloading (in
// requests_progress.go) covering the rest. A television that predates those three is
// sent this word for all four; see collapseRequestStatus.
RequestStatusProcessing = "processing"
// Accepted, but there is nothing to fetch yet — unreleased, or still only in cinemas.
RequestStatusPending = "pending"
@@ -68,6 +72,12 @@ type RequestSubject struct {
// Released is whether there is anything to fetch yet. A film not yet on digital and a
// series whose first episode has not aired are both false.
Released bool
// Progress is what the download client is doing with it, already folded by
// downloadProgress. It is passed in rather than computed here so that the caller — which
// has the queue rows and has to send the percentage and the ETA anyway — folds them
// once. An empty Status means the queue had nothing to say, which is the ordinary case
// and not a state of its own.
Progress requestProgress
}
// requestStatusFor is the whole rule, and it is ordered by how much each signal is worth.
@@ -77,16 +87,48 @@ type RequestSubject struct {
// recorded. Only then does absence from the *arr mean removal — checked before the release
// state, because an untracked title's release date says nothing about a request nobody is
// working on any more.
//
// The download client outranks the release state, and that ordering is deliberate. Radarr's
// minimum-availability setting is a policy about when to *start looking*, not a fact about
// whether bytes are moving — a household that fetches on the cinema date has films that are
// "not released" and 40% downloaded at the same time, and the honest thing to tell somebody
// watching that card is the 40%.
//
// With nothing in the queue and nothing on disk, a tracked and released title is being
// searched for. That is the state this feature exists to make visible: before it, the whole
// span from "somebody asked" to "the file landed" was one word, so a viewer could not tell a
// request nothing had been found for from one that was minutes away.
func requestStatusFor(subject RequestSubject) string {
switch {
case subject.InLibrary || subject.HasFile:
return RequestStatusAvailable
case !subject.Tracked:
return RequestStatusUnavailable
case subject.Progress.Status != "":
return subject.Progress.Status
case !subject.Released:
return RequestStatusPending
default:
return RequestStatusSearching
}
}
// collapseRequestStatus is what a television that predates the download states is told.
//
// The four new words all live inside the span the old "processing" covered, so an older app
// is sent that one word and reads it exactly as it always did — "Searching for a copy",
// filed under On the way. It loses the percentage and the estimate, which it has nowhere to
// draw anyway, and it gains nothing wrong.
//
// This is a *narrowing*, never a translation in the other direction: a build that declares
// request_progress_v1 is sent the truth. See requestProgressSupported.
func collapseRequestStatus(status string) string {
switch status {
case RequestStatusSearching, RequestStatusFound,
RequestStatusDownloading, RequestStatusFailed:
return RequestStatusProcessing
default:
return status
}
}
@@ -131,11 +173,23 @@ func seriesReleased(status string, nextAiring *time.Time, now time.Time) bool {
func requestStatusLabel(status string) string {
switch status {
case RequestStatusAvailable:
return "Available"
return "Ready to watch"
case RequestStatusSearching:
return "Searching"
case RequestStatusFound:
return "Found"
// Deliberately just the word. The percentage is a number that moves every ten seconds
// and the wording does not, so the television composes "Downloading - 43%" from this
// label and the progress field rather than the server sending a sentence that is stale
// before it is drawn.
case RequestStatusDownloading:
return "Downloading"
case RequestStatusProcessing:
return "Processing"
case RequestStatusPending:
return "Pending"
case RequestStatusFailed:
return "Unable to download"
case RequestStatusUnavailable:
return "Unavailable"
case RequestStatusRequestable:
@@ -147,7 +201,14 @@ func requestStatusLabel(status string) string {
// requestStatusDetail is the quiet second line: what the state means for the viewer, in
// plain language, rather than a repeat of the word above it.
func requestStatusDetail(status, mediaType string) string {
//
// The download states are where this line earns its place, because the word above it is not
// the news. "Downloading" is not what somebody is standing there wanting to know — when it
// will be ready is — and this is the only line that can say so. The estimate is passed in
// rather than recomputed so that the sentence and the estimatedReadySeconds field on the
// wire can never disagree; when there is no estimate the line says so plainly instead of
// reaching for a vaguer number, which is the whole of "never manufacture an ETA".
func requestStatusDetail(status, mediaType string, estimateSeconds int) string {
thing := "film"
if mediaType == "series" {
thing = "series"
@@ -155,8 +216,19 @@ func requestStatusDetail(status, mediaType string) string {
switch status {
case RequestStatusAvailable:
return "Ready to watch now"
case RequestStatusSearching:
return "We haven't found a suitable release yet"
case RequestStatusFound:
return "Preparing the download"
case RequestStatusDownloading:
if label := estimatedReadyLabel(estimateSeconds); label != "" {
return label
}
return "We'll let you know when it's ready"
case RequestStatusProcessing:
return "Searching for a copy"
return "Should be ready in a few minutes"
case RequestStatusFailed:
return "Memby will keep looking for another release"
case RequestStatusPending:
if thing == "series" {
return "Waiting for it to air"
+78 -8
View File
@@ -1,6 +1,7 @@
package api
import (
"strings"
"testing"
"time"
)
@@ -38,14 +39,62 @@ func TestRequestStatusReportsRemovalBeforeReleaseState(t *testing.T) {
}
}
func TestRequestStatusSeparatesPendingFromProcessing(t *testing.T) {
func TestRequestStatusSeparatesPendingFromSearching(t *testing.T) {
pending := requestStatusFor(RequestSubject{Tracked: true, Released: false})
if pending != RequestStatusPending {
t.Fatalf("expected %q, got %q", RequestStatusPending, pending)
}
processing := requestStatusFor(RequestSubject{Tracked: true, Released: true})
if processing != RequestStatusProcessing {
t.Fatalf("expected %q, got %q", RequestStatusProcessing, processing)
// Tracked, released, and the download client has never heard of it: nothing has been
// found. This is the distinction the whole feature rests on — before it, this case and
// a download two minutes from finishing were the same word.
searching := requestStatusFor(RequestSubject{Tracked: true, Released: true})
if searching != RequestStatusSearching {
t.Fatalf("expected %q, got %q", RequestStatusSearching, searching)
}
}
func TestQueueOutranksTheReleaseState(t *testing.T) {
// Radarr's minimum-availability setting decides when to start looking, not whether bytes
// are moving. A household that fetches on the cinema date has films that are "not
// released" and half downloaded at once, and the honest answer is the half.
got := requestStatusFor(RequestSubject{
Tracked: true,
Released: false,
Progress: requestProgress{Status: RequestStatusDownloading, Progress: 50},
})
if got != RequestStatusDownloading {
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got)
}
// But nothing outranks having it. A queue row against a title already on the shelf is an
// upgrade, and a card somebody can press Play on must not read as downloading.
got = requestStatusFor(RequestSubject{
Tracked: true,
InLibrary: true,
Progress: requestProgress{Status: RequestStatusDownloading, Progress: 50},
})
if got != RequestStatusAvailable {
t.Fatalf("expected %q, got %q", RequestStatusAvailable, got)
}
}
func TestCollapseNarrowsOnlyTheDownloadStates(t *testing.T) {
// An older television is told "processing" for the whole span it used to cover, and
// nothing else it understands is disturbed.
for _, state := range []string{
RequestStatusSearching, RequestStatusFound,
RequestStatusDownloading, RequestStatusFailed,
} {
if got := collapseRequestStatus(state); got != RequestStatusProcessing {
t.Fatalf("%q should collapse to %q, got %q", state, RequestStatusProcessing, got)
}
}
for _, state := range []string{
RequestStatusAvailable, RequestStatusPending, RequestStatusRequested,
RequestStatusUnavailable, RequestStatusRequestable, RequestStatusProcessing,
} {
if got := collapseRequestStatus(state); got != state {
t.Fatalf("%q must survive collapsing, got %q", state, got)
}
}
}
@@ -114,8 +163,10 @@ func TestRequestStatusLabelAndDetailCoverEveryState(t *testing.T) {
// a state that fell through to the default would show "Requested" on a card that is
// actually available.
states := []string{
RequestStatusAvailable, RequestStatusProcessing, RequestStatusPending,
RequestStatusUnavailable, RequestStatusRequestable, RequestStatusRequested,
RequestStatusAvailable, RequestStatusSearching, RequestStatusFound,
RequestStatusDownloading, RequestStatusProcessing, RequestStatusPending,
RequestStatusFailed, RequestStatusUnavailable, RequestStatusRequestable,
RequestStatusRequested,
}
seen := map[string]bool{}
for _, state := range states {
@@ -128,8 +179,27 @@ func TestRequestStatusLabelAndDetailCoverEveryState(t *testing.T) {
}
seen[label] = true
}
if requestStatusDetail(RequestStatusPending, "series") ==
requestStatusDetail(RequestStatusPending, "movie") {
for _, state := range states {
if requestStatusDetail(state, "movie", 0) == "" {
t.Fatalf("state %q has no detail line", state)
}
}
if requestStatusDetail(RequestStatusPending, "series", 0) ==
requestStatusDetail(RequestStatusPending, "movie", 0) {
t.Fatal("a pending series and a pending film are waiting for different things")
}
// The one rule this feature is judged on: an estimate is offered when the download
// client gave one and never otherwise. Both sentences are legitimate; saying the second
// while holding a number, or the first while holding none, is not.
withEstimate := requestStatusDetail(RequestStatusDownloading, "movie", 840)
withoutEstimate := requestStatusDetail(RequestStatusDownloading, "movie", 0)
if withEstimate == withoutEstimate {
t.Fatal("a download with an estimate must not read the same as one without")
}
if !strings.Contains(withEstimate, "14 minutes") {
t.Fatalf("840 seconds should read as about 14 minutes, got %q", withEstimate)
}
if strings.Contains(withoutEstimate, "~") {
t.Fatalf("a download with no estimate must not print one, got %q", withoutEstimate)
}
}
+26 -32
View File
@@ -12,38 +12,28 @@ import (
"github.com/ponzischeme89/memby/server/internal/store"
)
// WatchSonarrLifecycle seeds the durable status history on startup, then refreshes it
// daily. History stores changes rather than identical daily snapshots: it still records
// the complete lifecycle while making an active-to-cancelled transition unambiguous.
func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
if !s.sonarrEnabled(ctx) || interval <= 0 {
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)
}
}
scan()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
scan()
}
}
// sonarrLifecycleResult is what one scan looked at and what moved.
//
// Counted rather than only logged because these are the figures the integrations console
// prints beside the run: "412 series checked, 3 changed" is an answer, where "the scan
// finished" is a line in a log an operator has to go and find.
type sonarrLifecycleResult struct {
Series int
Changes int
Added int
Cancelled int
Notifications int
}
func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
// scanSonarrLifecycle seeds the durable status history and records what changed since
// the last reading. History stores changes rather than identical daily snapshots: it still
// records the complete lifecycle while making an active-to-cancelled transition
// unambiguous.
func (s *Server) scanSonarrLifecycle(ctx context.Context) (sonarrLifecycleResult, error) {
result := sonarrLifecycleResult{}
series, err := s.sonarr.Series(ctx)
if err != nil {
return fmt.Errorf("read Sonarr series: %w", err)
return result, fmt.Errorf("read Sonarr series: %w", err)
}
now := time.Now().UTC()
observations := make([]store.SonarrSeriesStatus, 0, len(series))
@@ -57,10 +47,12 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
Title: item.Title, Year: item.Year, Status: item.Status, ObservedAt: now,
})
}
result.Series = len(observations)
changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations)
if err != nil {
return err
return result, err
}
result.Changes = len(changes)
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
additions := make([]store.SonarrSeriesStatusChange, 0, len(changes))
for _, change := range changes {
@@ -71,14 +63,15 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
cancellations = append(cancellations, change)
}
}
result.Added, result.Cancelled = len(additions), len(cancellations)
if len(cancellations) == 0 && len(additions) == 0 {
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
return nil
return result, nil
}
users, err := s.store.KnownUsers(ctx)
if err != nil {
return err
return result, err
}
preferences := map[string]store.NotificationPreferences{}
preferenceErrors := map[string]bool{}
@@ -153,10 +146,11 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
}
}
}
result.Notifications = notifications
s.log.Info("Sonarr lifecycle scan complete",
"series", len(observations), "changes", len(changes),
"added", len(additions), "cancelled", len(cancellations), "notifications", notifications)
return nil
return result, nil
}
func sonarrLifecycleNotificationKind(previous, current string) string {