307 lines
13 KiB
Go
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
|
|
}
|