Files
memby/server/internal/api/requests.go
T
2026-08-19 14:25:44 +12:00

568 lines
22 KiB
Go

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()}),
})
}