diff --git a/internal/requests/repository.go b/internal/requests/repository.go index c8be6a10..24ce7e54 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -182,6 +182,23 @@ func (r *Repository) ListActiveByTMDB(ctx context.Context, mediaType MediaType, return out, nil } +func (r *Repository) DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) { + if tmdbID <= 0 { + return 0, nil + } + tag, err := r.pool.Exec(ctx, ` + DELETE FROM media_requests + WHERE media_type = $1 + AND provider = 'tmdb' + AND tmdb_id = $2 + AND outcome = 'failed' + `, mediaType, tmdbID) + if err != nil { + return 0, fmt.Errorf("delete failed requests by tmdb: %w", err) + } + return int(tag.RowsAffected()), nil +} + // quotaLockNamespace partitions advisory locks so request-quota locks do not // collide with advisory locks held elsewhere in the database. The value is // arbitrary; what matters is that it is stable. diff --git a/internal/requests/service.go b/internal/requests/service.go index 6f0d31fe..b6456210 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -278,6 +278,12 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, ErrAlreadyRequested } + // Re-requesting media that previously failed (e.g., transient integration + // error) should not leave stale failed rows behind in user/admin lists. + if _, err := s.store.DeleteFailedByTMDB(ctx, normalized.MediaType, normalized.TMDBID); err != nil { + return nil, err + } + policy, err := s.EffectivePolicy(ctx, viewer.UserID) if err != nil { return nil, err diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 472ea8f6..4f0689de 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -349,6 +349,41 @@ func TestCreateRequestNoActiveDuplicateCreatesRequest(t *testing.T) { } } +func TestCreateRequestClearsPriorFailedRequest(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + store.requests["req-prior-failed"] = &Request{ + ID: "req-prior-failed", + MediaType: MediaTypeMovie, + TMDBID: 550, + Outcome: OutcomeFailed, + Status: StatusApproved, + LastError: "arr: decode response: json: cannot unmarshal object into Go value of type []radarr.movieResource", + } + store.requests["req-other-media-failed"] = &Request{ + ID: "req-other-media-failed", + MediaType: MediaTypeMovie, + TMDBID: 999, + Outcome: OutcomeFailed, + } + service := newTestService(store) + + _, 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 _, ok := store.requests["req-prior-failed"]; ok { + t.Fatal("prior failed request was not cleared") + } + if _, ok := store.requests["req-other-media-failed"]; !ok { + t.Fatal("failed request for different media should not be cleared") + } +} + func TestSearchMarksSeriesAvailableByHydratedTVDBID(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true @@ -905,6 +940,19 @@ func (f *fakeStore) ListActiveByTMDB(_ context.Context, mediaType MediaType, ids return out, nil } +func (f *fakeStore) DeleteFailedByTMDB(_ context.Context, mediaType MediaType, tmdbID int) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + deleted := 0 + for id, req := range f.requests { + if req.MediaType == mediaType && req.TMDBID == tmdbID && req.Outcome == OutcomeFailed { + delete(f.requests, id) + deleted++ + } + } + return deleted, nil +} + func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) (*Request, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/requests/store.go b/internal/requests/store.go index 2241514f..91637b4e 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -12,6 +12,9 @@ type Store interface { UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) + // DeleteFailedByTMDB removes prior failed requests for a given media so a + // re-request does not leave behind stale rows in user/admin lists. + DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) GetRequest(ctx context.Context, id string) (*Request, error) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error)