From 8a357f7418e485086fe0b49c82b3e52b6f3b6d68 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 20:48:57 +0200 Subject: [PATCH] Filter dialog: only offer options that yield results Co-Authored-By: Claude Sonnet 5.5 --- SPECS.md | 2 +- internal/ui/app.go | 4 +- internal/ui/help.go | 3 +- internal/ui/libfilter.go | 169 ++++++++++++++++++++++++------------ internal/ui/library.go | 40 +++++++-- internal/ui/library_test.go | 53 +++++++++++ internal/ui/releases.go | 25 +++--- 7 files changed, 215 insertions(+), 81 deletions(-) diff --git a/SPECS.md b/SPECS.md index 5ba9c8b..c39094a 100644 --- a/SPECS.md +++ b/SPECS.md @@ -67,7 +67,7 @@ Notes: ### 4.2 v0.2 (done, M8) -- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter) and a text filter apply to both lists. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `i`/Enter opens the release search (movies) or the drill-down (series). +- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter; each dropdown only lists options that, together with the other chosen criteria, still match at least one item (series files are read when the dialog opens so this is accurate)) and a text filter apply to both lists. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `i`/Enter opens the release search (movies) or the drill-down (series). - **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search. ### 4.3 Later / ideas diff --git a/internal/ui/app.go b/internal/ui/app.go index 7434e46..bbe57fe 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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). diff --git a/internal/ui/help.go b/internal/ui/help.go index c0f08a5..0ece690 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -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 diff --git a/internal/ui/libfilter.go b/internal/ui/libfilter.go index 2045ea6..e16462c 100644 --- a/internal/ui/libfilter.go +++ b/internal/ui/libfilter.go @@ -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 } diff --git a/internal/ui/library.go b/internal/ui/library.go index 7d5d996..a6ab239 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -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() + } } }) } diff --git a/internal/ui/library_test.go b/internal/ui/library_test.go index 16a5b75..5936a5b 100644 --- a/internal/ui/library_test.go +++ b/internal/ui/library_test.go @@ -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) + } +} diff --git a/internal/ui/releases.go b/internal/ui/releases.go index c1904b3..2060638 100644 --- a/internal/ui/releases.go +++ b/internal/ui/releases.go @@ -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 }