From 5b8aa9f34c407e42bdaebc46021670ddfadcabbd Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 17:03:29 -0400 Subject: [PATCH] feat(requests): add media detail page with TMDB metadata - Add GetMediaDetail TMDB client returning normalized detail with cast, crew, recommendations, and certifications - Add /api/requests/detail/{media_type}/{tmdb_id} endpoint overlaying availability and request state - Add RequestDetail page and link poster cards to it - Treat empty/truncated Radarr/Sonarr POST responses as accepted; drop pre-submit existence lookups --- internal/api/handlers/requests.go | 20 + internal/api/router.go | 1 + internal/metadata/tmdb/client.go | 254 ++++++++++++ internal/metadata/tmdb/types.go | 169 ++++++++ internal/requests/arrclient/client.go | 24 +- internal/requests/radarr/client.go | 53 +-- internal/requests/service.go | 96 +++++ internal/requests/service_test.go | 5 + internal/requests/sonarr/client.go | 56 +-- internal/requests/types.go | 41 ++ web/src/App.tsx | 2 + web/src/api/types.ts | 41 ++ web/src/components/RequestPosterCard.tsx | 22 +- web/src/hooks/queries/keys.ts | 1 + web/src/hooks/queries/requests.ts | 13 + web/src/pages/AdminRequests.tsx | 28 +- web/src/pages/RequestDetail.tsx | 469 +++++++++++++++++++++++ web/src/pages/Requests.tsx | 52 +-- 18 files changed, 1246 insertions(+), 101 deletions(-) create mode 100644 web/src/pages/RequestDetail.tsx diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index 51152e63..da6d0729 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -19,6 +19,7 @@ type RequestService interface { Search(ctx context.Context, viewer mediarequests.Viewer, query string, mediaType mediarequests.MediaType, page int) (*mediarequests.MediaPage, error) Discover(ctx context.Context, viewer mediarequests.Viewer, section string, page int) (*mediarequests.DiscoverySection, error) DiscoverAll(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverySection, error) + GetDetail(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (*mediarequests.MediaDetail, error) CreateRequest(ctx context.Context, viewer mediarequests.Viewer, input mediarequests.CreateRequestInput) (*mediarequests.Request, error) ListMine(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error) ListAdmin(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error) @@ -98,6 +99,25 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusOK, section) } +func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + mediaType := mediarequests.MediaType(strings.TrimSpace(chi.URLParam(r, "media_type"))) + tmdbID, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, "tmdb_id"))) + if err != nil || tmdbID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid tmdb id") + return + } + detail, err := h.service.GetDetail(r.Context(), viewer, mediaType, tmdbID) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, detail) +} + func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { viewer, ok := requestViewer(w, r, true) if !ok { diff --git a/internal/api/router.go b/internal/api/router.go index 75e2ffad..d39634b0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1397,6 +1397,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/search", requestHandler.HandleSearch) r.Get("/discover", requestHandler.HandleDiscover) r.Get("/discover/{section}", requestHandler.HandleDiscoverSection) + r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail) r.Post("/", requestHandler.HandleCreate) r.Get("/mine", requestHandler.HandleListMine) r.Get("/{id}", requestHandler.HandleGet) diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 614267e5..c5a02d7a 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -614,6 +614,260 @@ func (c *Client) GetCollection(ctx context.Context, id int) (*Collection, error) }, nil } +// GetMediaDetail fetches a single TMDB movie or series with credits, external +// IDs, recommendations, and the appropriate certification feed, returning a +// normalized MediaDetail. mediaType accepts Silo-facing "movie" or "series". +// +// Cast is sorted by TMDB billing order and capped at 24 entries to keep the +// payload bounded. +func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) (*MediaDetail, error) { + if id <= 0 { + return nil, fmt.Errorf("tmdb: media id must be > 0 (got %d)", id) + } + + switch mediaType { + case "movie": + path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates", id) + var resp movieDetailResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return normalizeMovieDetail(&resp), nil + case "series", "tv": + path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings", id) + var resp tvDetailResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return normalizeTVDetail(&resp), nil + default: + return nil, fmt.Errorf("tmdb: invalid media type for detail: %q", mediaType) + } +} + +func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail { + detail := &MediaDetail{ + MediaType: "movie", + ID: resp.ID, + IMDbID: resp.IMDbID, + Title: resp.Title, + OriginalTitle: resp.OriginalTitle, + Tagline: resp.Tagline, + Overview: resp.Overview, + PosterPath: resp.PosterPath, + BackdropPath: resp.BackdropPath, + ReleaseDate: resp.ReleaseDate, + Year: releaseYear(resp.ReleaseDate), + Runtime: resp.Runtime, + Genres: namesFromGenres(resp.Genres), + VoteAverage: resp.VoteAverage, + VoteCount: resp.VoteCount, + Status: resp.Status, + Homepage: resp.Homepage, + ContentRating: pickMovieCertification(resp.ReleaseDates), + } + for _, company := range resp.ProductionCompanies { + if name := strings.TrimSpace(company.Name); name != "" { + detail.ProductionCompanies = append(detail.ProductionCompanies, name) + } + } + if resp.ExternalIDs != nil { + detail.TVDBID = resp.ExternalIDs.TVDBID + if detail.IMDbID == "" { + detail.IMDbID = resp.ExternalIDs.IMDbID + } + } + if resp.Credits != nil { + detail.Cast = normalizeCast(resp.Credits.Cast) + detail.Director = pickDirector(resp.Credits.Crew) + } + if resp.Recommendations != nil { + detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results)) + for _, item := range resp.Recommendations.Results { + detail.Recommendations = append(detail.Recommendations, MediaResult{ + ID: item.ID, + MediaType: "movie", + Title: item.Title, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.ReleaseDate, + Year: releaseYear(item.ReleaseDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + } + return detail +} + +func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail { + detail := &MediaDetail{ + MediaType: "series", + ID: resp.ID, + Title: resp.Name, + OriginalTitle: resp.OriginalName, + Tagline: resp.Tagline, + Overview: resp.Overview, + PosterPath: resp.PosterPath, + BackdropPath: resp.BackdropPath, + ReleaseDate: resp.FirstAirDate, + FirstAirDate: resp.FirstAirDate, + LastAirDate: resp.LastAirDate, + Year: releaseYear(resp.FirstAirDate), + Genres: namesFromGenres(resp.Genres), + VoteAverage: resp.VoteAverage, + VoteCount: resp.VoteCount, + Status: resp.Status, + Homepage: resp.Homepage, + NumberOfSeasons: resp.NumberOfSeasons, + NumberOfEpisodes: resp.NumberOfEpisodes, + ContentRating: pickTVRating(resp.ContentRatings), + } + if len(resp.EpisodeRunTime) > 0 { + detail.Runtime = resp.EpisodeRunTime[0] + } + for _, network := range resp.Networks { + if name := strings.TrimSpace(network.Name); name != "" { + detail.Networks = append(detail.Networks, name) + } + } + if resp.ExternalIDs != nil { + detail.IMDbID = resp.ExternalIDs.IMDbID + detail.TVDBID = resp.ExternalIDs.TVDBID + } + if resp.Credits != nil { + detail.Cast = normalizeCast(resp.Credits.Cast) + } + for _, person := range resp.CreatedBy { + if name := strings.TrimSpace(person.Name); name != "" { + detail.Creators = append(detail.Creators, name) + } + } + if resp.Recommendations != nil { + detail.Recommendations = make([]MediaResult, 0, len(resp.Recommendations.Results)) + for _, item := range resp.Recommendations.Results { + detail.Recommendations = append(detail.Recommendations, MediaResult{ + ID: item.ID, + MediaType: "series", + Title: item.Name, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.FirstAirDate, + Year: releaseYear(item.FirstAirDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + } + return detail +} + +func namesFromGenres(genres []genreEntry) []string { + if len(genres) == 0 { + return nil + } + out := make([]string, 0, len(genres)) + for _, g := range genres { + if name := strings.TrimSpace(g.Name); name != "" { + out = append(out, name) + } + } + return out +} + +// normalizeCast sorts by billing order and caps the result so the response +// payload stays bounded — the request detail UI surfaces only the top of the +// list anyway. +func normalizeCast(cast []castEntry) []MediaCastMember { + if len(cast) == 0 { + return nil + } + sorted := make([]castEntry, len(cast)) + copy(sorted, cast) + for i := 1; i < len(sorted); i++ { + for j := i; j > 0 && sorted[j].Order < sorted[j-1].Order; j-- { + sorted[j], sorted[j-1] = sorted[j-1], sorted[j] + } + } + const maxCast = 24 + if len(sorted) > maxCast { + sorted = sorted[:maxCast] + } + out := make([]MediaCastMember, 0, len(sorted)) + for _, member := range sorted { + out = append(out, MediaCastMember{ + Name: strings.TrimSpace(member.Name), + Character: strings.TrimSpace(member.Character), + ProfilePath: member.ProfilePath, + Order: member.Order, + }) + } + return out +} + +func pickDirector(crew []crewEntry) string { + for _, member := range crew { + if strings.EqualFold(member.Job, "Director") { + return strings.TrimSpace(member.Name) + } + } + return "" +} + +// pickMovieCertification picks the US theatrical certification if available, +// then falls back to any non-empty US certification, then any non-empty +// certification at all. Type 3 is theatrical in TMDB's release-type taxonomy. +func pickMovieCertification(rd *releaseDatesResponse) string { + if rd == nil { + return "" + } + var fallbackUS, fallbackAny string + for _, country := range rd.Results { + isUS := strings.EqualFold(country.ISO3166, "US") + for _, entry := range country.ReleaseDates { + cert := strings.TrimSpace(entry.Certification) + if cert == "" { + continue + } + if isUS && entry.Type == 3 { + return cert + } + if isUS && fallbackUS == "" { + fallbackUS = cert + } + if fallbackAny == "" { + fallbackAny = cert + } + } + } + if fallbackUS != "" { + return fallbackUS + } + return fallbackAny +} + +func pickTVRating(cr *contentRatingsResponse) string { + if cr == nil { + return "" + } + var fallback string + for _, entry := range cr.Results { + rating := strings.TrimSpace(entry.Rating) + if rating == "" { + continue + } + if strings.EqualFold(entry.ISO3166, "US") { + return rating + } + if fallback == "" { + fallback = rating + } + } + return fallback +} + // GetExternalIDs fetches external IDs for a TMDB movie or TV entry. func (c *Client) GetExternalIDs(ctx context.Context, mediaType string, id int) (*ExternalIDs, error) { var path string diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index efedb394..4a3e462f 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -149,3 +149,172 @@ type apiError struct { StatusMessage string `json:"status_message"` StatusCode int `json:"status_code"` } + +// MediaDetail is a normalized TMDB detail payload for the request system. +// MediaType is Silo-facing: "movie" or "series". Series-specific fields are +// zero-valued for movies and vice versa. +type MediaDetail struct { + MediaType string + ID int + IMDbID string + TVDBID int + Title string + OriginalTitle string + Tagline string + Overview string + PosterPath string + BackdropPath string + ReleaseDate string + Year int + Runtime int + Genres []string + VoteAverage float64 + VoteCount int + Status string + Homepage string + ContentRating string + ProductionCompanies []string + + NumberOfSeasons int + NumberOfEpisodes int + FirstAirDate string + LastAirDate string + Networks []string + + Cast []MediaCastMember + Director string + Creators []string + Recommendations []MediaResult +} + +// MediaCastMember is a single cast member entry from a TMDB credits response, +// normalized for the request detail surface. +type MediaCastMember struct { + Name string + Character string + ProfilePath string + Order int +} + +// genreEntry / companyEntry / networkEntry / personEntry mirror small object +// shapes from the TMDB JSON. They're internal to the decode path. +type genreEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type companyEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type networkEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type creditsResponse struct { + Cast []castEntry `json:"cast"` + Crew []crewEntry `json:"crew"` +} + +type castEntry struct { + ID int `json:"id"` + Name string `json:"name"` + Character string `json:"character"` + ProfilePath string `json:"profile_path"` + Order int `json:"order"` +} + +type crewEntry struct { + ID int `json:"id"` + Name string `json:"name"` + Job string `json:"job"` + Department string `json:"department"` +} + +type personEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type recommendationsMovieResponse struct { + Results []mediaMovieResponse `json:"results"` +} + +type recommendationsTVResponse struct { + Results []mediaTVResponse `json:"results"` +} + +type releaseDatesResponse struct { + Results []releaseDatesCountryEntry `json:"results"` +} + +type releaseDatesCountryEntry struct { + ISO3166 string `json:"iso_3166_1"` + ReleaseDates []releaseDateEntry `json:"release_dates"` +} + +type releaseDateEntry struct { + Certification string `json:"certification"` + ReleaseDate string `json:"release_date"` + Type int `json:"type"` +} + +type contentRatingsResponse struct { + Results []contentRatingEntry `json:"results"` +} + +type contentRatingEntry struct { + ISO3166 string `json:"iso_3166_1"` + Rating string `json:"rating"` +} + +type movieDetailResponse struct { + ID int `json:"id"` + IMDbID string `json:"imdb_id"` + Title string `json:"title"` + OriginalTitle string `json:"original_title"` + Tagline string `json:"tagline"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + Runtime int `json:"runtime"` + Genres []genreEntry `json:"genres"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Status string `json:"status"` + Homepage string `json:"homepage"` + ProductionCompanies []companyEntry `json:"production_companies"` + Credits *creditsResponse `json:"credits"` + ExternalIDs *ExternalIDs `json:"external_ids"` + Recommendations *recommendationsMovieResponse `json:"recommendations"` + ReleaseDates *releaseDatesResponse `json:"release_dates"` +} + +type tvDetailResponse struct { + ID int `json:"id"` + Name string `json:"name"` + OriginalName string `json:"original_name"` + Tagline string `json:"tagline"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + LastAirDate string `json:"last_air_date"` + EpisodeRunTime []int `json:"episode_run_time"` + NumberOfSeasons int `json:"number_of_seasons"` + NumberOfEpisodes int `json:"number_of_episodes"` + Genres []genreEntry `json:"genres"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Status string `json:"status"` + Homepage string `json:"homepage"` + Networks []networkEntry `json:"networks"` + CreatedBy []personEntry `json:"created_by"` + Credits *creditsResponse `json:"credits"` + ExternalIDs *ExternalIDs `json:"external_ids"` + Recommendations *recommendationsTVResponse `json:"recommendations"` + ContentRatings *contentRatingsResponse `json:"content_ratings"` +} diff --git a/internal/requests/arrclient/client.go b/internal/requests/arrclient/client.go index c2832569..e5d3a135 100644 --- a/internal/requests/arrclient/client.go +++ b/internal/requests/arrclient/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -24,6 +25,11 @@ type HTTPError struct { Body string } +type DecodeError struct { + StatusCode int + Err error +} + func (e HTTPError) Error() string { if e.Body == "" { return fmt.Sprintf("arr: HTTP %d", e.StatusCode) @@ -31,6 +37,22 @@ func (e HTTPError) Error() string { return fmt.Sprintf("arr: HTTP %d: %s", e.StatusCode, e.Body) } +func (e DecodeError) Error() string { + return fmt.Sprintf("arr: decode response: %v", e.Err) +} + +func (e DecodeError) Unwrap() error { + return e.Err +} + +func IsEmptyOrTruncatedDecodeError(err error) bool { + var decodeErr DecodeError + if !errors.As(err, &decodeErr) { + return false + } + return errors.Is(decodeErr.Err, io.EOF) || errors.Is(decodeErr.Err, io.ErrUnexpectedEOF) +} + func New(baseURL, apiKey string, httpClient *http.Client) *Client { if httpClient == nil { httpClient = &http.Client{Timeout: 30 * time.Second} @@ -92,7 +114,7 @@ func (c *Client) DoJSON(ctx context.Context, method, path string, body, dest any return nil } if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest); err != nil { - return fmt.Errorf("arr: decode response: %w", err) + return DecodeError{StatusCode: resp.StatusCode, Err: err} } return nil } diff --git a/internal/requests/radarr/client.go b/internal/requests/radarr/client.go index 760e6105..fce76ed5 100644 --- a/internal/requests/radarr/client.go +++ b/internal/requests/radarr/client.go @@ -86,14 +86,6 @@ func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, int } client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - existing, err := c.lookupExisting(ctx, client, req.TMDBID) - if err != nil { - return mediarequests.FulfillmentResult{}, err - } - if existing != nil { - return resultFromMovie(*existing), nil - } - movie, err := c.lookupMovie(ctx, client, req.TMDBID) if err != nil { return mediarequests.FulfillmentResult{}, err @@ -114,6 +106,9 @@ func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, int var created movieResource if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil { + if arrclient.IsEmptyOrTruncatedDecodeError(err) { + return acceptedWithoutResponse("radarr"), nil + } return mediarequests.FulfillmentResult{}, err } return resultFromMovie(created), nil @@ -123,18 +118,11 @@ func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) movieID, _ := strconv.Atoi(req.ExternalID) if movieID <= 0 { - existing, err := c.lookupExisting(ctx, client, req.TMDBID) - if err != nil { - return mediarequests.FulfillmentStatus{}, err - } - if existing == nil { - return mediarequests.FulfillmentStatus{ - Status: mediarequests.StatusQueued, - IntegrationKind: "radarr", - ExternalStatus: "missing_from_radarr", - }, nil - } - movieID = existing.ID + return mediarequests.FulfillmentStatus{ + Status: mediarequests.StatusQueued, + IntegrationKind: "radarr", + ExternalStatus: "external_id_unavailable", + }, nil } queues, err := c.queueDetails(ctx, client, movieID) @@ -145,18 +133,6 @@ func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request return statusFromQueueEvaluation("radarr", movieID, evaluation), nil } -func (c *Client) lookupExisting(ctx context.Context, client *arrclient.Client, tmdbID int) (*movieResource, error) { - path := "/api/v3/movie?tmdbId=" + strconv.Itoa(tmdbID) - var movies []movieResource - if err := client.GetJSON(ctx, path, &movies); err != nil { - return nil, err - } - if len(movies) == 0 { - return nil, nil - } - return &movies[0], nil -} - func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) { values := url.Values{} values.Set("tmdbId", strconv.Itoa(tmdbID)) @@ -228,13 +204,24 @@ func (c *Client) tags(ctx context.Context, client *arrclient.Client) ([]mediareq } func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult { + externalID := "" + if movie.ID > 0 { + externalID = strconv.Itoa(movie.ID) + } return mediarequests.FulfillmentResult{ IntegrationKind: "radarr", - ExternalID: strconv.Itoa(movie.ID), + ExternalID: externalID, ExternalStatus: "queued", } } +func acceptedWithoutResponse(kind string) mediarequests.FulfillmentResult { + return mediarequests.FulfillmentResult{ + IntegrationKind: kind, + ExternalStatus: "accepted_without_response", + } +} + func statusFromQueueEvaluation(kind string, externalID int, evaluation arrclient.QueueEvaluation) mediarequests.FulfillmentStatus { status := mediarequests.StatusQueued outcome := mediarequests.Outcome("") diff --git a/internal/requests/service.go b/internal/requests/service.go index a4280f62..473aa272 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -14,6 +14,7 @@ import ( type TMDBClient interface { SearchMedia(ctx context.Context, mediaType, query string, page int) (*tmdb.MediaPage, error) DiscoverSection(ctx context.Context, section string, page int) (*tmdb.MediaPage, error) + GetMediaDetail(ctx context.Context, mediaType string, id int) (*tmdb.MediaDetail, error) } type TMDBExternalIDClient interface { @@ -142,6 +143,101 @@ func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySe return sections, nil } +// GetDetail fetches a TMDB detail payload and overlays the same availability / +// request-state signals used by search and discovery. Recommendations carry +// their own per-item state so the detail page can render them as request cards. +func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) (*MediaDetail, error) { + if s == nil || s.store == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + mediaType, err := normalizeMediaType(mediaType) + if err != nil { + return nil, err + } + if tmdbID <= 0 { + return nil, fmt.Errorf("%w: tmdb id is required", ErrInvalidInput) + } + + raw, err := s.tmdb.GetMediaDetail(ctx, string(mediaType), tmdbID) + if err != nil { + return nil, err + } + if raw == nil { + return nil, ErrNotFound + } + + policy, err := s.EffectivePolicy(ctx, viewer.UserID) + if err != nil { + return nil, err + } + + primaryAvailable, err := s.lookupAvailable(ctx, mediaType, []int{raw.ID}) + if err != nil { + return nil, err + } + primaryRequests, err := s.store.ListActiveByTMDB(ctx, mediaType, []int{raw.ID}) + if err != nil { + return nil, err + } + + detail := &MediaDetail{ + MediaType: mediaType, + TMDBID: raw.ID, + IMDbID: raw.IMDbID, + Title: raw.Title, + OriginalTitle: raw.OriginalTitle, + Tagline: raw.Tagline, + Overview: raw.Overview, + PosterPath: raw.PosterPath, + BackdropPath: raw.BackdropPath, + ReleaseDate: raw.ReleaseDate, + Year: raw.Year, + Runtime: raw.Runtime, + Genres: raw.Genres, + VoteAverage: raw.VoteAverage, + VoteCount: raw.VoteCount, + Status: raw.Status, + Homepage: raw.Homepage, + ContentRating: raw.ContentRating, + ProductionCompanies: raw.ProductionCompanies, + NumberOfSeasons: raw.NumberOfSeasons, + NumberOfEpisodes: raw.NumberOfEpisodes, + FirstAirDate: raw.FirstAirDate, + LastAirDate: raw.LastAirDate, + Networks: raw.Networks, + Director: raw.Director, + Creators: raw.Creators, + Availability: availabilityValue(primaryAvailable[raw.ID]), + Request: requestStateFor(viewer, policy, primaryAvailable[raw.ID], primaryRequests[raw.ID]), + } + if raw.TVDBID > 0 { + tvdb := raw.TVDBID + detail.TVDBID = &tvdb + } + if len(raw.Cast) > 0 { + detail.Cast = make([]MediaCastMember, 0, len(raw.Cast)) + for _, member := range raw.Cast { + detail.Cast = append(detail.Cast, MediaCastMember{ + Name: member.Name, + Character: member.Character, + ProfilePath: member.ProfilePath, + Order: member.Order, + }) + } + } + + if len(raw.Recommendations) > 0 { + recPage := &tmdb.MediaPage{Results: raw.Recommendations} + enriched, err := s.enrichPage(ctx, viewer, recPage) + if err != nil { + return nil, err + } + detail.Recommendations = enriched.Results + } + + return detail, nil +} + func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input CreateRequestInput) (*Request, error) { if err := validateViewer(viewer); err != nil { return nil, err diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 63b864c9..1f6967b6 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -517,6 +517,7 @@ func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids [] type fakeTMDBClient struct { page *tmdb.MediaPage externalIDs *tmdb.ExternalIDs + detail *tmdb.MediaDetail } func (f *fakeTMDBClient) SearchMedia(context.Context, string, string, int) (*tmdb.MediaPage, error) { @@ -531,6 +532,10 @@ func (f *fakeTMDBClient) GetExternalIDs(context.Context, string, int) (*tmdb.Ext return f.externalIDs, nil } +func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.MediaDetail, error) { + return f.detail, nil +} + type fakeMovieAdapter struct { result FulfillmentResult status FulfillmentStatus diff --git a/internal/requests/sonarr/client.go b/internal/requests/sonarr/client.go index d02adf5d..476210b1 100644 --- a/internal/requests/sonarr/client.go +++ b/internal/requests/sonarr/client.go @@ -91,14 +91,6 @@ func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, in } client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) - existing, err := c.lookupExisting(ctx, client, *req.TVDBID) - if err != nil { - return mediarequests.FulfillmentResult{}, err - } - if existing != nil { - return resultFromSeries(*existing), nil - } - series, err := c.lookupSeries(ctx, client, *req.TVDBID) if err != nil { return mediarequests.FulfillmentResult{}, err @@ -121,6 +113,9 @@ func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, in var created seriesResource if err := client.PostJSON(ctx, "/api/v3/series", series, &created); err != nil { + if arrclient.IsEmptyOrTruncatedDecodeError(err) { + return acceptedWithoutResponse("sonarr"), nil + } return mediarequests.FulfillmentResult{}, err } return resultFromSeries(created), nil @@ -130,21 +125,11 @@ func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Reques client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) seriesID, _ := strconv.Atoi(req.ExternalID) if seriesID <= 0 { - if req.TVDBID == nil || *req.TVDBID <= 0 { - return mediarequests.FulfillmentStatus{}, fmt.Errorf("sonarr: tvdb_id is required") - } - existing, err := c.lookupExisting(ctx, client, *req.TVDBID) - if err != nil { - return mediarequests.FulfillmentStatus{}, err - } - if existing == nil { - return mediarequests.FulfillmentStatus{ - Status: mediarequests.StatusQueued, - IntegrationKind: "sonarr", - ExternalStatus: "missing_from_sonarr", - }, nil - } - seriesID = existing.ID + return mediarequests.FulfillmentStatus{ + Status: mediarequests.StatusQueued, + IntegrationKind: "sonarr", + ExternalStatus: "external_id_unavailable", + }, nil } queues, err := c.queueDetails(ctx, client, seriesID) @@ -155,18 +140,6 @@ func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Reques return statusFromQueueEvaluation("sonarr", seriesID, evaluation), nil } -func (c *Client) lookupExisting(ctx context.Context, client *arrclient.Client, tvdbID int) (*seriesResource, error) { - path := "/api/v3/series?tvdbId=" + strconv.Itoa(tvdbID) - var series []seriesResource - if err := client.GetJSON(ctx, path, &series); err != nil { - return nil, err - } - if len(series) == 0 { - return nil, nil - } - return &series[0], nil -} - func (c *Client) lookupSeries(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) { values := url.Values{} values.Set("term", "tvdb:"+strconv.Itoa(tvdbID)) @@ -243,13 +216,24 @@ func (c *Client) tags(ctx context.Context, client *arrclient.Client) ([]mediareq } func resultFromSeries(series seriesResource) mediarequests.FulfillmentResult { + externalID := "" + if series.ID > 0 { + externalID = strconv.Itoa(series.ID) + } return mediarequests.FulfillmentResult{ IntegrationKind: "sonarr", - ExternalID: strconv.Itoa(series.ID), + ExternalID: externalID, ExternalStatus: "queued", } } +func acceptedWithoutResponse(kind string) mediarequests.FulfillmentResult { + return mediarequests.FulfillmentResult{ + IntegrationKind: kind, + ExternalStatus: "accepted_without_response", + } +} + func statusFromQueueEvaluation(kind string, externalID int, evaluation arrclient.QueueEvaluation) mediarequests.FulfillmentStatus { status := mediarequests.StatusQueued outcome := mediarequests.Outcome("") diff --git a/internal/requests/types.go b/internal/requests/types.go index 92c70375..488f8603 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -155,6 +155,47 @@ type MediaPage struct { Results []MediaResult `json:"results"` } +type MediaCastMember struct { + Name string `json:"name"` + Character string `json:"character,omitempty"` + ProfilePath string `json:"profile_path,omitempty"` + Order int `json:"order"` +} + +type MediaDetail struct { + MediaType MediaType `json:"media_type"` + TMDBID int `json:"tmdb_id"` + IMDbID string `json:"imdb_id,omitempty"` + TVDBID *int `json:"tvdb_id,omitempty"` + Title string `json:"title"` + OriginalTitle string `json:"original_title,omitempty"` + Tagline string `json:"tagline,omitempty"` + Overview string `json:"overview,omitempty"` + PosterPath string `json:"poster_path,omitempty"` + BackdropPath string `json:"backdrop_path,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + Year int `json:"year,omitempty"` + Runtime int `json:"runtime,omitempty"` + Genres []string `json:"genres,omitempty"` + VoteAverage float64 `json:"vote_average,omitempty"` + VoteCount int `json:"vote_count,omitempty"` + Status string `json:"status,omitempty"` + Homepage string `json:"homepage,omitempty"` + ContentRating string `json:"content_rating,omitempty"` + ProductionCompanies []string `json:"production_companies,omitempty"` + NumberOfSeasons int `json:"number_of_seasons,omitempty"` + NumberOfEpisodes int `json:"number_of_episodes,omitempty"` + FirstAirDate string `json:"first_air_date,omitempty"` + LastAirDate string `json:"last_air_date,omitempty"` + Networks []string `json:"networks,omitempty"` + Cast []MediaCastMember `json:"cast,omitempty"` + Director string `json:"director,omitempty"` + Creators []string `json:"creators,omitempty"` + Recommendations []MediaResult `json:"recommendations,omitempty"` + Availability Availability `json:"availability"` + Request RequestState `json:"request"` +} + type CreateRequestInput struct { MediaType MediaType `json:"media_type"` TMDBID int `json:"tmdb_id"` diff --git a/web/src/App.tsx b/web/src/App.tsx index 4cfd6df7..583a2910 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import PersonDetail from "@/pages/PersonDetail"; import Collections from "@/pages/Collections"; import CollectionEditor from "@/pages/CollectionEditor"; import Requests from "@/pages/Requests"; +import RequestDetail from "@/pages/RequestDetail"; import AdminDashboard from "@/pages/AdminDashboard"; import AdminActivity from "@/pages/AdminActivity"; import AdminLogs from "@/pages/AdminLogs"; @@ -448,6 +449,7 @@ function AppRoutes() { element={} /> } /> + } /> } /> + { e.preventDefault(); + e.stopPropagation(); onRequest(); }} className="pointer-events-auto inline-flex items-center gap-1.5 rounded-full bg-white px-3.5 py-1.5 text-[12px] font-semibold tracking-wide text-black shadow-lg shadow-black/40 transition-all hover:scale-[1.03] active:scale-[0.97] disabled:opacity-70" @@ -92,7 +100,7 @@ function DiscoverCard({ - + ); } @@ -106,7 +114,13 @@ function MineCard({ request }: { request: MediaRequest }) { request.outcome === "cancelled"; return ( -
+ ) : null} -
+ ); } diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 53e18432..ad7325f0 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -123,6 +123,7 @@ export const requestKeys = { ["requests", "discovery", section, page] as const, search: (mediaType: string, query: string, page: number) => ["requests", "search", mediaType, query, page] as const, + detail: (mediaType: string, tmdbID: number) => ["requests", "detail", mediaType, tmdbID] as const, mine: (params: Record) => ["requests", "mine", params] as const, }; diff --git a/web/src/hooks/queries/requests.ts b/web/src/hooks/queries/requests.ts index bf5ece6f..eab55ed0 100644 --- a/web/src/hooks/queries/requests.ts +++ b/web/src/hooks/queries/requests.ts @@ -12,6 +12,7 @@ import type { RequestIntegrationOptions, RequestIntegrationsResponse, RequestListParams, + RequestMediaDetail, RequestMediaPage, RequestMediaType, RequestSettings, @@ -66,6 +67,18 @@ export function useRequestDiscoverySection(section: string, page = 1) { }); } +export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: number) { + return useQuery({ + queryKey: requestKeys.detail(mediaType, tmdbID), + queryFn: () => + api( + `/requests/detail/${encodeURIComponent(mediaType)}/${encodeURIComponent(String(tmdbID))}`, + ), + enabled: tmdbID > 0, + staleTime: REQUESTS_STALE_TIME, + }); +} + export function useRequestSearch(mediaType: RequestMediaType, query: string, page = 1) { const normalizedQuery = query.trim(); return useQuery({ diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index d1b89e5d..044b8c86 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import type { ReactNode } from "react"; -import { useSearchParams } from "react-router"; +import { Link, useSearchParams } from "react-router"; import { Check, Plug, RefreshCw, Save, Settings2, SlidersHorizontal, X } from "lucide-react"; import type { MediaRequest, @@ -130,9 +130,13 @@ function RequestQueueTab() { const [status, setStatus] = useState("all"); const [outcome, setOutcome] = useState("all"); const requests = useAdminMediaRequests({ status, outcome, limit: 100 }); + const users = useAdminUsers(); const approve = useApproveMediaRequest(); const decline = useDeclineMediaRequest(); const retry = useRetryMediaRequest(); + const usernamesByID = useMemo(() => { + return new Map((users.data ?? []).map((user) => [user.id, user.username])); + }, [users.data]); function handleDecline(request: MediaRequest) { const reason = window.prompt(`Decline "${request.title}"?`, ""); @@ -207,6 +211,11 @@ function RequestQueueTab() {
- {request.title} + + {request.title} + {formatMediaType(request.media_type)}
{request.year ? {request.year} : null} TMDB {request.tmdb_id} - {request.requested_by_user_id ? User {request.requested_by_user_id} : null} + {request.requested_by_user_id ? ( + + {requesterLabel} + + ) : null}
{request.last_error ? (

{request.last_error}

diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx new file mode 100644 index 00000000..f36a510b --- /dev/null +++ b/web/src/pages/RequestDetail.tsx @@ -0,0 +1,469 @@ +import { useMemo } from "react"; +import { useNavigate, useParams } from "react-router"; +import { ArrowLeft, Check, Clock, Loader2, Plus, Star } from "lucide-react"; +import MediaCarousel from "@/components/MediaCarousel"; +import RequestPosterCard from "@/components/RequestPosterCard"; +import DetailHero from "@/pages/ItemDetail/DetailHero"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { RequestMediaCastMember, RequestMediaDetail, RequestMediaResult } from "@/api/types"; +import { useCreateMediaRequest, useRequestMediaDetail } from "@/hooks/queries/requests"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { cn } from "@/lib/utils"; +import { getInitials } from "@/lib/text"; +import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests"; + +export default function RequestDetail() { + const navigate = useNavigate(); + const params = useParams<{ mediaType: string; tmdbId: string }>(); + const mediaType = (params.mediaType === "series" ? "series" : "movie") as "movie" | "series"; + const tmdbID = Number(params.tmdbId) || 0; + + const detail = useRequestMediaDetail(mediaType, tmdbID); + const createRequest = useCreateMediaRequest(); + + useDocumentTitle(detail.data?.title ?? "Request"); + + if (detail.isLoading) { + return ; + } + + if (detail.isError || !detail.data) { + return ( +
+

Couldn't load this title.

+

+ The TMDB record may be temporarily unavailable. +

+
+ +
+
+ ); + } + + const item = detail.data; + const backdropUrl = tmdbImageURL(item.backdrop_path, "original") ?? undefined; + const posterUrl = tmdbImageURL(item.poster_path, "w500") ?? undefined; + const studioLabel = pickStudioLabel(item); + + return ( +
+ } + studioLabel={studioLabel} + backdropUrl={backdropUrl} + posterUrl={posterUrl} + tagline={item.tagline || undefined} + metadata={} + scoreRow={} + crewLine={} + overview={item.overview} + actions={ + + createRequest.mutate({ + media_type: item.media_type, + tmdb_id: item.tmdb_id, + title: item.title, + year: item.year || undefined, + overview: item.overview || undefined, + poster_path: item.poster_path || undefined, + backdrop_path: item.backdrop_path || undefined, + }) + } + onBack={() => navigate(-1)} + /> + } + /> + +
+ {item.cast && item.cast.length > 0 && ( +
+

Cast

+ +
+ )} + + {item.recommendations && item.recommendations.length > 0 && ( + + createRequest.mutate({ + media_type: rec.media_type, + tmdb_id: rec.tmdb_id, + title: rec.title, + year: rec.year || undefined, + overview: rec.overview || undefined, + poster_path: rec.poster_path || undefined, + backdrop_path: rec.backdrop_path || undefined, + }) + } + /> + )} +
+
+ ); +} + +function RequestContext({ mediaType }: { mediaType: "movie" | "series" }) { + return ( + + Request · {mediaType === "series" ? "Series" : "Movie"} + + ); +} + +function MetaPills({ item }: { item: RequestMediaDetail }) { + const pills: string[] = []; + if (item.year) pills.push(String(item.year)); + if (item.content_rating) pills.push(item.content_rating); + if (item.media_type === "movie" && item.runtime) pills.push(formatDuration(item.runtime)); + if (item.media_type === "series" && item.number_of_seasons) + pills.push(`${item.number_of_seasons} Season${item.number_of_seasons === 1 ? "" : "s"}`); + if (item.media_type === "series" && item.status) pills.push(item.status); + + return ( +
+ {pills.map((pill) => ( + + {pill} + + ))} + {(item.genres ?? []).slice(0, 4).map((genre) => ( + + {genre} + + ))} +
+ ); +} + +function RequestScoreRow({ item }: { item: RequestMediaDetail }) { + if (!item.vote_average) return null; + return ( +
+ + + {item.vote_average.toFixed(1)} + TMDB + + {item.vote_count ? ( + {formatVoteCount(item.vote_count)} votes + ) : null} +
+ ); +} + +function RequestCrewLine({ item }: { item: RequestMediaDetail }) { + const parts: { label: string; value: string }[] = []; + if (item.director) parts.push({ label: "Director", value: item.director }); + if (item.creators && item.creators.length > 0) + parts.push({ label: "Created by", value: item.creators.join(", ") }); + if (item.networks && item.networks.length > 0) + parts.push({ label: "Network", value: item.networks.join(", ") }); + + if (parts.length === 0) return null; + + return ( +
+ {parts.map((part) => ( + + {part.label}: + {part.value} + + ))} +
+ ); +} + +function RequestActions({ + item, + isSubmitting, + onRequest, + onBack, +}: { + item: RequestMediaDetail; + isSubmitting: boolean; + onRequest: () => void; + onBack: () => void; +}) { + const requestable = item.request.requestable; + const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; + const reasonLabel = + !requestable && !item.request.status ? formatRequestReason(item.request.reason) : null; + const availableInLibrary = item.availability === "available" && !item.request.status; + + return ( +
+ + + {requestable ? ( + + ) : availableInLibrary ? ( + } + label="Already in your library" + /> + ) : statusLabel ? ( + } + label={statusLabel} + /> + ) : ( + } + label={reasonLabel ?? "Unavailable"} + /> + )} + + {item.imdb_id ? ( + + IMDb + + ) : null} + + TMDB + +
+ ); +} + +const STATUS_TONES: Record<"amber" | "sky" | "emerald" | "zinc", string> = { + amber: "bg-amber-500/15 text-amber-100 ring-amber-400/40", + sky: "bg-sky-500/15 text-sky-100 ring-sky-400/40", + emerald: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40", + zinc: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40", +}; + +function StatusBlock({ + tone, + icon, + label, +}: { + tone: "amber" | "sky" | "emerald" | "zinc"; + icon: React.ReactNode; + label: string; +}) { + return ( + + {icon} + {label} + + ); +} + +function statusToneForStatus(status: string): "amber" | "sky" | "emerald" | "zinc" { + switch (status) { + case "pending": + return "amber"; + case "approved": + case "completed": + return "emerald"; + case "queued": + case "downloading": + return "sky"; + default: + return "zinc"; + } +} + +function RequestCastRow({ cast }: { cast: RequestMediaCastMember[] }) { + const sorted = useMemo( + () => + cast + .slice() + .sort((a, b) => a.order - b.order) + .slice(0, 24), + [cast], + ); + return ( +
+
    + {sorted.map((member, index) => { + const photo = tmdbImageURL(member.profile_path, "w185"); + return ( +
  • +
    + {photo ? ( + {member.name} + ) : ( +
    + {getInitials(member.name)} +
    + )} +
    +
    +
    + {member.name} +
    + {member.character ? ( +
    + {member.character} +
    + ) : null} +
    +
  • + ); + })} +
+
+ ); +} + +function RecommendationsRow({ + recommendations, + pendingTMDBID, + isSubmitting, + onRequest, +}: { + recommendations: RequestMediaResult[]; + pendingTMDBID?: number; + isSubmitting: boolean; + onRequest: (item: RequestMediaResult) => void; +}) { + return ( +
+
+ + More like this + +
+ + {recommendations.map((item) => ( + onRequest(item)} + /> + ))} + +
+ ); +} + +function pickStudioLabel(item: RequestMediaDetail): string | undefined { + if (item.media_type === "series" && item.networks && item.networks.length > 0) { + return item.networks[0]; + } + if (item.production_companies && item.production_companies.length > 0) { + return item.production_companies[0]; + } + return undefined; +} + +function formatDuration(minutes: number): string { + if (minutes <= 0) return ""; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h <= 0) return `${m}m`; + return m === 0 ? `${h}h` : `${h}h ${m}m`; +} + +function formatVoteCount(count: number): string { + if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; + return String(count); +} + +function RequestDetailSkeleton() { + return ( +
+
+
+
+
+
+ +
+ + + + + + +
+ + +
+
+
+
+
+
+
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+
+
+ ); +} diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx index d3db7de1..42095945 100644 --- a/web/src/pages/Requests.tsx +++ b/web/src/pages/Requests.tsx @@ -82,33 +82,37 @@ export default function Requests() { const totalMine = (mine.data ?? []).length; return ( -
- +
+
+ - + +
- - - Discover - - - Yours - {totalMine > 0 && ( - - {totalMine} - - )} - - +
+ + + Discover + + + Yours + {totalMine > 0 && ( + + {totalMine} + + )} + + +
{isSearching ? (