Show movies and series side by side in the library view

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:04:13 +02:00
co-authored by Claude Sonnet 5.5
parent 5afaa15649
commit 5195edc3b2
4 changed files with 98 additions and 74 deletions
+2 -2
View File
@@ -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 |
+1 -1
View File
@@ -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" }
+4 -3
View File
@@ -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
+91 -68
View File
@@ -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() {