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"` InLibrary bool `json:"inLibrary"` } 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...) movieIDs, seriesIDs := []int{}, []int{} for _, candidate := range candidates { if candidate.MediaType == "movie" { movieIDs = append(movieIDs, candidate.ForeignID) } else { seriesIDs = append(seriesIDs, candidate.ForeignID) } } moviesInLibrary, movieErr := s.store.LibraryContainsProviderIDs(r.Context(), "Tmdb", movieIDs) seriesInLibrary, seriesErr := s.store.LibraryContainsProviderIDs(r.Context(), "Tvdb", seriesIDs) if movieErr != nil || seriesErr != nil { s.loggerFor(r.Context()).Warn("request library status unavailable", "movie_error", movieErr, "series_error", seriesErr) } for index := range candidates { if candidates[index].MediaType == "movie" { candidates[index].InLibrary = moviesInLibrary[candidates[index].ForeignID] } else { candidates[index].InLibrary = seriesInLibrary[candidates[index].ForeignID] } } 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"` Title string `json:"title"` } 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 } req.Title = strings.TrimSpace(req.Title) if titleRunes := []rune(req.Title); len(titleRunes) > 240 { req.Title = string(titleRunes[:240]) } switch req.MediaType { case "movie": if s.radarr == nil { s.logMediaRequest(r.Context(), req, "failed", errors.New("movie requests are not configured")) writeError(w, http.StatusServiceUnavailable, "movie requests are not configured") return } movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID)) if err != nil { s.logMediaRequest(r.Context(), req, "failed", err) s.writeRequestUpstreamError(r.Context(), w, err, "movie 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. req.Title = movie.Title s.logMediaRequest(r.Context(), req, "already added", nil) writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title}) return } req.Title = movie.Title added, err := s.radarr.AddRequested(r.Context(), movie) if err != nil { s.logMediaRequest(r.Context(), req, "failed", err) s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie") return } req.Title = added.Title s.logMediaRequest(r.Context(), req, "successful", nil) writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title}) return } case "series": if s.sonarr == nil { s.logMediaRequest(r.Context(), req, "failed", errors.New("series requests are not configured")) writeError(w, http.StatusServiceUnavailable, "series requests are not configured") return } series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID)) if err != nil { s.logMediaRequest(r.Context(), req, "failed", err) s.writeRequestUpstreamError(r.Context(), w, err, "series lookup failed") return } for _, show := range series { if show.TVDBID != req.ForeignID { continue } if show.ID > 0 { req.Title = show.Title s.logMediaRequest(r.Context(), req, "already added", nil) writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title}) return } req.Title = show.Title added, err := s.sonarr.AddRequested(r.Context(), show) if err != nil { s.logMediaRequest(r.Context(), req, "failed", err) s.writeRequestUpstreamError(r.Context(), w, err, "could not request that series") return } req.Title = added.Title s.logMediaRequest(r.Context(), req, "successful", nil) writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title}) return } default: s.logMediaRequest(r.Context(), req, "failed", errors.New("unsupported media type")) writeError(w, http.StatusBadRequest, `mediaType must be "movie" or "series"`) return } s.logMediaRequest(r.Context(), req, "failed", errors.New("title was not found")) writeError(w, http.StatusNotFound, "title was not found") } func (s *Server) logMediaRequest( ctx context.Context, req requestPayload, outcome string, err error, ) { fields := []any{ "type", req.MediaType, "title", clientLogValue(req.Title), "foreign_id", req.ForeignID, "outcome", outcome, } if err != nil { fields = append(fields, "error", err) s.loggerFor(ctx).Warn("media request failed", fields...) return } s.loggerFor(ctx).Info("media request "+outcome, fields...) } 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) }