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 (
-
-