fix(requests): harden integration submission and queue handling

- Batch integration upserts in a single transaction
- Treat radarr/sonarr lookup results as arrays and require exact matches
- Prefer queue failures over downloading state when evaluating arr queues
- Allow retrying queued/downloading requests and block declines once fulfillment started
- Fall back to pending when auto-approval integration check fails
- Rename requests query hooks file and fix discover card request affordance
This commit is contained in:
Silo Server Migration
2026-05-24 21:40:34 -04:00
parent f023c4a6f9
commit 98bebd4e5e
22 changed files with 418 additions and 169 deletions
@@ -5,10 +5,10 @@ Status: design approved 2026-05-24
## Dependency
This feature extends the media request system spec'd in
`docs/superpowers/specs/request-system.md` (currently on branch
`t3code/df875f01`). It must land on top of that branch — it depends on
`internal/requests/`, `internal/metadata/tmdb/`, `Requests.tsx`, and the
existing `/api/v1/requests/discover/*` route prefix.
`docs/superpowers/specs/request-system.md`. It depends on `internal/requests/`,
`internal/metadata/tmdb/`, `Requests.tsx`, and the existing
`/api/v1/requests/discover/*` route prefix. Commands assume the repository root
is the cwd.
## Goal
@@ -170,7 +170,7 @@ discover routes.
### List endpoints
```
```http
GET /api/v1/requests/discover/studios
GET /api/v1/requests/discover/networks
GET /api/v1/requests/discover/genres
@@ -182,7 +182,7 @@ the response is not failed wholesale.
### Browse endpoints
```
```http
GET /api/v1/requests/discover/browse/studio/{slug}?page=1&sort=popularity
GET /api/v1/requests/discover/browse/network/{slug}?page=1&sort=popularity
GET /api/v1/requests/discover/browse/genre/{slug}?media_type=movie&page=1&sort=popularity
@@ -247,7 +247,7 @@ remains.
### New routes (`web/src/App.tsx`)
```
```text
/requests/browse/studio/:slug
/requests/browse/network/:slug
/requests/browse/genre/:slug
@@ -276,7 +276,7 @@ Empty/error states:
- Empty results → "Nothing matched — try a different sort."
- Network error → existing toast pattern from the request hooks.
### New hooks (`web/src/hooks/queries/requests.ts`)
### New hooks (`web/src/hooks/queries/useRequests.ts`)
- `useDiscoverStudios()`, `useDiscoverNetworks()`, `useDiscoverGenres()` —
24h stale time matching the server cache.
+9 -9
View File
@@ -33,6 +33,7 @@ type RequestService interface {
UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error)
ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error)
UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
UpsertIntegrations(ctx context.Context, viewer mediarequests.Viewer, integrations []mediarequests.Integration) ([]mediarequests.Integration, error)
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
@@ -311,7 +312,10 @@ func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request)
Reason string `json:"reason"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
}
req, err := h.service.Decline(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
if err != nil {
@@ -392,14 +396,10 @@ func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *htt
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
updated := make([]mediarequests.Integration, 0, len(body.Integrations))
for _, integration := range body.Integrations {
result, err := h.service.UpsertIntegration(r.Context(), viewer, integration)
if err != nil {
writeRequestServiceError(w, err)
return
}
updated = append(updated, *result)
updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations)
if err != nil {
writeRequestServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, struct {
Integrations []requestIntegrationResponse `json:"integrations"`
+4
View File
@@ -123,6 +123,10 @@ func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Vi
return nil, nil
}
func (f *fakeRequestService) UpsertIntegrations(context.Context, mediarequests.Viewer, []mediarequests.Integration) ([]mediarequests.Integration, error) {
return nil, nil
}
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
return nil, nil
}
+5 -1
View File
@@ -889,8 +889,12 @@ func normalizeCast(cast []castEntry) []MediaCastMember {
}
out := make([]MediaCastMember, 0, len(sorted))
for _, member := range sorted {
name := strings.TrimSpace(member.Name)
if name == "" {
continue
}
out = append(out, MediaCastMember{
Name: strings.TrimSpace(member.Name),
Name: name,
Character: strings.TrimSpace(member.Character),
ProfilePath: member.ProfilePath,
Order: member.Order,
+9 -1
View File
@@ -32,6 +32,7 @@ func EvaluateQueue(resources []QueueResource) QueueEvaluation {
}
queued := false
downloadingStatus := ""
for _, resource := range resources {
status := strings.TrimSpace(resource.Status)
state := strings.TrimSpace(resource.TrackedDownloadState)
@@ -45,10 +46,17 @@ func EvaluateQueue(resources []QueueResource) QueueEvaluation {
}
}
if status == "downloading" || state == "downloading" || state == "importPending" || state == "importing" {
return QueueEvaluation{State: QueueStateDownloading, ExternalStatus: externalStatus}
if downloadingStatus == "" {
downloadingStatus = externalStatus
}
queued = true
continue
}
queued = true
}
if downloadingStatus != "" {
return QueueEvaluation{State: QueueStateDownloading, ExternalStatus: downloadingStatus}
}
if queued {
return QueueEvaluation{State: QueueStateQueued, ExternalStatus: "queued"}
}
+20
View File
@@ -0,0 +1,20 @@
package arrclient
import "testing"
func TestEvaluateQueueFailureWinsOverDownloading(t *testing.T) {
result := EvaluateQueue([]QueueResource{
{Status: "downloading", TrackedDownloadState: "downloading"},
{Title: "Bad Release", Status: "queued", TrackedDownloadState: "failedPending"},
})
if result.State != QueueStateFailed {
t.Fatalf("state = %q, want failed", result.State)
}
if result.ExternalStatus != "queued/failedPending" {
t.Fatalf("external status = %q, want queued/failedPending", result.ExternalStatus)
}
if result.Message == "" {
t.Fatal("message should describe the queue failure")
}
}
+3
View File
@@ -30,6 +30,9 @@ func (p *CatalogPresence) LookupTMDB(ctx context.Context, mediaType MediaType, t
ids = append(ids, strconv.Itoa(id))
}
}
if len(ids) == 0 {
return out, nil
}
internalType := string(mediaType)
rows, err := p.items.LookupTMDBIDs(ctx, internalType, ids)
if err != nil {
+6 -2
View File
@@ -136,10 +136,14 @@ func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request
func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
values := url.Values{}
values.Set("tmdbId", strconv.Itoa(tmdbID))
var movie movieResource
if err := client.GetJSON(ctx, "/api/v3/movie/lookup/tmdb?"+values.Encode(), &movie); err != nil {
var matches []movieResource
if err := client.GetJSON(ctx, "/api/v3/movie/lookup/tmdb?"+values.Encode(), &matches); err != nil {
return movieResource{}, err
}
if len(matches) == 0 {
return movieResource{}, fmt.Errorf("radarr: no movie found for tmdb_id %d", tmdbID)
}
movie := matches[0]
if movie.TMDBID == 0 {
movie.TMDBID = tmdbID
}
+1 -1
View File
@@ -37,7 +37,7 @@ func TestSubmitMovieAddsLookupResult(t *testing.T) {
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("lookup tmdbId = %q, want 550", got)
}
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
w.Write([]byte(`[{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}]`))
return
}
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
+34 -3
View File
@@ -478,6 +478,35 @@ func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error
}
func (r *Repository) UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) {
out, err := r.upsertIntegration(ctx, r.pool, integration)
if err != nil {
return nil, fmt.Errorf("upsert request integration: %w", err)
}
return out, nil
}
func (r *Repository) UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request integrations transaction: %w", err)
}
defer tx.Rollback(ctx)
out := make([]Integration, 0, len(integrations))
for _, integration := range integrations {
updated, err := r.upsertIntegration(ctx, tx, integration)
if err != nil {
return nil, err
}
out = append(out, *updated)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request integrations transaction: %w", err)
}
return out, nil
}
func (r *Repository) upsertIntegration(ctx context.Context, exec requestExecutor, integration Integration) (*Integration, error) {
if integration.Options == nil {
integration.Options = map[string]any{}
}
@@ -486,7 +515,7 @@ func (r *Repository) UpsertIntegration(ctx context.Context, integration Integrat
return nil, fmt.Errorf("marshal request integration options: %w", err)
}
tags := int32Slice(integration.Tags)
row := r.pool.QueryRow(ctx, `
row := exec.QueryRow(ctx, `
INSERT INTO request_integrations (
kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
tags, options, updated_at
@@ -511,7 +540,7 @@ func (r *Repository) UpsertIntegration(ctx context.Context, integration Integrat
integration.QualityProfileID, tags, options)
out, err := scanIntegration(row)
if err != nil {
return nil, fmt.Errorf("upsert request integration: %w", err)
return nil, err
}
return &out, nil
}
@@ -655,7 +684,9 @@ func scanIntegration(row integrationScanner) (Integration, error) {
}
integration.Tags = intsFromInt32(tags)
if len(optionsRaw) > 0 {
_ = json.Unmarshal(optionsRaw, &integration.Options)
if err := json.Unmarshal(optionsRaw, &integration.Options); err != nil {
return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", integration.Kind, err)
}
}
if integration.Options == nil {
integration.Options = map[string]any{}
+28 -9
View File
@@ -247,8 +247,6 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
if err != nil {
return nil, err
}
s.enrichExternalIDs(ctx, &normalized)
available, err := s.lookupAvailable(ctx, normalized.MediaType, []int{normalized.TMDBID})
if err != nil {
return nil, err
@@ -273,6 +271,8 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
return nil, err
}
s.enrichExternalIDs(ctx, &normalized)
id, err := idgen.NextID()
if err != nil {
return nil, err
@@ -280,10 +280,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
status := StatusPending
if policy.AutoApprove {
configured, err := s.integrationConfigured(ctx, normalized.MediaType)
if err != nil {
return nil, err
}
if configured {
if err == nil && configured {
status = StatusApproved
}
}
@@ -358,7 +355,12 @@ func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string)
if err != nil {
return nil, err
}
if req.Outcome != OutcomeActive || req.Status == StatusCompleted {
if req.Outcome != OutcomeActive ||
req.Status == StatusCompleted ||
req.Status == StatusQueued ||
req.Status == StatusDownloading ||
strings.TrimSpace(req.ExternalID) != "" ||
strings.TrimSpace(req.IntegrationKind) != "" {
return nil, ErrInvalidState
}
return s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason)
@@ -379,8 +381,10 @@ func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request
if err != nil {
return nil, err
}
if active.Status == StatusApproved {
return s.submitApprovedRequest(ctx, *active, viewer)
if active.Status == StatusApproved || active.Status == StatusQueued || active.Status == StatusDownloading {
retry := *active
retry.Status = StatusApproved
return s.submitApprovedRequest(ctx, retry, viewer)
}
return active, nil
}
@@ -489,6 +493,21 @@ func (s *Service) UpsertIntegration(ctx context.Context, viewer Viewer, integrat
return s.store.UpsertIntegration(ctx, normalized)
}
func (s *Service) UpsertIntegrations(ctx context.Context, viewer Viewer, integrations []Integration) ([]Integration, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
normalized := make([]Integration, 0, len(integrations))
for _, integration := range integrations {
item, err := normalizeIntegration(integration)
if err != nil {
return nil, err
}
normalized = append(normalized, item)
}
return s.store.UpsertIntegrations(ctx, normalized)
}
func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
+140 -20
View File
@@ -107,6 +107,35 @@ func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) {
}
}
func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
qualityProfileID := 1
store.integrations = []Integration{{
Kind: "radarr",
Enabled: true,
BaseURL: "http://radarr.local",
APIKeyRef: "requests.radarr.api_key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
}}
service := newTestService(store)
service.SetSecretResolver(fakeSecretError{err: errors.New("secret lookup unavailable")})
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest returned error: %v", err)
}
if req.Status != StatusPending {
t.Fatalf("status = %q, want pending", req.Status)
}
}
func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
@@ -406,6 +435,66 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) {
}
}
func TestDeclineRejectsQueuedRequests(t *testing.T) {
store := newFakeStore()
store.requests["req-1"] = &Request{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeActive,
IntegrationKind: "radarr",
ExternalID: "42",
}
service := newTestService(store)
_, err := service.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1", "not needed")
if !errors.Is(err, ErrInvalidState) {
t.Fatalf("err = %v, want ErrInvalidState", err)
}
}
func TestRetryResubmitsFailedQueuedRequest(t *testing.T) {
store := newFakeStore()
qualityProfileID := 1
store.integrations = []Integration{{
Kind: "radarr",
Enabled: true,
BaseURL: "http://radarr.local",
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
}}
store.requests["req-1"] = &Request{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Status: StatusQueued,
Outcome: OutcomeFailed,
}
adapter := &fakeMovieAdapter{result: FulfillmentResult{
IntegrationKind: "radarr",
ExternalID: "99",
ExternalStatus: "queued",
}}
service := newTestService(store)
service.SetFulfillmentAdapters(adapter, nil)
req, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1")
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if adapter.calls != 1 {
t.Fatalf("adapter calls = %d, want 1", adapter.calls)
}
if adapter.gotReq.Status != StatusApproved {
t.Fatalf("submitted status = %q, want approved", adapter.gotReq.Status)
}
if req.Status != StatusQueued || req.ExternalID != "99" {
t.Fatalf("request = %+v, want re-queued with external id 99", req)
}
}
func newTestService(store *fakeStore) *Service {
return newTestServiceWithTMDB(store, &fakeTMDBClient{})
}
@@ -430,6 +519,7 @@ type fakeStore struct {
queued []QueueUpdate
candidates []*Request
statusUpdates []Status
requests map[string]*Request
}
func newFakeStore() *fakeStore {
@@ -442,6 +532,7 @@ func newFakeStore() *fakeStore {
MediaTypeMovie: {},
MediaTypeSeries: {},
},
requests: map[string]*Request{},
}
}
@@ -496,8 +587,13 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord)
}, nil
}
func (f *fakeStore) GetRequest(context.Context, string) (*Request, error) {
return nil, ErrNotFound
func (f *fakeStore) GetRequest(_ context.Context, id string) (*Request, error) {
req := f.requests[strings.TrimSpace(id)]
if req == nil {
return nil, ErrNotFound
}
copy := *req
return &copy, nil
}
func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Request, error) {
@@ -514,31 +610,42 @@ func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) {
func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Viewer) (*Request, error) {
f.statusUpdates = append(f.statusUpdates, status)
return &Request{
ID: id,
Status: status,
Outcome: OutcomeActive,
}, nil
req := f.requests[id]
if req == nil {
req = &Request{ID: id, Outcome: OutcomeActive}
f.requests[id] = req
}
req.Status = status
copy := *req
return &copy, nil
}
func (f *fakeStore) MarkQueued(_ context.Context, id string, update QueueUpdate, _ Viewer) (*Request, error) {
f.queued = append(f.queued, update)
return &Request{
ID: id,
Status: StatusQueued,
Outcome: OutcomeActive,
IntegrationKind: update.IntegrationKind,
ExternalID: update.ExternalID,
ExternalStatus: update.ExternalStatus,
}, nil
req := f.requests[id]
if req == nil {
req = &Request{ID: id}
f.requests[id] = req
}
req.Status = StatusQueued
req.Outcome = OutcomeActive
req.IntegrationKind = update.IntegrationKind
req.ExternalID = update.ExternalID
req.ExternalStatus = update.ExternalStatus
copy := *req
return &copy, nil
}
func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) {
return &Request{
ID: id,
Outcome: outcome,
LastError: message,
}, nil
req := f.requests[id]
if req == nil {
req = &Request{ID: id}
f.requests[id] = req
}
req.Outcome = outcome
req.LastError = message
copy := *req
return &copy, nil
}
func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) {
@@ -549,6 +656,11 @@ func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integratio
return nil, nil
}
func (f *fakeStore) UpsertIntegrations(_ context.Context, integrations []Integration) ([]Integration, error) {
f.integrations = append([]Integration(nil), integrations...)
return append([]Integration(nil), integrations...), nil
}
func TestListStudiosReturnsBundleWithDuotoneLogos(t *testing.T) {
service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{})
@@ -812,3 +924,11 @@ type fakeSecrets map[string]string
func (f fakeSecrets) Get(_ context.Context, key string) (string, error) {
return f[key], nil
}
type fakeSecretError struct {
err error
}
func (f fakeSecretError) Get(context.Context, string) (string, error) {
return "", f.err
}
-3
View File
@@ -152,9 +152,6 @@ func (c *Client) lookupSeries(ctx context.Context, client *arrclient.Client, tvd
return match, nil
}
}
if len(matches) > 0 {
return matches[0], nil
}
return seriesResource{}, fmt.Errorf("sonarr: no series found for tvdb_id %d", tvdbID)
}
+29
View File
@@ -75,6 +75,35 @@ func TestSubmitSeriesAddsLookupResult(t *testing.T) {
}
}
func TestSubmitSeriesRejectsNonExactTVDBLookupMatch(t *testing.T) {
qualityProfileID := 3
tvdbID := 121361
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/series/lookup" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
w.Write([]byte(`[{"title":"Wrong Show","tvdbId":999,"titleSlug":"wrong-show"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
_, err := client.SubmitSeries(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeSeries,
TMDBID: 1399,
TVDBID: &tvdbID,
Title: "Game of Thrones",
}, mediarequests.Integration{
Kind: "sonarr",
BaseURL: server.URL,
APIKeyRef: "sonarr-key",
RootFolder: "/series",
QualityProfileID: &qualityProfileID,
})
if err == nil {
t.Fatal("expected error for non-exact TVDB lookup match")
}
}
func TestCheckSeriesStatusReadsQueueDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "sonarr-key" {
+1
View File
@@ -22,6 +22,7 @@ type Store interface {
SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error)
ListIntegrations(ctx context.Context) ([]Integration, error)
UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error)
UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error)
}
type CreateRequestRecord struct {
+2 -2
View File
@@ -54,8 +54,8 @@ check_pattern \
.
check_pattern \
"absolute /Users path in generated superpowers docs" \
'/Users/[^[:space:]]+' \
"absolute local filesystem path in generated superpowers docs" \
'(/Users/[^[:space:]]+|/home/[^[:space:]]+|/Volumes/[^[:space:]]+|/var/folders/[^[:space:]]+|/private/tmp/[^[:space:]]+|[A-Za-z]:\\Users\\[^[:space:]]+)' \
docs/superpowers/specs docs/superpowers/plans
if [[ "$failed" -ne 0 ]]; then
+48 -44
View File
@@ -64,57 +64,61 @@ function DiscoverCard({
: null;
return (
<Link
to={`/requests/${item.media_type}/${item.tmdb_id}`}
<div
className={cn(
"group/req-card relative block focus:outline-none focus-visible:outline-none",
"group/req-card relative block focus-within:outline-none",
fluid ? "w-full" : POSTER_WIDTH,
)}
>
<PosterFrame
poster={poster}
title={item.title}
mediaType={item.media_type}
dim={!requestable}
accent={ribbon?.kind ?? null}
<Link
to={`/requests/${item.media_type}/${item.tmdb_id}`}
className="block focus:outline-none focus-visible:outline-none"
>
{ribbon && <StatusRibbon status={ribbon.kind} label={ribbon.label} />}
<PosterFrame
poster={poster}
title={item.title}
mediaType={item.media_type}
dim={!requestable}
accent={ribbon?.kind ?? null}
>
{ribbon && <StatusRibbon status={ribbon.kind} label={ribbon.label} />}
</PosterFrame>
{requestable && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex translate-y-2 items-end justify-center bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 opacity-0 transition-all duration-200 ease-out group-focus-within/req-card:translate-y-0 group-focus-within/req-card:opacity-100 group-hover/req-card:translate-y-0 group-hover/req-card:opacity-100">
<button
type="button"
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"
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<Plus className="h-3.5 w-3.5 stroke-[2.5]" />
Request
</>
)}
</button>
</div>
)}
</PosterFrame>
<CardMeta
title={item.title}
year={item.year}
rating={item.vote_average}
mediaType={item.media_type}
/>
</Link>
<CardMeta
title={item.title}
year={item.year}
rating={item.vote_average}
mediaType={item.media_type}
/>
</Link>
{requestable && (
<div className="pointer-events-none absolute inset-x-0 top-0 flex aspect-[2/3] translate-y-2 items-end justify-center bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 opacity-0 transition-all duration-200 ease-out group-focus-within/req-card:translate-y-0 group-focus-within/req-card:opacity-100 group-hover/req-card:translate-y-0 group-hover/req-card:opacity-100">
<button
type="button"
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"
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<Plus className="h-3.5 w-3.5 stroke-[2.5]" />
Request
</>
)}
</button>
</div>
)}
</div>
);
}
+22 -18
View File
@@ -48,7 +48,7 @@ import {
useUpdateRequestIntegrations,
useUpdateRequestSettings,
useUpdateRequestUserLimit,
} from "@/hooks/queries/requests";
} from "@/hooks/queries/useRequests";
import {
formatMediaType,
formatRequestDate,
@@ -535,24 +535,28 @@ function IntegrationEditor({
form.base_url.trim().length > 0 && Boolean(form.api_key_ref.trim() || form.has_api_key);
async function handleLoadOptions() {
const loaded = await loadOptions.mutateAsync({
kind: form.kind,
body: {
base_url: form.base_url,
api_key_ref: form.api_key_ref.trim() || undefined,
},
});
setOptions(loaded);
try {
const loaded = await loadOptions.mutateAsync({
kind: form.kind,
body: {
base_url: form.base_url,
api_key_ref: form.api_key_ref.trim() || undefined,
},
});
setOptions(loaded);
const patch: Partial<IntegrationFormState> = {};
if (!form.root_folder && loaded.root_folders[0]?.path) {
patch.root_folder = loaded.root_folders[0].path;
}
if (!form.quality_profile_id && loaded.quality_profiles[0]?.id) {
patch.quality_profile_id = String(loaded.quality_profiles[0].id);
}
if (Object.keys(patch).length > 0) {
onChange(patch);
const patch: Partial<IntegrationFormState> = {};
if (!form.root_folder && loaded.root_folders[0]?.path) {
patch.root_folder = loaded.root_folders[0].path;
}
if (!form.quality_profile_id && loaded.quality_profiles[0]?.id) {
patch.quality_profile_id = String(loaded.quality_profiles[0].id);
}
if (Object.keys(patch).length > 0) {
onChange(patch);
}
} catch {
// Error toast is handled by useLoadRequestIntegrationOptions.onError.
}
}
+12 -3
View File
@@ -12,7 +12,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useCreateMediaRequest, useRequestBrowse } from "@/hooks/queries/requests";
import { useCreateMediaRequest, useRequestBrowse } from "@/hooks/queries/useRequests";
import { requestInputFromMediaResult } from "@/lib/mediaRequests";
import type {
DiscoverBrowseKind,
@@ -38,13 +38,17 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) {
const [searchParams, setSearchParams] = useSearchParams();
const sort = normalizeSort(searchParams.get("sort"));
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const rawPage = Number(searchParams.get("page") ?? "1");
const page = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1;
const mediaTypeFromQuery = normalizeMediaType(searchParams.get("media_type"));
const mediaType: RequestMediaType | undefined =
kind === "studio" ? "movie" : kind === "network" ? "series" : (mediaTypeFromQuery ?? "movie");
const browse = useRequestBrowse({ kind, slug, mediaType, sort, page });
const createRequest = useCreateMediaRequest();
const pendingRequestKey = createRequest.variables
? mediaRequestKey(createRequest.variables.media_type, createRequest.variables.tmdb_id)
: undefined;
const title = browse.data?.display_name ?? humanizeSlug(slug);
useDocumentTitle(title ? `${title} - Requests` : "Requests");
@@ -161,7 +165,8 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) {
item={item}
onRequest={() => submitRequest(item)}
isSubmitting={
createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id
createRequest.isPending &&
pendingRequestKey === mediaRequestKey(item.media_type, item.tmdb_id)
}
/>
))}
@@ -246,6 +251,10 @@ function normalizeMediaType(value: string | null): RequestMediaType | undefined
return value === "movie" || value === "series" ? value : undefined;
}
function mediaRequestKey(mediaType: RequestMediaType, tmdbID: number): string {
return `${mediaType}-${tmdbID}`;
}
function humanizeSlug(slug: string) {
return slug
.split("-")
+9 -24
View File
@@ -7,11 +7,16 @@ 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 { useCreateMediaRequest, useRequestMediaDetail } from "@/hooks/queries/useRequests";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { cn } from "@/lib/utils";
import { getInitials } from "@/lib/text";
import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests";
import {
formatRequestReason,
formatRequestStatus,
requestInputFromMediaResult,
tmdbImageURL,
} from "@/lib/mediaRequests";
export default function RequestDetail() {
const navigate = useNavigate();
@@ -69,17 +74,7 @@ export default function RequestDetail() {
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,
})
}
onRequest={() => createRequest.mutate(requestInputFromMediaResult(item))}
onBack={() => navigate(-1)}
/>
}
@@ -98,17 +93,7 @@ export default function RequestDetail() {
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,
})
}
onRequest={(rec) => createRequest.mutate(requestInputFromMediaResult(rec))}
/>
)}
</div>
+28 -21
View File
@@ -31,7 +31,7 @@ import {
useMyMediaRequests,
useRequestDiscovery,
useRequestSearch,
} from "@/hooks/queries/requests";
} from "@/hooks/queries/useRequests";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { cn } from "@/lib/utils";
import { formatRequestStatus, requestInputFromMediaResult } from "@/lib/mediaRequests";
@@ -131,6 +131,9 @@ export default function Requests() {
const search = useRequestSearch(mediaType, submittedQuery, searchPage);
const mine = useMyMediaRequests({ limit: 100 });
const createRequest = useCreateMediaRequest();
const pendingRequestKey = createRequest.variables
? mediaRequestKey(createRequest.variables.media_type, createRequest.variables.tmdb_id)
: undefined;
const isSearching = submittedQuery.length > 0;
@@ -204,7 +207,7 @@ export default function Requests() {
totalPages={search.data?.total_pages ?? 0}
totalResults={search.data?.total_results ?? 0}
results={search.data?.results ?? []}
pendingTMDBID={createRequest.variables?.tmdb_id}
pendingRequestKey={pendingRequestKey}
isSubmitting={createRequest.isPending}
onRequest={submitRequest}
/>
@@ -221,7 +224,7 @@ export default function Requests() {
<DiscoverySectionRow
key={section.key}
section={section}
pendingTMDBID={createRequest.variables?.tmdb_id}
pendingRequestKey={pendingRequestKey}
isSubmitting={createRequest.isPending}
onRequest={submitRequest}
/>
@@ -448,12 +451,12 @@ function SearchBar({
function DiscoverySectionRow({
section,
pendingTMDBID,
pendingRequestKey,
isSubmitting,
onRequest,
}: {
section: RequestDiscoverySection;
pendingTMDBID?: number;
pendingRequestKey?: string;
isSubmitting: boolean;
onRequest: (item: RequestMediaResult) => void;
}) {
@@ -471,7 +474,9 @@ function DiscoverySectionRow({
key={`${item.media_type}-${item.tmdb_id}`}
variant="discover"
item={item}
isSubmitting={isSubmitting && pendingTMDBID === item.tmdb_id}
isSubmitting={
isSubmitting && pendingRequestKey === mediaRequestKey(item.media_type, item.tmdb_id)
}
onRequest={() => onRequest(item)}
/>
))}
@@ -508,7 +513,7 @@ function SearchResultsView({
totalPages,
totalResults,
results,
pendingTMDBID,
pendingRequestKey,
isSubmitting,
onRequest,
}: {
@@ -521,14 +526,13 @@ function SearchResultsView({
totalPages: number;
totalResults: number;
results: RequestMediaResult[];
pendingTMDBID?: number;
pendingRequestKey?: string;
isSubmitting: boolean;
onRequest: (item: RequestMediaResult) => void;
}) {
const typeLabel =
mediaType === "series" ? "series" : mediaType === "movie" ? "movies" : "movies and series";
const filterLabel =
mediaType === "series" ? "Series" : mediaType === "movie" ? "Movies" : "All";
const filterLabel = mediaType === "series" ? "Series" : mediaType === "movie" ? "Movies" : "All";
const shown = results.length;
const showCount = !isLoading && !isError && shown > 0;
@@ -599,7 +603,10 @@ function SearchResultsView({
key={`${item.media_type}-${item.tmdb_id}`}
variant="discover"
item={item}
isSubmitting={isSubmitting && pendingTMDBID === item.tmdb_id}
isSubmitting={
isSubmitting &&
pendingRequestKey === mediaRequestKey(item.media_type, item.tmdb_id)
}
onRequest={() => onRequest(item)}
fluid
/>
@@ -751,6 +758,14 @@ function sectionEyebrow(key: string): string {
return "Discover";
}
function mediaRequestKey(mediaType: RequestMediaResult["media_type"], tmdbID: number): string {
return `${mediaType}-${tmdbID}`;
}
function isIssueOutcome(outcome: MediaRequestOutcome): boolean {
return outcome === "declined" || outcome === "cancelled" || outcome === "failed";
}
function groupMineRequests(requests: MediaRequest[]) {
const buckets: Record<MineBucketKey, MediaRequest[]> = {
motion: [],
@@ -758,11 +773,7 @@ function groupMineRequests(requests: MediaRequest[]) {
issues: [],
};
for (const request of requests) {
if (
request.outcome === "declined" ||
request.outcome === "cancelled" ||
request.outcome === "failed"
) {
if (isIssueOutcome(request.outcome)) {
buckets.issues.push(request);
} else if (request.status === "completed") {
buckets.completed.push(request);
@@ -779,11 +790,7 @@ function countMineStatuses(requests: MediaRequest[]) {
let completed = 0;
let issues = 0;
for (const request of requests) {
if (
request.outcome === "declined" ||
request.outcome === "cancelled" ||
request.outcome === "failed"
) {
if (isIssueOutcome(request.outcome)) {
issues += 1;
} else if (request.status === "completed") {
completed += 1;