568 lines
22 KiB
Go
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()}),
|
|
})
|
|
}
|