From 5195edc3b213ef9b0cbbefd1eb5aaf1c5ae9912d Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 20:04:13 +0200 Subject: [PATCH] Show movies and series side by side in the library view Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +- internal/ui/app.go | 2 +- internal/ui/help.go | 7 +- internal/ui/library.go | 159 +++++++++++++++++++++++------------------ 4 files changed, 98 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 898b62b..c05456f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video optionally start the automatic search. - **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), sort and filter them, and grab one. Series: pick a season or a single episode first. -- **Library**: browse your movies and series, filter by missing / downloaded / monitored, toggle +- **Library**: browse your movies and series side by side, filter by missing / downloaded / monitored, toggle monitoring, trigger searches. Series open into seasons and episodes with per-season and per-episode monitoring, automatic search and release search. - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, @@ -56,7 +56,7 @@ Keep the file private (`chmod 600`); it holds your API keys. | `/` | focus search box | | `Enter`, `a` | add selected title | | `i` | interactive release search | -| `m`, `f`, `/`, `t`, `s`, `i`/`Enter`, `r` (library) | movies/series, filter, text filter, monitor, search, releases/episodes, reload | +| `Tab`, `f`, `/`, `t`, `s`, `i`/`Enter`, `r` (library) | movies/series list, filter, text filter, monitor, search, releases/episodes, reload | | `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor | | `Enter`/`g`, `s`, `f` (release list) | grab, cycle sort, hide rejected | | `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh | diff --git a/internal/ui/app.go b/internal/ui/app.go index 6162f9f..2b63397 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -341,7 +341,7 @@ func searchHints() string { } func libraryHints() string { - return "m kind · f filter · / text · t monitor · s search · Enter open" + return "Tab movies/series · f filter · / text · t monitor · s search · Enter open" } func logHints() string { return "↑↓ scroll · G follow · c clear" } diff --git a/internal/ui/help.go b/internal/ui/help.go index b0121c7..3b58507 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -19,9 +19,10 @@ const helpText = `[yellow]Global[-] i interactive release search (library titles) [yellow]Library[-] - m switch between movies and series - f cycle filter (all, missing, downloaded, monitored, unmonitored) - /, Tab text filter (Enter/Esc/Tab returns to the list) + Tab switch between the movies and series lists + f cycle filter (all, missing, downloaded, monitored, unmonitored); + filters apply to both lists + / text filter (Enter or Esc returns to the list) t toggle monitored s automatic search (series: all missing, asks first) i, Enter movie: release search; series: seasons and episodes diff --git a/internal/ui/library.go b/internal/ui/library.go index 92855c8..c76c0db 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -160,7 +160,18 @@ func filterItems(items []libItem, filter int, text string) []libItem { return out } -// libraryView lists the movies of Radarr or the series of Sonarr. +// libPane is one of the two lists (movies, series) of the library view. +type libPane struct { + kind libKind + table *tview.Table + all []libItem + shown []libItem + loaded bool + err error + gen int // invalidates stale loads +} + +// libraryView shows the movies of Radarr and the series of Sonarr side by side. type libraryView struct { app *App radarr *radarr.Client @@ -169,17 +180,12 @@ type libraryView struct { root *tview.Flex header *tview.TextView input *tview.InputField - table *tview.Table details *tview.TextView + panes [2]*libPane // UI-goroutine state. - kind libKind - filter int - all map[libKind][]libItem - loaded map[libKind]bool - loadErr map[libKind]error - shown []libItem - gen int // invalidates stale loads + cur libKind // the pane that has (or last had) focus + filter int } func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView { @@ -187,44 +193,49 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView { app: app, radarr: r, sonarr: s, header: tview.NewTextView().SetDynamicColors(true), input: tview.NewInputField().SetLabel("Filter: ").SetFieldWidth(0), - table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0), details: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), - all: map[libKind][]libItem{}, - loaded: map[libKind]bool{}, - loadErr: map[libKind]error{}, } - v.table.SetBorder(true) v.details.SetBorder(true).SetTitle(" Details ") + + lists := tview.NewFlex() + for _, kind := range []libKind{kindMovies, kindSeries} { + p := &libPane{kind: kind, table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0)} + p.table.SetBorder(true) + p.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() }) + p.table.SetSelectedFunc(func(int, int) { v.open() }) + p.table.SetInputCapture(v.handleKey) + p.table.SetFocusFunc(func() { + v.cur = kind + p.table.SetBorderColor(tcell.ColorYellow) + v.renderDetails() + }) + p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) }) + v.panes[kind] = p + lists.AddItem(p.table, 0, 1, kind == kindMovies) + } v.root = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(v.header, 1, 0, false). AddItem(v.input, 1, 0, false). - AddItem(tview.NewFlex(). - AddItem(v.table, 0, 3, true). - AddItem(v.details, 0, 2, false), 0, 1, true). - AddItem(tview.NewTextView().SetDynamicColors(true). - SetText("[gray]m movies/series · f filter · / text · t monitor · s search · Enter releases/episodes · r reload"), 1, 0, false) + AddItem(lists, 0, 3, true). + AddItem(v.details, 0, 2, false) v.input.SetChangedFunc(func(string) { v.render() }) - v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.table) }) - v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() }) - v.table.SetSelectedFunc(func(int, int) { v.open() }) - v.table.SetInputCapture(v.handleKey) + v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) }) v.render() return v } +func (v *libraryView) pane() *libPane { return v.panes[v.cur] } + func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey { switch ev.Key() { case tcell.KeyTab, tcell.KeyBacktab: - v.app.app.SetFocus(v.input) + v.app.app.SetFocus(v.panes[1-v.cur].table) return nil case tcell.KeyRune: switch ev.Rune() { case '/': v.app.app.SetFocus(v.input) - case 'm': - v.kind = (v.kind + 1) % 2 - v.activate() case 'f': v.filter = (v.filter + 1) % len(libFilters) v.render() @@ -244,20 +255,24 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey { return ev } -// activate is called when the view becomes visible or the kind changes; it -// always refreshes so changes made elsewhere show up. +// activate is called when the view becomes visible; it always refreshes so +// changes made elsewhere show up. func (v *libraryView) activate() { + v.app.app.SetFocus(v.pane().table) v.render() v.reload() } +// reload fetches both lists in parallel. func (v *libraryView) reload() { - v.gen++ - gen, kind := v.gen, v.kind - wasLoaded := v.loaded[kind] - if !wasLoaded { - v.app.SetMessage("Loading " + strings.ToLower(kind.String()) + "…") + for _, p := range v.panes { + v.reloadPane(p) } +} + +func (v *libraryView) reloadPane(p *libPane) { + p.gen++ + gen, kind := p.gen, p.kind go func() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() @@ -280,69 +295,77 @@ func (v *libraryView) reload() { } v.app.post(func() { v.app.SetService(kind.service(), "", err) - if gen != v.gen { + if gen != p.gen { return } - v.loadErr[kind] = err + p.err = err if err != nil { + // Keep showing the last good list; say why it is stale. v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error())) } else { - v.all[kind], v.loaded[kind] = items, true - if !wasLoaded { - v.app.SetMessage("") - } - } - if kind == v.kind { - v.render() + p.all, p.loaded = items, true } + v.render() }) }() } func (v *libraryView) selected() (libItem, bool) { - r, _ := v.table.GetSelection() - if r < 1 || r > len(v.shown) { + p := v.pane() + r, _ := p.table.GetSelection() + if r < 1 || r > len(p.shown) { return libItem{}, false } - return v.shown[r-1], true + return p.shown[r-1], true } func (v *libraryView) render() { - selID := -1 - if it, ok := v.selected(); ok { - selID = it.id + for _, p := range v.panes { + v.renderPane(p) } - v.shown = filterItems(v.all[v.kind], v.filter, v.input.GetText()) + v.header.SetText(fmt.Sprintf("[::b]Library[::-] · %s %d/%d · %s %d/%d · filter: [yellow]%s[-]", + kindMovies, len(v.panes[kindMovies].shown), len(v.panes[kindMovies].all), + kindSeries, len(v.panes[kindSeries].shown), len(v.panes[kindSeries].all), + libFilters[v.filter].name)) + v.renderDetails() +} - v.table.Clear() - header(v.table, 0, "Title", "Status", "Size") +func (v *libraryView) renderPane(p *libPane) { + selID := -1 + if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) { + selID = p.shown[r-1].id + } + p.shown = filterItems(p.all, v.filter, v.input.GetText()) + + p.table.Clear() + header(p.table, 0, "Title", "Status", "Size") selRow := 0 - for i, it := range v.shown { - v.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1)) - v.table.SetCell(i+1, 1, tview.NewTableCell(it.status)) - v.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight)) + for i, it := range p.shown { + p.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1)) + p.table.SetCell(i+1, 1, tview.NewTableCell(it.status)) + p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight)) if it.id == selID { selRow = i + 1 } } placeholder := "Nothing matches the filter" switch { - case v.loadErr[v.kind] != nil && !v.loaded[v.kind]: - placeholder = "Could not load the library, press r to retry" - case !v.loaded[v.kind]: + case p.err != nil && !p.loaded: + placeholder = "Could not load, press r to retry" + case !p.loaded: placeholder = "Loading…" - case len(v.all[v.kind]) == 0: - placeholder = "The library is empty" + case len(p.all) == 0: + placeholder = "Empty" } - restoreSelection(v.table, len(v.shown), selRow, placeholder) + restoreSelection(p.table, len(p.shown), selRow, placeholder) if selRow == 0 { - v.table.ScrollToBeginning() + p.table.ScrollToBeginning() } - v.table.SetTitle(fmt.Sprintf(" %s ", v.kind)) - - v.header.SetText(fmt.Sprintf("[::b]%s[::-] · %d of %d · filter: [yellow]%s[-]", - v.kind, len(v.shown), len(v.all[v.kind]), libFilters[v.filter].name)) - v.renderDetails() + title := fmt.Sprintf(" %s ", p.kind) + if p.err != nil && p.loaded { + title = fmt.Sprintf(" %s [red](outdated)[-] ", p.kind) + } + p.table.SetTitle(title) } func (v *libraryView) renderDetails() {