diff --git a/internal/requests/radarr/client.go b/internal/requests/radarr/client.go index cdc99a1c..bc6fb8e7 100644 --- a/internal/requests/radarr/client.go +++ b/internal/requests/radarr/client.go @@ -106,14 +106,35 @@ func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, int var created movieResource if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil { - if arrclient.IsEmptyOrTruncatedDecodeError(err) { - return acceptedWithoutResponse("radarr"), nil + if !arrclient.IsEmptyOrTruncatedDecodeError(err) { + return mediarequests.FulfillmentResult{}, err } - return mediarequests.FulfillmentResult{}, err + // POST accepted but Radarr returned an empty body. Recover the + // new movie's Radarr ID by listing movies filtered by TMDB ID; + // without the ID the reconcile loop cannot advance the request. + if found, lookErr := c.findMovieByTMDBID(ctx, client, req.TMDBID); lookErr == nil && found.ID > 0 { + return resultFromMovie(found), nil + } + return acceptedWithoutResponse("radarr"), nil } return resultFromMovie(created), nil } +func (c *Client) findMovieByTMDBID(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) { + values := url.Values{} + values.Set("tmdbId", strconv.Itoa(tmdbID)) + var matches []movieResource + if err := client.GetJSON(ctx, "/api/v3/movie?"+values.Encode(), &matches); err != nil { + return movieResource{}, err + } + for _, m := range matches { + if m.ID > 0 { + return m, nil + } + } + return movieResource{}, fmt.Errorf("radarr: movie not found after add for tmdb_id %d", tmdbID) +} + func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) { client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) movieID, _ := strconv.Atoi(req.ExternalID) diff --git a/internal/requests/radarr/client_test.go b/internal/requests/radarr/client_test.go index 24849bd9..d9592334 100644 --- a/internal/requests/radarr/client_test.go +++ b/internal/requests/radarr/client_test.go @@ -73,6 +73,94 @@ func TestSubmitMovieAddsLookupResult(t *testing.T) { } } +func TestSubmitMovieRecoversFromEmptyAddResponse(t *testing.T) { + qualityProfileID := 7 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/movie/lookup/tmdb": + w.Write([]byte(`[{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}]`)) + case "/api/v3/movie": + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == http.MethodGet { + if got := r.URL.Query().Get("tmdbId"); got != "550" { + t.Fatalf("tmdbId = %q, want 550", got) + } + w.Write([]byte(`[{"id":99,"tmdbId":550,"title":"Fight Club"}]`)) + return + } + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + result, err := client.SubmitMovie(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }, mediarequests.Integration{ + Kind: "radarr", + BaseURL: server.URL, + APIKeyRef: "radarr-key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }) + if err != nil { + t.Fatalf("SubmitMovie returned error: %v", err) + } + if result.ExternalID != "99" { + t.Fatalf("ExternalID = %q, want 99 (recovered after empty 201)", result.ExternalID) + } + if result.ExternalStatus != "queued" { + t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus) + } +} + +func TestSubmitMovieFallsBackWhenEmptyResponseAndLookupFails(t *testing.T) { + qualityProfileID := 7 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/movie/lookup/tmdb": + w.Write([]byte(`[{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}]`)) + case "/api/v3/movie": + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == http.MethodGet { + w.Write([]byte(`[]`)) + return + } + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + result, err := client.SubmitMovie(context.Background(), mediarequests.Request{ + MediaType: mediarequests.MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }, mediarequests.Integration{ + Kind: "radarr", + BaseURL: server.URL, + APIKeyRef: "radarr-key", + RootFolder: "/movies", + QualityProfileID: &qualityProfileID, + }) + if err != nil { + t.Fatalf("SubmitMovie returned error: %v", err) + } + if result.ExternalStatus != "accepted_without_response" { + t.Fatalf("ExternalStatus = %q, want accepted_without_response", result.ExternalStatus) + } +} + func TestCheckMovieStatusReadsQueueDetails(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("X-Api-Key"); got != "radarr-key" { diff --git a/internal/requests/sonarr/client.go b/internal/requests/sonarr/client.go index fbb0bc14..304dc941 100644 --- a/internal/requests/sonarr/client.go +++ b/internal/requests/sonarr/client.go @@ -113,14 +113,35 @@ func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, in var created seriesResource if err := client.PostJSON(ctx, "/api/v3/series", series, &created); err != nil { - if arrclient.IsEmptyOrTruncatedDecodeError(err) { - return acceptedWithoutResponse("sonarr"), nil + if !arrclient.IsEmptyOrTruncatedDecodeError(err) { + return mediarequests.FulfillmentResult{}, err } - return mediarequests.FulfillmentResult{}, err + // POST accepted but Sonarr returned an empty body. Recover the + // new series' Sonarr ID by listing series filtered by TVDB ID; + // without the ID the reconcile loop cannot advance the request. + if found, lookErr := c.findSeriesByTVDBID(ctx, client, *req.TVDBID); lookErr == nil && found.ID > 0 { + return resultFromSeries(found), nil + } + return acceptedWithoutResponse("sonarr"), nil } return resultFromSeries(created), nil } +func (c *Client) findSeriesByTVDBID(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) { + values := url.Values{} + values.Set("tvdbId", strconv.Itoa(tvdbID)) + var matches []seriesResource + if err := client.GetJSON(ctx, "/api/v3/series?"+values.Encode(), &matches); err != nil { + return seriesResource{}, err + } + for _, s := range matches { + if s.ID > 0 && s.TVDBID == tvdbID { + return s, nil + } + } + return seriesResource{}, fmt.Errorf("sonarr: series not found after add for tvdb_id %d", tvdbID) +} + func (c *Client) CheckSeriesStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) { client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient) seriesID, _ := strconv.Atoi(req.ExternalID) diff --git a/internal/requests/sonarr/client_test.go b/internal/requests/sonarr/client_test.go index b0ba45f7..07ac24ad 100644 --- a/internal/requests/sonarr/client_test.go +++ b/internal/requests/sonarr/client_test.go @@ -75,6 +75,55 @@ func TestSubmitSeriesAddsLookupResult(t *testing.T) { } } +func TestSubmitSeriesRecoversFromEmptyAddResponse(t *testing.T) { + qualityProfileID := 3 + tvdbID := 121361 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/series/lookup": + w.Write([]byte(`[{"title":"Game of Thrones","tvdbId":121361,"titleSlug":"game-of-thrones"}]`)) + case "/api/v3/series": + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == http.MethodGet { + if got := r.URL.Query().Get("tvdbId"); got != "121361" { + t.Fatalf("tvdbId = %q, want 121361", got) + } + w.Write([]byte(`[{"id":77,"tvdbId":121361,"title":"Game of Thrones"}]`)) + return + } + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + result, 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.Fatalf("SubmitSeries returned error: %v", err) + } + if result.ExternalID != "77" { + t.Fatalf("ExternalID = %q, want 77 (recovered after empty 201)", result.ExternalID) + } + if result.ExternalStatus != "queued" { + t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus) + } +} + func TestSubmitSeriesRejectsNonExactTVDBLookupMatch(t *testing.T) { qualityProfileID := 3 tvdbID := 121361