326 lines
12 KiB
Go
326 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
|
"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"`
|
|
// 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"`
|
|
// 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, requestProgressSupported(r)),
|
|
Allowed: true,
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// decorateRequests turns stored asks into cards by asking the two catalogues and the
|
|
// library what has become of each.
|
|
//
|
|
// 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 —
|
|
// 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. 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.
|
|
func (s *Server) decorateRequests(
|
|
ctx context.Context, stored []store.MediaRequest, progressAware bool,
|
|
) []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{}
|
|
// 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
|
|
)
|
|
now := time.Now()
|
|
|
|
if len(movieIDs) > 0 && s.radarrEnabled(ctx) {
|
|
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
|
|
}
|
|
tmdbByMovieID[movie.ID] = movie.TMDBID
|
|
movies[movie.TMDBID] = requestCatalogueEntry{
|
|
tracked: true,
|
|
hasFile: movie.HasFile,
|
|
released: movieReleased(movie.Status),
|
|
overview: movie.Overview,
|
|
poster: radarrCoverURL(movie.Images, "poster"),
|
|
}
|
|
}
|
|
}()
|
|
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
|
|
}()
|
|
}
|
|
if len(seriesIDs) > 0 && s.sonarrEnabled(ctx) {
|
|
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
|
|
}
|
|
tvdbBySeriesID[show.ID] = show.TVDBID
|
|
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()
|
|
queue, err := s.sonarrQueue(ctx)
|
|
if err != nil {
|
|
s.loggerFor(ctx).Warn("sonarr queue unavailable for requests", "error", err)
|
|
return
|
|
}
|
|
seriesQueue = queue
|
|
}()
|
|
}
|
|
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()
|
|
|
|
movieWork := groupMovieWork(movieQueue, tmdbByMovieID)
|
|
seriesWork := groupSeriesWork(seriesQueue, tvdbBySeriesID)
|
|
|
|
cards := make([]myRequest, 0, len(stored))
|
|
for _, req := range stored {
|
|
entry, inLibrary, itemID := movies[req.ForeignID], moviesInLibrary[req.ForeignID], movieItemIDs[req.ForeignID]
|
|
work := movieWork[req.ForeignID]
|
|
if req.MediaType == "series" {
|
|
entry, inLibrary, itemID = series[req.ForeignID], seriesInLibrary[req.ForeignID], seriesItemIDs[req.ForeignID]
|
|
work = seriesWork[req.ForeignID]
|
|
}
|
|
progress := downloadProgress(work)
|
|
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,
|
|
Progress: progress,
|
|
})
|
|
}
|
|
poster := req.PosterURL
|
|
if poster == "" {
|
|
poster = entry.poster
|
|
}
|
|
card := 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, progress.EstimatedReadySeconds),
|
|
EmbyItemID: itemID,
|
|
}
|
|
// 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)
|
|
}
|
|
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)
|
|
}
|