package api import ( "context" "net/http" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/store" ) // radarrItemPrefix is what a schedule card's id looks like: "radarr:412". The row has used // it since the movie schedule shipped, and it is also what the trailer routes recognise — // see radarrTrailerManifest — so a film with no Emby record can still be asked about // through the ordinary /v1/items/{id}/trailers path. const radarrItemPrefix = "radarr:" // radarrMovieDetail is everything the Radarr-only detail page draws. // // It is deliberately not a BaseItem. A film Radarr is tracking but Emby has never imported // has no Emby record, no user data and nothing to play, and dressing it as one would put a // Play button, a watched tick and a progress bar on a page where all three are lies. The // television has a state of its own for this, and the moment Emby does hold the film // [EmbyItemID] is what sends the viewer to the ordinary page instead. // // Every piece of wording here is the gateway's, the arrangement the schedule cards, the // hero captions and the lifecycle tags already take: a phrasing invented next month reads // correctly on a television that predates it. type radarrMovieDetail struct { ID string `json:"id"` Title string `json:"title"` OriginalTitle string `json:"originalTitle,omitempty"` Overview string `json:"overview,omitempty"` Year int `json:"year,omitempty"` RuntimeMinutes int `json:"runtimeMinutes,omitempty"` Genres []string `json:"genres"` Studio string `json:"studio,omitempty"` Certificate string `json:"certificate,omitempty"` Monitored bool `json:"monitored"` // Radarr's own lifecycle word, as the schedule card wears it: ANNOUNCED, IN CINEMAS, // RELEASED. Distinct from [StateLabel], which is about the household's copy. Lifecycle string `json:"lifecycle,omitempty"` LifecycleText string `json:"lifecycleText,omitempty"` // The subtle status treatment at the top of the page: "Coming Soon", "Awaiting // Release", "Not Yet Available", with one line under it saying what that means here. StateLabel string `json:"stateLabel"` StateDetail string `json:"stateDetail,omitempty"` // The one prominent date. "Expected 14 November 2026" when something has published the // day, "Expected November 2026" when the day is inferred rather than published, and // "Release date not yet announced" when nothing is known — never a precise-looking // date standing in for a guess. ExpectedLabel string `json:"expectedLabel"` // Cinema, digital and physical dates as Radarr holds them, for the viewer who wants to // know which of the three the headline came from. Any of them may be absent. ReleaseDates []radarrReleaseDate `json:"releaseDates"` // The sentence saying, in as many words, that this cannot be watched here yet. AvailabilityNotice string `json:"availabilityNotice"` // Whether the Trailer action should be offered at all. Deciding it here rather than on // the television is what keeps the button from being one that fails after selection. TrailerAvailable bool `json:"trailerAvailable"` // Scores from the same store every other page reads, when this title has been looked // up before. Empty is the honest answer and the strip simply does not appear. Ratings []movieRating `json:"ratings"` // Set once Emby holds the film. The television reopens on the ordinary detail page // when it sees this, which is how a title stops being a Radarr card without anything // having to be invalidated. EmbyItemID string `json:"embyItemId,omitempty"` } type radarrReleaseDate struct { // cinema | digital | physical — a lookup key, not prose. Kind string `json:"kind"` Label string `json:"label"` Value string `json:"value"` } func (s *Server) handleRadarrMovie(w http.ResponseWriter, r *http.Request, sess store.Session) { _ = sess movieID, ok := radarrMovieID(r.PathValue("id")) if !ok { writeError(w, http.StatusBadRequest, "a radarr movie id is required") return } if !s.radarrEnabled(r.Context()) { writeError(w, http.StatusNotFound, "radarr is not available") return } movie, err := s.radarrMovie(r.Context(), movieID) if err != nil { s.writeUpstreamError(r.Context(), w, err, "could not read that movie") return } location := s.cfg.RadarrLocation if location == nil { location = time.Local } detail := buildRadarrMovieDetail(movie, time.Now().In(location), location) detail.EmbyItemID = s.embyMovieItemID(r.Context(), movie.TMDBID) detail.Ratings = s.radarrMovieRatings(r.Context(), movie) writeJSON(w, http.StatusOK, detail) } // radarrMovieID reads the movie out of either form of id: the card's own "radarr:412", and // the bare number, because a caller holding the number should not have to know the prefix. func radarrMovieID(raw string) (int, bool) { trimmed := strings.TrimPrefix(strings.TrimSpace(raw), radarrItemPrefix) id, err := strconv.Atoi(trimmed) if err != nil || id <= 0 { return 0, false } return id, true } // radarrMovie reads one film, preferring the household's cached catalogue. // // That catalogue is one request answering for every title, already shared across the house // and already refreshed on its own schedule, so a detail page opening normally costs Radarr // nothing at all. Asking directly is the fallback for a title added since it was read. func (s *Server) radarrMovie(ctx context.Context, movieID int) (radarr.Movie, error) { if movies, err := s.radarrMovieCatalogue(ctx); err == nil { for _, movie := range movies { if movie.ID == movieID { return movie, nil } } } return s.radarr.Movie(ctx, movieID) } // embyMovieItemID is embyMovieIndex for one title. A failure costs the redirect and never // the page: the worst case is a Radarr page for a film Emby has quietly imported, which the // next home refresh corrects. func (s *Server) embyMovieItemID(ctx context.Context, tmdbID int) string { if s.store == nil || tmdbID <= 0 { return "" } found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", []int{tmdbID}) if err != nil { s.loggerFor(ctx).Warn("emby movie lookup failed for radarr detail", "error", err) return "" } return found[tmdbID] } // radarrMovieRatings reuses the household's ratings store rather than Radarr's own scores. // Radarr carries a ratings block, but a page showing TMDb's number from Radarr here and // MDBList's everywhere else would print two different scores for one film under one name. func (s *Server) radarrMovieRatings(ctx context.Context, movie radarr.Movie) []movieRating { settings, enabled := s.mdblistSettings(ctx) if !enabled || s.mdblist == nil || s.store == nil { return []movieRating{} } key := store.RatingKey{MediaType: "movie"} switch { case movie.TMDBID > 0: key.Provider, key.ProviderID = "tmdb", strconv.Itoa(movie.TMDBID) case strings.TrimSpace(movie.IMDBID) != "": key.Provider, key.ProviderID = "imdb", strings.TrimSpace(movie.IMDBID) default: return []movieRating{} } ratings, err := s.loadMDBListRatings(ctx, settings.APIKey, key) if err != nil { s.logMDBListFailure(ctx, "ratings unavailable", radarrItemPrefix+strconv.Itoa(movie.ID), err) return []movieRating{} } return selectedMovieRatings(settings.Sources, ratings) } // buildRadarrMovieDetail is the whole of the page's wording, and it is pure so that every // case a household can actually produce — a film with three dates, one with only a cinema // date, one Radarr has never been given a date for at all — is answerable without a Radarr. func buildRadarrMovieDetail(movie radarr.Movie, now time.Time, location *time.Location) radarrMovieDetail { release, hasRelease := effectiveRadarrRelease(movie) lifecycle := movieLifecycleTag(movie.Status) detail := radarrMovieDetail{ ID: radarrItemPrefix + strconv.Itoa(movie.ID), Title: strings.TrimSpace(movie.Title), Overview: strings.TrimSpace(movie.Overview), Year: movie.Year, RuntimeMinutes: movie.Runtime, Genres: nonNilStrings(movie.Genres), Studio: strings.TrimSpace(movie.Studio), Certificate: strings.TrimSpace(movie.Certification), Monitored: movie.Monitored, Lifecycle: lifecycle.Status, LifecycleText: lifecycle.Label, ExpectedLabel: radarrExpectedLabel(release, hasRelease, now, location), ReleaseDates: radarrReleaseDates(movie, location), AvailabilityNotice: "Not available to watch in Memby yet", TrailerAvailable: strings.TrimSpace(movie.YouTubeTrailerID) != "", Ratings: []movieRating{}, } // Only when it says something the heading does not, the rule the ordinary Details pane // already applies: a film whose original title is its title is the common case, and // printing it is a row that reads as a mistake. if original := strings.TrimSpace(movie.OriginalTitle); !strings.EqualFold(original, detail.Title) { detail.OriginalTitle = original } detail.StateLabel, detail.StateDetail = radarrMovieState(movie, release, hasRelease, now) return detail } // radarrMovieState is the status treatment at the top of the page: two or three words for // what this film is doing, and a line saying what that means to somebody who wanted to // watch it tonight. func radarrMovieState( movie radarr.Movie, release radarrRelease, hasRelease bool, now time.Time, ) (string, string) { switch { case movie.HasFile: // Downloaded, and yet this page is what opened — so Emby has not scanned it in // yet. A matter of minutes rather than of months, and worth saying so. return "Almost Ready", "Downloaded — waiting for Memby's library to pick it up" case !movie.Monitored: return "Not Tracked", "This film is not being monitored, so no copy is being sought" case !hasRelease: // Deliberately not "Release date not yet announced" — that is what the page has // just printed as its headline, and the line under a state exists to add to it. return "Awaiting Release", "Nothing to download until a date is announced" case release.at.After(now): return "Coming Soon", "Not released yet" default: return "Not Yet Available", "Released — waiting for a copy to arrive" } } // radarrExpectedLabel is the one date the page leads with, and most of its job is refusing // to be precise about a date nothing has published. // // Radarr's digital date is a published fact and is printed to the day. The cinema-plus-a- // month estimate the schedule row falls back on is not, so it is printed to the month: // "Expected November 2026" is true where "Expected 14 November 2026" is a number somebody // would plan an evening around. Nothing known at all is said plainly rather than guessed. func radarrExpectedLabel( release radarrRelease, hasRelease bool, now time.Time, location *time.Location, ) string { if !hasRelease { return "Release date not yet announced" } local := release.at.In(location) verb := "Expected " if !local.After(now.In(location)) { verb = "Released " } if release.estimated { return verb + local.Format("January 2006") } return verb + local.Format("2 January 2006") } // radarrReleaseDates lists what Radarr actually holds, so a viewer can see which of the // three the headline came from. Only dates that exist appear; an absent one is absent // rather than dashed. func radarrReleaseDates(movie radarr.Movie, location *time.Location) []radarrReleaseDate { dates := []radarrReleaseDate{} add := func(kind, label string, value *time.Time) { if value == nil || value.IsZero() { return } dates = append(dates, radarrReleaseDate{ Kind: kind, Label: label, Value: value.In(location).Format("2 January 2006"), }) } add("cinema", "In cinemas", movie.InCinemas) add("digital", "Digital release", movie.DigitalRelease) add("physical", "Physical release", movie.PhysicalRelease) return dates } // radarrTrailerManifest is the trailer chain for a film with no Emby record. // // It is the same manifest shape the ordinary path builds, so the television's existing // trailer machinery — the availability check, the resolve call, the report, the player's // candidate exclusion and its retry onto the next provider — works on a Radarr card with no // second implementation anywhere. The one candidate is Radarr's own YouTube trailer id, // which comes from TMDb's official trailer field and is ranked as an official source // rather than as a spare. func (s *Server) radarrTrailerManifest(ctx context.Context, itemID string, movieID int) (trailerManifest, error) { manifest := trailerManifest{SubjectID: itemID, Candidates: []trailerCandidate{}} if !s.radarrEnabled(ctx) { return manifest, nil } movie, err := s.radarrMovie(ctx, movieID) if err != nil { return trailerManifest{}, err } manifest.Title = strings.TrimSpace(movie.Title) trailerID := strings.TrimSpace(movie.YouTubeTrailerID) if trailerID == "" { return manifest, nil } source := "https://www.youtube.com/watch?v=" + trailerID manifest.Candidates = append(manifest.Candidates, trailerCandidate{ ID: trailerCandidateID("youtube", source), Provider: "youtube", Name: "Official Trailer", SourceURL: source, Priority: remoteTrailerPriority("youtube", "Official Trailer"), }) return manifest, nil }