263 lines
11 KiB
Go
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"
|
||
|
|
}
|
||
|
|
}
|