diff --git a/docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md b/docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md index 4add6ddb..78154283 100644 --- a/docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md +++ b/docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md @@ -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. diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index 497eb9c0..872ba7c6 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -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"` diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index c3945d17..7f97bc15 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -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 } diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 9fd3dd95..907a06ba 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -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, diff --git a/internal/requests/arrclient/queue.go b/internal/requests/arrclient/queue.go index 50251d35..31caf33d 100644 --- a/internal/requests/arrclient/queue.go +++ b/internal/requests/arrclient/queue.go @@ -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"} } diff --git a/internal/requests/arrclient/queue_test.go b/internal/requests/arrclient/queue_test.go new file mode 100644 index 00000000..df441e40 --- /dev/null +++ b/internal/requests/arrclient/queue_test.go @@ -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") + } +} diff --git a/internal/requests/presence.go b/internal/requests/presence.go index 9cdc5ab0..e1ca9d7a 100644 --- a/internal/requests/presence.go +++ b/internal/requests/presence.go @@ -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 { diff --git a/internal/requests/radarr/client.go b/internal/requests/radarr/client.go index fce76ed5..cdc99a1c 100644 --- a/internal/requests/radarr/client.go +++ b/internal/requests/radarr/client.go @@ -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 } diff --git a/internal/requests/radarr/client_test.go b/internal/requests/radarr/client_test.go index 389c398d..24849bd9 100644 --- a/internal/requests/radarr/client_test.go +++ b/internal/requests/radarr/client_test.go @@ -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()) diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 441bbb1f..04a06eff 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -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{} diff --git a/internal/requests/service.go b/internal/requests/service.go index 1ae6258d..00861278 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -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 diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 5873ace8..99773e17 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -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 +} diff --git a/internal/requests/sonarr/client.go b/internal/requests/sonarr/client.go index 476210b1..fbb0bc14 100644 --- a/internal/requests/sonarr/client.go +++ b/internal/requests/sonarr/client.go @@ -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) } diff --git a/internal/requests/sonarr/client_test.go b/internal/requests/sonarr/client_test.go index e1a077b9..b0ba45f7 100644 --- a/internal/requests/sonarr/client_test.go +++ b/internal/requests/sonarr/client_test.go @@ -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" { diff --git a/internal/requests/store.go b/internal/requests/store.go index 205f2b6b..3dc218dc 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -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 { diff --git a/scripts/check-local-path-leaks.sh b/scripts/check-local-path-leaks.sh index af860c68..0847660f 100755 --- a/scripts/check-local-path-leaks.sh +++ b/scripts/check-local-path-leaks.sh @@ -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 diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx index b5df43d6..3bbe286d 100644 --- a/web/src/components/RequestPosterCard.tsx +++ b/web/src/components/RequestPosterCard.tsx @@ -64,57 +64,61 @@ function DiscoverCard({ : null; return ( - - - {ribbon && } + + {ribbon && } + - {requestable && ( -
- -
- )} -
+ + - - + {requestable && ( +
+ +
+ )} + ); } diff --git a/web/src/hooks/queries/requests.ts b/web/src/hooks/queries/useRequests.ts similarity index 100% rename from web/src/hooks/queries/requests.ts rename to web/src/hooks/queries/useRequests.ts diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index 044b8c86..e3d16f79 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -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 = {}; - 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 = {}; + 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. } } diff --git a/web/src/pages/RequestBrowse.tsx b/web/src/pages/RequestBrowse.tsx index 5d32e936..aca92885 100644 --- a/web/src/pages/RequestBrowse.tsx +++ b/web/src/pages/RequestBrowse.tsx @@ -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("-") diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx index f36a510b..1314d715 100644 --- a/web/src/pages/RequestDetail.tsx +++ b/web/src/pages/RequestDetail.tsx @@ -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))} /> )} diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx index 770a2469..88f7939d 100644 --- a/web/src/pages/Requests.tsx +++ b/web/src/pages/Requests.tsx @@ -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() { @@ -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 = { 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;