The radarr and sonarr clients carried byte-identical copies of rootFolderResource, qualityProfileResource, tagResource (and the corresponding list helpers) plus acceptedWithoutResponse and statusFromQueueEvaluation. Move the shared wire types and helpers into the arrclient package and update the callers to use the exported helpers. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
6.7 KiB
Go
184 lines
6.7 KiB
Go
package sonarr
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
|
"github.com/Silo-Server/silo-server/internal/requests/arrclient"
|
|
)
|
|
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type seriesResource struct {
|
|
ID int `json:"id,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
TVDBID int `json:"tvdbId,omitempty"`
|
|
TMDBID int `json:"tmdbId,omitempty"`
|
|
TitleSlug string `json:"titleSlug,omitempty"`
|
|
QualityProfileID int `json:"qualityProfileId,omitempty"`
|
|
RootFolderPath string `json:"rootFolderPath,omitempty"`
|
|
SeasonFolder bool `json:"seasonFolder"`
|
|
Monitored bool `json:"monitored"`
|
|
SeriesType string `json:"seriesType,omitempty"`
|
|
Tags []int `json:"tags,omitempty"`
|
|
AddOptions addSeriesOptions `json:"addOptions,omitempty"`
|
|
}
|
|
|
|
type addSeriesOptions struct {
|
|
Monitor string `json:"monitor,omitempty"`
|
|
SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"`
|
|
SearchForCutoffUnmetEpisodes bool `json:"searchForCutoffUnmetEpisodes,omitempty"`
|
|
}
|
|
|
|
func NewClient(httpClient *http.Client) *Client {
|
|
return &Client{httpClient: httpClient}
|
|
}
|
|
|
|
func (c *Client) ListSeriesIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
|
|
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
|
|
rootFolders, err := arrclient.ListRootFolders(ctx, client)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tags, err := arrclient.ListTags(ctx, client)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &mediarequests.IntegrationOptions{
|
|
Kind: "sonarr",
|
|
RootFolders: rootFolders,
|
|
QualityProfiles: qualityProfiles,
|
|
Tags: tags,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) SubmitSeries(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) {
|
|
if req.MediaType != mediarequests.MediaTypeSeries {
|
|
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: request is not a series")
|
|
}
|
|
if integration.QualityProfileID == nil {
|
|
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: quality profile is required")
|
|
}
|
|
if req.TVDBID == nil || *req.TVDBID <= 0 {
|
|
return mediarequests.FulfillmentResult{}, fmt.Errorf("sonarr: tvdb_id is required")
|
|
}
|
|
|
|
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
|
|
series, err := c.lookupSeries(ctx, client, *req.TVDBID)
|
|
if err != nil {
|
|
return mediarequests.FulfillmentResult{}, err
|
|
}
|
|
series.RootFolderPath = integration.RootFolder
|
|
series.QualityProfileID = *integration.QualityProfileID
|
|
series.SeasonFolder = arrclient.BoolOption(integration.Options, "season_folder", true)
|
|
series.Monitored = arrclient.BoolOption(integration.Options, "monitored", true)
|
|
series.SeriesType = arrclient.StringOption(integration.Options, "series_type", "standard")
|
|
series.Tags = integration.Tags
|
|
series.AddOptions = addSeriesOptions{
|
|
Monitor: arrclient.StringOption(integration.Options, "monitor", "all"),
|
|
SearchForMissingEpisodes: arrclient.BoolOption(
|
|
integration.Options,
|
|
"search_for_missing_episodes",
|
|
arrclient.BoolOption(integration.Options, "search_on_add", true),
|
|
),
|
|
SearchForCutoffUnmetEpisodes: arrclient.BoolOption(integration.Options, "search_for_cutoff_unmet", false),
|
|
}
|
|
|
|
var created seriesResource
|
|
if err := client.PostJSON(ctx, "/api/v3/series", series, &created); err != nil {
|
|
if !arrclient.IsEmptyOrTruncatedDecodeError(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 arrclient.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)
|
|
if seriesID <= 0 {
|
|
return mediarequests.FulfillmentStatus{
|
|
Status: mediarequests.StatusQueued,
|
|
IntegrationKind: "sonarr",
|
|
ExternalStatus: "external_id_unavailable",
|
|
}, nil
|
|
}
|
|
|
|
queues, err := c.queueDetails(ctx, client, seriesID)
|
|
if err != nil {
|
|
return mediarequests.FulfillmentStatus{}, err
|
|
}
|
|
evaluation := arrclient.EvaluateQueue(queues)
|
|
return arrclient.StatusFromQueueEvaluation("sonarr", seriesID, evaluation), nil
|
|
}
|
|
|
|
func (c *Client) lookupSeries(ctx context.Context, client *arrclient.Client, tvdbID int) (seriesResource, error) {
|
|
values := url.Values{}
|
|
values.Set("term", "tvdb:"+strconv.Itoa(tvdbID))
|
|
var matches []seriesResource
|
|
if err := client.GetJSON(ctx, "/api/v3/series/lookup?"+values.Encode(), &matches); err != nil {
|
|
return seriesResource{}, err
|
|
}
|
|
for _, match := range matches {
|
|
if match.TVDBID == tvdbID {
|
|
return match, nil
|
|
}
|
|
}
|
|
return seriesResource{}, fmt.Errorf("sonarr: no series found for tvdb_id %d", tvdbID)
|
|
}
|
|
|
|
func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, seriesID int) ([]arrclient.QueueResource, error) {
|
|
values := url.Values{}
|
|
values.Set("seriesId", strconv.Itoa(seriesID))
|
|
var queues []arrclient.QueueResource
|
|
if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil {
|
|
return nil, err
|
|
}
|
|
return queues, nil
|
|
}
|
|
|
|
func resultFromSeries(series seriesResource) mediarequests.FulfillmentResult {
|
|
externalID := ""
|
|
if series.ID > 0 {
|
|
externalID = strconv.Itoa(series.ID)
|
|
}
|
|
return mediarequests.FulfillmentResult{
|
|
IntegrationKind: "sonarr",
|
|
ExternalID: externalID,
|
|
ExternalStatus: "queued",
|
|
}
|
|
}
|
|
|