0.2.58 - Requests module

This commit is contained in:
ponzischeme89
2026-08-12 14:13:19 +12:00
parent 64f19aeef2
commit 613f203cf9
27 changed files with 3026 additions and 23 deletions
+81 -5
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr"
@@ -24,6 +25,21 @@ type requestCandidate struct {
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 {
@@ -73,6 +89,10 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
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),
})
}
}()
@@ -94,6 +114,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
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()),
})
}
}()
@@ -114,12 +135,32 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
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]
// 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)
@@ -213,6 +254,11 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
// 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"))
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
return
@@ -225,6 +271,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
return
}
req.Title = added.Title
s.recordMediaRequest(r.Context(), sess, req, added.Year,
radarrCoverURL(added.Images, "poster"))
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
@@ -247,6 +295,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
}
if show.ID > 0 {
req.Title = show.Title
s.recordMediaRequest(r.Context(), sess, req, show.Year,
sonarrCoverURL(show.Images, "poster"))
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
return
@@ -259,6 +309,8 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
return
}
req.Title = added.Title
s.recordMediaRequest(r.Context(), sess, req, added.Year,
sonarrCoverURL(added.Images, "poster"))
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
@@ -272,6 +324,30 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
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 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,
})
if err != nil {
s.loggerFor(ctx).Warn("media request not recorded",
"type", req.MediaType, "foreign_id", req.ForeignID, "error", err)
}
}
func (s *Server) logMediaRequest(
ctx context.Context, req requestPayload, outcome string, err error,
) {