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:
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user