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:
Silo Server Migration
2026-05-25 00:12:53 -04:00
co-authored by Claude Opus 4.7
parent fca23d77e9
commit 860978cf2f
4 changed files with 185 additions and 6 deletions
+24 -3
View File
@@ -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)
+88
View File
@@ -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" {
+24 -3
View File
@@ -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)
+49
View File
@@ -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