package api import ( "context" "encoding/json" "errors" "net/http" "sort" "strconv" "strings" "sync" "github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) type requestCandidate struct { MediaType string `json:"mediaType"` ForeignID int `json:"foreignId"` Title string `json:"title"` Year int `json:"year"` Overview string `json:"overview"` PosterURL string `json:"posterUrl,omitempty"` AlreadyAdded bool `json:"alreadyAdded"` } type requestLookupResponse struct { Candidates []requestCandidate `json:"candidates"` } func (s *Server) requestAllowed(r *http.Request, sess store.Session) bool { if s.store == nil || (s.sonarr == nil && s.radarr == nil) { return false } policy, err := s.store.RequestPolicy(r.Context()) if err != nil { s.loggerFor(r.Context()).Error("request policy read failed", "error", err) return false } return policy.Allows(sess.EmbyUserID) } func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, sess store.Session) { if !s.requestAllowed(r, sess) { writeError(w, http.StatusForbidden, "media requests are not enabled for this user") return } term := strings.TrimSpace(r.URL.Query().Get("q")) if len(term) < 2 || len(term) > 160 { writeError(w, http.StatusBadRequest, "query must be between 2 and 160 characters") return } var movieCandidates []requestCandidate var seriesCandidates []requestCandidate var wg sync.WaitGroup if s.radarr != nil { wg.Add(1) go func() { defer wg.Done() movies, err := s.radarr.Lookup(r.Context(), term) if err != nil { s.loggerFor(r.Context()).Warn("Radarr request lookup failed", "error", err) return } for _, movie := range movies { if movie.TMDBID == 0 || len(movieCandidates) >= 5 { continue } movieCandidates = append(movieCandidates, requestCandidate{ MediaType: "movie", ForeignID: movie.TMDBID, Title: movie.Title, Year: movie.Year, Overview: movie.Overview, PosterURL: radarrCoverURL(movie.Images, "poster"), AlreadyAdded: movie.ID > 0, }) } }() } if s.sonarr != nil { wg.Add(1) go func() { defer wg.Done() series, err := s.sonarr.Lookup(r.Context(), term) if err != nil { s.loggerFor(r.Context()).Warn("Sonarr request lookup failed", "error", err) return } for _, show := range series { if show.TVDBID == 0 || len(seriesCandidates) >= 5 { continue } seriesCandidates = append(seriesCandidates, requestCandidate{ MediaType: "series", ForeignID: show.TVDBID, Title: show.Title, Year: show.Year, Overview: show.Overview, PosterURL: sonarrCoverURL(show.Images, "poster"), AlreadyAdded: show.ID > 0, }) } }() } wg.Wait() candidates := append(movieCandidates, seriesCandidates...) sort.SliceStable(candidates, func(i, j int) bool { return requestMatchScore(term, candidates[i].Title) < requestMatchScore(term, candidates[j].Title) }) writeJSON(w, http.StatusOK, requestLookupResponse{Candidates: candidates}) } func requestMatchScore(term, title string) int { term = strings.ToLower(strings.TrimSpace(term)) title = strings.ToLower(strings.TrimSpace(title)) switch { case title == term: return 0 case strings.HasPrefix(title, term): return 1 case strings.Contains(title, term): return 2 default: return 3 } } func radarrCoverURL(images []radarr.Image, kind string) string { for _, image := range images { if image.CoverType == kind { if image.RemoteURL != "" { return image.RemoteURL } return image.URL } } return "" } func sonarrCoverURL(images []sonarr.Image, kind string) string { for _, image := range images { if image.CoverType == kind { if image.RemoteURL != "" { return image.RemoteURL } return image.URL } } return "" } type requestPayload struct { MediaType string `json:"mediaType"` ForeignID int `json:"foreignId"` } func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess store.Session) { if !s.requestAllowed(r, sess) { writeError(w, http.StatusForbidden, "media requests are not enabled for this user") return } var req requestPayload if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if req.ForeignID <= 0 { writeError(w, http.StatusBadRequest, "foreignId is required") return } switch req.MediaType { case "movie": if s.radarr == nil { writeError(w, http.StatusServiceUnavailable, "Radarr is not configured") return } movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID)) if err != nil { s.writeRequestUpstreamError(r.Context(), w, err, "Radarr lookup failed") return } for _, movie := range movies { if movie.TMDBID != req.ForeignID { continue } if movie.ID > 0 { // Idempotent under a lost response: OkHttp may replay a repeatable POST after // a connection reset. If the first request already added it, the retry is the // same successful action rather than an error shown to the viewer. writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title}) return } added, err := s.radarr.AddUnmonitored(r.Context(), movie) if err != nil { s.writeRequestUpstreamError(r.Context(), w, err, "could not add movie to Radarr") return } s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "movie", "title", added.Title) writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title}) return } case "series": if s.sonarr == nil { writeError(w, http.StatusServiceUnavailable, "Sonarr is not configured") return } series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID)) if err != nil { s.writeRequestUpstreamError(r.Context(), w, err, "Sonarr lookup failed") return } for _, show := range series { if show.TVDBID != req.ForeignID { continue } if show.ID > 0 { writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title}) return } added, err := s.sonarr.AddUnmonitored(r.Context(), show) if err != nil { s.writeRequestUpstreamError(r.Context(), w, err, "could not add series to Sonarr") return } s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "series", "title", added.Title) writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title}) return } default: writeError(w, http.StatusBadRequest, `mediaType must be "movie" or "series"`) return } writeError(w, http.StatusNotFound, "title was not found") } func (s *Server) writeRequestUpstreamError( ctx context.Context, w http.ResponseWriter, err error, message string, ) { var radarrErr *radarr.APIError var sonarrErr *sonarr.APIError if (errors.As(err, &radarrErr) && radarrErr.StatusCode == http.StatusBadRequest) || (errors.As(err, &sonarrErr) && sonarrErr.StatusCode == http.StatusBadRequest) { writeError(w, http.StatusConflict, "the title could not be added; it may already exist") return } s.loggerFor(ctx).Error(message, "error", err) writeError(w, http.StatusBadGateway, message) }