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) }