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
+2
View File
@@ -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))
+7
View File
@@ -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)},
})
}
+47
View File
@@ -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
}
+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,
) {
+243
View File
@@ -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)
}
+170
View File
@@ -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"
}
}
+135
View File
@@ -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")
}
}
+13
View File
@@ -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
}
+46
View File
@@ -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.
+93
View File
@@ -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
}
+31
View File
@@ -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);