Publish current app and server
This commit is contained in:
@@ -26,13 +26,65 @@ type Image struct {
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
Year int `json:"year"`
|
||||
Network string `json:"network"`
|
||||
Genres []string `json:"genres"`
|
||||
Images []Image `json:"images"`
|
||||
ID int `json:"id"`
|
||||
TVDBID int `json:"tvdbId"`
|
||||
Title string `json:"title"`
|
||||
TitleSlug string `json:"titleSlug"`
|
||||
Overview string `json:"overview"`
|
||||
Year int `json:"year"`
|
||||
Network string `json:"network"`
|
||||
Genres []string `json:"genres"`
|
||||
Images []Image `json:"images"`
|
||||
RootFolderPath string `json:"rootFolderPath,omitempty"`
|
||||
QualityProfileID int `json:"qualityProfileId,omitempty"`
|
||||
Monitored bool `json:"monitored"`
|
||||
SeasonFolder bool `json:"seasonFolder"`
|
||||
Seasons []Season `json:"seasons"`
|
||||
Status string `json:"status"`
|
||||
NextAiring *time.Time `json:"nextAiring"`
|
||||
}
|
||||
|
||||
// Series returns Sonarr's current catalogue, including lifecycle and next-airing data.
|
||||
func (c *Client) Series(ctx context.Context) ([]Series, error) {
|
||||
var series []Series
|
||||
if err := c.get(ctx, "/api/v3/series", &series); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// Episodes returns Sonarr's complete episode list for one series. Unlike the calendar,
|
||||
// this includes future episodes, which is what makes finale detection trustworthy rather
|
||||
// than mistaking the newest downloaded episode for the end of a season.
|
||||
func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) {
|
||||
if seriesID <= 0 {
|
||||
return nil, fmt.Errorf("sonarr: invalid series id")
|
||||
}
|
||||
req, err := c.request(ctx, "/api/v3/episode", url.Values{
|
||||
"seriesId": {strconv.Itoa(seriesID)},
|
||||
"includeSeries": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var episodes []Episode
|
||||
if err := c.do(req, &episodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
type Season struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Monitored bool `json:"monitored"`
|
||||
}
|
||||
|
||||
type RootFolder struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type QualityProfile struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type EpisodeFile struct {
|
||||
@@ -101,6 +153,50 @@ func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Episode,
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
|
||||
req, err := c.request(ctx, "/api/v3/series/lookup", url.Values{"term": {term}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var series []Series
|
||||
if err := c.do(req, &series); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// AddUnmonitored adds a series without monitoring it or starting an episode search.
|
||||
func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, error) {
|
||||
var roots []RootFolder
|
||||
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
var profiles []QualityProfile
|
||||
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
if len(roots) == 0 || len(profiles) == 0 {
|
||||
return Series{}, fmt.Errorf("sonarr: no root folder or quality profile configured")
|
||||
}
|
||||
series.ID = 0
|
||||
series.RootFolderPath = roots[0].Path
|
||||
series.QualityProfileID = profiles[0].ID
|
||||
series.Monitored = false
|
||||
series.SeasonFolder = true
|
||||
for i := range series.Seasons {
|
||||
series.Seasons[i].Monitored = false
|
||||
}
|
||||
body := struct {
|
||||
Series
|
||||
AddOptions map[string]bool `json:"addOptions"`
|
||||
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": false}}
|
||||
var added Series
|
||||
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// MediaCover fetches a series poster or fanart without exposing the Sonarr API key.
|
||||
func (c *Client) MediaCover(ctx context.Context, seriesID int, coverType string) (*http.Response, error) {
|
||||
if seriesID <= 0 || (coverType != "poster" && coverType != "fanart") {
|
||||
@@ -138,6 +234,29 @@ func (c *Client) request(ctx context.Context, path string, params url.Values) (*
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, out any) error {
|
||||
req, err := c.request(ctx, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, path string, body, out any) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sonarr: encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package sonarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -45,6 +46,51 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/rootfolder":
|
||||
_, _ = w.Write([]byte(`[{"path":"/tv"}]`))
|
||||
case "/api/v3/qualityprofile":
|
||||
_, _ = w.Write([]byte(`[{"id":3}]`))
|
||||
case "/api/v3/series":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["monitored"] != false || body["seasonFolder"] != true ||
|
||||
body["rootFolderPath"] != "/tv" || body["qualityProfileId"] != float64(3) {
|
||||
t.Errorf("unexpected add body: %#v", body)
|
||||
}
|
||||
seasons := body["seasons"].([]any)
|
||||
if seasons[0].(map[string]any)["monitored"] != false {
|
||||
t.Errorf("season remained monitored: %#v", body)
|
||||
}
|
||||
options := body["addOptions"].(map[string]any)
|
||||
if options["searchForMissingEpisodes"] != false {
|
||||
t.Errorf("episode search was enabled: %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
|
||||
context.Background(), Series{
|
||||
TVDBID: 44, Title: "Severance", Monitored: true,
|
||||
Seasons: []Season{{SeasonNumber: 1, Monitored: true}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if added.ID != 8 {
|
||||
t.Fatalf("unexpected series: %+v", added)
|
||||
}
|
||||
}
|
||||
|
||||
func rHasAPIKey(query string) bool {
|
||||
return strings.Contains(query, "secret")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user