package api import ( "context" "crypto/subtle" "encoding/json" "net/http" "github.com/ponzischeme89/memby/server/internal/library" ) // The two things that push into the gateway. // // Radarr's hook was already here, announcing a film as news. Both hooks now also *record* // what changed, which is the half that replaces asking Emby every hour whether anything // had happened: Sonarr and Radarr are the things that put files on disk, so they are the // things that know. // // Recording is all a hook does. The lookup, the import and the retry all belong to the // worker in internal/library, which is what lets these answer in a millisecond and, more // importantly, what lets them answer *at all* during quiet hours — see below. // ingesterHandle is the slice of the ingest worker the API needs, so api does not depend // on the concrete type for testing. Same arrangement as syncerHandle. type ingesterHandle interface { Enqueue(ctx context.Context, source string, requests []library.IngestRequest) (int, error) } // handleSonarrWebhook accepts Sonarr's import, upgrade, rename and delete notifications. // // Unconfigured means absent — the stance /admin and the Radarr hook already take: a // deployment that never set a token must not expose an endpoint anything can post to. func (s *Server) handleSonarrWebhook(w http.ResponseWriter, r *http.Request) { if s.cfg.SonarrWebhookToken == "" { http.NotFound(w, r) return } if subtle.ConstantTimeCompare( []byte(webhookToken(r)), []byte(s.cfg.SonarrWebhookToken), ) != 1 { 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 { writeError(w, http.StatusBadRequest, "invalid webhook payload") return } // Sonarr's Test button posts a stub. Answering 200 without recording work about a // series that does not exist is what makes that button mean "reachable". if library.IsTestEvent(payload.EventType) { s.loggerFor(r.Context()).Info("sonarr webhook test received") writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true}) return } queued := s.queueIngest(r, "sonarr", payload.EventType, library.SonarrRequests(payload)) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": queued}) } // queueIngest records the work a notification implies and reports how much of it was news. // // The context is deliberately detached from the request. A webhook is answered in a // millisecond and Sonarr closes the connection; hanging the insert off the request would // abandon exactly the deliveries that arrive in bursts, which is what a season pack is. func (s *Server) queueIngest( r *http.Request, source, eventType string, requests []library.IngestRequest, ) int { log := s.loggerFor(r.Context()) if s.ingester == nil { return 0 } if len(requests) == 0 { // A grab, a health check, an unfollowed series whose files stayed on disk: all // real events, none of them a reason to re-read anything. log.Debug("webhook implies no catalogue work", "source", source, "event", eventType) return 0 } ctx := context.WithoutCancel(r.Context()) queued, err := s.ingester.Enqueue(ctx, source, requests) if err != nil { // The event is lost, which is the one failure worth an error line here: the *arrs // do not re-deliver, so nothing will bring this news again. The reconciliation // sweep is what eventually covers it. log.Error("could not record webhook work", "source", source, "event", eventType, "error", err) return queued } if queued > 0 { log.Info("arr ingest queued", "event", "arr_ingest", "source", source, "webhook_event", eventType, "outcome", "queued", "items", queued, "reason", requests[0].Reason) } return queued }