Filter by audio format; apply filter changes live behind the dialog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:36:26 +02:00
co-authored by Claude Sonnet 5.5
parent 47b9459a5e
commit 6b780242c9
9 changed files with 167 additions and 27 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
- **Movies / Series**: search as you type, add titles with a quality profile and root folder, - **Movies / Series**: search as you type, add titles with a quality profile and root folder,
optionally start the automatic search. optionally start the automatic search.
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), - **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, language), and grab one. Series: pick a season or a single episode first. sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, language), toggle - **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle
monitoring, trigger searches. Series open into seasons and episodes with per-season and monitoring, trigger searches. Series open into seasons and episodes with per-season and
per-episode monitoring, automatic search and release search. per-episode monitoring, automatic search and release search.
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
+3 -3
View File
@@ -57,7 +57,7 @@ Notes:
- **Config:** load URLs + API keys from a config file; fail with a clear message if missing or unreachable. - **Config:** load URLs + API keys from a config file; fail with a clear message if missing or unreachable.
- **Search:** a Movies view (Radarr) and a Series view (Sonarr), each with a search box. Results list with title, year, rating, status, and an "in library" marker. Detail pane with overview, runtime, genres, network/studio. - **Search:** a Movies view (Radarr) and a Series view (Sonarr), each with a search box. Results list with title, year, rating, status, and an "in library" marker. Detail pane with overview, runtime, genres, network/studio.
- **Add:** from a result, open a small form: quality profile, root folder, monitored (yes/no), "search now" (yes/no); for series also monitor mode (all / future / first season / none). Submit calls `POST /movie` or `POST /series` with `addOptions.searchForMovie` / `searchForMissingEpisodes`. - **Add:** from a result, open a small form: quality profile, root folder, monitored (yes/no), "search now" (yes/no); for series also monitor mode (all / future / first season / none). Submit calls `POST /movie` or `POST /series` with `addOptions.searchForMovie` / `searchForMissingEpisodes`.
- **Interactive release search:** for a movie (or a series' season/episode) in the library, list releases from indexers (`GET /release`) with title, indexer, size, quality, age, and rejection reasons; sort/filter; grab a chosen one (`POST /release`). Rejected releases can be grabbed only after an explicit confirmation. The list can be filtered by quality, codec (x265/x264/AV1), HDR/DV/SDR and language; releases carry no media info, so codec and HDR are read from the title, quality and custom formats (a release that does not name its codec never matches a codec filter). - **Interactive release search:** for a movie (or a series' season/episode) in the library, list releases from indexers (`GET /release`) with title, indexer, size, quality, age, and rejection reasons; sort/filter; grab a chosen one (`POST /release`). Rejected releases can be grabbed only after an explicit confirmation. The list can be filtered by quality, codec (x265/x264/AV1), HDR/DV/SDR, audio format and language; releases carry no media info, so codec and HDR are read from the title, quality and custom formats (a release that does not name its codec never matches a codec filter).
- **Downloads view:** all downloads and queues in one place, auto-refreshing every few seconds: - **Downloads view:** all downloads and queues in one place, auto-refreshing every few seconds:
- SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry). - SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry).
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible. - Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
@@ -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 language) 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 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).
- **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
@@ -113,7 +113,7 @@ Keybindings (the status bar shows the ones that apply to the current view):
| `/` | Movies, Series, Library: focus the search / text filter | | `/` | Movies, Series, Library: focus the search / text filter |
| `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) | | `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) |
| `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) | | `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) |
| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, language) | | `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) |
| `f` | Library: cycle the status filter; release list: hide rejected releases | | `f` | Library: cycle the status filter; release list: hide rejected releases |
| `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download | | `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download |
| `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) | | `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) |
+71 -6
View File
@@ -21,6 +21,8 @@ type MediaFile struct {
VideoCodec string `json:"videoCodec"` VideoCodec string `json:"videoCodec"`
VideoDynamicRange string `json:"videoDynamicRange"` VideoDynamicRange string `json:"videoDynamicRange"`
VideoDynamicRangeType string `json:"videoDynamicRangeType"` VideoDynamicRangeType string `json:"videoDynamicRangeType"`
AudioCodec string `json:"audioCodec"`
AudioAdditional string `json:"audioAdditionalFeatures"`
} `json:"mediaInfo"` } `json:"mediaInfo"`
Languages []struct { Languages []struct {
Name string `json:"name"` Name string `json:"name"`
@@ -40,16 +42,43 @@ type FileAttrs struct {
Codec string // CodecX265, CodecX264, CodecAV1 or "" Codec string // CodecX265, CodecX264, CodecAV1 or ""
HDR bool // HDR10, HDR10+, HLG, PQ (also with Dolby Vision on top) HDR bool // HDR10, HDR10+, HLG, PQ (also with Dolby Vision on top)
DV bool // Dolby Vision DV bool // Dolby Vision
Audio []string // audio formats, see the Audio* constants
Languages []string // audio languages Languages []string // audio languages
} }
// Audio formats offered as filters. Atmos and the DTS flavours overlap with
// their carrier format: a "TrueHD Atmos" track is both Atmos and TrueHD, and
// every DTS flavour also counts as plain DTS.
const (
AudioAtmos = "Atmos"
AudioTrueHD = "TrueHD"
AudioDTSX = "DTS:X"
AudioDTSHD = "DTS-HD"
AudioDTS = "DTS"
AudioDDPlus = "DD+"
AudioDD = "DD"
AudioAAC = "AAC"
AudioFLAC = "FLAC"
AudioOpus = "Opus"
)
var ( var (
reRes = regexp.MustCompile(`(?i)\b(2160|1080|720|576|480)p\b`) reRes = regexp.MustCompile(`(?i)\b(2160|1080|720|576|480)p\b`)
re265 = regexp.MustCompile(`(?i)(x\.?265|h\.?265|hevc)`) re265 = regexp.MustCompile(`(?i)(x\.?265|h\.?265|hevc)`)
re264 = regexp.MustCompile(`(?i)(x\.?264|h\.?264|\bavc\b)`) re264 = regexp.MustCompile(`(?i)(x\.?264|h\.?264|\bavc\b)`)
reAV1 = regexp.MustCompile(`(?i)\bav1\b`) reAV1 = regexp.MustCompile(`(?i)\bav1\b`)
reDV = regexp.MustCompile(`(?i)(\bdv\b|dovi|dolby[ .]?vision)`) reDV = regexp.MustCompile(`(?i)(\bdv\b|dovi|dolby[ .]?vision)`)
reHDR = regexp.MustCompile(`(?i)\b(hdr(10(\+|plus)?)?|hlg|pq)\b`) reAtmos = regexp.MustCompile(`(?i)(atmos|\bddpa\b)`)
reTrueHD = regexp.MustCompile(`(?i)true[ .-]?hd`)
reDTSX = regexp.MustCompile(`(?i)\bdts[ .:-]?x\b`)
reDTSHD = regexp.MustCompile(`(?i)dts[ .-]?(hd|ma|hra|es)`)
reDTS = regexp.MustCompile(`(?i)\bdts([^a-z]|$)`)
reDDPlus = regexp.MustCompile(`(?i)(e[ .-]?ac[ .-]?3|\bddp|dd\+|dolby digital plus)`)
reDD = regexp.MustCompile(`(?i)(\bac[ .-]?3\b|\bdd[ .]?[0-9]|dolby digital\b)`)
reAAC = regexp.MustCompile(`(?i)\baac`)
reFLAC = regexp.MustCompile(`(?i)\bflac\b`)
reOpus = regexp.MustCompile(`(?i)\bopus\b`)
reHDR = regexp.MustCompile(`(?i)\b(hdr(10(\+|plus)?)?|hlg|pq)\b`)
) )
// Attrs derives the filterable properties. Radarr/Sonarr's media info is used // Attrs derives the filterable properties. Radarr/Sonarr's media info is used
@@ -77,6 +106,11 @@ func (f MediaFile) Attrs() FileAttrs {
} }
a.DV = reDV.MatchString(dr) a.DV = reDV.MatchString(dr)
a.HDR = reHDR.MatchString(dr) a.HDR = reHDR.MatchString(dr)
audio := f.MediaInfo.AudioCodec + " " + f.MediaInfo.AudioAdditional
if strings.TrimSpace(audio) == "" {
audio = names
}
a.Audio = audioTags(audio)
for _, l := range f.Languages { for _, l := range f.Languages {
if l.Name != "" { if l.Name != "" {
a.Languages = append(a.Languages, l.Name) a.Languages = append(a.Languages, l.Name)
@@ -85,6 +119,37 @@ func (f MediaFile) Attrs() FileAttrs {
return a return a
} }
// audioTags finds the audio formats we offer as filters in free text.
func audioTags(s string) []string {
var tags []string
add := func(re *regexp.Regexp, tag string) {
if re.MatchString(s) {
tags = append(tags, tag)
}
}
add(reAtmos, AudioAtmos)
add(reTrueHD, AudioTrueHD)
dtsx, dtshd := reDTSX.MatchString(s), reDTSHD.MatchString(s)
if dtsx {
tags = append(tags, AudioDTSX)
}
if dtshd {
tags = append(tags, AudioDTSHD)
}
if dtsx || dtshd || reDTS.MatchString(s) {
tags = append(tags, AudioDTS)
}
if reDDPlus.MatchString(s) {
tags = append(tags, AudioDDPlus)
} else {
add(reDD, AudioDD)
}
add(reAAC, AudioAAC)
add(reFLAC, AudioFLAC)
add(reOpus, AudioOpus)
return tags
}
func atoi(s string) (n int) { func atoi(s string) (n int) {
for _, c := range s { for _, c := range s {
n = n*10 + int(c-'0') n = n*10 + int(c-'0')
+41
View File
@@ -71,3 +71,44 @@ func TestReleaseAttrs(t *testing.T) {
t.Errorf("HDTV detected as HDR: %+v", got) t.Errorf("HDTV detected as HDR: %+v", got)
} }
} }
func TestAudioTags(t *testing.T) {
for _, tc := range []struct {
in string
want []string
}{
{"Movie.2160p.UHD.BluRay.TrueHD.7.1.Atmos.x265-G", []string{"Atmos", "TrueHD"}},
{"Movie.1080p.DDP5.1.Atmos.x265", []string{"Atmos", "DD+"}},
{"Movie.1080p.DTS-HD.MA.5.1.x264", []string{"DTS-HD", "DTS"}},
{"Movie.2160p.DTS-X.7.1", []string{"DTS:X", "DTS"}},
{"Movie.1080p.DTS.5.1", []string{"DTS"}},
{"Movie.1080p.EAC3.5.1", []string{"DD+"}},
{"Movie.1080p.E-AC-3.5.1", []string{"DD+"}},
{"Movie.1080p.AC3.5.1", []string{"DD"}},
{"Movie.720p.DD5.1.x264", []string{"DD"}},
{"Show.S01E01.AAC.2.0", []string{"AAC"}},
{"Album.FLAC", []string{"FLAC"}},
{"Show.Opus", []string{"Opus"}},
{"Movie.1080p.BluRay.x264-DTSAG", nil}, // a group name is not an audio format
{"Movie.1080p.x264", nil},
} {
if got := audioTags(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("audioTags(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestMediaFileAudio(t *testing.T) {
a := attrs(t, `{"sceneName":"Movie.x264.AC3","mediaInfo":{"audioCodec":"TrueHD Atmos"}}`)
if !reflect.DeepEqual(a.Audio, []string{"Atmos", "TrueHD"}) {
t.Errorf("media info wins over the name: %v", a.Audio)
}
a = attrs(t, `{"mediaInfo":{"audioCodec":"EAC3","audioAdditionalFeatures":"Atmos"}}`)
if !reflect.DeepEqual(a.Audio, []string{"Atmos", "DD+"}) {
t.Errorf("additional features: %v", a.Audio)
}
a = attrs(t, `{"relativePath":"Movie.1080p.DTS-HD.MA.mkv"}`)
if !reflect.DeepEqual(a.Audio, []string{"DTS-HD", "DTS"}) {
t.Errorf("fallback to the name: %v", a.Audio)
}
}
+1
View File
@@ -87,6 +87,7 @@ func (r Release) Attrs() FileAttrs {
} }
a.DV = reDV.MatchString(text) a.DV = reDV.MatchString(text)
a.HDR = reHDR.MatchString(text) a.HDR = reHDR.MatchString(text)
a.Audio = audioTags(text)
for _, l := range r.Languages { for _, l := range r.Languages {
if l.Name != "" { if l.Name != "" {
a.Languages = append(a.Languages, l.Name) a.Languages = append(a.Languages, l.Name)
+11
View File
@@ -207,6 +207,17 @@ func (a *App) openModal(p tview.Primitive, width, height int) {
a.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel", false, nil) a.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel", false, nil)
} }
// 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) {
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)
}
// 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).
func (a *App) openFull(p tview.Primitive, hints string, onClose func()) { func (a *App) openFull(p tview.Primitive, hints string, onClose func()) {
a.pushOverlay(p, p, hints, true, onClose) a.pushOverlay(p, p, hints, true, onClose)
+5 -3
View File
@@ -23,7 +23,9 @@ const helpText = `[yellow]Global[-]
f cycle filter (all, missing, downloaded, monitored, unmonitored); f cycle filter (all, missing, downloaded, monitored, unmonitored);
filters apply to both lists filters apply to both lists
F filter by file properties: quality, codec (x265, x264, AV1), F filter by file properties: quality, codec (x265, x264, AV1),
HDR / DV / SDR, language. Series are read on demand. HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language.
Changes apply live behind the dialog; Esc undoes them.
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
s automatic search (series: all missing, asks first) s automatic search (series: all missing, asks first)
@@ -39,8 +41,8 @@ const helpText = `[yellow]Global[-]
Enter, g grab the selected release Enter, g grab the selected release
s cycle sort (best, size, age) s cycle sort (best, size, age)
f hide rejected releases f hide rejected releases
F filter by quality, codec, HDR / DV, language (read from the F filter by quality, codec, HDR / DV, audio, language (read from
release title, quality and custom formats) the release title, quality and custom formats)
[yellow]Downloads[-] [yellow]Downloads[-]
p pause / resume the SABnzbd queue p pause / resume the SABnzbd queue
+31 -13
View File
@@ -13,7 +13,7 @@ import (
// empty field means "any". All fields must match the same file; a series // empty field means "any". All fields must match the same file; a series
// matches when any of its files does. // matches when any of its files does.
type attrFilter struct { type attrFilter struct {
res, codec, hdr, lang string res, codec, hdr, audio, lang string
} }
const anyOption = "any" const anyOption = "any"
@@ -22,6 +22,8 @@ var (
resOptions = []string{"2160p", "1080p", "720p", "SD"} resOptions = []string{"2160p", "1080p", "720p", "SD"}
codecOptions = []string{arr.CodecX265, arr.CodecX264, arr.CodecAV1} codecOptions = []string{arr.CodecX265, arr.CodecX264, arr.CodecAV1}
hdrOptions = []string{"HDR", "DV", "SDR"} 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{} } func (f attrFilter) active() bool { return f != attrFilter{} }
@@ -29,7 +31,7 @@ func (f attrFilter) active() bool { return f != attrFilter{} }
// String lists the active criteria for the header, e.g. "1080p x265 DV". // String lists the active criteria for the header, e.g. "1080p x265 DV".
func (f attrFilter) String() string { func (f attrFilter) String() string {
var parts []string var parts []string
for _, p := range []string{f.res, f.codec, f.hdr, f.lang} { for _, p := range []string{f.res, f.codec, f.hdr, f.audio, f.lang} {
if p != "" { if p != "" {
parts = append(parts, p) parts = append(parts, p)
} }
@@ -66,6 +68,9 @@ func (f attrFilter) matchFile(a arr.FileAttrs) bool {
return false 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) }) { if f.lang != "" && !slices.ContainsFunc(a.Languages, func(l string) bool { return strings.EqualFold(l, f.lang) }) {
return false return false
} }
@@ -104,8 +109,10 @@ func indexOf(options []string, v string) int {
return 0 return 0
} }
// openAttrFilter shows the dialog to pick quality, codec, HDR and language. // openAttrFilter shows the dialog to pick quality, codec, HDR, audio and
func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attrFilter)) { // 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) { if cur.lang != "" && !slices.Contains(languages, cur.lang) {
languages = append([]string{cur.lang}, languages...) languages = append([]string{cur.lang}, languages...)
} }
@@ -114,12 +121,14 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attr
lRes = "Quality" lRes = "Quality"
lCodec = "Codec" lCodec = "Codec"
lHDR = "HDR" lHDR = "HDR"
lAudio = "Audio"
lLang = "Language" lLang = "Language"
) )
form := tview.NewForm(). form := tview.NewForm().
AddDropDown(lRes, withAny(resOptions), indexOf(resOptions, cur.res), nil). AddDropDown(lRes, withAny(resOptions), indexOf(resOptions, cur.res), nil).
AddDropDown(lCodec, withAny(codecOptions), indexOf(codecOptions, cur.codec), nil). AddDropDown(lCodec, withAny(codecOptions), indexOf(codecOptions, cur.codec), nil).
AddDropDown(lHDR, withAny(hdrOptions), indexOf(hdrOptions, cur.hdr), 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) AddDropDown(lLang, withAny(languages), indexOf(languages, cur.lang), nil)
pick := func(label string, options []string) string { pick := func(label string, options []string) string {
@@ -129,22 +138,31 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attr
} }
return options[i-1] return options[i-1]
} }
form.AddButton("Apply", func() { current := func() attrFilter {
a.closeModal() return attrFilter{
apply(attrFilter{
res: pick(lRes, resOptions), res: pick(lRes, resOptions),
codec: pick(lCodec, codecOptions), codec: pick(lCodec, codecOptions),
hdr: pick(lHDR, hdrOptions), hdr: pick(lHDR, hdrOptions),
audio: pick(lAudio, audioOptions),
lang: pick(lLang, languages), lang: pick(lLang, languages),
}) }
}) }
for _, label := range []string{lRes, lCodec, lHDR, lAudio, lLang} {
form.GetFormItemByLabel(label).(*tview.DropDown).SetSelectedFunc(func(string, int) { set(current()) })
}
cancel := func() {
a.closeModal()
set(cur)
}
form.AddButton("Apply", a.closeModal)
form.AddButton("Reset", func() { form.AddButton("Reset", func() {
a.closeModal() a.closeModal()
apply(attrFilter{}) set(attrFilter{})
}) })
form.AddButton("Cancel", a.closeModal) form.AddButton("Cancel", cancel)
form.SetCancelFunc(a.closeModal) form.SetCancelFunc(cancel)
arrowNavigation(form) arrowNavigation(form)
form.SetBorder(true).SetTitle(" Filter by properties ") form.SetBorder(true).SetTitle(" Filter by properties ")
a.openModal(form, 50, 13) a.openSidePanel(form, 40, 17, "↑↓ move · ←→ change (applies live) · Enter apply · Esc cancel")
} }
+2
View File
@@ -113,6 +113,8 @@ func TestAttrFilter(t *testing.T) {
{"dv", attrFilter{hdr: "DV"}, []arr.FileAttrs{hd}, false}, {"dv", attrFilter{hdr: "DV"}, []arr.FileAttrs{hd}, false},
{"sdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{hd}, true}, {"sdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{hd}, true},
{"sdr excludes hdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{uhd}, false}, {"sdr excludes hdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{uhd}, false},
{"audio", attrFilter{audio: "Atmos"}, []arr.FileAttrs{{Audio: []string{"Atmos", "TrueHD"}}}, true},
{"audio mismatch", attrFilter{audio: "DTS"}, []arr.FileAttrs{{Audio: []string{"DD+"}}}, false},
{"language is case-insensitive", attrFilter{lang: "german"}, []arr.FileAttrs{uhd}, true}, {"language is case-insensitive", attrFilter{lang: "german"}, []arr.FileAttrs{uhd}, true},
{"all criteria must match one file", attrFilter{res: "1080p", codec: "x265"}, []arr.FileAttrs{hd, uhd}, false}, {"all criteria must match one file", attrFilter{res: "1080p", codec: "x265"}, []arr.FileAttrs{hd, uhd}, false},
{"any file of a series", attrFilter{res: "1080p", codec: "x264"}, []arr.FileAttrs{uhd, hd}, true}, {"any file of a series", attrFilter{res: "1080p", codec: "x264"}, []arr.FileAttrs{uhd, hd}, true},