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

106 lines
4.3 KiB
Go

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
}