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