0.2.58 - Requests module
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user