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 }