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

267 lines
8.1 KiB
Go

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"`
}
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)
}