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:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- **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
|
(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.
|
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.
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ Notes:
|
|||||||
### 4.2 v0.2 (done, M8)
|
### 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).
|
- **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
|
### 4.3 Later / ideas
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,3 +64,13 @@ func (a *App) restyleCovers() {
|
|||||||
p.Show(p.detail, p.lastRef) // fetch the poster again for the new style
|
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
@@ -37,7 +37,8 @@ type seriesView struct {
|
|||||||
root *tview.Flex
|
root *tview.Flex
|
||||||
header *tview.TextView
|
header *tview.TextView
|
||||||
table *tview.Table
|
table *tview.Table
|
||||||
detail *tview.TextView
|
detail *detailPane // poster, series and the selected season or episode
|
||||||
|
cover coverRef
|
||||||
|
|
||||||
episodes []sonarr.Episode
|
episodes []sonarr.Episode
|
||||||
expanded map[int]bool
|
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,
|
app: a, c: c, series: s, onChange: onChange,
|
||||||
header: tview.NewTextView().SetDynamicColors(true),
|
header: tview.NewTextView().SetDynamicColors(true),
|
||||||
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
||||||
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
detail: newDetailPane(a),
|
||||||
expanded: map[int]bool{},
|
expanded: map[int]bool{},
|
||||||
}
|
}
|
||||||
v.table.SetBorder(true)
|
v.table.SetBorder(true)
|
||||||
|
v.cover = coverFor(c.Client, s.Images, true)
|
||||||
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.header, 1, 0, false).
|
AddItem(v.header, 1, 0, false).
|
||||||
AddItem(v.table, 0, 1, true).
|
AddItem(tview.NewFlex().
|
||||||
AddItem(v.detail, 2, 0, false)
|
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.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
|
||||||
v.table.SetSelectedFunc(func(int, int) { v.enter() })
|
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()
|
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()
|
v.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,14 +247,54 @@ func episodeStatus(e sonarr.Episode, today string) string {
|
|||||||
|
|
||||||
func (v *seriesView) renderDetail() {
|
func (v *seriesView) renderDetail() {
|
||||||
r, ok := v.selected()
|
r, ok := v.selected()
|
||||||
switch {
|
if !ok {
|
||||||
case !ok:
|
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, ""), v.cover)
|
||||||
v.detail.SetText("")
|
return
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
|
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.
|
// enter expands or collapses a season, or opens the release search for an episode.
|
||||||
|
|||||||
Reference in New Issue
Block a user