Files
2026-08-19 14:25:44 +12:00

133 lines
5.5 KiB
Go

package api
import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/library"
)
// Radarr's import notification arrives as a webhook, which is why this is the one part
// of the gateway something else pushes to. Polling the calendar could only ever notice
// an import a minute or two late and only for titles inside the five-day window;
// "a movie just landed" is an event, so it is delivered as one. The alert itself goes
// into the shared store in alerts.go, like every other event.
// radarrWebhookPayload is the subset of Radarr's webhook body Memby reads. Radarr sends
// considerably more; anything not named here is ignored on purpose, so a Radarr upgrade
// that adds fields cannot break the hook.
type radarrWebhookPayload struct {
EventType string `json:"eventType"`
IsUpgrade bool `json:"isUpgrade"`
Movie struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
TMDBID int `json:"tmdbId"`
} `json:"movie"`
MovieFile struct {
ID int `json:"id"`
Quality string `json:"quality"`
} `json:"movieFile"`
// The delete events carry the file id at the top level rather than under movieFile,
// and say whether the media went with the entry. Both are read by the catalogue half
// only; the banner has nothing to say about a deletion.
MovieFileID int `json:"movieFileId"`
DeletedFiles bool `json:"deletedFiles"`
}
// ingestPayload hands the same notification to the catalogue rules.
//
// Written out rather than shared as one struct because the two halves genuinely read
// different fields for different reasons — the banner wants the quality string, the
// catalogue wants the deletion flags — and a single type would grow whichever field
// either of them needed next.
func ingestPayload(payload radarrWebhookPayload) library.RadarrWebhook {
var out library.RadarrWebhook
out.EventType = payload.EventType
out.IsUpgrade = payload.IsUpgrade
out.Movie.ID = payload.Movie.ID
out.Movie.Title = payload.Movie.Title
out.Movie.Year = payload.Movie.Year
out.MovieFile.ID = payload.MovieFile.ID
out.MovieFileID = payload.MovieFileID
out.DeletedFiles = payload.DeletedFiles
return out
}
// handleRadarrWebhook accepts Radarr's "On Import" notification.
//
// It sits outside both the auth middleware (Radarr has no Memby session) and the
// maintenance gate (an event dropped while maintenance is on is lost for good, and
// recording one costs nothing while the client API is off).
func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
// Unconfigured means absent, the same stance /admin takes: a deployment that never
// set a token must not expose an endpoint that writes to what every TV displays.
if s.cfg.RadarrWebhookToken == "" {
http.NotFound(w, r)
return
}
if subtle.ConstantTimeCompare(
[]byte(webhookToken(r)), []byte(s.cfg.RadarrWebhookToken),
) != 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(), integrationRadarr) {
s.loggerFor(r.Context()).Debug("radarr webhook ignored: integration switched off")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ignored": true})
return
}
var payload radarrWebhookPayload
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid webhook payload")
return
}
// Radarr's "Test" button posts a stub payload. Answering 200 without announcing a
// film that does not exist is what makes the test button mean "reachable".
if strings.EqualFold(payload.EventType, "Test") {
s.loggerFor(r.Context()).Info("radarr webhook test received")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true})
return
}
// Recording is now the whole of what this hook does. The banner used to be published
// from right here, which meant it was published before Emby had scanned the film in —
// hence its wording, that the film would be available "shortly". It is announced from
// the far end of the scan instead (AnnounceLibraryIngest), where it can say the film is
// actually there and where an episode can be announced on the same terms.
//
// An upgrade is still recorded and still silent as news: the file genuinely changed, so
// the row must be re-read, but the film was already there.
queued := s.queueIngest(r, "radarr", payload.EventType, library.RadarrRequests(ingestPayload(payload)))
if queued > 0 {
s.loggerFor(r.Context()).Debug("radarr import recorded",
"movie", payload.Movie.Title, "quality", payload.MovieFile.Quality)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": queued})
}
// webhookToken accepts the shared secret three ways because Radarr's webhook settings
// differ by version: a header where custom headers exist, basic auth where they do not,
// and a query parameter as the form that always works.
func webhookToken(r *http.Request) string {
if token := strings.TrimSpace(r.Header.Get("X-Memby-Token")); token != "" {
return token
}
if token := bearerToken(r); token != "" {
return token
}
if _, password, ok := r.BasicAuth(); ok && password != "" {
return password
}
return strings.TrimSpace(r.URL.Query().Get("token"))
}