162 lines
5.3 KiB
Go
162 lines
5.3 KiB
Go
package api
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Reading the download queues, which is the one part of the request page that cannot be
|
||
|
|
// cached for long and is read by every television in the house at once.
|
||
|
|
//
|
||
|
|
// The two *arr catalogues beside this are cached for the day: what Radarr holds and whether
|
||
|
|
// it has a file changes a handful of times a day, and a viewer finding out at midnight costs
|
||
|
|
// nothing. A queue is the opposite — it is the number moving on the card — so it carries its
|
||
|
|
// own short cache instead. requestQueueTTL is what stops four televisions on the launcher
|
||
|
|
// turning a page refresh into four requests apiece: within the window they share one answer,
|
||
|
|
// and past it the figure is stale by at most that window, which at this cadence is invisible
|
||
|
|
// against a download measured in minutes.
|
||
|
|
const (
|
||
|
|
radarrQueueCacheKey = "radarr:queue:v1"
|
||
|
|
sonarrQueueCacheKey = "sonarr:queue:v1"
|
||
|
|
requestQueueTTL = 15 * time.Second
|
||
|
|
)
|
||
|
|
|
||
|
|
// radarrQueue is what Radarr's download client is working on.
|
||
|
|
//
|
||
|
|
// Single-flighted behind the same mutex the catalogue uses, so a burst of polls costs one
|
||
|
|
// upstream read. A miss is not an error the page can be failed over: see decorateRequests,
|
||
|
|
// where a queue that will not answer costs the percentage and never the card.
|
||
|
|
func (s *Server) radarrQueue(ctx context.Context) ([]radarr.QueueItem, error) {
|
||
|
|
if !s.radarrEnabled(ctx) {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if queue, ok := cachedQueue[radarr.QueueItem](ctx, s, radarrQueueCacheKey); ok {
|
||
|
|
return queue, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
s.radarrMu.Lock()
|
||
|
|
defer s.radarrMu.Unlock()
|
||
|
|
if queue, ok := cachedQueue[radarr.QueueItem](ctx, s, radarrQueueCacheKey); ok {
|
||
|
|
return queue, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
queue, err := s.radarr.Queue(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
s.cacheQueue(ctx, radarrQueueCacheKey, queue)
|
||
|
|
return queue, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// sonarrQueue is what Sonarr's download client is working on.
|
||
|
|
func (s *Server) sonarrQueue(ctx context.Context) ([]sonarr.QueueItem, error) {
|
||
|
|
if !s.sonarrEnabled(ctx) {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if queue, ok := cachedQueue[sonarr.QueueItem](ctx, s, sonarrQueueCacheKey); ok {
|
||
|
|
return queue, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
s.sonarrSeriesMu.Lock()
|
||
|
|
defer s.sonarrSeriesMu.Unlock()
|
||
|
|
if queue, ok := cachedQueue[sonarr.QueueItem](ctx, s, sonarrQueueCacheKey); ok {
|
||
|
|
return queue, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
queue, err := s.sonarr.Queue(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
s.cacheQueue(ctx, sonarrQueueCacheKey, queue)
|
||
|
|
return queue, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// cachedQueue reads a stored queue. An empty queue is a real and common answer — most of the
|
||
|
|
// time the household is downloading nothing — so the second return distinguishes "nothing is
|
||
|
|
// stored" from "nothing is downloading", which a nil slice could not.
|
||
|
|
func cachedQueue[T any](ctx context.Context, s *Server, key string) ([]T, bool) {
|
||
|
|
if s.cache == nil {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
raw, err := s.cache.Get(ctx, key)
|
||
|
|
if err != nil {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
var queue []T
|
||
|
|
if err := json.Unmarshal(raw, &queue); err != nil {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
if queue == nil {
|
||
|
|
queue = []T{}
|
||
|
|
}
|
||
|
|
return queue, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) cacheQueue(ctx context.Context, key string, queue any) {
|
||
|
|
if s.cache == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
body, err := json.Marshal(queue)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := s.cache.Set(ctx, key, body, requestQueueTTL); err != nil {
|
||
|
|
s.loggerFor(ctx).Warn("download queue cache write failed", "key", key, "error", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// groupMovieWork turns Radarr's queue into work per *TMDb* id.
|
||
|
|
//
|
||
|
|
// The translation is the point. A queue row names Radarr's own movie id, which is an
|
||
|
|
// internal number nothing outside Radarr has ever seen; a request is recorded against the
|
||
|
|
// TMDb id, which is what the catalogue, the library and Emby all agree on. The map comes
|
||
|
|
// from the catalogue the caller already has, so this costs no extra request — and a row
|
||
|
|
// whose film is not in that map is dropped rather than guessed at, which is what a download
|
||
|
|
// for something added since the catalogue was cached looks like.
|
||
|
|
func groupMovieWork(queue []radarr.QueueItem, tmdbByMovieID map[int]int) map[int][]requestWork {
|
||
|
|
work := map[int][]requestWork{}
|
||
|
|
for _, row := range queue {
|
||
|
|
tmdbID, ok := tmdbByMovieID[row.MovieID]
|
||
|
|
if !ok || tmdbID == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
work[tmdbID] = append(work[tmdbID], requestWork{
|
||
|
|
Size: row.Size,
|
||
|
|
SizeLeft: row.Sizeleft,
|
||
|
|
TimeLeft: row.Timeleft,
|
||
|
|
Status: row.Status,
|
||
|
|
TrackedState: row.TrackedDownloadState,
|
||
|
|
TrackedStatus: row.TrackedDownloadStatus,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return work
|
||
|
|
}
|
||
|
|
|
||
|
|
// groupSeriesWork turns Sonarr's queue into work per TVDb id.
|
||
|
|
//
|
||
|
|
// A show legitimately has many rows — a season pack is one row per episode — and they are
|
||
|
|
// kept as a list rather than reduced here, because downloadProgress is what decides how a
|
||
|
|
// dozen episodes at a dozen stages become one sentence.
|
||
|
|
func groupSeriesWork(queue []sonarr.QueueItem, tvdbBySeriesID map[int]int) map[int][]requestWork {
|
||
|
|
work := map[int][]requestWork{}
|
||
|
|
for _, row := range queue {
|
||
|
|
tvdbID, ok := tvdbBySeriesID[row.SeriesID]
|
||
|
|
if !ok || tvdbID == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
work[tvdbID] = append(work[tvdbID], requestWork{
|
||
|
|
Size: row.Size,
|
||
|
|
SizeLeft: row.Sizeleft,
|
||
|
|
TimeLeft: row.Timeleft,
|
||
|
|
Status: row.Status,
|
||
|
|
TrackedState: row.TrackedDownloadState,
|
||
|
|
TrackedStatus: row.TrackedDownloadStatus,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return work
|
||
|
|
}
|