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