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.
|
optionally start the automatic search.
|
||||||
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
|
- **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.
|
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
|
monitoring, trigger searches. Series open into seasons and episodes with per-season and
|
||||||
per-episode monitoring, automatic search and release search.
|
per-episode monitoring, automatic search and release search.
|
||||||
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
- **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 |
|
| `/` | focus search box |
|
||||||
| `Enter`, `a` | add selected title |
|
| `Enter`, `a` | add selected title |
|
||||||
| `i` | interactive release search |
|
| `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`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
|
||||||
| `Enter`/`g`, `s`, `f` (release list) | grab, cycle sort, hide rejected |
|
| `Enter`/`g`, `s`, `f` (release list) | grab, cycle sort, hide rejected |
|
||||||
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
||||||
|
|||||||
+1
-1
@@ -341,7 +341,7 @@ func searchHints() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func libraryHints() 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" }
|
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)
|
i interactive release search (library titles)
|
||||||
|
|
||||||
[yellow]Library[-]
|
[yellow]Library[-]
|
||||||
m switch between movies and series
|
Tab switch between the movies and series lists
|
||||||
f cycle filter (all, missing, downloaded, monitored, unmonitored)
|
f cycle filter (all, missing, downloaded, monitored, unmonitored);
|
||||||
/, Tab text filter (Enter/Esc/Tab returns to the list)
|
filters apply to both lists
|
||||||
|
/ text filter (Enter or Esc returns to the list)
|
||||||
t toggle monitored
|
t toggle monitored
|
||||||
s automatic search (series: all missing, asks first)
|
s automatic search (series: all missing, asks first)
|
||||||
i, Enter movie: release search; series: seasons and episodes
|
i, Enter movie: release search; series: seasons and episodes
|
||||||
|
|||||||
+89
-66
@@ -160,7 +160,18 @@ func filterItems(items []libItem, filter int, text string) []libItem {
|
|||||||
return out
|
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 {
|
type libraryView struct {
|
||||||
app *App
|
app *App
|
||||||
radarr *radarr.Client
|
radarr *radarr.Client
|
||||||
@@ -169,17 +180,12 @@ type libraryView struct {
|
|||||||
root *tview.Flex
|
root *tview.Flex
|
||||||
header *tview.TextView
|
header *tview.TextView
|
||||||
input *tview.InputField
|
input *tview.InputField
|
||||||
table *tview.Table
|
|
||||||
details *tview.TextView
|
details *tview.TextView
|
||||||
|
panes [2]*libPane
|
||||||
|
|
||||||
// UI-goroutine state.
|
// UI-goroutine state.
|
||||||
kind libKind
|
cur libKind // the pane that has (or last had) focus
|
||||||
filter int
|
filter int
|
||||||
all map[libKind][]libItem
|
|
||||||
loaded map[libKind]bool
|
|
||||||
loadErr map[libKind]error
|
|
||||||
shown []libItem
|
|
||||||
gen int // invalidates stale loads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
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,
|
app: app, radarr: r, sonarr: s,
|
||||||
header: tview.NewTextView().SetDynamicColors(true),
|
header: tview.NewTextView().SetDynamicColors(true),
|
||||||
input: tview.NewInputField().SetLabel("Filter: ").SetFieldWidth(0),
|
input: tview.NewInputField().SetLabel("Filter: ").SetFieldWidth(0),
|
||||||
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
|
||||||
details: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
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 ")
|
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).
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.header, 1, 0, false).
|
AddItem(v.header, 1, 0, false).
|
||||||
AddItem(v.input, 1, 0, false).
|
AddItem(v.input, 1, 0, false).
|
||||||
AddItem(tview.NewFlex().
|
AddItem(lists, 0, 3, true).
|
||||||
AddItem(v.table, 0, 3, true).
|
AddItem(v.details, 0, 2, false)
|
||||||
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)
|
|
||||||
|
|
||||||
v.input.SetChangedFunc(func(string) { v.render() })
|
v.input.SetChangedFunc(func(string) { v.render() })
|
||||||
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.table) })
|
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) })
|
||||||
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
|
|
||||||
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
|
||||||
v.table.SetInputCapture(v.handleKey)
|
|
||||||
v.render()
|
v.render()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *libraryView) pane() *libPane { return v.panes[v.cur] }
|
||||||
|
|
||||||
func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
switch ev.Key() {
|
switch ev.Key() {
|
||||||
case tcell.KeyTab, tcell.KeyBacktab:
|
case tcell.KeyTab, tcell.KeyBacktab:
|
||||||
v.app.app.SetFocus(v.input)
|
v.app.app.SetFocus(v.panes[1-v.cur].table)
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyRune:
|
case tcell.KeyRune:
|
||||||
switch ev.Rune() {
|
switch ev.Rune() {
|
||||||
case '/':
|
case '/':
|
||||||
v.app.app.SetFocus(v.input)
|
v.app.app.SetFocus(v.input)
|
||||||
case 'm':
|
|
||||||
v.kind = (v.kind + 1) % 2
|
|
||||||
v.activate()
|
|
||||||
case 'f':
|
case 'f':
|
||||||
v.filter = (v.filter + 1) % len(libFilters)
|
v.filter = (v.filter + 1) % len(libFilters)
|
||||||
v.render()
|
v.render()
|
||||||
@@ -244,20 +255,24 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
|
|
||||||
// activate is called when the view becomes visible or the kind changes; it
|
// activate is called when the view becomes visible; it always refreshes so
|
||||||
// always refreshes so changes made elsewhere show up.
|
// changes made elsewhere show up.
|
||||||
func (v *libraryView) activate() {
|
func (v *libraryView) activate() {
|
||||||
|
v.app.app.SetFocus(v.pane().table)
|
||||||
v.render()
|
v.render()
|
||||||
v.reload()
|
v.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reload fetches both lists in parallel.
|
||||||
func (v *libraryView) reload() {
|
func (v *libraryView) reload() {
|
||||||
v.gen++
|
for _, p := range v.panes {
|
||||||
gen, kind := v.gen, v.kind
|
v.reloadPane(p)
|
||||||
wasLoaded := v.loaded[kind]
|
|
||||||
if !wasLoaded {
|
|
||||||
v.app.SetMessage("Loading " + strings.ToLower(kind.String()) + "…")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *libraryView) reloadPane(p *libPane) {
|
||||||
|
p.gen++
|
||||||
|
gen, kind := p.gen, p.kind
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -280,69 +295,77 @@ func (v *libraryView) reload() {
|
|||||||
}
|
}
|
||||||
v.app.post(func() {
|
v.app.post(func() {
|
||||||
v.app.SetService(kind.service(), "", err)
|
v.app.SetService(kind.service(), "", err)
|
||||||
if gen != v.gen {
|
if gen != p.gen {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v.loadErr[kind] = err
|
p.err = err
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Keep showing the last good list; say why it is stale.
|
||||||
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
|
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
|
||||||
} else {
|
} else {
|
||||||
v.all[kind], v.loaded[kind] = items, true
|
p.all, p.loaded = items, true
|
||||||
if !wasLoaded {
|
|
||||||
v.app.SetMessage("")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if kind == v.kind {
|
|
||||||
v.render()
|
v.render()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *libraryView) selected() (libItem, bool) {
|
func (v *libraryView) selected() (libItem, bool) {
|
||||||
r, _ := v.table.GetSelection()
|
p := v.pane()
|
||||||
if r < 1 || r > len(v.shown) {
|
r, _ := p.table.GetSelection()
|
||||||
|
if r < 1 || r > len(p.shown) {
|
||||||
return libItem{}, false
|
return libItem{}, false
|
||||||
}
|
}
|
||||||
return v.shown[r-1], true
|
return p.shown[r-1], true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *libraryView) render() {
|
func (v *libraryView) render() {
|
||||||
selID := -1
|
for _, p := range v.panes {
|
||||||
if it, ok := v.selected(); ok {
|
v.renderPane(p)
|
||||||
selID = it.id
|
}
|
||||||
|
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.shown = filterItems(v.all[v.kind], v.filter, v.input.GetText())
|
|
||||||
|
|
||||||
v.table.Clear()
|
func (v *libraryView) renderPane(p *libPane) {
|
||||||
header(v.table, 0, "Title", "Status", "Size")
|
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
|
selRow := 0
|
||||||
for i, it := range v.shown {
|
for i, it := range p.shown {
|
||||||
v.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1))
|
p.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1))
|
||||||
v.table.SetCell(i+1, 1, tview.NewTableCell(it.status))
|
p.table.SetCell(i+1, 1, tview.NewTableCell(it.status))
|
||||||
v.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight))
|
p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight))
|
||||||
if it.id == selID {
|
if it.id == selID {
|
||||||
selRow = i + 1
|
selRow = i + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
placeholder := "Nothing matches the filter"
|
placeholder := "Nothing matches the filter"
|
||||||
switch {
|
switch {
|
||||||
case v.loadErr[v.kind] != nil && !v.loaded[v.kind]:
|
case p.err != nil && !p.loaded:
|
||||||
placeholder = "Could not load the library, press r to retry"
|
placeholder = "Could not load, press r to retry"
|
||||||
case !v.loaded[v.kind]:
|
case !p.loaded:
|
||||||
placeholder = "Loading…"
|
placeholder = "Loading…"
|
||||||
case len(v.all[v.kind]) == 0:
|
case len(p.all) == 0:
|
||||||
placeholder = "The library is empty"
|
placeholder = "Empty"
|
||||||
}
|
}
|
||||||
restoreSelection(v.table, len(v.shown), selRow, placeholder)
|
restoreSelection(p.table, len(p.shown), selRow, placeholder)
|
||||||
if selRow == 0 {
|
if selRow == 0 {
|
||||||
v.table.ScrollToBeginning()
|
p.table.ScrollToBeginning()
|
||||||
}
|
}
|
||||||
v.table.SetTitle(fmt.Sprintf(" %s ", v.kind))
|
title := fmt.Sprintf(" %s ", p.kind)
|
||||||
|
if p.err != nil && p.loaded {
|
||||||
v.header.SetText(fmt.Sprintf("[::b]%s[::-] · %d of %d · filter: [yellow]%s[-]",
|
title = fmt.Sprintf(" %s [red](outdated)[-] ", p.kind)
|
||||||
v.kind, len(v.shown), len(v.all[v.kind]), libFilters[v.filter].name))
|
}
|
||||||
v.renderDetails()
|
p.table.SetTitle(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *libraryView) renderDetails() {
|
func (v *libraryView) renderDetails() {
|
||||||
|
|||||||
Reference in New Issue
Block a user