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 }