package ui import ( "slices" "strings" "github.com/rivo/tview" "git.stinnesbeck.com/nils/arr-tui/internal/arr" ) // attrFilter narrows the library by properties of the downloaded files. An // empty field means "any". All fields must match the same file; a series // matches when any of its files does. type attrFilter struct { res, codec, hdr, audio, lang string } const anyOption = "any" var ( resOptions = []string{"2160p", "1080p", "720p", "SD"} codecOptions = []string{arr.CodecX265, arr.CodecX264, arr.CodecAV1} hdrOptions = []string{"HDR", "DV", "SDR"} audioOptions = []string{arr.AudioAtmos, arr.AudioTrueHD, arr.AudioDTSX, arr.AudioDTSHD, arr.AudioDTS, arr.AudioDDPlus, arr.AudioDD, arr.AudioAAC, arr.AudioFLAC, arr.AudioOpus} ) func (f attrFilter) active() bool { return f != attrFilter{} } // String lists the active criteria for the header, e.g. "1080p x265 DV". func (f attrFilter) String() string { var parts []string for _, p := range []string{f.res, f.codec, f.hdr, f.audio, f.lang} { if p != "" { parts = append(parts, p) } } return strings.Join(parts, " ") } func (f attrFilter) matchFile(a arr.FileAttrs) bool { switch f.res { case "": case "SD": if a.Resolution <= 0 || a.Resolution > 576 { return false } default: if f.res != itoa(a.Resolution)+"p" { return false } } if f.codec != "" && f.codec != a.Codec { return false } switch f.hdr { case "HDR": if !a.HDR { return false } case "DV": if !a.DV { return false } case "SDR": if a.HDR || a.DV { return false } } if f.audio != "" && !slices.Contains(a.Audio, f.audio) { return false } if f.lang != "" && !slices.ContainsFunc(a.Languages, func(l string) bool { return strings.EqualFold(l, f.lang) }) { return false } return true } // match reports whether any of the files matches. Without files nothing matches. func (f attrFilter) match(files []arr.FileAttrs) bool { return slices.ContainsFunc(files, f.matchFile) } // 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 _, files := range p.files { for _, a := range files { for _, l := range a.Languages { if !seen[l] { seen[l] = true out = append(out, l) } } } } slices.Sort(out) return out } // 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}, {"Language", filterPool.languages, func(f attrFilter) string { return f.lang }, func(f attrFilter, v string) attrFilter { f.lang = 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}, } // 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, language, codec, HDR and // audio (the most used first). Every change is passed to set immediately so the list behind the // 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 } 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 } vals := listedValues(*f, state, p) if slices.Equal(vals, f.values) { continue } f.values = vals f.dd.SetOptions(append([]string{anyOption}, vals[1:]...), onSelect(f)) f.dd.SetCurrentOption(slices.Index(vals, f.get(state))) } } 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(orig) } form.AddButton("Apply", a.closeModal) form.AddButton("Reset", func() { a.closeModal() set(attrFilter{}) }) form.AddButton("Cancel", cancel) arrowNavigation(form, cancel, &browseHooks{ 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", onClose) return refresh }