244 lines
8.0 KiB
Go
244 lines
8.0 KiB
Go
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.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
|
|
}
|
|
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.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
|
|
}
|
|
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)
|
|
}
|