package api import ( "crypto/subtle" "encoding/json" "fmt" "net/http" "strings" "time" ) // 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"` } // 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 } 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.log.Info("radarr webhook test received") writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true}) return } alert, ok := radarrImportAlert(payload, time.Now().UTC()) if !ok { // A grab, a rename, a health check or an upgrade of something already in the // library: all real events, none of them "a new film is here". writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false}) return } if s.cfg.RadarrAlertWindow <= 0 { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false}) return } s.publishAlert(r.Context(), alert, s.cfg.RadarrAlertWindow) s.log.Info("radarr import announced", "movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true}) } // 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")) } // radarrImportAlert turns an import notification into the banner a TV shows, or reports // that this event is not worth announcing. // // An upgrade is deliberately silent: the film was already there, and "new movie added" // would be a lie about a file that was replaced with a better copy. func radarrImportAlert(payload radarrWebhookPayload, now time.Time) (clientAlert, bool) { if !isRadarrImportEvent(payload.EventType) || payload.IsUpgrade { return clientAlert{}, false } title := strings.TrimSpace(payload.Movie.Title) if title == "" || payload.Movie.ID <= 0 { return clientAlert{}, false } // Keyed on the file, so a title deleted and re-imported is news again while a // repeated delivery of the same import is not. Clients dedupe on this id forever. id := fmt.Sprintf("radarr:%d:file:%d", payload.Movie.ID, payload.MovieFile.ID) if payload.MovieFile.ID <= 0 { id = fmt.Sprintf("radarr:%d:imported:%d", payload.Movie.ID, now.Unix()) } name := title if payload.Movie.Year > 0 { name = fmt.Sprintf("%s (%d)", title, payload.Movie.Year) } return clientAlert{ ID: id, Kind: alertKindRadarrImport, Label: "NEW MOVIE ADDED", Title: name, Message: fmt.Sprintf("%s will be available in Emby shortly.", title), // The image proxy already serves Radarr covers under this id and tag, so the // banner shows the poster before Emby has finished scanning the film in. ItemID: fmt.Sprintf("radarr:%d", payload.Movie.ID), ImageTag: "radarr", AiredAt: now.UTC().Format(time.RFC3339), }, true } // isRadarrImportEvent matches the event Radarr fires once a downloaded file has been // imported into the library. The name has moved between versions, so both are accepted. func isRadarrImportEvent(eventType string) bool { switch strings.ToLower(strings.TrimSpace(eventType)) { case "download", "moviefileimported": return true default: return false } }