Add library view and series drill-down (M8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
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 string
|
||||
movie *radarr.Movie
|
||||
series *sonarr.Series
|
||||
}
|
||||
|
||||
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{
|
||||
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,
|
||||
}
|
||||
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) + 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.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) + 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 and the text filter, sorted by title.
|
||||
func filterItems(items []libItem, filter int, text string) []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) {
|
||||
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
|
||||
}
|
||||
|
||||
// libraryView lists the movies of Radarr or the series of Sonarr.
|
||||
type libraryView struct {
|
||||
app *App
|
||||
radarr *radarr.Client
|
||||
sonarr *sonarr.Client
|
||||
|
||||
root *tview.Flex
|
||||
header *tview.TextView
|
||||
input *tview.InputField
|
||||
table *tview.Table
|
||||
details *tview.TextView
|
||||
|
||||
// UI-goroutine state.
|
||||
kind libKind
|
||||
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 {
|
||||
v := &libraryView{
|
||||
app: app, radarr: r, sonarr: s,
|
||||
header: tview.NewTextView().SetDynamicColors(true),
|
||||
input: tview.NewInputField().SetLabel("Filter: ").SetFieldWidth(0),
|
||||
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
||||
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.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(v.header, 1, 0, false).
|
||||
AddItem(v.input, 1, 0, false).
|
||||
AddItem(tview.NewFlex().
|
||||
AddItem(v.table, 0, 3, true).
|
||||
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.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.table) })
|
||||
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
|
||||
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
||||
v.table.SetInputCapture(v.handleKey)
|
||||
v.render()
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||
switch ev.Key() {
|
||||
case tcell.KeyTab, tcell.KeyBacktab:
|
||||
v.app.app.SetFocus(v.input)
|
||||
return nil
|
||||
case tcell.KeyRune:
|
||||
switch ev.Rune() {
|
||||
case '/':
|
||||
v.app.app.SetFocus(v.input)
|
||||
case 'm':
|
||||
v.kind = (v.kind + 1) % 2
|
||||
v.activate()
|
||||
case 'f':
|
||||
v.filter = (v.filter + 1) % len(libFilters)
|
||||
v.render()
|
||||
case 'r':
|
||||
v.reload()
|
||||
case 'i':
|
||||
v.open()
|
||||
case 's':
|
||||
v.search()
|
||||
case 't':
|
||||
v.toggleMonitored()
|
||||
default:
|
||||
return ev
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// activate is called when the view becomes visible or the kind changes; it
|
||||
// always refreshes so changes made elsewhere show up.
|
||||
func (v *libraryView) activate() {
|
||||
v.render()
|
||||
v.reload()
|
||||
}
|
||||
|
||||
func (v *libraryView) reload() {
|
||||
v.gen++
|
||||
gen, kind := v.gen, v.kind
|
||||
wasLoaded := v.loaded[kind]
|
||||
if !wasLoaded {
|
||||
v.app.SetMessage("Loading " + strings.ToLower(kind.String()) + "…")
|
||||
}
|
||||
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 {
|
||||
items = append(items, newMovieItem(m))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var series []sonarr.Series
|
||||
if series, err = v.sonarr.Series(ctx); err == nil {
|
||||
for _, s := range series {
|
||||
items = append(items, newSeriesItem(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
v.app.post(func() {
|
||||
v.app.SetService(kind.service(), "", err)
|
||||
if gen != v.gen {
|
||||
return
|
||||
}
|
||||
v.loadErr[kind] = err
|
||||
if err != nil {
|
||||
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
|
||||
} else {
|
||||
v.all[kind], v.loaded[kind] = items, true
|
||||
if !wasLoaded {
|
||||
v.app.SetMessage("")
|
||||
}
|
||||
}
|
||||
if kind == v.kind {
|
||||
v.render()
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (v *libraryView) selected() (libItem, bool) {
|
||||
r, _ := v.table.GetSelection()
|
||||
if r < 1 || r > len(v.shown) {
|
||||
return libItem{}, false
|
||||
}
|
||||
return v.shown[r-1], true
|
||||
}
|
||||
|
||||
func (v *libraryView) render() {
|
||||
selID := -1
|
||||
if it, ok := v.selected(); ok {
|
||||
selID = it.id
|
||||
}
|
||||
v.shown = filterItems(v.all[v.kind], v.filter, v.input.GetText())
|
||||
|
||||
v.table.Clear()
|
||||
header(v.table, 0, "Title", "Status", "Size")
|
||||
selRow := 0
|
||||
for i, it := range v.shown {
|
||||
v.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1))
|
||||
v.table.SetCell(i+1, 1, tview.NewTableCell(it.status))
|
||||
v.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 v.loadErr[v.kind] != nil && !v.loaded[v.kind]:
|
||||
placeholder = "Could not load the library, press r to retry"
|
||||
case !v.loaded[v.kind]:
|
||||
placeholder = "Loading…"
|
||||
case len(v.all[v.kind]) == 0:
|
||||
placeholder = "The library is empty"
|
||||
}
|
||||
restoreSelection(v.table, len(v.shown), selRow, placeholder)
|
||||
if selRow == 0 {
|
||||
v.table.ScrollToBeginning()
|
||||
}
|
||||
v.table.SetTitle(fmt.Sprintf(" %s ", v.kind))
|
||||
|
||||
v.header.SetText(fmt.Sprintf("[::b]%s[::-] · %d of %d · filter: [yellow]%s[-]",
|
||||
v.kind, len(v.shown), len(v.all[v.kind]), libFilters[v.filter].name))
|
||||
v.renderDetails()
|
||||
}
|
||||
|
||||
func (v *libraryView) renderDetails() {
|
||||
if it, ok := v.selected(); ok {
|
||||
v.details.SetText(it.details)
|
||||
v.details.ScrollToBeginning()
|
||||
return
|
||||
}
|
||||
v.details.SetText("")
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user