Filter dialog: only offer options that yield results
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -209,13 +209,13 @@ func (a *App) openModal(p tview.Primitive, width, height int) {
|
||||
|
||||
// openSidePanel shows p at the top right so most of the view behind it stays
|
||||
// visible, for dialogs whose changes are applied live.
|
||||
func (a *App) openSidePanel(p tview.Primitive, width, height int, hints string) {
|
||||
func (a *App) openSidePanel(p tview.Primitive, width, height int, hints string, onClose func()) {
|
||||
box := tview.NewFlex().
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(p, height, 0, true).
|
||||
AddItem(nil, 0, 1, false), width, 0, true)
|
||||
a.pushOverlay(box, p, hints, false, nil)
|
||||
a.pushOverlay(box, p, hints, false, onClose)
|
||||
}
|
||||
|
||||
// openFull shows p above the whole view area (the tab bar and status bar stay visible).
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ const helpText = `[yellow]Global[-]
|
||||
F filter by file properties: quality, codec (x265, x264, AV1),
|
||||
HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language.
|
||||
Changes apply live behind the dialog, also while you move through an
|
||||
open list; Esc undoes them.
|
||||
open list; Esc undoes them. Options that would leave no
|
||||
results are not offered.
|
||||
Series are read on demand.
|
||||
/ text filter (Enter or Esc returns to the list)
|
||||
t toggle monitored
|
||||
|
||||
+112
-57
@@ -82,18 +82,28 @@ func (f attrFilter) match(files []arr.FileAttrs) bool {
|
||||
return slices.ContainsFunc(files, f.matchFile)
|
||||
}
|
||||
|
||||
// languagesOf collects the languages of all known files, sorted.
|
||||
func languagesOf(items ...[]libItem) []string {
|
||||
// filterPool is what the filter dialog works on: the files of every item that
|
||||
// passes the other filters. It decides which dropdown entries are offered.
|
||||
type filterPool struct {
|
||||
files [][]arr.FileAttrs // one entry per item (a series has several files)
|
||||
unknown bool // some files are not read yet: nothing can be ruled out
|
||||
}
|
||||
|
||||
// has reports whether at least one item matches f.
|
||||
func (p filterPool) has(f attrFilter) bool {
|
||||
return p.unknown || slices.ContainsFunc(p.files, f.match)
|
||||
}
|
||||
|
||||
// languages lists the audio languages in the pool, sorted.
|
||||
func (p filterPool) languages() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, list := range items {
|
||||
for _, it := range list {
|
||||
for _, a := range it.attrs {
|
||||
for _, l := range a.Languages {
|
||||
if !seen[l] {
|
||||
seen[l] = true
|
||||
out = append(out, l)
|
||||
}
|
||||
for _, files := range p.files {
|
||||
for _, a := range files {
|
||||
for _, l := range a.Languages {
|
||||
if !seen[l] {
|
||||
seen[l] = true
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,65 +112,105 @@ func languagesOf(items ...[]libItem) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func indexOf(options []string, v string) int {
|
||||
if i := slices.Index(options, v); i >= 0 {
|
||||
return i + 1 // option 0 is "any"
|
||||
// facet is one dropdown of the filter dialog.
|
||||
type facet struct {
|
||||
label string
|
||||
candidates func(filterPool) []string
|
||||
get func(attrFilter) string
|
||||
with func(attrFilter, string) attrFilter
|
||||
|
||||
dd *tview.DropDown
|
||||
values []string // the listed options; values[0] is "" for "any"
|
||||
}
|
||||
|
||||
func staticOptions(o []string) func(filterPool) []string {
|
||||
return func(filterPool) []string { return o }
|
||||
}
|
||||
|
||||
var facetDefs = []facet{
|
||||
{"Quality", staticOptions(resOptions), func(f attrFilter) string { return f.res }, func(f attrFilter, v string) attrFilter { f.res = v; return f }, nil, nil},
|
||||
{"Codec", staticOptions(codecOptions), func(f attrFilter) string { return f.codec }, func(f attrFilter, v string) attrFilter { f.codec = v; return f }, nil, nil},
|
||||
{"HDR", staticOptions(hdrOptions), func(f attrFilter) string { return f.hdr }, func(f attrFilter, v string) attrFilter { f.hdr = v; return f }, nil, nil},
|
||||
{"Audio", staticOptions(audioOptions), func(f attrFilter) string { return f.audio }, func(f attrFilter, v string) attrFilter { f.audio = v; return f }, nil, nil},
|
||||
{"Language", filterPool.languages, func(f attrFilter) string { return f.lang }, func(f attrFilter, v string) attrFilter { f.lang = v; return f }, nil, nil},
|
||||
}
|
||||
|
||||
// listedValues returns the options a facet offers: those that, together with
|
||||
// the other chosen criteria, still leave at least one result, plus the
|
||||
// current choice. The first entry is always "" (any).
|
||||
func listedValues(f facet, state attrFilter, pool filterPool) []string {
|
||||
cur := f.get(state)
|
||||
vals := []string{""}
|
||||
cands := f.candidates(pool)
|
||||
if cur != "" && !slices.Contains(cands, cur) {
|
||||
cands = append([]string{cur}, cands...)
|
||||
}
|
||||
return 0
|
||||
for _, o := range cands {
|
||||
if o == cur || pool.has(f.with(state, o)) {
|
||||
vals = append(vals, o)
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// openAttrFilter shows the dialog to pick quality, codec, HDR, audio and
|
||||
// language. Every change is passed to set immediately so the list behind the
|
||||
// dialog follows along; Cancel and Esc restore cur.
|
||||
func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFilter)) {
|
||||
if cur.lang != "" && !slices.Contains(languages, cur.lang) {
|
||||
languages = append([]string{cur.lang}, languages...)
|
||||
// dialog follows along, also while moving through an open list; Cancel and Esc
|
||||
// restore cur. Entries that would leave no results are not offered. The
|
||||
// returned function re-evaluates them when the pool has changed; onClose runs
|
||||
// when the dialog goes away.
|
||||
func (a *App) openAttrFilter(cur attrFilter, pool func() filterPool, set func(attrFilter), onClose func()) (refresh func()) {
|
||||
orig, state := cur, cur
|
||||
facets := make([]*facet, len(facetDefs))
|
||||
form := tview.NewForm()
|
||||
byDropDown := map[*tview.DropDown]*facet{}
|
||||
for i := range facetDefs {
|
||||
f := facetDefs[i]
|
||||
facets[i] = &f
|
||||
form.AddDropDown(f.label, []string{anyOption}, 0, nil)
|
||||
f.dd = form.GetFormItem(i).(*tview.DropDown)
|
||||
f.values = []string{""}
|
||||
byDropDown[f.dd] = facets[i]
|
||||
facets[i].dd = f.dd
|
||||
}
|
||||
withAny := func(o []string) []string { return append([]string{anyOption}, o...) }
|
||||
const (
|
||||
lRes = "Quality"
|
||||
lCodec = "Codec"
|
||||
lHDR = "HDR"
|
||||
lAudio = "Audio"
|
||||
lLang = "Language"
|
||||
)
|
||||
form := tview.NewForm().
|
||||
AddDropDown(lRes, withAny(resOptions), indexOf(resOptions, cur.res), nil).
|
||||
AddDropDown(lCodec, withAny(codecOptions), indexOf(codecOptions, cur.codec), nil).
|
||||
AddDropDown(lHDR, withAny(hdrOptions), indexOf(hdrOptions, cur.hdr), nil).
|
||||
AddDropDown(lAudio, withAny(audioOptions), indexOf(audioOptions, cur.audio), nil).
|
||||
AddDropDown(lLang, withAny(languages), indexOf(languages, cur.lang), nil)
|
||||
|
||||
// filterWith reads the dialog; the entry at index in dropdown over (if
|
||||
// any) stands in for that dropdown's committed choice.
|
||||
filterWith := func(over *tview.DropDown, index int) attrFilter {
|
||||
pick := func(label string, options []string) string {
|
||||
dd := form.GetFormItemByLabel(label).(*tview.DropDown)
|
||||
i, _ := dd.GetCurrentOption()
|
||||
if dd == over {
|
||||
i = index
|
||||
rebuilding := false
|
||||
var onSelect func(f *facet) func(string, int)
|
||||
refresh = func() {
|
||||
p := pool()
|
||||
rebuilding = true
|
||||
defer func() { rebuilding = false }()
|
||||
for _, f := range facets {
|
||||
if f.dd.IsOpen() {
|
||||
continue // do not pull the list from under the user
|
||||
}
|
||||
if i <= 0 {
|
||||
return ""
|
||||
vals := listedValues(*f, state, p)
|
||||
if slices.Equal(vals, f.values) {
|
||||
continue
|
||||
}
|
||||
return options[i-1]
|
||||
}
|
||||
return attrFilter{
|
||||
res: pick(lRes, resOptions),
|
||||
codec: pick(lCodec, codecOptions),
|
||||
hdr: pick(lHDR, hdrOptions),
|
||||
audio: pick(lAudio, audioOptions),
|
||||
lang: pick(lLang, languages),
|
||||
f.values = vals
|
||||
f.dd.SetOptions(append([]string{anyOption}, vals[1:]...), onSelect(f))
|
||||
f.dd.SetCurrentOption(slices.Index(vals, f.get(state)))
|
||||
}
|
||||
}
|
||||
current := func() attrFilter { return filterWith(nil, 0) }
|
||||
for _, label := range []string{lRes, lCodec, lHDR, lAudio, lLang} {
|
||||
form.GetFormItemByLabel(label).(*tview.DropDown).SetSelectedFunc(func(string, int) { set(current()) })
|
||||
onSelect = func(f *facet) func(string, int) {
|
||||
return func(_ string, index int) {
|
||||
if rebuilding || index < 0 || index >= len(f.values) {
|
||||
return
|
||||
}
|
||||
state = f.with(state, f.values[index])
|
||||
set(state)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
for _, f := range facets {
|
||||
f.dd.SetSelectedFunc(onSelect(f))
|
||||
}
|
||||
refresh()
|
||||
|
||||
cancel := func() {
|
||||
a.closeModal()
|
||||
set(cur)
|
||||
set(orig)
|
||||
}
|
||||
form.AddButton("Apply", a.closeModal)
|
||||
form.AddButton("Reset", func() {
|
||||
@@ -169,9 +219,14 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFi
|
||||
})
|
||||
form.AddButton("Cancel", cancel)
|
||||
arrowNavigation(form, cancel, &browseHooks{
|
||||
browse: func(dd *tview.DropDown, index int) { set(filterWith(dd, index)) },
|
||||
abort: func() { set(current()) }, // list closed without choosing: back to the committed value
|
||||
browse: func(dd *tview.DropDown, index int) {
|
||||
if f := byDropDown[dd]; f != nil && index >= 0 && index < len(f.values) {
|
||||
set(f.with(state, f.values[index]))
|
||||
}
|
||||
},
|
||||
abort: func() { set(state) }, // list closed without choosing: back to the committed value
|
||||
})
|
||||
form.SetBorder(true).SetTitle(" Filter by properties ")
|
||||
a.openSidePanel(form, 40, 17, "↑↓ move · ←→ change (applies live) · Enter apply · Esc cancel")
|
||||
a.openSidePanel(form, 40, 17, "↑↓ move · ←→ change (applies live) · Enter apply · Esc cancel", onClose)
|
||||
return refresh
|
||||
}
|
||||
|
||||
+34
-6
@@ -208,6 +208,8 @@ type libraryView struct {
|
||||
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 {
|
||||
@@ -271,7 +273,7 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||
v.filter = (v.filter + 1) % len(libFilters)
|
||||
v.render()
|
||||
case 'F':
|
||||
v.app.openAttrFilter(v.attr, languagesOf(v.panes[kindMovies].all, v.panes[kindSeries].all), v.setAttrFilter)
|
||||
v.openFilterDialog()
|
||||
case 'r':
|
||||
v.seriesFiles = map[int]seriesFiles{} // a manual reload also refreshes file properties
|
||||
v.reload()
|
||||
@@ -334,7 +336,7 @@ func (v *libraryView) reloadPane(p *libPane) {
|
||||
}
|
||||
p.err = err
|
||||
if err == nil && kind == kindSeries {
|
||||
defer v.loadSeriesFiles()
|
||||
defer v.loadSeriesFiles(false)
|
||||
}
|
||||
if err != nil {
|
||||
// Keep showing the last good list; say why it is stale.
|
||||
@@ -494,7 +496,29 @@ func (v *libraryView) run(doing, done string, fn func(ctx context.Context) error
|
||||
func (v *libraryView) setAttrFilter(f attrFilter) {
|
||||
v.attr = f
|
||||
v.render()
|
||||
v.loadSeriesFiles()
|
||||
v.loadSeriesFiles(false)
|
||||
}
|
||||
|
||||
// openFilterDialog opens the property filter. The files of all series are read
|
||||
// so the dialog can leave out options that would match nothing.
|
||||
func (v *libraryView) openFilterDialog() {
|
||||
v.refreshDialog = v.app.openAttrFilter(v.attr, v.filterPool, v.setAttrFilter, func() { v.refreshDialog = nil })
|
||||
v.loadSeriesFiles(true)
|
||||
}
|
||||
|
||||
// filterPool lists the files of the items that pass the status and text
|
||||
// filters, for the filter dialog.
|
||||
func (v *libraryView) filterPool() filterPool {
|
||||
var p filterPool
|
||||
for _, pane := range v.panes {
|
||||
for _, it := range filterItems(pane.all, v.filter, v.input.GetText(), attrFilter{}) {
|
||||
if !it.attrsKnown {
|
||||
p.unknown = true
|
||||
}
|
||||
p.files = append(p.files, it.attrs)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// withCachedFiles fills in the file properties of a series from the cache.
|
||||
@@ -510,9 +534,10 @@ func (v *libraryView) withCachedFiles(it libItem) libItem {
|
||||
}
|
||||
|
||||
// loadSeriesFiles fetches the file properties of all series that lack them,
|
||||
// but only while a property filter is active (it costs one request per series).
|
||||
func (v *libraryView) loadSeriesFiles() {
|
||||
if !v.attr.active() {
|
||||
// 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.attr.active() {
|
||||
return
|
||||
}
|
||||
p := v.panes[kindSeries]
|
||||
@@ -580,6 +605,9 @@ func (v *libraryView) loadSeriesFiles() {
|
||||
}
|
||||
if v.filePending%10 == 0 { // keep redraws cheap for big libraries
|
||||
v.render()
|
||||
if v.refreshDialog != nil {
|
||||
v.refreshDialog()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -156,3 +157,55 @@ func TestMovieItemAttrs(t *testing.T) {
|
||||
t.Error("series with files must fetch its attributes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListedValues(t *testing.T) {
|
||||
uhd := arr.FileAttrs{Resolution: 2160, Codec: arr.CodecX265, HDR: true, Audio: []string{"Atmos"}, Languages: []string{"English"}}
|
||||
hd := arr.FileAttrs{Resolution: 1080, Codec: arr.CodecX264, Audio: []string{"DD"}, Languages: []string{"German"}}
|
||||
pool := filterPool{files: [][]arr.FileAttrs{{uhd}, {hd}, nil}}
|
||||
byLabel := func(label string) facet {
|
||||
for _, f := range facetDefs {
|
||||
if f.label == label {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("no facet %q", label)
|
||||
return facet{}
|
||||
}
|
||||
list := func(label string, state attrFilter, p filterPool) []string {
|
||||
return listedValues(byLabel(label), state, p)
|
||||
}
|
||||
|
||||
// Without other criteria only options present in the pool are offered.
|
||||
if got := list("Quality", attrFilter{}, pool); !slices.Equal(got, []string{"", "2160p", "1080p"}) {
|
||||
t.Errorf("quality = %v", got)
|
||||
}
|
||||
if got := list("Audio", attrFilter{}, pool); !slices.Equal(got, []string{"", "Atmos", "DD"}) {
|
||||
t.Errorf("audio = %v", got)
|
||||
}
|
||||
if got := list("Language", attrFilter{}, pool); !slices.Equal(got, []string{"", "English", "German"}) {
|
||||
t.Errorf("language = %v", got)
|
||||
}
|
||||
// Other criteria narrow the options: nothing in 2160p is x264.
|
||||
if got := list("Codec", attrFilter{res: "2160p"}, pool); !slices.Equal(got, []string{"", "x265"}) {
|
||||
t.Errorf("codec given 2160p = %v", got)
|
||||
}
|
||||
// A facet is not narrowed by its own choice.
|
||||
if got := list("Quality", attrFilter{res: "2160p"}, pool); !slices.Equal(got, []string{"", "2160p", "1080p"}) {
|
||||
t.Errorf("quality given 2160p = %v", got)
|
||||
}
|
||||
// The current choice stays listed even when it matches nothing.
|
||||
if got := list("Audio", attrFilter{res: "1080p", audio: "Atmos"}, pool); !slices.Equal(got, []string{"", "Atmos", "DD"}) {
|
||||
t.Errorf("audio = %v", got)
|
||||
}
|
||||
if got := list("Language", attrFilter{lang: "Swahili"}, pool); got[1] != "Swahili" {
|
||||
t.Errorf("a language not in the pool must stay listed: %v", got)
|
||||
}
|
||||
// While files are unknown nothing is ruled out.
|
||||
if got := list("Quality", attrFilter{}, filterPool{unknown: true}); len(got) != 1+len(resOptions) {
|
||||
t.Errorf("unknown pool: %v", got)
|
||||
}
|
||||
// An empty pool offers nothing but "any".
|
||||
if got := list("Codec", attrFilter{}, filterPool{}); !slices.Equal(got, []string{""}) {
|
||||
t.Errorf("empty pool: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-14
@@ -73,10 +73,10 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele
|
||||
v.sortMode = (v.sortMode + 1) % numSortModes
|
||||
v.render()
|
||||
case ev.Key() == tcell.KeyRune && ev.Rune() == 'F':
|
||||
v.app.openAttrFilter(v.attr, releaseLanguages(v.all), func(f attrFilter) {
|
||||
v.app.openAttrFilter(v.attr, v.filterPool, func(f attrFilter) {
|
||||
v.attr = f
|
||||
v.render()
|
||||
})
|
||||
}, nil)
|
||||
case ev.Key() == tcell.KeyRune && ev.Rune() == 'f':
|
||||
v.hideRejected = !v.hideRejected
|
||||
v.render()
|
||||
@@ -349,18 +349,15 @@ func formatReleaseAge(minutes float64) string {
|
||||
return fmt.Sprintf("%dd", int(minutes/(24*60)))
|
||||
}
|
||||
|
||||
// releaseLanguages lists the languages the releases announce, sorted.
|
||||
func releaseLanguages(all []arr.Release) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, r := range all {
|
||||
for _, l := range r.Attrs().Languages {
|
||||
if !seen[l] {
|
||||
seen[l] = true
|
||||
out = append(out, l)
|
||||
}
|
||||
// filterPool lists the properties of the releases that pass the rejected
|
||||
// filter, for the filter dialog.
|
||||
func (v *releasesView) filterPool() filterPool {
|
||||
var p filterPool
|
||||
for _, r := range v.all {
|
||||
if v.hideRejected && isRejected(r) {
|
||||
continue
|
||||
}
|
||||
p.files = append(p.files, []arr.FileAttrs{r.Attrs()})
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
return p
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user