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" } }