Files
memby/server/internal/api/radarr_detail.go
2026-08-19 06:57:59 +12:00

307 lines
13 KiB
Go

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
}