From 60fd71bc924f43c0f9b7702b07756fc22585c543 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 21 Oct 2026 08:28:49 +0200 Subject: [PATCH] Series drill-down: poster and details of the selected season or episode Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW --- CHANGELOG.md | 2 ++ SPECS.md | 2 +- internal/ui/calendar_test.go | 45 ++++++++++++++++++++++++ internal/ui/configapply.go | 10 ++++++ internal/ui/episodes.go | 68 +++++++++++++++++++++++++++++------- 5 files changed, 114 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2acec31..60b9f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/SPECS.md b/SPECS.md index ac19b8d..0c500e7 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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 diff --git a/internal/ui/calendar_test.go b/internal/ui/calendar_test.go index ff5493d..bcb2245 100644 --- a/internal/ui/calendar_test.go +++ b/internal/ui/calendar_test.go @@ -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) + } +} diff --git a/internal/ui/configapply.go b/internal/ui/configapply.go index c314f5b..41d92f4 100644 --- a/internal/ui/configapply.go +++ b/internal/ui/configapply.go @@ -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 + } + } +} diff --git a/internal/ui/episodes.go b/internal/ui/episodes.go index 0a871ee..ab73f72 100644 --- a/internal/ui/episodes.go +++ b/internal/ui/episodes.go @@ -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.