Files

162 lines
5.3 KiB
Go
Raw Permalink Normal View History

2026-08-19 14:25:44 +12:00
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
}