633 lines
16 KiB
Go
633 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(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(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) })
|
|
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()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}()
|
|
}
|