Filter dialog: only offer options that yield results
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,7 @@ Notes:
|
|||||||
|
|
||||||
### 4.2 v0.2 (done, M8)
|
### 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.
|
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search.
|
||||||
|
|
||||||
### 4.3 Later / ideas
|
### 4.3 Later / ideas
|
||||||
|
|||||||
+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
|
// openSidePanel shows p at the top right so most of the view behind it stays
|
||||||
// visible, for dialogs whose changes are applied live.
|
// 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().
|
box := tview.NewFlex().
|
||||||
AddItem(nil, 0, 1, false).
|
AddItem(nil, 0, 1, false).
|
||||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(p, height, 0, true).
|
AddItem(p, height, 0, true).
|
||||||
AddItem(nil, 0, 1, false), width, 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).
|
// 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),
|
F filter by file properties: quality, codec (x265, x264, AV1),
|
||||||
HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language.
|
HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language.
|
||||||
Changes apply live behind the dialog, also while you move through an
|
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.
|
Series are read on demand.
|
||||||
/ text filter (Enter or Esc returns to the list)
|
/ text filter (Enter or Esc returns to the list)
|
||||||
t toggle monitored
|
t toggle monitored
|
||||||
|
|||||||
+108
-53
@@ -82,13 +82,24 @@ func (f attrFilter) match(files []arr.FileAttrs) bool {
|
|||||||
return slices.ContainsFunc(files, f.matchFile)
|
return slices.ContainsFunc(files, f.matchFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// languagesOf collects the languages of all known files, sorted.
|
// filterPool is what the filter dialog works on: the files of every item that
|
||||||
func languagesOf(items ...[]libItem) []string {
|
// 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{}
|
seen := map[string]bool{}
|
||||||
var out []string
|
var out []string
|
||||||
for _, list := range items {
|
for _, files := range p.files {
|
||||||
for _, it := range list {
|
for _, a := range files {
|
||||||
for _, a := range it.attrs {
|
|
||||||
for _, l := range a.Languages {
|
for _, l := range a.Languages {
|
||||||
if !seen[l] {
|
if !seen[l] {
|
||||||
seen[l] = true
|
seen[l] = true
|
||||||
@@ -97,70 +108,109 @@ func languagesOf(items ...[]libItem) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
slices.Sort(out)
|
slices.Sort(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func indexOf(options []string, v string) int {
|
// facet is one dropdown of the filter dialog.
|
||||||
if i := slices.Index(options, v); i >= 0 {
|
type facet struct {
|
||||||
return i + 1 // option 0 is "any"
|
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"
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
|
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...)
|
||||||
|
}
|
||||||
|
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
|
// openAttrFilter shows the dialog to pick quality, codec, HDR, audio and
|
||||||
// language. Every change is passed to set immediately so the list behind the
|
// language. Every change is passed to set immediately so the list behind the
|
||||||
// dialog follows along; Cancel and Esc restore cur.
|
// dialog follows along, also while moving through an open list; Cancel and Esc
|
||||||
func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFilter)) {
|
// restore cur. Entries that would leave no results are not offered. The
|
||||||
if cur.lang != "" && !slices.Contains(languages, cur.lang) {
|
// returned function re-evaluates them when the pool has changed; onClose runs
|
||||||
languages = append([]string{cur.lang}, languages...)
|
// 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
|
rebuilding := false
|
||||||
// any) stands in for that dropdown's committed choice.
|
var onSelect func(f *facet) func(string, int)
|
||||||
filterWith := func(over *tview.DropDown, index int) attrFilter {
|
refresh = func() {
|
||||||
pick := func(label string, options []string) string {
|
p := pool()
|
||||||
dd := form.GetFormItemByLabel(label).(*tview.DropDown)
|
rebuilding = true
|
||||||
i, _ := dd.GetCurrentOption()
|
defer func() { rebuilding = false }()
|
||||||
if dd == over {
|
for _, f := range facets {
|
||||||
i = index
|
if f.dd.IsOpen() {
|
||||||
|
continue // do not pull the list from under the user
|
||||||
}
|
}
|
||||||
if i <= 0 {
|
vals := listedValues(*f, state, p)
|
||||||
return ""
|
if slices.Equal(vals, f.values) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return options[i-1]
|
f.values = vals
|
||||||
}
|
f.dd.SetOptions(append([]string{anyOption}, vals[1:]...), onSelect(f))
|
||||||
return attrFilter{
|
f.dd.SetCurrentOption(slices.Index(vals, f.get(state)))
|
||||||
res: pick(lRes, resOptions),
|
|
||||||
codec: pick(lCodec, codecOptions),
|
|
||||||
hdr: pick(lHDR, hdrOptions),
|
|
||||||
audio: pick(lAudio, audioOptions),
|
|
||||||
lang: pick(lLang, languages),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
current := func() attrFilter { return filterWith(nil, 0) }
|
onSelect = func(f *facet) func(string, int) {
|
||||||
for _, label := range []string{lRes, lCodec, lHDR, lAudio, lLang} {
|
return func(_ string, index int) {
|
||||||
form.GetFormItemByLabel(label).(*tview.DropDown).SetSelectedFunc(func(string, int) { set(current()) })
|
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() {
|
cancel := func() {
|
||||||
a.closeModal()
|
a.closeModal()
|
||||||
set(cur)
|
set(orig)
|
||||||
}
|
}
|
||||||
form.AddButton("Apply", a.closeModal)
|
form.AddButton("Apply", a.closeModal)
|
||||||
form.AddButton("Reset", func() {
|
form.AddButton("Reset", func() {
|
||||||
@@ -169,9 +219,14 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFi
|
|||||||
})
|
})
|
||||||
form.AddButton("Cancel", cancel)
|
form.AddButton("Cancel", cancel)
|
||||||
arrowNavigation(form, cancel, &browseHooks{
|
arrowNavigation(form, cancel, &browseHooks{
|
||||||
browse: func(dd *tview.DropDown, index int) { set(filterWith(dd, index)) },
|
browse: func(dd *tview.DropDown, index int) {
|
||||||
abort: func() { set(current()) }, // list closed without choosing: back to the committed value
|
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 ")
|
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
|
seriesFiles map[int]seriesFiles
|
||||||
fileGen int // invalidates running fetches
|
fileGen int // invalidates running fetches
|
||||||
filePending int
|
filePending int
|
||||||
|
|
||||||
|
refreshDialog func() // re-evaluates the options of the open filter dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
type seriesFiles struct {
|
type seriesFiles struct {
|
||||||
@@ -271,7 +273,7 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|||||||
v.filter = (v.filter + 1) % len(libFilters)
|
v.filter = (v.filter + 1) % len(libFilters)
|
||||||
v.render()
|
v.render()
|
||||||
case 'F':
|
case 'F':
|
||||||
v.app.openAttrFilter(v.attr, languagesOf(v.panes[kindMovies].all, v.panes[kindSeries].all), v.setAttrFilter)
|
v.openFilterDialog()
|
||||||
case 'r':
|
case 'r':
|
||||||
v.seriesFiles = map[int]seriesFiles{} // a manual reload also refreshes file properties
|
v.seriesFiles = map[int]seriesFiles{} // a manual reload also refreshes file properties
|
||||||
v.reload()
|
v.reload()
|
||||||
@@ -334,7 +336,7 @@ func (v *libraryView) reloadPane(p *libPane) {
|
|||||||
}
|
}
|
||||||
p.err = err
|
p.err = err
|
||||||
if err == nil && kind == kindSeries {
|
if err == nil && kind == kindSeries {
|
||||||
defer v.loadSeriesFiles()
|
defer v.loadSeriesFiles(false)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Keep showing the last good list; say why it is stale.
|
// 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) {
|
func (v *libraryView) setAttrFilter(f attrFilter) {
|
||||||
v.attr = f
|
v.attr = f
|
||||||
v.render()
|
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.
|
// 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,
|
// 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).
|
// but only while a property filter is active or the filter dialog is open
|
||||||
func (v *libraryView) loadSeriesFiles() {
|
// (it costs one request per series).
|
||||||
if !v.attr.active() {
|
func (v *libraryView) loadSeriesFiles(force bool) {
|
||||||
|
if !force && !v.attr.active() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := v.panes[kindSeries]
|
p := v.panes[kindSeries]
|
||||||
@@ -580,6 +605,9 @@ func (v *libraryView) loadSeriesFiles() {
|
|||||||
}
|
}
|
||||||
if v.filePending%10 == 0 { // keep redraws cheap for big libraries
|
if v.filePending%10 == 0 { // keep redraws cheap for big libraries
|
||||||
v.render()
|
v.render()
|
||||||
|
if v.refreshDialog != nil {
|
||||||
|
v.refreshDialog()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -156,3 +157,55 @@ func TestMovieItemAttrs(t *testing.T) {
|
|||||||
t.Error("series with files must fetch its attributes")
|
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.sortMode = (v.sortMode + 1) % numSortModes
|
||||||
v.render()
|
v.render()
|
||||||
case ev.Key() == tcell.KeyRune && ev.Rune() == 'F':
|
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.attr = f
|
||||||
v.render()
|
v.render()
|
||||||
})
|
}, nil)
|
||||||
case ev.Key() == tcell.KeyRune && ev.Rune() == 'f':
|
case ev.Key() == tcell.KeyRune && ev.Rune() == 'f':
|
||||||
v.hideRejected = !v.hideRejected
|
v.hideRejected = !v.hideRejected
|
||||||
v.render()
|
v.render()
|
||||||
@@ -349,18 +349,15 @@ func formatReleaseAge(minutes float64) string {
|
|||||||
return fmt.Sprintf("%dd", int(minutes/(24*60)))
|
return fmt.Sprintf("%dd", int(minutes/(24*60)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// releaseLanguages lists the languages the releases announce, sorted.
|
// filterPool lists the properties of the releases that pass the rejected
|
||||||
func releaseLanguages(all []arr.Release) []string {
|
// filter, for the filter dialog.
|
||||||
seen := map[string]bool{}
|
func (v *releasesView) filterPool() filterPool {
|
||||||
var out []string
|
var p filterPool
|
||||||
for _, r := range all {
|
for _, r := range v.all {
|
||||||
for _, l := range r.Attrs().Languages {
|
if v.hideRejected && isRejected(r) {
|
||||||
if !seen[l] {
|
continue
|
||||||
seen[l] = true
|
|
||||||
out = append(out, l)
|
|
||||||
}
|
}
|
||||||
|
p.files = append(p.files, []arr.FileAttrs{r.Attrs()})
|
||||||
}
|
}
|
||||||
}
|
return p
|
||||||
slices.Sort(out)
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user