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")) }