Files
arr-tui/internal/ui/library.go
T
nilsandClaude Opus 5.5 15f7fc7ee8 Library: cut long titles to the width of the list
Long titles made the movies and series tables wider than their pane, so
they scrolled sideways and status and size left the view. The title
column is now cut on every draw to what the other columns leave, ending
in an ellipsis (done by hand: tview only draws its ellipsis for untagged
text, and every title starts with the colored monitored mark). Titles are
now escaped, so square brackets in them are not taken as color tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 14:26:09 +02:00

729 lines
19 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(fitTitles{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, prevRow := -1, 0
if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) {
selID, prevRow = p.shown[r-1].id, r
}
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 {
title := titleCell{prefix: monMark(it.monitored) + " ", title: it.label}
p.table.SetCell(i+1, 0, tview.NewTableCell(title.fit(0)).SetExpansion(1).SetReference(title))
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
}
}
if selRow == 0 && selID != -1 && !containsItem(p.all, selID) {
// The selected title is gone from the library (removed): stay at its
// place, on the title that moved up into it (or the new last one),
// instead of jumping to the top. A title that is only filtered out
// still starts the list from the top.
selRow = min(prevRow, len(p.shown))
}
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 + " ")
}
// fitTitles draws a table whose first column (the title) is cut to the width
// the other columns leave, with an ellipsis, so long titles never make the
// list scroll sideways. The cut is worked out on every draw, so it follows
// the window size.
type fitTitles struct{ *tview.Table }
func (f fitTitles) Draw(screen tcell.Screen) {
fitFirstColumn(f.Table)
f.Table.Draw(screen)
}
// fitFirstColumn cuts the title cells (those with a titleCell reference) to
// the width the table leaves after the other columns and their separators.
// The cut is done here rather than with the cell's maximum width, because tview
// only marks a cut with an ellipsis for text without color tags, and every
// title starts with the colored monitored mark.
func fitFirstColumn(t *tview.Table) {
_, _, width, _ := t.GetInnerRect()
rows, cols := t.GetRowCount(), t.GetColumnCount()
rest := 0
for c := 1; c < cols; c++ {
w := 0
for r := 0; r < rows; r++ {
if cell := t.GetCell(r, c); cell != nil {
w = max(w, tview.TaggedStringWidth(cell.Text))
}
}
rest += w + 1 // the column and the separator before it
}
limit := max(width-rest, 8)
for r := 0; r < rows; r++ {
cell := t.GetCell(r, 0)
if title, ok := cell.GetReference().(titleCell); ok {
cell.SetText(title.fit(limit))
}
}
}
// titleCell is the text of a title cell: a tagged prefix (the monitored mark)
// and the plain title, kept whole so it can be cut again for another width.
type titleCell struct{ prefix, title string }
// fit returns the cell text at most width columns wide, the title cut with an
// ellipsis if needed; width 0 means no limit.
func (c titleCell) fit(width int) string {
title := c.title
if width > 0 {
title = cutToWidth(title, width-tview.TaggedStringWidth(c.prefix))
}
return c.prefix + tview.Escape(title)
}
// cutToWidth shortens s to at most w screen columns, ending with "…" when
// anything was cut.
func cutToWidth(s string, w int) string {
width := func(s string) int { return tview.TaggedStringWidth(tview.Escape(s)) }
if width(s) <= w {
return s
}
if w < 1 {
return ""
}
runes := []rune(s)
for n := len(runes); n > 0; n-- {
if cut := string(runes[:n]) + "…"; width(cut) <= w {
return cut
}
}
return "…"
}
func containsItem(items []libItem, id int) bool {
for _, it := range items {
if it.id == id {
return true
}
}
return false
}
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()
}
}
})
}
}()
}