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) if path := pathLine(m.Path); path != "" { it.details.plot += "\n\n" + 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() 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() } } }) } }() }