428 lines
16 KiB
Go
428 lines
16 KiB
Go
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
|
|
}
|