Files
memby/server/internal/api/requests_list.go
T

326 lines
12 KiB
Go
Raw Normal View History

2026-08-12 14:13:19 +12:00
package api
import (
"context"
"net/http"
2026-08-19 14:25:44 +12:00
"slices"
2026-08-12 14:13:19 +12:00
"strconv"
"strings"
"sync"
"time"
2026-08-19 14:25:44 +12:00
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr"
2026-08-12 14:13:19 +12:00
"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"`
2026-08-19 14:25:44 +12:00
// Progress is whole percent and rides only a downloading card. It is omitted rather
// than sent as zero everywhere else, so the television draws a bar when it is given a
// figure and nothing at all when it is not — there is no "0%" state to confuse with a
// download that has not started.
Progress int `json:"progress,omitempty"`
// EstimatedReadySeconds is how long the download client says the bytes will take, and is
// omitted whenever nothing could say. That omission is the whole of "never fabricate an
// ETA": a card with no number here prints the plain wording in StatusDetail instead.
EstimatedReadySeconds int `json:"estimatedReadySeconds,omitempty"`
2026-08-12 14:13:19 +12:00
// 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{
2026-08-19 14:25:44 +12:00
Requests: s.decorateRequests(r.Context(), stored, requestProgressSupported(r)),
2026-08-12 14:13:19 +12:00
Allowed: true,
})
}
2026-08-19 14:25:44 +12:00
// requestProgressSupported asks whether this television can draw the download states.
//
// A capability rather than a version floor, because that is what the client already declares
// and what the console reports against — and because the answer is about what the app can
// *draw*, which is exactly what a capability token says. An older build is sent the collapsed
// vocabulary it understands (collapseRequestStatus) rather than four words it would file
// under "Nothing happening" and a percentage it has nowhere to put.
func requestProgressSupported(r *http.Request) bool {
return slices.Contains(clientCapabilities(r), "request_progress_v1")
}
2026-08-12 14:13:19 +12:00
// decorateRequests turns stored asks into cards by asking the two catalogues and the
// library what has become of each.
//
2026-08-19 14:25:44 +12:00
// The 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 —
2026-08-12 14:13:19 +12:00
// 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
2026-08-19 14:25:44 +12:00
// title has not arrived. The download queues degrade one step further and more gently
// still: losing them costs the percentage and the estimate, and the card falls back to
// "Searching", which is what the page said before there was a queue reader at all.
//
// progressAware is what this television can draw; see requestProgressSupported.
2026-08-12 14:13:19 +12:00
func (s *Server) decorateRequests(
2026-08-19 14:25:44 +12:00
ctx context.Context, stored []store.MediaRequest, progressAware bool,
2026-08-12 14:13:19 +12:00
) []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{}
2026-08-19 14:25:44 +12:00
// The two halves of the queue join, gathered separately and put together after the
// wait: a queue row names the *arr's own internal id, and only the catalogue can say
// which TMDb or TVDb id that is. Fetching them concurrently and joining afterwards is
// what keeps the queue read off the catalogue's critical path.
tmdbByMovieID = map[int]int{}
tvdbBySeriesID = map[int]int{}
movieQueue []radarr.QueueItem
seriesQueue []sonarr.QueueItem
2026-08-12 14:13:19 +12:00
)
now := time.Now()
2026-08-14 11:47:32 +12:00
if len(movieIDs) > 0 && s.radarrEnabled(ctx) {
2026-08-12 14:13:19 +12:00
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
}
2026-08-19 14:25:44 +12:00
tmdbByMovieID[movie.ID] = movie.TMDBID
2026-08-12 14:13:19 +12:00
movies[movie.TMDBID] = requestCatalogueEntry{
tracked: true,
hasFile: movie.HasFile,
released: movieReleased(movie.Status),
overview: movie.Overview,
poster: radarrCoverURL(movie.Images, "poster"),
}
}
}()
2026-08-19 14:25:44 +12:00
wg.Add(1)
go func() {
defer wg.Done()
queue, err := s.radarrQueue(ctx)
if err != nil {
s.loggerFor(ctx).Warn("radarr queue unavailable for requests", "error", err)
return
}
movieQueue = queue
}()
2026-08-12 14:13:19 +12:00
}
2026-08-14 11:47:32 +12:00
if len(seriesIDs) > 0 && s.sonarrEnabled(ctx) {
2026-08-12 14:13:19 +12:00
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
}
2026-08-19 14:25:44 +12:00
tvdbBySeriesID[show.ID] = show.TVDBID
2026-08-12 14:13:19 +12:00
series[show.TVDBID] = requestCatalogueEntry{
tracked: true,
released: seriesReleased(show.Status, show.NextAiring, now),
overview: show.Overview,
poster: sonarrCoverURL(show.Images, "poster"),
}
}
}()
2026-08-19 14:25:44 +12:00
wg.Add(1)
go func() {
defer wg.Done()
queue, err := s.sonarrQueue(ctx)
if err != nil {
s.loggerFor(ctx).Warn("sonarr queue unavailable for requests", "error", err)
return
}
seriesQueue = queue
}()
2026-08-12 14:13:19 +12:00
}
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()
2026-08-19 14:25:44 +12:00
movieWork := groupMovieWork(movieQueue, tmdbByMovieID)
seriesWork := groupSeriesWork(seriesQueue, tvdbBySeriesID)
2026-08-12 14:13:19 +12:00
cards := make([]myRequest, 0, len(stored))
for _, req := range stored {
entry, inLibrary, itemID := movies[req.ForeignID], moviesInLibrary[req.ForeignID], movieItemIDs[req.ForeignID]
2026-08-19 14:25:44 +12:00
work := movieWork[req.ForeignID]
2026-08-12 14:13:19 +12:00
if req.MediaType == "series" {
entry, inLibrary, itemID = series[req.ForeignID], seriesInLibrary[req.ForeignID], seriesItemIDs[req.ForeignID]
2026-08-19 14:25:44 +12:00
work = seriesWork[req.ForeignID]
2026-08-12 14:13:19 +12:00
}
2026-08-19 14:25:44 +12:00
progress := downloadProgress(work)
2026-08-12 14:13:19 +12:00
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,
2026-08-19 14:25:44 +12:00
Progress: progress,
2026-08-12 14:13:19 +12:00
})
}
poster := req.PosterURL
if poster == "" {
poster = entry.poster
}
2026-08-19 14:25:44 +12:00
card := myRequest{
2026-08-12 14:13:19 +12:00
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),
2026-08-19 14:25:44 +12:00
StatusDetail: requestStatusDetail(status, req.MediaType, progress.EstimatedReadySeconds),
2026-08-12 14:13:19 +12:00
EmbyItemID: itemID,
2026-08-19 14:25:44 +12:00
}
// The figures belong to a download and to nothing else. A title that is available,
// pending or unavailable may still have a row in the queue — an upgrade, a stale
// entry — and pinning a percentage to it would put a progress bar under a card
// somebody can already press Play on.
if status == RequestStatusDownloading {
card.Progress = progress.Progress
card.EstimatedReadySeconds = progress.EstimatedReadySeconds
}
if !progressAware {
// Narrowed on the way out rather than on the way in, so the label and the
// detail are still computed from the truth and only the *slug* is generalised.
// That is the better half of the trade: an older television files the card under
// "On the way" as it always did, and still reads "Downloading" on the chip and
// the honest sentence underneath it. Only the bar and the number go.
card.Status = collapseRequestStatus(card.Status)
card.Progress, card.EstimatedReadySeconds = 0, 0
}
cards = append(cards, card)
2026-08-12 14:13:19 +12:00
}
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)
}