0.2.78
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user