package api import ( "strings" "time" ) // What a request is doing, as a slug the television renders. // // These are the states Radarr and Sonarr can actually answer for. Neither has an approval // workflow — an added movie is simply added — so there is deliberately no "approved" or // "declined" here: inventing one would put a word on a card that nothing behind it can ever // change. The client renders an unknown slug in its neutral treatment (see requestStatusTone // on the television), so if a household ever puts an approval layer in front of the *arrs, // those states can be added here and reach existing builds without an app release. const ( // The household has it. Either Emby has imported it or the *arr reports a file. RequestStatusAvailable = "available" // Being filed away: the bytes have landed and the *arr is importing them. This used to // mean "released, monitored, no file yet" — everything between the ask and the arrival — // and now means only the last step of it, with searching, found and downloading (in // requests_progress.go) covering the rest. A television that predates those three is // sent this word for all four; see collapseRequestStatus. RequestStatusProcessing = "processing" // Accepted, but there is nothing to fetch yet — unreleased, or still only in cinemas. RequestStatusPending = "pending" // Recorded by Memby, but the *arr could not be asked. The honest fallback: we know // somebody asked and nothing more. RequestStatusRequested = "requested" // Recorded by Memby and the *arr no longer has it, so somebody removed it downstream. RequestStatusUnavailable = "unavailable" // Search only: nothing has it and nobody has asked, so the button does something. RequestStatusRequestable = "requestable" ) // lookupStatusFor answers what a search result is, which is a different question from what // a stored request is doing: a candidate nothing tracks is the ordinary case here — it is // the whole point of searching — where in a request list it would mean somebody had removed // it. So the two rules are separate rather than one rule with a flag, and only this one can // return "requestable". // // Being this viewer's own ask outranks the household merely having added it: "Requested" // tells them they already did this, which is the thing they most need to know before // pressing a button a second time. It does not outrank availability — a title that is in // the library is watchable now, and that is better news. func lookupStatusFor(subject RequestSubject, mine bool) string { switch { case subject.InLibrary || subject.HasFile: return RequestStatusAvailable case mine: return RequestStatusRequested case !subject.Tracked: return RequestStatusRequestable case !subject.Released: return RequestStatusPending default: return RequestStatusProcessing } } // RequestSubject is what the *arr and the library between them know about one title, in // the narrow shape requestStatusFor needs. Keeping it free of Radarr and Sonarr types is // what lets one rule answer for both catalogues and be tested without either. type RequestSubject struct { // Tracked is whether the *arr still holds the title at all. Tracked bool // HasFile is the *arr's own answer about media on disk. Sonarr's series list does not // report this, so a series leaves it false and leans on InLibrary. HasFile bool // InLibrary is whether Emby has imported it, matched on the catalogue's provider id. InLibrary bool // Released is whether there is anything to fetch yet. A film not yet on digital and a // series whose first episode has not aired are both false. Released bool // Progress is what the download client is doing with it, already folded by // downloadProgress. It is passed in rather than computed here so that the caller — which // has the queue rows and has to send the percentage and the ETA anyway — folds them // once. An empty Status means the queue had nothing to say, which is the ordinary case // and not a state of its own. Progress requestProgress } // requestStatusFor is the whole rule, and it is ordered by how much each signal is worth. // // Availability wins over everything: a title the household can watch is available whether // or not the *arr still tracks it, whether or not it was ever released on the date anybody // recorded. Only then does absence from the *arr mean removal — checked before the release // state, because an untracked title's release date says nothing about a request nobody is // working on any more. // // The download client outranks the release state, and that ordering is deliberate. Radarr's // minimum-availability setting is a policy about when to *start looking*, not a fact about // whether bytes are moving — a household that fetches on the cinema date has films that are // "not released" and 40% downloaded at the same time, and the honest thing to tell somebody // watching that card is the 40%. // // With nothing in the queue and nothing on disk, a tracked and released title is being // searched for. That is the state this feature exists to make visible: before it, the whole // span from "somebody asked" to "the file landed" was one word, so a viewer could not tell a // request nothing had been found for from one that was minutes away. func requestStatusFor(subject RequestSubject) string { switch { case subject.InLibrary || subject.HasFile: return RequestStatusAvailable case !subject.Tracked: return RequestStatusUnavailable case subject.Progress.Status != "": return subject.Progress.Status case !subject.Released: return RequestStatusPending default: return RequestStatusSearching } } // collapseRequestStatus is what a television that predates the download states is told. // // The four new words all live inside the span the old "processing" covered, so an older app // is sent that one word and reads it exactly as it always did — "Searching for a copy", // filed under On the way. It loses the percentage and the estimate, which it has nowhere to // draw anyway, and it gains nothing wrong. // // This is a *narrowing*, never a translation in the other direction: a build that declares // request_progress_v1 is sent the truth. See requestProgressSupported. func collapseRequestStatus(status string) string { switch status { case RequestStatusSearching, RequestStatusFound, RequestStatusDownloading, RequestStatusFailed: return RequestStatusProcessing default: return status } } // movieReleased reads Radarr's own status word rather than comparing dates. // // Radarr already decides this, applying the household's minimum-availability setting, and // a date comparison here would disagree with it for exactly the titles that are marginal — // which are the ones somebody is watching their request page for. "announced" and // "incinemas" are the two that mean there is nothing to fetch; anything else, including a // word this build has never seen, is treated as released so a new Radarr vocabulary // degrades to "processing" rather than parking a request on "pending" for ever. func movieReleased(status string) bool { switch strings.ToLower(strings.TrimSpace(status)) { case "announced", "incinemas": return false default: return true } } // seriesReleased asks whether any of the show exists yet. // // Sonarr's series list carries no per-episode file information, so the question it can // answer is narrower than Radarr's: a show whose only airing is in the future has nothing // to fetch. "upcoming" is Sonarr's own word for that; a next-airing date in the future with // no library presence is the same thing said with a timestamp, which is what a show added // before its premiere looks like. func seriesReleased(status string, nextAiring *time.Time, now time.Time) bool { if strings.EqualFold(strings.TrimSpace(status), "upcoming") { return false } if nextAiring != nil && nextAiring.After(now) && strings.EqualFold(strings.TrimSpace(status), "") { return false } return true } // requestStatusLabel is the wording the card shows, sent from here rather than derived on // the television — the MembyAirLabel precedent. A build that predates a state renders the // label it was handed instead of falling back to a slug. func requestStatusLabel(status string) string { switch status { case RequestStatusAvailable: return "Ready to watch" case RequestStatusSearching: return "Searching" case RequestStatusFound: return "Found" // Deliberately just the word. The percentage is a number that moves every ten seconds // and the wording does not, so the television composes "Downloading - 43%" from this // label and the progress field rather than the server sending a sentence that is stale // before it is drawn. case RequestStatusDownloading: return "Downloading" case RequestStatusProcessing: return "Processing" case RequestStatusPending: return "Pending" case RequestStatusFailed: return "Unable to download" case RequestStatusUnavailable: return "Unavailable" case RequestStatusRequestable: return "Request" default: return "Requested" } } // requestStatusDetail is the quiet second line: what the state means for the viewer, in // plain language, rather than a repeat of the word above it. // // The download states are where this line earns its place, because the word above it is not // the news. "Downloading" is not what somebody is standing there wanting to know — when it // will be ready is — and this is the only line that can say so. The estimate is passed in // rather than recomputed so that the sentence and the estimatedReadySeconds field on the // wire can never disagree; when there is no estimate the line says so plainly instead of // reaching for a vaguer number, which is the whole of "never manufacture an ETA". func requestStatusDetail(status, mediaType string, estimateSeconds int) string { thing := "film" if mediaType == "series" { thing = "series" } switch status { case RequestStatusAvailable: return "Ready to watch now" case RequestStatusSearching: return "We haven't found a suitable release yet" case RequestStatusFound: return "Preparing the download" case RequestStatusDownloading: if label := estimatedReadyLabel(estimateSeconds); label != "" { return label } return "We'll let you know when it's ready" case RequestStatusProcessing: return "Should be ready in a few minutes" case RequestStatusFailed: return "Memby will keep looking for another release" case RequestStatusPending: if thing == "series" { return "Waiting for it to air" } return "Waiting for release" case RequestStatusUnavailable: return "No longer being tracked" default: return "Waiting on the " + thing + " service" } }