Colors no longer come from the terminal's palette, whose color 0 many color themes set to a grey that is not their background. A theme sets tview's default styles and the color names used in texts, all chosen from entries 16-255 of the 256-color table so terminals without 24-bit color show them exactly. "auto" picks light or dark from the background color the terminal reports. Settable in the Config view, applied at the next start (widgets copy their colors when created). Replaces using the terminal's own background and text colors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
641 lines
16 KiB
Go
641 lines
16 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
|
)
|
|
|
|
type libKind int
|
|
|
|
const (
|
|
kindMovies libKind = iota
|
|
kindSeries
|
|
)
|
|
|
|
func (k libKind) String() string {
|
|
if k == kindMovies {
|
|
return "Movies"
|
|
}
|
|
return "Series"
|
|
}
|
|
|
|
func (k libKind) service() string {
|
|
if k == kindMovies {
|
|
return "Radarr"
|
|
}
|
|
return "Sonarr"
|
|
}
|
|
|
|
// libItem is one row of the library: a movie or a series in display form.
|
|
type libItem struct {
|
|
id int
|
|
title string // plain, used for sorting and filtering
|
|
label string // escaped title with year
|
|
monitored bool
|
|
have bool // at least one file on disk
|
|
missing bool // monitored and not complete
|
|
status string
|
|
size string
|
|
details detailText
|
|
cover coverRef
|
|
movie *radarr.Movie
|
|
series *sonarr.Series
|
|
// File properties for the attribute filter. For series they are fetched on
|
|
// demand, attrsKnown tells whether that has happened.
|
|
attrs []arr.FileAttrs
|
|
attrsKnown bool
|
|
}
|
|
|
|
var libFilters = []struct {
|
|
name string
|
|
match func(libItem) bool
|
|
}{
|
|
{"all", func(libItem) bool { return true }},
|
|
{"missing", func(i libItem) bool { return i.missing }},
|
|
{"downloaded", func(i libItem) bool { return i.have }},
|
|
{"monitored", func(i libItem) bool { return i.monitored }},
|
|
{"unmonitored", func(i libItem) bool { return !i.monitored }},
|
|
}
|
|
|
|
func monMark(monitored bool) string {
|
|
if monitored {
|
|
return "[green]●[-]"
|
|
}
|
|
return "[gray]○[-]"
|
|
}
|
|
|
|
func sizeOrBlank(b int64) string {
|
|
if b <= 0 {
|
|
return ""
|
|
}
|
|
return formatSize(b)
|
|
}
|
|
|
|
func newMovieItem(m radarr.Movie) libItem {
|
|
it := libItem{
|
|
attrsKnown: true,
|
|
id: m.ID, title: m.Title, monitored: m.Monitored, have: m.HasFile,
|
|
missing: m.Monitored && !m.HasFile && m.IsAvailable,
|
|
size: sizeOrBlank(m.SizeOnDisk), movie: &m,
|
|
}
|
|
if m.HasFile && m.MovieFile != nil {
|
|
it.attrs = []arr.FileAttrs{m.MovieFile.Attrs()}
|
|
}
|
|
it.label = tview.Escape(m.Title)
|
|
if m.Year > 0 {
|
|
it.label += fmt.Sprintf(" (%d)", m.Year)
|
|
}
|
|
switch {
|
|
case m.HasFile:
|
|
it.status = "[green]✓ " + tview.Escape(m.QualityName())
|
|
case !m.Monitored:
|
|
it.status = "[gray]unmonitored"
|
|
case !m.IsAvailable:
|
|
it.status = "[gray]not released"
|
|
default:
|
|
it.status = "[yellow]missing"
|
|
}
|
|
it.details = movieDetails(m)
|
|
it.details.head += monitoredLine(m.Monitored) + sizeLine(m.SizeOnDisk) + pathLine(m.Path)
|
|
return it
|
|
}
|
|
|
|
func newSeriesItem(s sonarr.Series) libItem {
|
|
st := s.Statistics
|
|
it := libItem{
|
|
id: s.ID, title: s.Title, monitored: s.Monitored, have: st.EpisodeFileCount > 0,
|
|
missing: s.Monitored && st.EpisodeFileCount < st.EpisodeCount,
|
|
size: sizeOrBlank(st.SizeOnDisk), series: &s,
|
|
}
|
|
it.attrsKnown = st.EpisodeFileCount == 0 // nothing to fetch for series without files
|
|
it.label = tview.Escape(s.Title)
|
|
if s.Year > 0 {
|
|
it.label += fmt.Sprintf(" (%d)", s.Year)
|
|
}
|
|
counts := fmt.Sprintf("%d/%d", st.EpisodeFileCount, st.EpisodeCount)
|
|
switch {
|
|
case !s.Monitored:
|
|
it.status = "[gray]" + counts + " unmonitored"
|
|
case st.EpisodeFileCount >= st.EpisodeCount:
|
|
it.status = "[green]" + counts
|
|
default:
|
|
it.status = "[yellow]" + counts
|
|
}
|
|
it.details = seriesDetails(s)
|
|
it.details.head += monitoredLine(s.Monitored) + sizeLine(st.SizeOnDisk)
|
|
return it
|
|
}
|
|
|
|
func monitoredLine(m bool) string {
|
|
if m {
|
|
return "Monitored: yes\n"
|
|
}
|
|
return "Monitored: [gray]no[-]\n"
|
|
}
|
|
|
|
func sizeLine(b int64) string {
|
|
if b <= 0 {
|
|
return ""
|
|
}
|
|
return "Size on disk: " + formatSize(b) + "\n"
|
|
}
|
|
|
|
func pathLine(p string) string {
|
|
if p == "" {
|
|
return ""
|
|
}
|
|
return "Path: " + tview.Escape(p) + "\n"
|
|
}
|
|
|
|
// filterItems applies the filter preset, the text filter and the file
|
|
// property filter, sorted by title. Items whose file properties are not known
|
|
// yet do not match an active property filter.
|
|
func filterItems(items []libItem, filter int, text string, af attrFilter) []libItem {
|
|
text = strings.ToLower(strings.TrimSpace(text))
|
|
var out []libItem
|
|
for _, it := range items {
|
|
if !libFilters[filter].match(it) || !strings.Contains(strings.ToLower(it.title), text) {
|
|
continue
|
|
}
|
|
if !af.active() || af.match(it.attrs) {
|
|
out = append(out, it)
|
|
}
|
|
}
|
|
slices.SortFunc(out, func(a, b libItem) int {
|
|
return strings.Compare(strings.ToLower(a.title), strings.ToLower(b.title))
|
|
})
|
|
return out
|
|
}
|
|
|
|
// 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
|
|
text string // text filter, set with / while this pane is the current one
|
|
// Each list has its own filters.
|
|
filter int // status preset, index into libFilters
|
|
attr attrFilter // file properties
|
|
}
|
|
|
|
// libraryView shows the movies of Radarr above the series of Sonarr, with the
|
|
// details of the selected item on the right.
|
|
type libraryView struct {
|
|
app *App
|
|
radarr *radarr.Client
|
|
sonarr *sonarr.Client
|
|
|
|
root *tview.Flex
|
|
header *tview.TextView
|
|
input *tview.InputField
|
|
details *detailPane
|
|
panes [2]*libPane
|
|
|
|
// UI-goroutine state.
|
|
cur libKind // the pane that has (or last had) focus
|
|
|
|
// File properties of series, fetched on demand and cached. An entry is
|
|
// reused while the series' file count and size stay the same.
|
|
seriesFiles map[int]seriesFiles
|
|
fileGen int // invalidates running fetches
|
|
filePending int
|
|
|
|
refreshDialog func() // re-evaluates the options of the open filter dialog
|
|
}
|
|
|
|
type seriesFiles struct {
|
|
count int
|
|
size int64
|
|
attrs []arr.FileAttrs
|
|
}
|
|
|
|
func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
|
v := &libraryView{
|
|
app: app, radarr: r, sonarr: s,
|
|
header: tview.NewTextView().SetDynamicColors(true),
|
|
input: tview.NewInputField().SetLabel("Filter Movies: ").SetFieldWidth(0),
|
|
details: newDetailPane(app),
|
|
|
|
seriesFiles: map[int]seriesFiles{},
|
|
}
|
|
|
|
lists := tview.NewFlex().SetDirection(tview.FlexRow) // movies above series
|
|
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
|
|
v.input.SetLabel("Filter " + kind.String() + ": ")
|
|
v.input.SetText(p.text)
|
|
p.table.SetBorderColor(named("yellow"))
|
|
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(lists, 0, 3, true).
|
|
AddItem(v.details, 0, 2, false), 0, 1, true)
|
|
|
|
v.input.SetChangedFunc(func(text string) {
|
|
v.pane().text = text // the text filter only acts on the current pane
|
|
v.render()
|
|
})
|
|
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) })
|
|
app.reconnect = append(app.reconnect, func() {
|
|
// Another server: forget what the old one said, reload when shown.
|
|
v.seriesFiles = map[int]seriesFiles{}
|
|
for _, p := range v.panes {
|
|
p.all, p.shown, p.loaded, p.err = nil, nil, false, nil
|
|
}
|
|
v.render()
|
|
})
|
|
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.panes[1-v.cur].table)
|
|
return nil
|
|
case tcell.KeyRune:
|
|
switch ev.Rune() {
|
|
case '/':
|
|
v.app.app.SetFocus(v.input)
|
|
case 'f':
|
|
p := v.pane()
|
|
p.filter = (p.filter + 1) % len(libFilters)
|
|
v.render()
|
|
case 'F':
|
|
v.openFilterDialog()
|
|
case 'r':
|
|
v.seriesFiles = map[int]seriesFiles{} // a manual reload also refreshes file properties
|
|
v.reload()
|
|
case 'i':
|
|
v.open()
|
|
case 's':
|
|
v.search()
|
|
case 't':
|
|
v.toggleMonitored()
|
|
case 'd':
|
|
v.remove()
|
|
default:
|
|
return ev
|
|
}
|
|
return nil
|
|
}
|
|
return ev
|
|
}
|
|
|
|
// 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() {
|
|
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()
|
|
var items []libItem
|
|
var err error
|
|
if kind == kindMovies {
|
|
var movies []radarr.Movie
|
|
if movies, err = v.radarr.Movies(ctx); err == nil {
|
|
for _, m := range movies {
|
|
it := newMovieItem(m)
|
|
it.cover = coverFor(v.radarr.Client, m.Images, true)
|
|
items = append(items, it)
|
|
}
|
|
}
|
|
} else {
|
|
var series []sonarr.Series
|
|
if series, err = v.sonarr.Series(ctx); err == nil {
|
|
for _, s := range series {
|
|
it := newSeriesItem(s)
|
|
it.cover = coverFor(v.sonarr.Client, s.Images, true)
|
|
items = append(items, v.withCachedFiles(it))
|
|
}
|
|
}
|
|
}
|
|
v.app.post(func() {
|
|
v.app.SetService(kind.service(), "", err)
|
|
if gen != p.gen {
|
|
return
|
|
}
|
|
p.err = err
|
|
if err == nil && kind == kindSeries {
|
|
defer v.loadSeriesFiles(false)
|
|
}
|
|
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 {
|
|
p.all, p.loaded = items, true
|
|
}
|
|
v.render()
|
|
})
|
|
}()
|
|
}
|
|
|
|
func (v *libraryView) selected() (libItem, bool) {
|
|
p := v.pane()
|
|
r, _ := p.table.GetSelection()
|
|
if r < 1 || r > len(p.shown) {
|
|
return libItem{}, false
|
|
}
|
|
return p.shown[r-1], true
|
|
}
|
|
|
|
func (v *libraryView) render() {
|
|
for _, p := range v.panes {
|
|
v.renderPane(p)
|
|
}
|
|
v.header.SetText("[::b]Library[::-] · Tab switches list · each list has its own filters")
|
|
v.renderDetails()
|
|
}
|
|
|
|
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, p.filter, p.text, p.attr)
|
|
|
|
p.table.Clear()
|
|
header(p.table, 0, "Title", "Status", "Size")
|
|
selRow := 0
|
|
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 p.err != nil && !p.loaded:
|
|
placeholder = "Could not load, press r to retry"
|
|
case !p.loaded:
|
|
placeholder = "Loading…"
|
|
case len(p.all) == 0:
|
|
placeholder = "Empty"
|
|
}
|
|
restoreSelection(p.table, len(p.shown), selRow, placeholder)
|
|
if selRow == 0 {
|
|
p.table.ScrollToBeginning()
|
|
}
|
|
title := fmt.Sprintf(" %s %d/%d", p.kind, len(p.shown), len(p.all))
|
|
if p.filter > 0 {
|
|
title += " · [yellow]" + libFilters[p.filter].name + "[-]"
|
|
}
|
|
if p.attr.active() {
|
|
title += " · [yellow]" + p.attr.String() + "[-]"
|
|
if p.kind == kindSeries && v.filePending > 0 {
|
|
title += fmt.Sprintf(" [gray](reading %d…)[-]", v.filePending)
|
|
}
|
|
}
|
|
if p.err != nil && p.loaded {
|
|
title += " [red](outdated)[-]"
|
|
}
|
|
p.table.SetTitle(title + " ")
|
|
}
|
|
|
|
func (v *libraryView) renderDetails() {
|
|
if it, ok := v.selected(); ok {
|
|
v.details.Show(it.details, it.cover)
|
|
return
|
|
}
|
|
v.details.Clear()
|
|
}
|
|
|
|
// open starts the interactive search (movie) or the episode drill-down (series).
|
|
func (v *libraryView) open() {
|
|
it, ok := v.selected()
|
|
if !ok {
|
|
return
|
|
}
|
|
if it.movie != nil {
|
|
m := *it.movie
|
|
v.app.openReleases("Releases for "+tview.Escape(movieTitleYear(m)),
|
|
func(ctx context.Context) ([]arr.Release, error) { return v.radarr.Releases(ctx, m.ID) },
|
|
v.radarr.Grab)
|
|
return
|
|
}
|
|
v.app.openSeries(v.sonarr, *it.series, v.reload)
|
|
}
|
|
|
|
func (v *libraryView) search() {
|
|
it, ok := v.selected()
|
|
if !ok {
|
|
return
|
|
}
|
|
if it.movie != nil {
|
|
v.run("Searching for "+it.label, "Search started for "+it.label,
|
|
func(ctx context.Context) error { return v.radarr.SearchMovie(ctx, it.id) }, nil)
|
|
return
|
|
}
|
|
v.app.confirm("Search for all missing episodes of "+it.label+"?", func() {
|
|
v.run("Searching for "+it.label, "Search started for "+it.label,
|
|
func(ctx context.Context) error { return v.sonarr.SearchSeries(ctx, it.id) }, nil)
|
|
})
|
|
}
|
|
|
|
func (v *libraryView) toggleMonitored() {
|
|
it, ok := v.selected()
|
|
if !ok {
|
|
return
|
|
}
|
|
want := !it.monitored
|
|
doing, done := "Monitoring ", "Monitored "
|
|
if !want {
|
|
doing, done = "Unmonitoring ", "Unmonitored "
|
|
}
|
|
v.run(doing+it.label, done+it.label, func(ctx context.Context) error {
|
|
if it.movie != nil {
|
|
return v.radarr.SetMonitored(ctx, it.id, want)
|
|
}
|
|
return v.sonarr.SetMonitored(ctx, it.id, want)
|
|
}, v.reload)
|
|
}
|
|
|
|
// run executes fn off the UI goroutine, reports the outcome and then calls after.
|
|
func (v *libraryView) run(doing, done string, fn func(ctx context.Context) error, after func()) {
|
|
v.app.SetMessage(doing + "…")
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
err := fn(ctx)
|
|
v.app.post(func() {
|
|
if err != nil {
|
|
v.app.SetMessage("[red]" + tview.Escape(err.Error()))
|
|
return
|
|
}
|
|
v.app.SetMessage("[green]" + done)
|
|
if after != nil {
|
|
after()
|
|
}
|
|
})
|
|
}()
|
|
}
|
|
|
|
// openFilterDialog opens the property filter for the focused list. For series
|
|
// the files of all series are read so the dialog can leave out options that
|
|
// would match nothing.
|
|
func (v *libraryView) openFilterDialog() {
|
|
p := v.pane()
|
|
set := func(f attrFilter) {
|
|
p.attr = f
|
|
v.render()
|
|
if p.kind == kindSeries {
|
|
v.loadSeriesFiles(false)
|
|
}
|
|
}
|
|
v.refreshDialog = v.app.openAttrFilter(p.attr, func() filterPool { return v.filterPool(p) }, set, func() { v.refreshDialog = nil })
|
|
if p.kind == kindSeries {
|
|
v.loadSeriesFiles(true)
|
|
}
|
|
}
|
|
|
|
// filterPool lists the files of the items of one list that pass its status
|
|
// and text filters, for the filter dialog.
|
|
func (v *libraryView) filterPool(p *libPane) filterPool {
|
|
var pool filterPool
|
|
for _, it := range filterItems(p.all, p.filter, p.text, attrFilter{}) {
|
|
if !it.attrsKnown {
|
|
pool.unknown = true
|
|
}
|
|
pool.files = append(pool.files, it.attrs)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
// withCachedFiles fills in the file properties of a series from the cache.
|
|
func (v *libraryView) withCachedFiles(it libItem) libItem {
|
|
if it.attrsKnown || it.series == nil {
|
|
return it
|
|
}
|
|
st := it.series.Statistics
|
|
if c, ok := v.seriesFiles[it.id]; ok && c.count == st.EpisodeFileCount && c.size == st.SizeOnDisk {
|
|
it.attrs, it.attrsKnown = c.attrs, true
|
|
}
|
|
return it
|
|
}
|
|
|
|
// loadSeriesFiles fetches the file properties of all series that lack them,
|
|
// but only while a property filter is active or the filter dialog is open
|
|
// (it costs one request per series).
|
|
func (v *libraryView) loadSeriesFiles(force bool) {
|
|
if !force && !v.panes[kindSeries].attr.active() {
|
|
return
|
|
}
|
|
p := v.panes[kindSeries]
|
|
var todo []sonarr.Series
|
|
for i, it := range p.all {
|
|
p.all[i] = v.withCachedFiles(it)
|
|
if !p.all[i].attrsKnown {
|
|
todo = append(todo, *p.all[i].series)
|
|
}
|
|
}
|
|
v.render()
|
|
if len(todo) == 0 {
|
|
return
|
|
}
|
|
v.fileGen++
|
|
gen := v.fileGen
|
|
v.filePending = len(todo)
|
|
v.render()
|
|
|
|
type fetched struct {
|
|
s sonarr.Series
|
|
attrs []arr.FileAttrs
|
|
err error
|
|
}
|
|
jobs := make(chan sonarr.Series)
|
|
results := make(chan fetched)
|
|
const workers = 6
|
|
for range workers {
|
|
go func() {
|
|
for s := range jobs {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
files, err := v.sonarr.EpisodeFiles(ctx, s.ID)
|
|
cancel()
|
|
f := fetched{s: s, err: err}
|
|
for _, mf := range files {
|
|
f.attrs = append(f.attrs, mf.Attrs())
|
|
}
|
|
results <- f
|
|
}
|
|
}()
|
|
}
|
|
go func() {
|
|
for _, s := range todo {
|
|
jobs <- s
|
|
}
|
|
close(jobs)
|
|
}()
|
|
go func() {
|
|
for range todo {
|
|
f := <-results
|
|
v.app.post(func() {
|
|
if gen != v.fileGen {
|
|
return
|
|
}
|
|
v.filePending--
|
|
if f.err != nil {
|
|
v.app.SetService("Sonarr", "", f.err)
|
|
} else {
|
|
v.seriesFiles[f.s.ID] = seriesFiles{f.s.Statistics.EpisodeFileCount, f.s.Statistics.SizeOnDisk, f.attrs}
|
|
for i, it := range p.all {
|
|
if it.id == f.s.ID {
|
|
p.all[i].attrs, p.all[i].attrsKnown = f.attrs, true
|
|
}
|
|
}
|
|
}
|
|
if v.filePending%10 == 0 { // keep redraws cheap for big libraries
|
|
v.render()
|
|
if v.refreshDialog != nil {
|
|
v.refreshDialog()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}()
|
|
}
|