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
This commit is contained in:
Silo Server Migration
2026-05-24 17:03:29 -04:00
parent 246c9da6ab
commit 5b8aa9f34c
18 changed files with 1246 additions and 101 deletions
+20
View File
@@ -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 {
+1
View File
@@ -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)
+254
View File
@@ -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
+169
View File
@@ -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"`
}
+23 -1
View File
@@ -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
}
+20 -33
View File
@@ -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("")
+96
View File
@@ -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
+5
View File
@@ -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
+20 -36
View File
@@ -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("")
+41
View File
@@ -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"`
+2
View File
@@ -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={<LegacyUserCollectionRedirect />}
/>
<Route path="/requests" element={<Requests />} />
<Route path="/requests/:mediaType/:tmdbId" element={<RequestDetail />} />
<Route path="/recommendations" element={<Recommendations />} />
<Route
path="/recommendations/section/:kind"
+41
View File
@@ -1419,6 +1419,47 @@ export interface RequestMediaPage {
results: RequestMediaResult[];
}
export interface RequestMediaCastMember {
name: string;
character?: string;
profile_path?: string;
order: number;
}
export interface RequestMediaDetail {
media_type: RequestMediaType;
tmdb_id: number;
imdb_id?: string;
tvdb_id?: number;
title: string;
original_title?: string;
tagline?: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
release_date?: string;
year?: number;
runtime?: number;
genres?: string[];
vote_average?: number;
vote_count?: number;
status?: string;
homepage?: string;
content_rating?: string;
production_companies?: string[];
number_of_seasons?: number;
number_of_episodes?: number;
first_air_date?: string;
last_air_date?: string;
networks?: string[];
cast?: RequestMediaCastMember[];
director?: string;
creators?: string[];
recommendations?: RequestMediaResult[];
availability: RequestAvailability;
request: RequestState;
}
export interface RequestDiscoverySection extends RequestMediaPage {
key: string;
title: string;
+18 -4
View File
@@ -1,3 +1,4 @@
import { Link } from "react-router";
import { Check, Film, Loader2, Plus, Tv } from "lucide-react";
import type { MediaRequest, RequestMediaResult } from "@/api/types";
import { cn } from "@/lib/utils";
@@ -45,7 +46,13 @@ function DiscoverCard({
const availableInLibrary = item.availability === "available" && !item.request.status;
return (
<article className={cn("group/req-card relative", POSTER_WIDTH)}>
<Link
to={`/requests/${item.media_type}/${item.tmdb_id}`}
className={cn(
"group/req-card relative block focus:outline-none focus-visible:outline-none",
POSTER_WIDTH,
)}
>
<PosterFrame
poster={poster}
title={item.title}
@@ -71,6 +78,7 @@ function DiscoverCard({
disabled={isSubmitting}
onClick={(e) => {
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({
</PosterFrame>
<CardMeta title={item.title} year={item.year} rating={item.vote_average} />
</article>
</Link>
);
}
@@ -106,7 +114,13 @@ function MineCard({ request }: { request: MediaRequest }) {
request.outcome === "cancelled";
return (
<article className={cn("group/req-card relative", POSTER_WIDTH)}>
<Link
to={`/requests/${request.media_type}/${request.tmdb_id}`}
className={cn(
"group/req-card relative block focus:outline-none focus-visible:outline-none",
POSTER_WIDTH,
)}
>
<PosterFrame
poster={poster}
title={request.title}
@@ -145,7 +159,7 @@ function MineCard({ request }: { request: MediaRequest }) {
{request.last_error}
</p>
) : null}
</article>
</Link>
);
}
+1
View File
@@ -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<string, unknown>) => ["requests", "mine", params] as const,
};
+13
View File
@@ -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<RequestMediaDetail>(
`/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({
+25 -3
View File
@@ -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<StatusFilter>("all");
const [outcome, setOutcome] = useState<OutcomeFilter>("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() {
<RequestQueueRow
key={request.id}
request={request}
requesterUsername={
request.requested_by_user_id
? usernamesByID.get(request.requested_by_user_id)
: undefined
}
approving={approve.isPending && approve.variables === request.id}
declining={decline.isPending && decline.variables?.id === request.id}
retrying={retry.isPending && retry.variables === request.id}
@@ -226,6 +235,7 @@ function RequestQueueTab() {
function RequestQueueRow({
request,
requesterUsername,
approving,
declining,
retrying,
@@ -234,6 +244,7 @@ function RequestQueueRow({
onRetry,
}: {
request: MediaRequest;
requesterUsername?: string;
approving: boolean;
declining: boolean;
retrying: boolean;
@@ -244,19 +255,30 @@ function RequestQueueRow({
const canApprove = request.status === "pending" && request.outcome === "active";
const canDecline = request.status !== "completed" && request.outcome === "active";
const canRetry = request.outcome === "failed";
const requesterLabel = requesterUsername ?? `User ${request.requested_by_user_id}`;
const requestDetailHref = `/requests/${request.media_type}/${request.tmdb_id}`;
return (
<TableRow>
<TableCell>
<div className="min-w-[220px]">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{request.title}</span>
<Link to={requestDetailHref} className="font-medium hover:underline">
{request.title}
</Link>
<Badge variant="secondary">{formatMediaType(request.media_type)}</Badge>
</div>
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
{request.year ? <span>{request.year}</span> : null}
<span>TMDB {request.tmdb_id}</span>
{request.requested_by_user_id ? <span>User {request.requested_by_user_id}</span> : null}
{request.requested_by_user_id ? (
<Link
to={`/admin/users/${request.requested_by_user_id}`}
className="hover:text-foreground hover:underline"
>
{requesterLabel}
</Link>
) : null}
</div>
{request.last_error ? (
<p className="text-destructive mt-1 max-w-md text-xs">{request.last_error}</p>
+469
View File
@@ -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 <RequestDetailSkeleton />;
}
if (detail.isError || !detail.data) {
return (
<div className="page-shell space-y-3 py-12 text-center">
<p className="text-foreground text-base font-semibold">Couldn't load this title.</p>
<p className="text-muted-foreground text-sm">
The TMDB record may be temporarily unavailable.
</p>
<div className="pt-4">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</div>
</div>
);
}
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 (
<div>
<DetailHero
title={item.title}
context={<RequestContext mediaType={mediaType} />}
studioLabel={studioLabel}
backdropUrl={backdropUrl}
posterUrl={posterUrl}
tagline={item.tagline || undefined}
metadata={<MetaPills item={item} />}
scoreRow={<RequestScoreRow item={item} />}
crewLine={<RequestCrewLine item={item} />}
overview={item.overview}
actions={
<RequestActions
item={item}
isSubmitting={
createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id
}
onRequest={() =>
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)}
/>
}
/>
<div className="page-shell space-y-12 py-10 sm:space-y-14">
{item.cast && item.cast.length > 0 && (
<section>
<h2 className="mb-5 text-xl font-semibold tracking-tight">Cast</h2>
<RequestCastRow cast={item.cast} />
</section>
)}
{item.recommendations && item.recommendations.length > 0 && (
<RecommendationsRow
recommendations={item.recommendations}
pendingTMDBID={createRequest.variables?.tmdb_id}
isSubmitting={createRequest.isPending}
onRequest={(rec) =>
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,
})
}
/>
)}
</div>
</div>
);
}
function RequestContext({ mediaType }: { mediaType: "movie" | "series" }) {
return (
<span className="text-muted-foreground inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-[0.22em] uppercase">
Request · {mediaType === "series" ? "Series" : "Movie"}
</span>
);
}
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 (
<div className="flex flex-wrap items-center gap-2">
{pills.map((pill) => (
<span
key={pill}
className="border-border/60 bg-card/40 inline-flex items-center rounded-md border px-2 py-0.5 text-[12px] font-medium tracking-normal"
>
{pill}
</span>
))}
{(item.genres ?? []).slice(0, 4).map((genre) => (
<span
key={genre}
className="text-muted-foreground inline-flex items-center rounded-md px-1 py-0.5 text-[12px]"
>
{genre}
</span>
))}
</div>
);
}
function RequestScoreRow({ item }: { item: RequestMediaDetail }) {
if (!item.vote_average) return null;
return (
<div className="text-muted-foreground flex items-center gap-3 text-[13px]">
<span className="inline-flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 fill-amber-300/90 text-amber-300/90" />
<span className="text-foreground tabular-nums">{item.vote_average.toFixed(1)}</span>
<span className="opacity-60">TMDB</span>
</span>
{item.vote_count ? (
<span className="tabular-nums opacity-70">{formatVoteCount(item.vote_count)} votes</span>
) : null}
</div>
);
}
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 (
<div className="text-muted-foreground flex flex-wrap gap-x-5 gap-y-1 text-[13px]">
{parts.map((part) => (
<span key={part.label} className="inline-flex gap-1.5">
<span className="opacity-60">{part.label}:</span>
<span className="text-foreground/90">{part.value}</span>
</span>
))}
</div>
);
}
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 (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
{requestable ? (
<Button
onClick={onRequest}
disabled={isSubmitting}
className="h-11 rounded-full px-6 text-sm font-semibold"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Submitting
</>
) : (
<>
<Plus className="h-4 w-4 stroke-[2.5]" />
Request {item.media_type === "series" ? "series" : "movie"}
</>
)}
</Button>
) : availableInLibrary ? (
<StatusBlock
tone="emerald"
icon={<Check className="h-4 w-4 stroke-[2.5]" />}
label="Already in your library"
/>
) : statusLabel ? (
<StatusBlock
tone={statusToneForStatus(item.request.status!)}
icon={<Clock className="h-4 w-4" />}
label={statusLabel}
/>
) : (
<StatusBlock
tone="zinc"
icon={<Clock className="h-4 w-4" />}
label={reasonLabel ?? "Unavailable"}
/>
)}
{item.imdb_id ? (
<a
href={`https://www.imdb.com/title/${item.imdb_id}`}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground border-border/60 inline-flex items-center rounded-full border px-3 py-1.5 text-[12px] font-medium tracking-wide transition-colors"
>
IMDb
</a>
) : null}
<a
href={`https://www.themoviedb.org/${item.media_type === "series" ? "tv" : "movie"}/${item.tmdb_id}`}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground border-border/60 inline-flex items-center rounded-full border px-3 py-1.5 text-[12px] font-medium tracking-wide transition-colors"
>
TMDB
</a>
</div>
);
}
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 (
<span
className={cn(
"inline-flex h-11 items-center gap-2 rounded-full px-5 text-sm font-semibold ring-1",
STATUS_TONES[tone],
)}
>
{icon}
{label}
</span>
);
}
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 (
<div className="-mx-4 overflow-x-auto px-4 sm:-mx-6 sm:px-6 lg:-mx-10 lg:px-10 xl:-mx-12 xl:px-12">
<ul role="list" className="flex w-max list-none gap-3">
{sorted.map((member, index) => {
const photo = tmdbImageURL(member.profile_path, "w185");
return (
<li key={`${member.name}-${index}`} className="w-[110px] shrink-0">
<div className="media-card-image mb-2.5 aspect-[2/3] overflow-hidden rounded-lg">
{photo ? (
<img
src={photo}
alt={member.name}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div className="bg-surface text-muted-foreground flex h-full w-full items-center justify-center text-lg font-semibold">
{getInitials(member.name)}
</div>
)}
</div>
<div className="px-0.5">
<div className="text-foreground truncate text-[13px] font-medium">
{member.name}
</div>
{member.character ? (
<div className="text-muted-foreground truncate text-[11px]">
{member.character}
</div>
) : null}
</div>
</li>
);
})}
</ul>
</div>
);
}
function RecommendationsRow({
recommendations,
pendingTMDBID,
isSubmitting,
onRequest,
}: {
recommendations: RequestMediaResult[];
pendingTMDBID?: number;
isSubmitting: boolean;
onRequest: (item: RequestMediaResult) => void;
}) {
return (
<section className="space-y-1">
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
<span className="text-muted-foreground text-[10px] font-semibold tracking-[0.22em] uppercase">
More like this
</span>
</div>
<MediaCarousel title="Recommendations">
{recommendations.map((item) => (
<RequestPosterCard
key={`${item.media_type}-${item.tmdb_id}`}
variant="discover"
item={item}
isSubmitting={isSubmitting && pendingTMDBID === item.tmdb_id}
onRequest={() => onRequest(item)}
/>
))}
</MediaCarousel>
</section>
);
}
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 (
<div>
<section className="border-border/10 relative isolate overflow-hidden border-b">
<div className="absolute inset-0 bg-gradient-to-r from-[var(--background)] via-[var(--background)]/70 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-[var(--background)] via-[var(--background)]/40 to-transparent" />
<div className="page-shell-wide relative flex min-h-[60dvh] flex-col justify-end pt-28 pb-8 lg:min-h-[72dvh]">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end">
<Skeleton className="aspect-[2/3] w-[170px] flex-shrink-0 rounded-lg sm:w-[220px]" />
<div className="max-w-3xl flex-1 space-y-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-80 max-w-full" />
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full max-w-2xl" />
<Skeleton className="h-4 w-5/6 max-w-xl" />
<Skeleton className="h-4 w-3/4 max-w-lg" />
<div className="flex gap-3 pt-2">
<Skeleton className="h-11 w-40 rounded-full" />
<Skeleton className="h-11 w-20 rounded-full" />
</div>
</div>
</div>
</div>
</section>
<div className="page-shell space-y-12 py-10 sm:space-y-14">
<div>
<Skeleton className="mb-5 h-6 w-24 rounded" />
<div className="flex gap-3 overflow-hidden">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-[2/3] w-[110px] shrink-0 rounded-lg" />
))}
</div>
</div>
</div>
</div>
);
}
+28 -24
View File
@@ -82,33 +82,37 @@ export default function Requests() {
const totalMine = (mine.data ?? []).length;
return (
<div className="page-shell space-y-8 py-6 sm:py-8">
<PageHeader />
<div className="space-y-8 py-6 sm:py-8">
<div className="space-y-8 px-4 sm:px-6 lg:px-10 xl:px-12">
<PageHeader />
<SearchBar
mediaType={mediaType}
onMediaTypeChange={setMediaType}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
onSubmit={handleSearch}
onClear={clearSearch}
isSearching={isSearching}
/>
<SearchBar
mediaType={mediaType}
onMediaTypeChange={setMediaType}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
onSubmit={handleSearch}
onClear={clearSearch}
isSearching={isSearching}
/>
</div>
<Tabs defaultValue="discover">
<TabsList variant="line" className="border-border w-full justify-start border-b">
<TabsTrigger value="discover" className="px-3 text-[13px]">
Discover
</TabsTrigger>
<TabsTrigger value="mine" className="px-3 text-[13px]">
Yours
{totalMine > 0 && (
<span className="bg-muted/80 text-muted-foreground ml-1.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] font-semibold tabular-nums">
{totalMine}
</span>
)}
</TabsTrigger>
</TabsList>
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
<TabsList variant="line" className="border-border w-full justify-start border-b">
<TabsTrigger value="discover" className="px-3 text-[13px]">
Discover
</TabsTrigger>
<TabsTrigger value="mine" className="px-3 text-[13px]">
Yours
{totalMine > 0 && (
<span className="bg-muted/80 text-muted-foreground ml-1.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] font-semibold tabular-nums">
{totalMine}
</span>
)}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="discover" className="space-y-8 pt-2">
{isSearching ? (