233 lines
7.0 KiB
Go
233 lines
7.0 KiB
Go
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},
|
|
{"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
|
|
// language. 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
|
|
}
|