2026-08-02 22:10:19 +12:00
|
|
|
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") {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Info("radarr webhook test received")
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-17 19:09:17 +12:00
|
|
|
window := s.radarrAlertWindow()
|
|
|
|
|
if window <= 0 {
|
2026-08-02 22:10:19 +12:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:09:17 +12:00
|
|
|
s.publishAlert(r.Context(), alert, window)
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Info("radarr import announced",
|
2026-08-02 22:10:19 +12:00
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
}
|