267 lines
8.1 KiB
Go
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)
|
|
}
|