package api import ( "context" "encoding/json" "errors" "net/http" "sort" "strconv" "strings" "sync" "time" "github.com/ponzischeme89/memby/server/internal/adminevents" "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"` // What pressing this card would mean, said once by the server so the television never // has to work it out from three booleans and get a different answer than the page next // door. Mine is this viewer's own ask, which alreadyAdded cannot distinguish — the // household adding a film is not the same as you having asked for it. Status string `json:"status"` StatusLabel string `json:"statusLabel"` Mine bool `json:"mine"` // Released feeds the status rule and is kept on the wire for the same reason the // lifecycle slugs are: it is evidence, and a later build may want to word it better. Released bool `json:"released"` // hasFile is the *arr's own answer about media on disk, which is a different claim from // InLibrary — Radarr can hold a downloaded film Emby has not imported yet. Unexported // because it only feeds the status rule; the television is told the verdict, not the // evidence behind it. hasFile bool } type requestLookupResponse struct { Candidates []requestCandidate `json:"candidates"` } func (s *Server) requestAllowed(r *http.Request, sess store.Session) bool { if s.store == nil || (!s.sonarrEnabled(r.Context()) && !s.radarrEnabled(r.Context())) { 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.radarrEnabled(r.Context()) { 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, // Radarr's lookup fills these in for a title it already tracks; for one it // does not, hasFile is false and the status rule never reads Released. hasFile: movie.HasFile, Released: movieReleased(movie.Status), }) } }() } if s.sonarrEnabled(r.Context()) { 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, Released: seriesReleased(show.Status, show.NextAiring, time.Now()), }) } }() } 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) } // Which of these the viewer has already asked for themselves. A failure here costs the // "Requested" wording and nothing else, so it is not allowed to fail the search. mine := map[string]bool{} if stored, err := s.store.MediaRequests(r.Context(), sess.EmbyUserID); err == nil { for _, req := range stored { mine[req.MediaType+":"+strconv.Itoa(req.ForeignID)] = true } } else { s.loggerFor(r.Context()).Warn("own requests unavailable for lookup", "error", err) } for index := range candidates { candidate := &candidates[index] if candidate.MediaType == "movie" { candidate.InLibrary = moviesInLibrary[candidate.ForeignID] } else { candidate.InLibrary = seriesInLibrary[candidate.ForeignID] } candidate.Mine = mine[candidate.MediaType+":"+strconv.Itoa(candidate.ForeignID)] candidate.Status = lookupStatusFor(RequestSubject{ Tracked: candidate.AlreadyAdded, HasFile: candidate.hasFile, InLibrary: candidate.InLibrary, Released: candidate.Released, }, candidate.Mine) candidate.StatusLabel = requestStatusLabel(candidate.Status) } 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.radarrEnabled(r.Context()) { 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 // Still recorded as this viewer's ask. The household having the film already // is not the same as them never having asked for it, and their page is the // only place that distinction is kept. s.recordMediaRequest(r.Context(), sess, req, movie.Year, radarrCoverURL(movie.Images, "poster"), openingRequestStatus(movie.HasFile)) 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 requestOptions, rootFolder, profileName, err := s.radarrRequestOptions(r.Context()) if err != nil { s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", "", 0, "", false, err) s.publishRadarrRequestConfigurationProblem(r.Context(), err) writeError(w, http.StatusServiceUnavailable, "movie requests are unavailable: "+err.Error()) return } added, err := s.radarr.AddRequested(r.Context(), movie, rootFolder, requestOptions) if err != nil { s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, err) s.logMediaRequest(r.Context(), req, "failed", err) s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie") return } req.Title = added.Title s.logRadarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil) s.recordMediaRequest(r.Context(), sess, req, added.Year, radarrCoverURL(added.Images, "poster"), openingRequestStatus(false)) s.logMediaRequest(r.Context(), req, "successful", nil) writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title}) return } case "series": if !s.sonarrEnabled(r.Context()) { 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.recordMediaRequest(r.Context(), sess, req, show.Year, sonarrCoverURL(show.Images, "poster"), openingRequestStatus(false)) 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 requestOptions, rootFolder, profileName, err := s.sonarrRequestOptions(r.Context()) if err != nil { s.logSonarrRequest(r.Context(), sess, req, show, 0, "failed", "", 0, "", false, err) s.publishSonarrRequestConfigurationProblem(r.Context(), err) writeError(w, http.StatusServiceUnavailable, "TV requests are unavailable: "+err.Error()) return } added, err := s.sonarr.AddRequested(r.Context(), show, rootFolder, requestOptions) if err != nil { s.logSonarrRequest(r.Context(), sess, req, show, 0, "failed", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, err) s.logMediaRequest(r.Context(), req, "failed", err) s.writeRequestUpstreamError(r.Context(), w, err, "could not request that series") return } req.Title = added.Title s.logSonarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil) s.recordMediaRequest(r.Context(), sess, req, added.Year, sonarrCoverURL(added.Images, "poster"), openingRequestStatus(false)) 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") } // recordMediaRequest writes down who asked, which is the one thing Radarr and Sonarr do not // keep. It is deliberately best-effort: the title has already been added by the time this // runs, so failing the response here would tell a viewer their request did not work when it // did. The cost of a lost write is that the ask is missing from their own page, which is // recoverable by asking again — the cost of the opposite is a viewer requesting it twice. func (s *Server) recordMediaRequest( ctx context.Context, sess store.Session, req requestPayload, year int, posterURL, opening string, ) { if s.store == nil || sess.EmbyUserID == "" { return } err := s.store.SaveMediaRequest(ctx, sess.EmbyUserID, store.MediaRequest{ MediaType: req.MediaType, ForeignID: req.ForeignID, Title: req.Title, Year: year, PosterURL: posterURL, LastStatus: opening, }) if err != nil { s.loggerFor(ctx).Warn("media request not recorded", "type", req.MediaType, "foreign_id", req.ForeignID, "error", err) } } // openingRequestStatus is the state a request is born in, and it exists so that the ready // sweep has something to compare against on its very first pass. // // Leaving it blank and letting the first sweep fill it in would lose exactly the arrivals // worth announcing: a film that downloads in the three minutes between the ask and the first // sweep would have its arrival recorded as its opening state, and nobody would ever be told. // So the ask itself records what was true at the moment somebody pressed the button — which // is the one moment the handler knows for certain, having just asked the *arr. // // A series is never born available. Sonarr's series list carries no file information, so // "the household has this show" is a claim only the library can make, and the sweep is where // it gets made. func openingRequestStatus(hasFile bool) string { if hasFile { return RequestStatusAvailable } return RequestStatusSearching } 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) } // sonarrRequestOptions validates every component before a POST can reach Sonarr. A missing // configured profile is an error, not permission to fall back to Sonarr's "Any" profile. func (s *Server) sonarrRequestOptions(ctx context.Context) (sonarr.RequestOptions, string, string, error) { if !s.sonarrEnabled(ctx) { return sonarr.RequestOptions{}, "", "", errors.New("Sonarr integration is unavailable") } policy, err := s.store.SonarrRequestPolicy(ctx) if err != nil { return sonarr.RequestOptions{}, "", "", errors.New("could not read the Sonarr request policy") } roots, err := s.sonarr.RootFolders(ctx) if err != nil { return sonarr.RequestOptions{}, "", "", errors.New("could not validate Sonarr root folders") } if len(roots) == 0 || strings.TrimSpace(roots[0].Path) == "" { return sonarr.RequestOptions{}, "", "", errors.New("Sonarr has no valid root folder") } profiles, err := s.sonarr.QualityProfiles(ctx) if err != nil { return sonarr.RequestOptions{}, "", "", errors.New("could not validate Sonarr quality profiles") } profileID := policy.QualityProfileID if profileID == 0 { for _, profile := range profiles { if is720pProfile(profile.Name) { profileID = profile.ID break } } if profileID == 0 { return sonarr.RequestOptions{}, "", "", errors.New("no request quality profile is configured and Sonarr has no 720p profile") } // First-run policy is the safe 720p recommendation. Persist its id immediately so // later profile renames or deletion are caught as configuration errors instead of // becoming a fresh name-based selection. policy.QualityProfileID = profileID if err := s.store.SetSonarrRequestPolicy(ctx, policy); err != nil { return sonarr.RequestOptions{}, "", "", errors.New("could not save the default Sonarr request quality profile") } } for _, profile := range profiles { if profile.ID == profileID { return sonarr.RequestOptions{QualityProfileID: profile.ID, SearchImmediately: policy.SearchImmediately}, roots[0].Path, profile.Name, nil } } return sonarr.RequestOptions{}, "", "", errors.New("the configured Sonarr request quality profile no longer exists") } func (s *Server) logSonarrRequest( ctx context.Context, sess store.Session, req requestPayload, series sonarr.Series, seriesID int, outcome, profileName string, profileID int, rootFolder string, searchImmediately bool, err error, ) { fields := []any{ "user", clientLogValue(sess.Username), "user_id", sess.EmbyUserID, "title", clientLogValue(series.Title), "tvdb_id", series.TVDBID, "sonarr_series_id", seriesID, "quality_profile", profileName, "quality_profile_id", profileID, "monitoring_strategy", "all", "root_folder", rootFolder, "search_immediately", searchImmediately, "outcome", outcome, } if err != nil { fields = append(fields, "sonarr_result", err.Error()) s.loggerFor(ctx).Warn("Sonarr TV request", fields...) return } fields = append(fields, "sonarr_result", "created") s.loggerFor(ctx).Info("Sonarr TV request", fields...) } func (s *Server) publishSonarrRequestConfigurationProblem(ctx context.Context, err error) { s.publishAdmin(ctx, adminevents.Event{ Type: "sonarr.request_configuration", Severity: adminevents.SeverityError, Title: "Sonarr TV requests need attention", Summary: err.Error(), Actor: "memby-server", Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}), }) } func (s *Server) radarrRequestOptions(ctx context.Context) (radarr.RequestOptions, string, string, error) { if !s.radarrEnabled(ctx) { return radarr.RequestOptions{}, "", "", errors.New("Radarr integration is unavailable") } policy, err := s.store.RadarrRequestPolicy(ctx) if err != nil { return radarr.RequestOptions{}, "", "", errors.New("could not read the Radarr request policy") } roots, err := s.radarr.RootFolders(ctx) if err != nil { return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr root folders") } if len(roots) == 0 || strings.TrimSpace(roots[0].Path) == "" { return radarr.RequestOptions{}, "", "", errors.New("Radarr has no valid root folder") } profiles, err := s.radarr.QualityProfiles(ctx) if err != nil { return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr quality profiles") } profileID := policy.QualityProfileID if profileID == 0 { for _, profile := range profiles { if is720pProfile(profile.Name) { profileID = profile.ID break } } if profileID == 0 { return radarr.RequestOptions{}, "", "", errors.New("no request quality profile is configured and Radarr has no 720p profile") } policy.QualityProfileID = profileID if err := s.store.SetRadarrRequestPolicy(ctx, policy); err != nil { return radarr.RequestOptions{}, "", "", errors.New("could not save the default Radarr request quality profile") } } for _, profile := range profiles { if profile.ID == profileID { return radarr.RequestOptions{QualityProfileID: profile.ID, SearchImmediately: policy.SearchImmediately}, roots[0].Path, profile.Name, nil } } return radarr.RequestOptions{}, "", "", errors.New("the configured Radarr request quality profile no longer exists") } func (s *Server) logRadarrRequest( ctx context.Context, sess store.Session, req requestPayload, movie radarr.Movie, movieID int, outcome, profileName string, profileID int, rootFolder string, searchImmediately bool, err error, ) { fields := []any{ "user", clientLogValue(sess.Username), "user_id", sess.EmbyUserID, "title", clientLogValue(movie.Title), "tmdb_id", movie.TMDBID, "radarr_movie_id", movieID, "quality_profile", profileName, "quality_profile_id", profileID, "monitoring_strategy", "movie", "root_folder", rootFolder, "search_immediately", searchImmediately, "outcome", outcome, } if err != nil { fields = append(fields, "radarr_result", err.Error()) s.loggerFor(ctx).Warn("Radarr movie request", fields...) return } fields = append(fields, "radarr_result", "created") s.loggerFor(ctx).Info("Radarr movie request", fields...) } func (s *Server) publishRadarrRequestConfigurationProblem(ctx context.Context, err error) { s.publishAdmin(ctx, adminevents.Event{ Type: "radarr.request_configuration", Severity: adminevents.SeverityError, Title: "Radarr movie requests need attention", Summary: err.Error(), Actor: "memby-server", Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}), }) }