Release list: filter by quality, codec, HDR, DV and language

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:30:12 +02:00
co-authored by Claude Sonnet 5.5
parent b34f88ef3e
commit 47b9459a5e
9 changed files with 131 additions and 27 deletions
+2
View File
@@ -39,6 +39,8 @@ const helpText = `[yellow]Global[-]
Enter, g grab the selected release
s cycle sort (best, size, age)
f hide rejected releases
F filter by quality, codec, HDR / DV, language (read from the
release title, quality and custom formats)
[yellow]Downloads[-]
p pause / resume the SABnzbd queue
+1 -1
View File
@@ -145,6 +145,6 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attr
form.AddButton("Cancel", a.closeModal)
form.SetCancelFunc(a.closeModal)
arrowNavigation(form)
form.SetBorder(true).SetTitle(" Filter by file properties ")
form.SetBorder(true).SetTitle(" Filter by properties ")
a.openModal(form, 50, 13)
}
+32 -4
View File
@@ -42,6 +42,7 @@ type releasesView struct {
searched bool // the indexer search has finished
sortMode int
hideRejected bool
attr attrFilter
}
// openReleases starts an interactive search and shows the results as they arrive.
@@ -71,6 +72,11 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele
case ev.Key() == tcell.KeyRune && ev.Rune() == 's':
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.attr = f
v.render()
})
case ev.Key() == tcell.KeyRune && ev.Rune() == 'f':
v.hideRejected = !v.hideRejected
v.render()
@@ -83,7 +89,7 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
v.cancel = cancel
v.render() // shows the "searching" placeholder
a.openFull(v.root, "Enter/g grab · s sort · f hide rejected · Esc back", v.cancel)
a.openFull(v.root, "Enter/g grab · s sort · f hide rejected · F filter · Esc back", v.cancel)
go func() {
releases, err := fetch(ctx)
@@ -125,7 +131,7 @@ func (v *releasesView) render() {
if r, ok := v.selected(); ok {
selGUID = r.GUID
}
v.shown = visibleReleases(v.all, v.hideRejected, v.sortMode)
v.shown = visibleReleases(v.all, v.hideRejected, v.sortMode, v.attr)
v.table.Clear()
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
@@ -158,7 +164,7 @@ func (v *releasesView) render() {
case len(v.shown) == 0:
msg := "No releases found. Are indexers configured?"
if len(v.all) > 0 {
msg = "All releases are hidden by the filter (f)"
msg = "All releases are hidden by the filters (f, F)"
}
v.table.SetCell(1, 1, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
}
@@ -187,6 +193,9 @@ func (v *releasesView) renderHeader() {
if v.hideRejected {
s += " · [yellow]rejected hidden[-]"
}
if v.attr.active() {
s += " · [yellow]" + v.attr.String() + "[-]"
}
if v.torrents > 0 {
s += fmt.Sprintf(" · [gray]%d torrent release%s hidden[-]", v.torrents, plural(v.torrents))
}
@@ -265,12 +274,15 @@ func (v *releasesView) doGrab(r arr.Release) {
}
// visibleReleases applies the filter and sort order, returning a new slice.
func visibleReleases(all []arr.Release, hideRejected bool, mode int) []arr.Release {
func visibleReleases(all []arr.Release, hideRejected bool, mode int, af attrFilter) []arr.Release {
out := make([]arr.Release, 0, len(all))
for _, r := range all {
if hideRejected && isRejected(r) {
continue
}
if af.active() && !af.matchFile(r.Attrs()) {
continue
}
out = append(out, r)
}
slices.SortStableFunc(out, func(a, b arr.Release) int {
@@ -336,3 +348,19 @@ 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)
}
}
}
slices.Sort(out)
return out
}
+18 -1
View File
@@ -50,7 +50,7 @@ func TestVisibleReleasesSort(t *testing.T) {
{"hide rejected", true, sortBest, []string{"ok-1080-score", "ok-1080", "ok-720"}},
}
for _, tc := range tests {
if got := guids(visibleReleases(all, tc.hide, tc.mode)); !equal(got, tc.want) {
if got := guids(visibleReleases(all, tc.hide, tc.mode, attrFilter{})); !equal(got, tc.want) {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
@@ -108,3 +108,20 @@ func TestSummarizeSeasons(t *testing.T) {
t.Error("seasonLabel")
}
}
func TestVisibleReleasesAttrFilter(t *testing.T) {
rel := func(guid, title string) arr.Release { return arr.Release{GUID: guid, Title: title} }
all := []arr.Release{
rel("a", "Movie.2160p.DV.x265-G"),
rel("b", "Movie.1080p.x264-G"),
rel("c", "Movie.1080p.x265-G"),
}
got := guids(visibleReleases(all, false, sortBest, attrFilter{res: "1080p", codec: "x265"}))
if !equal(got, []string{"c"}) {
t.Errorf("1080p x265 = %v", got)
}
got = guids(visibleReleases(all, false, sortBest, attrFilter{hdr: "DV"}))
if !equal(got, []string{"a"}) {
t.Errorf("DV = %v", got)
}
}