Files
memby/server/internal/api/integrations_catalogue.go
2026-08-19 14:25:44 +12:00

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
}