Series drill-down: poster and details of the selected season or episode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
2026-10-21 08:28:49 +02:00
co-authored by Claude Opus 5.5
parent 8601a2af6f
commit 60fd71bc92
5 changed files with 114 additions and 13 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
- **Series drill-down** shows the series poster and details of the selected season or episode,
including the episode's plot.
- **Posters in WebP and GIF:** Sonarr/Radarr can store WebP data under a `.jpg` name; such posters
(and GIFs) now show instead of failing with "unknown format". When a poster cannot be read, the Log
says what it looks like. If an instance has no copy of a poster, or only answers with its login page, the API key is also tried as a query parameter and then the provider's poster is used.
+1 -1
View File
@@ -75,7 +75,7 @@ Notes:
### 4.2 v0.2 (done, M8)
- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (status: all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter; each dropdown only lists options that, together with the other chosen criteria, still match at least one item (series files are read when the dialog opens so this is accurate)) act only on the focused list, and each list keeps its own status filter, property filter and text; the lists show their active filters in their titles. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `d` removes the item from the library (a dialog with "Delete files from disk" and "import exclusion" checkboxes, both off by default and Cancel focused first; `DELETE /movie|series/{id}?deleteFiles=&addImport(List)Exclusion=`), `i`/Enter opens the release search (movies) or the drill-down (series).
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search.
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search. A detail pane on the right shows the series poster, the series and the selected season (download count, monitoring) or episode (air date, status, monitoring, its own plot).
### 4.3 Later / ideas
+45
View File
@@ -124,3 +124,48 @@ func TestSortEntriesAndDayLabel(t *testing.T) {
}
}
}
func TestSeriesRowDetail(t *testing.T) {
s := sonarr.Series{Title: "Andor", Year: 2022, Network: "Disney+", Status: "ended", Overview: "Series plot"}
// A season: the summary and the series plot.
d := seriesRowDetail(s, nil, 2, seasonSummary{number: 2, total: 12, have: 5}, true, "2026-01-01")
for _, want := range []string{"Andor", "(2022)", "Disney+", "Season 2", "5 of 12 episodes", "Monitored: yes"} {
if !strings.Contains(d.head, want) {
t.Errorf("season head missing %q:\n%s", want, d.head)
}
}
if d.plot != "Series plot" {
t.Errorf("season plot = %q", d.plot)
}
// An episode: its own title, air date, status and plot.
ep := &sonarr.Episode{SeasonNumber: 2, EpisodeNumber: 5, Title: "Welcome [Home]", AirDate: "2025-05-01", HasFile: true, Monitored: true, Overview: "Episode plot"}
d = seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01")
for _, want := range []string{"S02E05 Welcome [Home[]", "Aired: 2025-05-01", "downloaded"} {
if !strings.Contains(d.head, want) {
t.Errorf("episode head missing %q:\n%s", want, d.head)
}
}
if d.plot != "Episode plot" {
t.Errorf("episode plot = %q", d.plot)
}
// Without an episode plot the series plot stays.
ep.Overview = ""
if d := seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01"); d.plot != "Series plot" {
t.Errorf("fallback plot = %q", d.plot)
}
}
func TestForgetPane(t *testing.T) {
a := newTestApp(t)
n := len(a.panes)
d := newDetailPane(a)
if len(a.panes) != n+1 {
t.Fatal("a new pane must be registered")
}
a.forgetPane(d)
if len(a.panes) != n {
t.Errorf("%d panes after forgetting, want %d", len(a.panes), n)
}
}
+10
View File
@@ -64,3 +64,13 @@ func (a *App) restyleCovers() {
p.Show(p.detail, p.lastRef) // fetch the poster again for the new style
}
}
// forgetPane drops a detail pane that is no longer shown (a closed drill-down).
func (a *App) forgetPane(d *detailPane) {
for i, p := range a.panes {
if p == d {
a.panes = append(a.panes[:i], a.panes[i+1:]...)
return
}
}
}
+56 -12
View File
@@ -37,7 +37,8 @@ type seriesView struct {
root *tview.Flex
header *tview.TextView
table *tview.Table
detail *tview.TextView
detail *detailPane // poster, series and the selected season or episode
cover coverRef
episodes []sonarr.Episode
expanded map[int]bool
@@ -51,14 +52,16 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
app: a, c: c, series: s, onChange: onChange,
header: tview.NewTextView().SetDynamicColors(true),
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
detail: newDetailPane(a),
expanded: map[int]bool{},
}
v.table.SetBorder(true)
v.cover = coverFor(c.Client, s.Images, true)
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(v.table, 0, 1, true).
AddItem(v.detail, 2, 0, false)
AddItem(tview.NewFlex().
AddItem(v.table, 0, 3, true).
AddItem(v.detail, 0, 2, false), 0, 1, true)
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
v.table.SetSelectedFunc(func(int, int) { v.enter() })
@@ -86,7 +89,8 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
})
v.render()
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", nil)
// The pane goes away with the view; stop restyling it then.
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", func() { a.forgetPane(v.detail) })
v.reload()
}
@@ -243,14 +247,54 @@ func episodeStatus(e sonarr.Episode, today string) string {
func (v *seriesView) renderDetail() {
r, ok := v.selected()
switch {
case !ok:
v.detail.SetText("")
case r.ep == nil:
v.detail.SetText("[gray]Enter expands the season · t toggles monitoring for the whole season · i searches for releases")
default:
v.detail.SetText(fmt.Sprintf("[gray]S%02dE%02d[-] %s", r.season, r.ep.EpisodeNumber, tview.Escape(r.ep.Title)))
if !ok {
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, ""), v.cover)
return
}
var sum seasonSummary
for _, se := range summarizeSeasons(v.episodes) {
if se.number == r.season {
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)
}
// seriesRowDetail is the detail text of the drill-down: the series, then the
// selected season (ep == nil) or episode. The plot is the episode's, or the
// series' while a season is selected.
func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum seasonSummary, seasonMonitored bool, today string) detailText {
head := "[::b]" + tview.Escape(s.Title) + "[::-]"
if s.Year > 0 {
head += fmt.Sprintf(" (%d)", s.Year)
}
head += "\n"
if s.Network != "" {
head += tview.Escape(s.Network) + " · "
}
head += tview.Escape(s.Status) + "\n\n"
plot := tview.Escape(s.Overview)
switch {
case ep == nil && sum.total == 0:
// Nothing selected yet (still loading).
case ep == nil:
head += "[::b]" + seasonLabel(season) + "[::-]\n"
head += fmt.Sprintf("%d of %d episodes downloaded\n", sum.have, sum.total)
head += monitoredLine(seasonMonitored)
head += "[gray]Enter expands · t monitors the whole season · i searches for releases[-]\n"
default:
head += fmt.Sprintf("[::b]S%02dE%02d %s[::-]\n", ep.SeasonNumber, ep.EpisodeNumber, tview.Escape(ep.Title))
if ep.AirDate != "" {
head += "Aired: " + ep.AirDate + "\n"
}
head += "Status: " + episodeStatus(*ep, today) + "[-]\n"
head += monitoredLine(ep.Monitored)
if ep.Overview != "" {
plot = tview.Escape(ep.Overview)
}
}
return detailText{head: head, plot: plot}
}
// enter expands or collapses a season, or opens the release search for an episode.