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
729 lines
19 KiB
Go
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()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}()
|
|
}
|