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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user