0.2.58 - Requests module
This commit is contained in:
@@ -144,7 +144,9 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
|
||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
||||
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
||||
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
|
||||
v1.Handle("POST /v1/requests", s.authed(s.handleRequest))
|
||||
v1.Handle("DELETE /v1/requests/{mediaType}/{foreignId}", s.authed(s.handleDeleteRequest))
|
||||
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
|
||||
v1.Handle("PUT /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
|
||||
v1.Handle("DELETE /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
|
||||
|
||||
@@ -142,5 +142,12 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
// season has to reach a set that is already switched on, without anybody doing
|
||||
// anything.
|
||||
"theme": themeStatus(s.themeFor(r.Context(), sess)),
|
||||
// Whether this viewer may ask the household for titles. Per person rather than per
|
||||
// household, so it cannot ride the feature map beside it: the allowlist is the
|
||||
// operator's decision about one account, and every television is polling this
|
||||
// anyway. It is what keeps the Requests entry out of the switcher for everybody
|
||||
// else — the handlers refuse regardless, but a menu item that only ever produces a
|
||||
// refusal is worse than no menu item.
|
||||
"requests": map[string]any{"allowed": s.requestAllowed(r, sess)},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -232,3 +232,50 @@ func hasRadarrCover(images []radarr.Image, coverType string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const radarrMovieCacheKey = "radarr:movies:v1"
|
||||
|
||||
// radarrMovieCatalogue is Radarr's whole movie list, cached the way sonarrSeriesCatalogue is
|
||||
// and for the same reason: the request page needs the state of every title one viewer has
|
||||
// ever asked for, and per-title lookups would be a round trip per card on a page somebody is
|
||||
// waiting in front of. Shared across the household, because Radarr's catalogue is.
|
||||
//
|
||||
// Failure degrades to asking Radarr directly — a cache that is down costs latency, never the
|
||||
// answer.
|
||||
func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, error) {
|
||||
if s.radarr == nil {
|
||||
return nil, fmt.Errorf("radarr: not configured")
|
||||
}
|
||||
if movies := s.cachedRadarrMovies(ctx); movies != nil {
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
s.radarrMu.Lock()
|
||||
defer s.radarrMu.Unlock()
|
||||
if movies := s.cachedRadarrMovies(ctx); movies != nil {
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
movies, err := s.radarr.Movies(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, marshalErr := json.Marshal(movies); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, radarrMovieCacheKey, body, s.cfg.RadarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("radarr movie cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (s *Server) cachedRadarrMovies(ctx context.Context) []radarr.Movie {
|
||||
raw, err := s.cache.Get(ctx, radarrMovieCacheKey)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var movies []radarr.Movie
|
||||
if json.Unmarshal(raw, &movies) != nil {
|
||||
return nil
|
||||
}
|
||||
return movies
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// myRequest is one card on the viewer's own page: what they asked for, and what has become
|
||||
// of it. The status half is derived per read and never stored — see the schema comment on
|
||||
// media_requests.
|
||||
type myRequest struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
ForeignID int `json:"foreignId"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"posterUrl,omitempty"`
|
||||
RequestedAt string `json:"requestedAt"`
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
// EmbyItemID is set once Emby has imported the title, so the card can open the ordinary
|
||||
// detail page instead of being a dead end at the moment it finally becomes watchable.
|
||||
EmbyItemID string `json:"embyItemId,omitempty"`
|
||||
}
|
||||
|
||||
type myRequestsResponse struct {
|
||||
Requests []myRequest `json:"requests"`
|
||||
// Allowed rides the response so a television that reached this page as permission was
|
||||
// withdrawn is told, rather than reading an empty list as "you have asked for nothing".
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
func (s *Server) handleMyRequests(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
|
||||
}
|
||||
stored, err := s.store.MediaRequests(r.Context(), sess.EmbyUserID)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("media request list failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read your requests")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, myRequestsResponse{
|
||||
Requests: s.decorateRequests(r.Context(), stored),
|
||||
Allowed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// decorateRequests turns stored asks into cards by asking the two catalogues and the
|
||||
// library what has become of each.
|
||||
//
|
||||
// The three lookups run concurrently and every one of them is allowed to fail: a request
|
||||
// whose state cannot be established falls back to "requested", which is the honest answer —
|
||||
// somebody asked and we cannot currently say more. A page that errored because Radarr was
|
||||
// restarting would be a page that is broken exactly when a viewer wants to know why their
|
||||
// title has not arrived.
|
||||
func (s *Server) decorateRequests(
|
||||
ctx context.Context, stored []store.MediaRequest,
|
||||
) []myRequest {
|
||||
if len(stored) == 0 {
|
||||
return []myRequest{}
|
||||
}
|
||||
movieIDs, seriesIDs := []int{}, []int{}
|
||||
for _, req := range stored {
|
||||
if req.MediaType == "series" {
|
||||
seriesIDs = append(seriesIDs, req.ForeignID)
|
||||
} else {
|
||||
movieIDs = append(movieIDs, req.ForeignID)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
movies = map[int]requestCatalogueEntry{}
|
||||
series = map[int]requestCatalogueEntry{}
|
||||
moviesInLibrary = map[int]bool{}
|
||||
seriesInLibrary = map[int]bool{}
|
||||
movieItemIDs = map[int]string{}
|
||||
seriesItemIDs = map[int]string{}
|
||||
)
|
||||
now := time.Now()
|
||||
|
||||
if len(movieIDs) > 0 && s.radarr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
catalogue, err := s.radarrMovieCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("radarr catalogue unavailable for requests", "error", err)
|
||||
return
|
||||
}
|
||||
for _, movie := range catalogue {
|
||||
if movie.TMDBID == 0 {
|
||||
continue
|
||||
}
|
||||
movies[movie.TMDBID] = requestCatalogueEntry{
|
||||
tracked: true,
|
||||
hasFile: movie.HasFile,
|
||||
released: movieReleased(movie.Status),
|
||||
overview: movie.Overview,
|
||||
poster: radarrCoverURL(movie.Images, "poster"),
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
if len(seriesIDs) > 0 && s.sonarr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
catalogue, err := s.sonarrSeriesCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("sonarr catalogue unavailable for requests", "error", err)
|
||||
return
|
||||
}
|
||||
for _, show := range catalogue {
|
||||
if show.TVDBID == 0 {
|
||||
continue
|
||||
}
|
||||
series[show.TVDBID] = requestCatalogueEntry{
|
||||
tracked: true,
|
||||
released: seriesReleased(show.Status, show.NextAiring, now),
|
||||
overview: show.Overview,
|
||||
poster: sonarrCoverURL(show.Images, "poster"),
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
moviesInLibrary, movieItemIDs, err = s.libraryPresence(ctx, "Tmdb", movieIDs)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("library presence unavailable for requests", "error", err)
|
||||
}
|
||||
seriesInLibrary, seriesItemIDs, err = s.libraryPresence(ctx, "Tvdb", seriesIDs)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("library presence unavailable for requests", "error", err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
cards := make([]myRequest, 0, len(stored))
|
||||
for _, req := range stored {
|
||||
entry, inLibrary, itemID := movies[req.ForeignID], moviesInLibrary[req.ForeignID], movieItemIDs[req.ForeignID]
|
||||
if req.MediaType == "series" {
|
||||
entry, inLibrary, itemID = series[req.ForeignID], seriesInLibrary[req.ForeignID], seriesItemIDs[req.ForeignID]
|
||||
}
|
||||
status := RequestStatusRequested
|
||||
// Only claim a state when something actually answered. With every catalogue down,
|
||||
// "requested" is all that is known and is what the card must say.
|
||||
if entry.tracked || inLibrary {
|
||||
status = requestStatusFor(RequestSubject{
|
||||
Tracked: entry.tracked,
|
||||
HasFile: entry.hasFile,
|
||||
InLibrary: inLibrary,
|
||||
Released: entry.released,
|
||||
})
|
||||
}
|
||||
poster := req.PosterURL
|
||||
if poster == "" {
|
||||
poster = entry.poster
|
||||
}
|
||||
cards = append(cards, myRequest{
|
||||
MediaType: req.MediaType,
|
||||
ForeignID: req.ForeignID,
|
||||
Title: req.Title,
|
||||
Year: req.Year,
|
||||
Overview: entry.overview,
|
||||
PosterURL: poster,
|
||||
RequestedAt: req.RequestedAt.UTC().Format(time.RFC3339),
|
||||
Status: status,
|
||||
StatusLabel: requestStatusLabel(status),
|
||||
StatusDetail: requestStatusDetail(status, req.MediaType),
|
||||
EmbyItemID: itemID,
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// requestCatalogueEntry is what one *arr knows about a title, in the shape the status rule
|
||||
// and the card between them need.
|
||||
type requestCatalogueEntry struct {
|
||||
tracked bool
|
||||
hasFile bool
|
||||
released bool
|
||||
overview string
|
||||
poster string
|
||||
}
|
||||
|
||||
// libraryPresence answers both "has Emby imported this" and "under which item id", so a
|
||||
// request that has become watchable can open its own detail page. The id is the reason this
|
||||
// is not simply LibraryContainsProviderIDs.
|
||||
func (s *Server) libraryPresence(
|
||||
ctx context.Context, provider string, ids []int,
|
||||
) (map[int]bool, map[int]string, error) {
|
||||
present, itemIDs := map[int]bool{}, map[int]string{}
|
||||
if len(ids) == 0 {
|
||||
return present, itemIDs, nil
|
||||
}
|
||||
found, err := s.store.LibraryProviderItemIDs(ctx, provider, ids)
|
||||
if err != nil {
|
||||
return present, itemIDs, err
|
||||
}
|
||||
for id, itemID := range found {
|
||||
present[id] = true
|
||||
itemIDs[id] = itemID
|
||||
}
|
||||
return present, itemIDs, nil
|
||||
}
|
||||
|
||||
// handleDeleteRequest removes a title from the viewer's own page.
|
||||
//
|
||||
// It deliberately leaves Radarr and Sonarr alone: the household may well be part-way
|
||||
// through downloading it, and somebody else may have asked for the same thing. This says
|
||||
// "stop listing this among mine", which is the only claim one viewer's page can make.
|
||||
func (s *Server) handleDeleteRequest(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
|
||||
}
|
||||
mediaType := strings.TrimSpace(r.PathValue("mediaType"))
|
||||
foreignID, err := strconv.Atoi(strings.TrimSpace(r.PathValue("foreignId")))
|
||||
if err != nil || foreignID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "a media type and foreign id are required")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteMediaRequest(r.Context(), sess.EmbyUserID, mediaType, foreignID); err != nil {
|
||||
s.loggerFor(r.Context()).Error("media request delete failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not remove that request")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("media request removed", "type", mediaType, "foreign_id", foreignID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What a request is doing, as a slug the television renders.
|
||||
//
|
||||
// These are the states Radarr and Sonarr can actually answer for. Neither has an approval
|
||||
// workflow — an added movie is simply added — so there is deliberately no "approved" or
|
||||
// "declined" here: inventing one would put a word on a card that nothing behind it can ever
|
||||
// change. The client renders an unknown slug in its neutral treatment (see requestStatusTone
|
||||
// on the television), so if a household ever puts an approval layer in front of the *arrs,
|
||||
// those states can be added here and reach existing builds without an app release.
|
||||
const (
|
||||
// The household has it. Either Emby has imported it or the *arr reports a file.
|
||||
RequestStatusAvailable = "available"
|
||||
// Accepted and being worked on: released, monitored, no file yet.
|
||||
RequestStatusProcessing = "processing"
|
||||
// Accepted, but there is nothing to fetch yet — unreleased, or still only in cinemas.
|
||||
RequestStatusPending = "pending"
|
||||
// Recorded by Memby, but the *arr could not be asked. The honest fallback: we know
|
||||
// somebody asked and nothing more.
|
||||
RequestStatusRequested = "requested"
|
||||
// Recorded by Memby and the *arr no longer has it, so somebody removed it downstream.
|
||||
RequestStatusUnavailable = "unavailable"
|
||||
// Search only: nothing has it and nobody has asked, so the button does something.
|
||||
RequestStatusRequestable = "requestable"
|
||||
)
|
||||
|
||||
// lookupStatusFor answers what a search result is, which is a different question from what
|
||||
// a stored request is doing: a candidate nothing tracks is the ordinary case here — it is
|
||||
// the whole point of searching — where in a request list it would mean somebody had removed
|
||||
// it. So the two rules are separate rather than one rule with a flag, and only this one can
|
||||
// return "requestable".
|
||||
//
|
||||
// Being this viewer's own ask outranks the household merely having added it: "Requested"
|
||||
// tells them they already did this, which is the thing they most need to know before
|
||||
// pressing a button a second time. It does not outrank availability — a title that is in
|
||||
// the library is watchable now, and that is better news.
|
||||
func lookupStatusFor(subject RequestSubject, mine bool) string {
|
||||
switch {
|
||||
case subject.InLibrary || subject.HasFile:
|
||||
return RequestStatusAvailable
|
||||
case mine:
|
||||
return RequestStatusRequested
|
||||
case !subject.Tracked:
|
||||
return RequestStatusRequestable
|
||||
case !subject.Released:
|
||||
return RequestStatusPending
|
||||
default:
|
||||
return RequestStatusProcessing
|
||||
}
|
||||
}
|
||||
|
||||
// RequestSubject is what the *arr and the library between them know about one title, in
|
||||
// the narrow shape requestStatusFor needs. Keeping it free of Radarr and Sonarr types is
|
||||
// what lets one rule answer for both catalogues and be tested without either.
|
||||
type RequestSubject struct {
|
||||
// Tracked is whether the *arr still holds the title at all.
|
||||
Tracked bool
|
||||
// HasFile is the *arr's own answer about media on disk. Sonarr's series list does not
|
||||
// report this, so a series leaves it false and leans on InLibrary.
|
||||
HasFile bool
|
||||
// InLibrary is whether Emby has imported it, matched on the catalogue's provider id.
|
||||
InLibrary bool
|
||||
// Released is whether there is anything to fetch yet. A film not yet on digital and a
|
||||
// series whose first episode has not aired are both false.
|
||||
Released bool
|
||||
}
|
||||
|
||||
// requestStatusFor is the whole rule, and it is ordered by how much each signal is worth.
|
||||
//
|
||||
// Availability wins over everything: a title the household can watch is available whether
|
||||
// or not the *arr still tracks it, whether or not it was ever released on the date anybody
|
||||
// recorded. Only then does absence from the *arr mean removal — checked before the release
|
||||
// state, because an untracked title's release date says nothing about a request nobody is
|
||||
// working on any more.
|
||||
func requestStatusFor(subject RequestSubject) string {
|
||||
switch {
|
||||
case subject.InLibrary || subject.HasFile:
|
||||
return RequestStatusAvailable
|
||||
case !subject.Tracked:
|
||||
return RequestStatusUnavailable
|
||||
case !subject.Released:
|
||||
return RequestStatusPending
|
||||
default:
|
||||
return RequestStatusProcessing
|
||||
}
|
||||
}
|
||||
|
||||
// movieReleased reads Radarr's own status word rather than comparing dates.
|
||||
//
|
||||
// Radarr already decides this, applying the household's minimum-availability setting, and
|
||||
// a date comparison here would disagree with it for exactly the titles that are marginal —
|
||||
// which are the ones somebody is watching their request page for. "announced" and
|
||||
// "incinemas" are the two that mean there is nothing to fetch; anything else, including a
|
||||
// word this build has never seen, is treated as released so a new Radarr vocabulary
|
||||
// degrades to "processing" rather than parking a request on "pending" for ever.
|
||||
func movieReleased(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "announced", "incinemas":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// seriesReleased asks whether any of the show exists yet.
|
||||
//
|
||||
// Sonarr's series list carries no per-episode file information, so the question it can
|
||||
// answer is narrower than Radarr's: a show whose only airing is in the future has nothing
|
||||
// to fetch. "upcoming" is Sonarr's own word for that; a next-airing date in the future with
|
||||
// no library presence is the same thing said with a timestamp, which is what a show added
|
||||
// before its premiere looks like.
|
||||
func seriesReleased(status string, nextAiring *time.Time, now time.Time) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(status), "upcoming") {
|
||||
return false
|
||||
}
|
||||
if nextAiring != nil && nextAiring.After(now) &&
|
||||
strings.EqualFold(strings.TrimSpace(status), "") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requestStatusLabel is the wording the card shows, sent from here rather than derived on
|
||||
// the television — the MembyAirLabel precedent. A build that predates a state renders the
|
||||
// label it was handed instead of falling back to a slug.
|
||||
func requestStatusLabel(status string) string {
|
||||
switch status {
|
||||
case RequestStatusAvailable:
|
||||
return "Available"
|
||||
case RequestStatusProcessing:
|
||||
return "Processing"
|
||||
case RequestStatusPending:
|
||||
return "Pending"
|
||||
case RequestStatusUnavailable:
|
||||
return "Unavailable"
|
||||
case RequestStatusRequestable:
|
||||
return "Request"
|
||||
default:
|
||||
return "Requested"
|
||||
}
|
||||
}
|
||||
|
||||
// requestStatusDetail is the quiet second line: what the state means for the viewer, in
|
||||
// plain language, rather than a repeat of the word above it.
|
||||
func requestStatusDetail(status, mediaType string) string {
|
||||
thing := "film"
|
||||
if mediaType == "series" {
|
||||
thing = "series"
|
||||
}
|
||||
switch status {
|
||||
case RequestStatusAvailable:
|
||||
return "Ready to watch now"
|
||||
case RequestStatusProcessing:
|
||||
return "Searching for a copy"
|
||||
case RequestStatusPending:
|
||||
if thing == "series" {
|
||||
return "Waiting for it to air"
|
||||
}
|
||||
return "Waiting for release"
|
||||
case RequestStatusUnavailable:
|
||||
return "No longer being tracked"
|
||||
default:
|
||||
return "Waiting on the " + thing + " service"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestStatusPrefersAvailabilityOverEverything(t *testing.T) {
|
||||
// A title the household can watch is available whether or not the *arr still tracks it
|
||||
// and whether or not anything thinks it has been released. Being watchable is the
|
||||
// strongest fact there is about a request, so nothing below may override it.
|
||||
cases := []struct {
|
||||
name string
|
||||
subject RequestSubject
|
||||
}{
|
||||
{"in library, untracked, unreleased", RequestSubject{InLibrary: true}},
|
||||
{"has file but not imported", RequestSubject{Tracked: true, HasFile: true}},
|
||||
{"in library and still being worked on", RequestSubject{
|
||||
Tracked: true, InLibrary: true, Released: true,
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := requestStatusFor(tc.subject); got != RequestStatusAvailable {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusAvailable, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestStatusReportsRemovalBeforeReleaseState(t *testing.T) {
|
||||
// An untracked title's release date says nothing: nobody is working on it either way,
|
||||
// so "unavailable" must win over "pending". Getting this the wrong way round would park
|
||||
// a removed request on "waiting for release" for ever.
|
||||
got := requestStatusFor(RequestSubject{Tracked: false, Released: false})
|
||||
if got != RequestStatusUnavailable {
|
||||
t.Fatalf("expected %q for an untracked title, got %q", RequestStatusUnavailable, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestStatusSeparatesPendingFromProcessing(t *testing.T) {
|
||||
pending := requestStatusFor(RequestSubject{Tracked: true, Released: false})
|
||||
if pending != RequestStatusPending {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusPending, pending)
|
||||
}
|
||||
processing := requestStatusFor(RequestSubject{Tracked: true, Released: true})
|
||||
if processing != RequestStatusProcessing {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusProcessing, processing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupStatusOffersRequestableOnlyWhenNothingHasIt(t *testing.T) {
|
||||
got := lookupStatusFor(RequestSubject{}, false)
|
||||
if got != RequestStatusRequestable {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusRequestable, got)
|
||||
}
|
||||
// The request list rule must never produce it: there, an untracked title is one somebody
|
||||
// removed rather than one nobody has asked for.
|
||||
if requestStatusFor(RequestSubject{}) == RequestStatusRequestable {
|
||||
t.Fatal("requestStatusFor must not return requestable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupStatusPutsOwnRequestAboveHouseholdButBelowAvailability(t *testing.T) {
|
||||
// Somebody needs to be told they already asked, before being told the household happens
|
||||
// to track it — that is what stops them pressing the button again.
|
||||
mine := lookupStatusFor(RequestSubject{Tracked: true, Released: true}, true)
|
||||
if mine != RequestStatusRequested {
|
||||
t.Fatalf("expected %q for the viewer's own ask, got %q", RequestStatusRequested, mine)
|
||||
}
|
||||
// But being watchable now is better news than having asked.
|
||||
available := lookupStatusFor(RequestSubject{InLibrary: true}, true)
|
||||
if available != RequestStatusAvailable {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusAvailable, available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovieReleasedTreatsUnknownStatusAsReleased(t *testing.T) {
|
||||
// A Radarr vocabulary this build has never seen must degrade to "processing", not park
|
||||
// the request on "pending" for ever.
|
||||
for _, status := range []string{"released", "deleted", "somethingNew", ""} {
|
||||
if !movieReleased(status) {
|
||||
t.Fatalf("expected %q to count as released", status)
|
||||
}
|
||||
}
|
||||
for _, status := range []string{"announced", "inCinemas", "INCINEMAS", " announced "} {
|
||||
if movieReleased(status) {
|
||||
t.Fatalf("expected %q to count as unreleased", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesReleasedReadsUpcomingAndFutureAirings(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
|
||||
later := now.Add(48 * time.Hour)
|
||||
earlier := now.Add(-48 * time.Hour)
|
||||
|
||||
if seriesReleased("upcoming", nil, now) {
|
||||
t.Fatal("an upcoming series has nothing to fetch yet")
|
||||
}
|
||||
if seriesReleased("", &later, now) {
|
||||
t.Fatal("a series whose only airing is in the future has nothing to fetch yet")
|
||||
}
|
||||
if !seriesReleased("continuing", &later, now) {
|
||||
t.Fatal("a continuing series with a future episode is still fetchable now")
|
||||
}
|
||||
if !seriesReleased("", &earlier, now) {
|
||||
t.Fatal("a series that has already aired is fetchable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestStatusLabelAndDetailCoverEveryState(t *testing.T) {
|
||||
// Every state must carry its own wording: the television renders what it is handed, so
|
||||
// a state that fell through to the default would show "Requested" on a card that is
|
||||
// actually available.
|
||||
states := []string{
|
||||
RequestStatusAvailable, RequestStatusProcessing, RequestStatusPending,
|
||||
RequestStatusUnavailable, RequestStatusRequestable, RequestStatusRequested,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, state := range states {
|
||||
label := requestStatusLabel(state)
|
||||
if label == "" {
|
||||
t.Fatalf("state %q has no label", state)
|
||||
}
|
||||
if seen[label] {
|
||||
t.Fatalf("state %q reuses the label %q", state, label)
|
||||
}
|
||||
seen[label] = true
|
||||
}
|
||||
if requestStatusDetail(RequestStatusPending, "series") ==
|
||||
requestStatusDetail(RequestStatusPending, "movie") {
|
||||
t.Fatal("a pending series and a pending film are waiting for different things")
|
||||
}
|
||||
}
|
||||
@@ -221,3 +221,16 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Movies returns Radarr's whole catalogue.
|
||||
//
|
||||
// The request page needs the current state of every title a viewer has ever asked for, and
|
||||
// asking Radarr per title would be one round trip per card. One catalogue read answers them
|
||||
// all; the caller caches it.
|
||||
func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
|
||||
var movies []Movie
|
||||
if err := c.get(ctx, "/api/v3/movie", &movies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
@@ -180,6 +180,52 @@ func (s *Store) LibraryContainsProviderIDs(
|
||||
return found, rows.Err()
|
||||
}
|
||||
|
||||
// LibraryProviderItemIDs is LibraryContainsProviderIDs with the answer the request page
|
||||
// needs: not only whether Emby has the title, but which item it is.
|
||||
//
|
||||
// That id is what lets a request that has finally downloaded stop being a status card and
|
||||
// become something a viewer can press. Ordering by item_id keeps the answer stable when a
|
||||
// household holds the same title twice — a duplicate import, or a remake sharing an id in
|
||||
// bad metadata — so a card does not point at a different copy between two reads.
|
||||
func (s *Store) LibraryProviderItemIDs(
|
||||
ctx context.Context, provider string, ids []int,
|
||||
) (map[int]string, error) {
|
||||
found := map[int]string{}
|
||||
if len(ids) == 0 {
|
||||
return found, nil
|
||||
}
|
||||
values := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
values = append(values, fmt.Sprint(id))
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return found, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON ((payload->'ProviderIds'->>$1)::int)
|
||||
(payload->'ProviderIds'->>$1)::int, id
|
||||
FROM library_items
|
||||
WHERE payload->'ProviderIds'->>$1 = ANY($2::text[])
|
||||
ORDER BY (payload->'ProviderIds'->>$1)::int, id`, provider, values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: library provider item ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int
|
||||
itemID string
|
||||
)
|
||||
if err := rows.Scan(&id, &itemID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found[id] = itemID
|
||||
}
|
||||
return found, rows.Err()
|
||||
}
|
||||
|
||||
// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the
|
||||
// recommendation engine. User state is applied by the caller, which is the only place
|
||||
// that knows it.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MediaRequest is one viewer's ask, as recorded. It carries no status: see the schema
|
||||
// comment on media_requests for why the state is derived per read rather than stored.
|
||||
type MediaRequest struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
ForeignID int `json:"foreignId"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterURL string `json:"posterUrl,omitempty"`
|
||||
RequestedAt time.Time `json:"requestedAt"`
|
||||
}
|
||||
|
||||
// MediaRequestLimit caps what one viewer's page will read back. A household that has been
|
||||
// asking for things for two years should not turn the page into an unbounded query, and
|
||||
// nobody scrolls past the most recent hundred by remote.
|
||||
const MediaRequestLimit = 100
|
||||
|
||||
// SaveMediaRequest records an ask, or refreshes one already held.
|
||||
//
|
||||
// The repeat is deliberately an update rather than a no-op: asking again is how somebody
|
||||
// says they still want it, and the page is ordered by when they asked.
|
||||
func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRequest) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" || req.ForeignID <= 0 {
|
||||
return fmt.Errorf("store: media request needs a user and a foreign id")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO media_requests
|
||||
(emby_user_id, media_type, foreign_id, title, year, poster_url, requested_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
ON CONFLICT (emby_user_id, media_type, foreign_id) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
year = EXCLUDED.year,
|
||||
poster_url = EXCLUDED.poster_url,
|
||||
requested_at = now()`,
|
||||
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: save media request: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MediaRequests returns one viewer's asks, most recent first.
|
||||
func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaRequest, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT media_type, foreign_id, title, year, poster_url, requested_at
|
||||
FROM media_requests
|
||||
WHERE emby_user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2`, strings.TrimSpace(userID), MediaRequestLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: read media requests: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
requests := []MediaRequest{}
|
||||
for rows.Next() {
|
||||
var req MediaRequest
|
||||
if err := rows.Scan(
|
||||
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL, &req.RequestedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan media request: %w", err)
|
||||
}
|
||||
requests = append(requests, req)
|
||||
}
|
||||
return requests, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteMediaRequest removes a viewer's ask from their own page.
|
||||
//
|
||||
// It deliberately does not touch Radarr or Sonarr. The title may well have been downloaded
|
||||
// by now, and other people may have asked for it too — this only says the viewer no longer
|
||||
// wants it listed among theirs.
|
||||
func (s *Store) DeleteMediaRequest(
|
||||
ctx context.Context, userID, mediaType string, foreignID int,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM media_requests
|
||||
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3`,
|
||||
strings.TrimSpace(userID), mediaType, foreignID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete media request: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -508,3 +508,34 @@ CREATE TABLE IF NOT EXISTS downloaded_subtitles (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS downloaded_subtitles_item_idx ON downloaded_subtitles (item_id);
|
||||
|
||||
-- What a viewer has asked the household to get hold of.
|
||||
--
|
||||
-- Radarr and Sonarr are the things that actually fetch a title, and neither keeps any idea
|
||||
-- of *who* wanted it: an added movie is an added movie. So this table is the only record of
|
||||
-- authorship, and it is what makes "My requests" a per-person page rather than a list of
|
||||
-- everything the household has ever added.
|
||||
--
|
||||
-- It deliberately stores no status. A request's state — waiting for a release, searching,
|
||||
-- downloaded, in the library — is Radarr's and Sonarr's to answer and changes without
|
||||
-- anybody touching Memby, so a status column here would be a second copy that is wrong
|
||||
-- within the hour. What is stored is the identity (which title, from which catalogue) plus
|
||||
-- enough metadata to draw the card before the *arr lookup returns; the state is derived per
|
||||
-- request by requestStatusFor.
|
||||
--
|
||||
-- The primary key is (viewer, catalogue, id) rather than a serial, so asking twice for the
|
||||
-- same film is the same request rather than two rows a viewer has to tell apart. The repeat
|
||||
-- refreshes requested_at, because the second ask is the one they remember making.
|
||||
CREATE TABLE IF NOT EXISTS media_requests (
|
||||
emby_user_id TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
foreign_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
year INTEGER NOT NULL DEFAULT 0,
|
||||
poster_url TEXT NOT NULL DEFAULT '',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (emby_user_id, media_type, foreign_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
|
||||
ON media_requests (emby_user_id, requested_at DESC);
|
||||
|
||||
Reference in New Issue
Block a user