Series drill-down: season posters

The drill-down reads the series with includeSeasonImages=true (Sonarr
only sends season posters when asked, as TheTVDB links). A selected
season or episode shows its season's poster; without one, or when it
cannot be loaded, the series poster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 23:42:49 +02:00
co-authored by Claude Opus 5.5
parent 73efe279d4
commit ca28a3c9bb
7 changed files with 92 additions and 2 deletions
+5 -1
View File
@@ -46,6 +46,9 @@ type Season struct {
SeasonNumber int `json:"seasonNumber"`
Monitored bool `json:"monitored"`
Statistics Statistics `json:"statistics"`
// Images are the season's posters, only filled by GetSeries; Sonarr keeps
// no copy of them, so they only have a remote URL (TheTVDB).
Images []arr.Image `json:"images,omitempty"`
}
// Statistics are the episode counts Sonarr reports for library series.
@@ -179,7 +182,8 @@ func (c *Client) SearchEpisode(ctx context.Context, episodeID int) error {
// GetSeries fetches one library series.
func (c *Client) GetSeries(ctx context.Context, id int) (Series, error) {
var s Series
err := c.Do(ctx, http.MethodGet, "series/"+strconv.Itoa(id), nil, nil, &s)
// Season posters are only sent when asked for.
err := c.Do(ctx, http.MethodGet, "series/"+strconv.Itoa(id), url.Values{"includeSeasonImages": {"true"}}, nil, &s)
return s, err
}
+20
View File
@@ -198,3 +198,23 @@ func TestCalendar(t *testing.T) {
t.Errorf("episode not decoded: %+v", e)
}
}
func TestGetSeriesAsksForSeasonPosters(t *testing.T) {
var query string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.RawQuery
w.Write([]byte(`{"id":7,"title":"Andor","seasons":[{"seasonNumber":1,"monitored":true,
"images":[{"coverType":"poster","remoteUrl":"https://artworks.thetvdb.com/banners/seasons/1.jpg"}]}]}`))
}))
defer srv.Close()
s, err := NewClient(srv.URL, "k").GetSeries(t.Context(), 7)
if err != nil {
t.Fatal(err)
}
if query != "includeSeasonImages=true" {
t.Errorf("query %q", query)
}
if len(s.Seasons) != 1 || len(s.Seasons[0].Images) != 1 || s.Seasons[0].Images[0].RemoteURL == "" {
t.Errorf("season images %+v", s.Seasons)
}
}
+26 -1
View File
@@ -257,7 +257,32 @@ func (v *seriesView) renderDetail() {
sum = se
}
}
v.detail.Show(seriesRowDetail(v.series, r.ep, r.season, sum, v.seasonMonitored(r.season), time.Now().Format("2006-01-02")), v.cover)
v.detail.Show(seriesRowDetail(v.series, r.ep, r.season, sum, v.seasonMonitored(r.season), time.Now().Format("2006-01-02")), v.seasonCover(r.season))
}
// seasonCover is the poster of a season, for its row and its episodes: the
// season's own poster where Sonarr has one (a TheTVDB link, fetched without
// credentials), else, or when it cannot be loaded, the series poster.
func (v *seriesView) seasonCover(season int) coverRef {
for _, se := range v.series.Seasons {
if se.SeasonNumber != season {
continue
}
for _, img := range se.Images {
if img.CoverType != "poster" || img.RemoteURL == "" {
continue
}
remote, series := img.RemoteURL, v.cover
return coverRef{key: "season|" + remote, fetch: func(ctx context.Context, large bool) ([]byte, error) {
data, err := arr.FetchURL(ctx, remote)
if err != nil && series.fetch != nil {
return series.fetch(ctx, large)
}
return data, err
}}
}
}
return v.cover
}
// seriesRowDetail is the detail text of the drill-down: the series, then the
+37
View File
@@ -1,6 +1,9 @@
package ui
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
@@ -308,3 +311,37 @@ func TestCutToWidth(t *testing.T) {
}
}
}
func TestSeasonPoster(t *testing.T) {
seasonSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/broken.jpg" {
http.NotFound(w, r)
return
}
w.Write([]byte("SEASON"))
}))
defer seasonSrv.Close()
series := coverRef{key: "series", fetch: func(context.Context, bool) ([]byte, error) { return []byte("SERIES"), nil }}
v := &seriesView{cover: series, series: sonarr.Series{Seasons: []sonarr.Season{
{SeasonNumber: 1, Images: []arr.Image{{CoverType: "poster", RemoteURL: seasonSrv.URL + "/s1.jpg"}}},
{SeasonNumber: 2},
{SeasonNumber: 3, Images: []arr.Image{{CoverType: "poster", RemoteURL: seasonSrv.URL + "/broken.jpg"}}},
}}}
fetch := func(season int) string {
data, err := v.seasonCover(season).fetch(t.Context(), false)
if err != nil {
t.Fatal(err)
}
return string(data)
}
if got := fetch(1); got != "SEASON" || v.seasonCover(1).key == series.key {
t.Errorf("season 1: %q", got)
}
if v.seasonCover(2).key != series.key {
t.Error("a season without a poster shows the series poster")
}
if got := fetch(3); got != "SERIES" {
t.Errorf("a season poster that cannot be loaded falls back to the series poster: %q", got)
}
}