97 lines
3.7 KiB
Go
97 lines
3.7 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
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|