Files
memby/server/internal/api/requests_progress.go
2026-08-19 14:25:44 +12:00

263 lines
11 KiB
Go

package api
import (
"math"
"strconv"
"strings"
"time"
)
// What the download client is doing with something somebody asked for, normalised into the
// few words a viewer can act on.
//
// The whole point of this file is that the television is never told about indexers, release
// profiles, trackers or import queues. Radarr and Sonarr describe one download in three
// overlapping vocabularies — see radarr.QueueItem — and between them they can produce
// something like twenty distinct states. A viewer standing in front of a card needs to know
// one of five things: nothing has been found yet, something has been found, it is coming
// down and how far through it is, it is being filed away, or it went wrong.
//
// Everything here is pure, because it is the half of the feature that has to be right and
// the half that is cheapest to be wrong about: an ETA is a promise, and a promise made from
// a misread field is worse than no promise at all.
// requestWork is one row of a download queue with the *arr it came from forgotten.
//
// Keeping it free of radarr and sonarr types is what lets one rule answer for both — a film
// and a season of a show are the same question about bytes — and lets the rule be tested
// without either service.
type requestWork struct {
// Size and SizeLeft are bytes. Both zero means the client has not said, which is
// different from a finished download and must not read as 100%.
Size float64
SizeLeft float64
// TimeLeft is the download client's own estimate as a .NET TimeSpan ("00:14:32",
// "1.02:03:04"), or empty when it will not say — which is the ordinary case for a
// queued or stalled item and exactly where an ETA must not be manufactured.
TimeLeft string
// Status is the download client's word, TrackedState is what the *arr will do with the
// bytes once they land, and TrackedStatus is the verdict over both.
Status string
TrackedState string
TrackedStatus string
}
// requestProgress is the normalised answer, and it is deliberately the shape of the wire.
//
// Progress and EstimatedReadySeconds are both zero when unknown, which is why they are
// omitempty on the response types that embed this: a card draws a figure it was given and
// says nothing at all when it was given none. Zero percent and "we have no idea" therefore
// look the same to the television, which is the correct conflation — neither is a number
// worth printing.
type requestProgress struct {
// Status is one of the request status slugs below, or empty when the queue had nothing
// to say about this title at all.
Status string
// Progress is whole percent, 0-100.
Progress int
// EstimatedReadySeconds is how long until the bytes have landed. It never includes the
// import that follows, because nothing measures that — see requestStatusDetail, where
// "a few minutes" is wording rather than an estimate.
EstimatedReadySeconds int
}
// Download-client states, in the order a request passes through them. These sit alongside
// the states in requests_status.go and share its vocabulary space; they are separate only
// because these four are the ones a queue can answer for.
const (
// RequestStatusSearching means monitored, released, nothing found yet — the honest
// reading of "the *arr holds this and the download client has never heard of it".
RequestStatusSearching = "searching"
// RequestStatusFound means a release has been grabbed and is waiting on the download
// client: queued, paused, held by a delay profile. There is something to wait for, but
// no bytes are moving.
RequestStatusFound = "found"
// RequestStatusDownloading means bytes are moving. This is the only state that carries
// a percentage.
RequestStatusDownloading = "downloading"
// RequestStatusFailed means the download failed and the *arr will look for another
// release. Deliberately not a dead end: it is the one state whose wording has to say
// that Memby is still trying.
RequestStatusFailed = "failed"
)
// workState is the per-row rule, and the order of its tests is the whole of it.
//
// A verdict of error outranks everything, because a row can look perfectly healthy —
// "completed", even — while the *arr has decided it cannot use what arrived. Importing
// outranks downloading next, since a row that has finished downloading is still reported by
// some clients with a downloading-ish status while the *arr moves the file. Only then does
// the download client's own word matter, and anything it says that is not "downloading" is
// a wait of some kind, which is what "found" means.
//
// A word this build has never seen falls through to found rather than to failed: a new
// vocabulary in a future Radarr must degrade to "something is happening" rather than
// telling a viewer their film is broken.
func workState(w requestWork) string {
status := strings.ToLower(strings.TrimSpace(w.Status))
tracked := strings.ToLower(strings.TrimSpace(w.TrackedState))
verdict := strings.ToLower(strings.TrimSpace(w.TrackedStatus))
switch {
case verdict == "error", status == "failed", tracked == "failed", tracked == "failedpending":
return RequestStatusFailed
case tracked == "importpending", tracked == "importing", tracked == "imported",
status == "completed":
return RequestStatusProcessing
case status == "downloading":
return RequestStatusDownloading
default:
return RequestStatusFound
}
}
// workRank orders the states by how much they deserve to be what a card says when one title
// has several rows — a season pack is a dozen episodes at a dozen different stages.
//
// Highest wins, and the ordering is "what is the most active thing happening to this": a
// show with one episode downloading and eleven already filed is downloading. Failed is
// lowest, so a single failed episode never overrides eleven healthy ones and a title only
// reads as failed when every row of it has.
func workRank(status string) int {
switch status {
case RequestStatusDownloading:
return 4
case RequestStatusFound:
return 3
case RequestStatusProcessing:
return 2
case RequestStatusFailed:
return 1
default:
return 0
}
}
// downloadProgress folds a title's queue rows into one answer.
//
// No rows is not an error and not a state: it is the caller's question to answer, since
// "nothing is downloading" means something different for a film that is already in the
// library, one nobody has released yet, and one the *arr has been searching for all
// afternoon. So this returns an empty status and requestStatusFor decides.
func downloadProgress(work []requestWork) requestProgress {
if len(work) == 0 {
return requestProgress{}
}
var (
best string
totalSize float64
totalLeft float64
// eta is the longest remaining time across the rows that have not landed yet: a
// season is ready when its slowest episode is, not its fastest.
eta time.Duration
// etaKnown starts true and is cleared by the first unfinished row that will not say.
// One silent row makes the total unknowable, and reporting the rest of the pack's
// time as the whole pack's would be an ETA that quietly expires and keeps going.
etaKnown = true
)
for _, row := range work {
state := workState(row)
if workRank(state) > workRank(best) {
best = state
}
if row.Size > 0 {
totalSize += row.Size
totalLeft += math.Min(math.Max(row.SizeLeft, 0), row.Size)
}
switch state {
case RequestStatusDownloading, RequestStatusFound:
remaining, ok := parseTimeLeft(row.TimeLeft)
if !ok {
etaKnown = false
continue
}
if remaining > eta {
eta = remaining
}
}
}
progress := requestProgress{Status: best}
if totalSize > 0 {
done := (totalSize - totalLeft) / totalSize * 100
progress.Progress = int(math.Round(math.Min(math.Max(done, 0), 100)))
}
// An estimate is only ever offered against moving bytes. A queue that is paused or
// waiting on a delay profile has a "time left" only in the sense that the download
// client is guessing, and an import has no measured duration at all.
if etaKnown && eta > 0 && best == RequestStatusDownloading {
progress.EstimatedReadySeconds = int(math.Round(eta.Seconds()))
}
return progress
}
// parseTimeLeft reads the .NET TimeSpan the *arrs send: "hh:mm:ss", with an optional
// "d." day part in front and an optional fractional-seconds part behind.
//
// It refuses anything it cannot read completely rather than salvaging a number from part of
// it. This is the one input that becomes a promise to a viewer, and a misparse here is how
// "ready in 14 minutes" becomes "ready in 14 hours".
func parseTimeLeft(value string) (time.Duration, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
days := 0
// A day part is separated by a full stop, and so is a fractional second — so a leading
// "d." only exists when what follows still holds two colons.
if dot := strings.Index(value, "."); dot > 0 && strings.Count(value[dot+1:], ":") == 2 {
parsed, err := strconv.Atoi(value[:dot])
if err != nil || parsed < 0 {
return 0, false
}
days = parsed
value = value[dot+1:]
}
if dot := strings.Index(value, "."); dot >= 0 {
value = value[:dot] // drop fractional seconds; nobody counts a film in milliseconds
}
parts := strings.Split(value, ":")
if len(parts) != 3 {
return 0, false
}
units := []time.Duration{time.Hour, time.Minute, time.Second}
total := time.Duration(days) * 24 * time.Hour
for index, part := range parts {
number, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || number < 0 {
return 0, false
}
total += time.Duration(number) * units[index]
}
return total, true
}
// estimatedReadyLabel is the second half of the download line: the sentence under
// "Downloading - 68%".
//
// It is worded in the coarsest unit that is still useful, because the precision the
// download client reports is not precision anybody has: a client that says 14 minutes 3
// seconds is guessing at the minutes, so printing the seconds claims an accuracy the number
// does not have. An estimate of zero is not an estimate and produces nothing at all, which
// is what makes "we will let you know when it is ready" reachable.
func estimatedReadyLabel(seconds int) string {
if seconds <= 0 {
return ""
}
remaining := time.Duration(seconds) * time.Second
switch {
case remaining < 90*time.Second:
return "Estimated ready in under a minute"
case remaining < time.Hour:
return "Estimated ready in ~" + strconv.Itoa(int(math.Round(remaining.Minutes()))) + " minutes"
case remaining < 2*time.Hour:
return "Estimated ready in about an hour"
case remaining < 24*time.Hour:
return "Estimated ready in ~" + strconv.Itoa(int(remaining.Hours())) + " hours"
default:
// Past a day the number stops being an estimate and starts being a warning that
// something is wrong with the release, so it is deliberately vague.
return "Estimated ready in over a day"
}
}