0.2.78
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user