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

243 lines
10 KiB
Go

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