239 lines
6.8 KiB
Go
239 lines
6.8 KiB
Go
package api
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"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"`
|
||
|
|
}
|
||
|
|
|
||
|
|
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.log.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.log.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.log.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...)
|
||
|
|
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(w, err, "Radarr lookup failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for _, movie := range movies {
|
||
|
|
if movie.TMDBID != req.ForeignID {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if movie.ID > 0 {
|
||
|
|
writeError(w, http.StatusConflict, "this movie is already in Radarr")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
added, err := s.radarr.AddUnmonitored(r.Context(), movie)
|
||
|
|
if err != nil {
|
||
|
|
s.writeRequestUpstreamError(w, err, "could not add movie to Radarr")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.log.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(w, err, "Sonarr lookup failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for _, show := range series {
|
||
|
|
if show.TVDBID != req.ForeignID {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if show.ID > 0 {
|
||
|
|
writeError(w, http.StatusConflict, "this series is already in Sonarr")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
added, err := s.sonarr.AddUnmonitored(r.Context(), show)
|
||
|
|
if err != nil {
|
||
|
|
s.writeRequestUpstreamError(w, err, "could not add series to Sonarr")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.log.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(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.log.Error(message, "error", err)
|
||
|
|
writeError(w, http.StatusBadGateway, message)
|
||
|
|
}
|