fix(requests): recover external id after empty arr add response
Radarr and Sonarr can return HTTP 201 with no body when a movie or series is added. The previous code returned an "accepted_without_response" result with an empty ExternalID, which trapped the reconciler: every subsequent CheckStatus call short-circuited on the empty ID and the request never advanced past queued. When the add POST decodes empty, look the freshly-added record up by TMDB or TVDB ID via the standard list endpoints and use the resulting Arr ID. Fall back to the previous accepted-without-response result only when the lookup also returns no match, preserving the original behavior as a safety net. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fca23d77e9
commit
860978cf2f
@@ -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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user