Files
arr-tui/internal/ui/library.go
T
nilsandClaude Opus 5.5 820b17842b Fixed dark and light themes, ui.theme auto/dark/light
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
2026-10-21 12:05:00 +02:00

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()
}
}
})
}
}()
}