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:
@@ -7,7 +7,7 @@ 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, 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, 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, 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.
|
||||||
@@ -58,7 +58,7 @@ Keep the file private (`chmod 600`); it holds your API keys.
|
|||||||
| `i` | interactive release search |
|
| `i` | interactive release search |
|
||||||
| `Tab`, `f`, `F`, `/`, `t`, `s`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, releases/episodes, reload |
|
| `Tab`, `f`, `F`, `/`, `t`, `s`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, releases/episodes, reload |
|
||||||
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
|
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
|
||||||
| `Enter`/`g`, `s`, `f` (release list) | grab, cycle sort, hide rejected |
|
| `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties |
|
||||||
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
||||||
| `Esc` | back / close dialog |
|
| `Esc` | back / close dialog |
|
||||||
| `q`, `Ctrl+C` | quit |
|
| `q`, `Ctrl+C` | quit |
|
||||||
|
|||||||
@@ -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.
|
- **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).
|
||||||
- **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.
|
||||||
@@ -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: filter by file properties (quality, codec, HDR/DV, language) |
|
| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, 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) |
|
||||||
|
|||||||
+19
-16
@@ -63,22 +63,12 @@ func (f MediaFile) Attrs() FileAttrs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
codec := f.MediaInfo.VideoCodec + " " + names
|
if f.MediaInfo.VideoCodec != "" {
|
||||||
switch {
|
// A codec we do not offer as a filter (VC-1, MPEG-2...) stays empty
|
||||||
case re265.MatchString(f.MediaInfo.VideoCodec):
|
// rather than being guessed from the name.
|
||||||
a.Codec = CodecX265
|
a.Codec = codecOf(f.MediaInfo.VideoCodec)
|
||||||
case re264.MatchString(f.MediaInfo.VideoCodec):
|
} else {
|
||||||
a.Codec = CodecX264
|
a.Codec = codecOf(names)
|
||||||
case reAV1.MatchString(f.MediaInfo.VideoCodec):
|
|
||||||
a.Codec = CodecAV1
|
|
||||||
case f.MediaInfo.VideoCodec != "":
|
|
||||||
// A codec we do not offer as a filter (VC-1, MPEG-2...): do not guess from the name.
|
|
||||||
case re265.MatchString(codec):
|
|
||||||
a.Codec = CodecX265
|
|
||||||
case re264.MatchString(codec):
|
|
||||||
a.Codec = CodecX264
|
|
||||||
case reAV1.MatchString(codec):
|
|
||||||
a.Codec = CodecAV1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dr := f.MediaInfo.VideoDynamicRangeType + " " + f.MediaInfo.VideoDynamicRange
|
dr := f.MediaInfo.VideoDynamicRangeType + " " + f.MediaInfo.VideoDynamicRange
|
||||||
@@ -101,3 +91,16 @@ func atoi(s string) (n int) {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// codecOf finds a codec we offer as a filter in free text ("x265", "H.264", "HEVC"...).
|
||||||
|
func codecOf(s string) string {
|
||||||
|
switch {
|
||||||
|
case re265.MatchString(s):
|
||||||
|
return CodecX265
|
||||||
|
case re264.MatchString(s):
|
||||||
|
return CodecX264
|
||||||
|
case reAV1.MatchString(s):
|
||||||
|
return CodecAV1
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,3 +47,27 @@ func TestAttrsVariants(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleaseAttrs(t *testing.T) {
|
||||||
|
var r Release
|
||||||
|
raw := `{"title":"Dune.2021.2160p.UHD.BluRay.DV.HDR10.x265-GRP","quality":{"quality":{"name":"Bluray-2160p","resolution":2160}},
|
||||||
|
"languages":[{"name":"English"}],"customFormats":[{"name":"HDR"}]}`
|
||||||
|
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := FileAttrs{Resolution: 2160, Codec: CodecX265, HDR: true, DV: true, Languages: []string{"English"}}
|
||||||
|
if got := r.Attrs(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("got %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolution from the title when the quality carries none; codec spelled H.264.
|
||||||
|
r = Release{Title: "Show.S01E01.720p.WEB.H.264-GRP"}
|
||||||
|
if got := r.Attrs(); got.Resolution != 720 || got.Codec != CodecX264 || got.HDR || got.DV {
|
||||||
|
t.Errorf("got %+v", got)
|
||||||
|
}
|
||||||
|
// "HDTV" and "HDRip" are not HDR.
|
||||||
|
r = Release{Title: "Show.S01E01.HDTV.x264-GRP"}
|
||||||
|
if got := r.Attrs(); got.HDR || got.DV {
|
||||||
|
t.Errorf("HDTV detected as HDR: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+31
-1
@@ -27,12 +27,16 @@ type Release struct {
|
|||||||
FullSeason bool `json:"fullSeason"`
|
FullSeason bool `json:"fullSeason"`
|
||||||
Quality struct {
|
Quality struct {
|
||||||
Quality struct {
|
Quality struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Resolution int `json:"resolution"`
|
||||||
} `json:"quality"`
|
} `json:"quality"`
|
||||||
} `json:"quality"`
|
} `json:"quality"`
|
||||||
Languages []struct {
|
Languages []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"languages"`
|
} `json:"languages"`
|
||||||
|
CustomFormats []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"customFormats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualityName returns the release's quality, e.g. "Bluray-1080p".
|
// QualityName returns the release's quality, e.g. "Bluray-1080p".
|
||||||
@@ -64,3 +68,29 @@ func (c *Client) Grab(ctx context.Context, r Release) error {
|
|||||||
body := map[string]any{"guid": r.GUID, "indexerId": r.IndexerID}
|
body := map[string]any{"guid": r.GUID, "indexerId": r.IndexerID}
|
||||||
return c.Do(ctx, http.MethodPost, "release", nil, body, nil)
|
return c.Do(ctx, http.MethodPost, "release", nil, body, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attrs derives the filterable properties from what the release announces:
|
||||||
|
// its title, quality, custom formats and languages. Codec and HDR only show up
|
||||||
|
// when the uploader put them into the title.
|
||||||
|
func (r Release) Attrs() FileAttrs {
|
||||||
|
names := []string{r.Title, r.Quality.Quality.Name}
|
||||||
|
for _, cf := range r.CustomFormats {
|
||||||
|
names = append(names, cf.Name)
|
||||||
|
}
|
||||||
|
text := strings.Join(names, " ")
|
||||||
|
|
||||||
|
a := FileAttrs{Resolution: r.Quality.Quality.Resolution, Codec: codecOf(text)}
|
||||||
|
if a.Resolution == 0 {
|
||||||
|
if m := reRes.FindStringSubmatch(text); m != nil {
|
||||||
|
a.Resolution = atoi(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.DV = reDV.MatchString(text)
|
||||||
|
a.HDR = reHDR.MatchString(text)
|
||||||
|
for _, l := range r.Languages {
|
||||||
|
if l.Name != "" {
|
||||||
|
a.Languages = append(a.Languages, l.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,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
|
||||||
|
release title, quality and custom formats)
|
||||||
|
|
||||||
[yellow]Downloads[-]
|
[yellow]Downloads[-]
|
||||||
p pause / resume the SABnzbd queue
|
p pause / resume the SABnzbd queue
|
||||||
|
|||||||
@@ -145,6 +145,6 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attr
|
|||||||
form.AddButton("Cancel", a.closeModal)
|
form.AddButton("Cancel", a.closeModal)
|
||||||
form.SetCancelFunc(a.closeModal)
|
form.SetCancelFunc(a.closeModal)
|
||||||
arrowNavigation(form)
|
arrowNavigation(form)
|
||||||
form.SetBorder(true).SetTitle(" Filter by file properties ")
|
form.SetBorder(true).SetTitle(" Filter by properties ")
|
||||||
a.openModal(form, 50, 13)
|
a.openModal(form, 50, 13)
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-4
@@ -42,6 +42,7 @@ type releasesView struct {
|
|||||||
searched bool // the indexer search has finished
|
searched bool // the indexer search has finished
|
||||||
sortMode int
|
sortMode int
|
||||||
hideRejected bool
|
hideRejected bool
|
||||||
|
attr attrFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
// openReleases starts an interactive search and shows the results as they arrive.
|
// 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':
|
case ev.Key() == tcell.KeyRune && ev.Rune() == 's':
|
||||||
v.sortMode = (v.sortMode + 1) % numSortModes
|
v.sortMode = (v.sortMode + 1) % numSortModes
|
||||||
v.render()
|
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':
|
case ev.Key() == tcell.KeyRune && ev.Rune() == 'f':
|
||||||
v.hideRejected = !v.hideRejected
|
v.hideRejected = !v.hideRejected
|
||||||
v.render()
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
v.cancel = cancel
|
v.cancel = cancel
|
||||||
v.render() // shows the "searching" placeholder
|
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() {
|
go func() {
|
||||||
releases, err := fetch(ctx)
|
releases, err := fetch(ctx)
|
||||||
@@ -125,7 +131,7 @@ func (v *releasesView) render() {
|
|||||||
if r, ok := v.selected(); ok {
|
if r, ok := v.selected(); ok {
|
||||||
selGUID = r.GUID
|
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()
|
v.table.Clear()
|
||||||
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
|
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
|
||||||
@@ -158,7 +164,7 @@ func (v *releasesView) render() {
|
|||||||
case len(v.shown) == 0:
|
case len(v.shown) == 0:
|
||||||
msg := "No releases found. Are indexers configured?"
|
msg := "No releases found. Are indexers configured?"
|
||||||
if len(v.all) > 0 {
|
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))
|
v.table.SetCell(1, 1, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
|
||||||
}
|
}
|
||||||
@@ -187,6 +193,9 @@ func (v *releasesView) renderHeader() {
|
|||||||
if v.hideRejected {
|
if v.hideRejected {
|
||||||
s += " · [yellow]rejected hidden[-]"
|
s += " · [yellow]rejected hidden[-]"
|
||||||
}
|
}
|
||||||
|
if v.attr.active() {
|
||||||
|
s += " · [yellow]" + v.attr.String() + "[-]"
|
||||||
|
}
|
||||||
if v.torrents > 0 {
|
if v.torrents > 0 {
|
||||||
s += fmt.Sprintf(" · [gray]%d torrent release%s hidden[-]", v.torrents, plural(v.torrents))
|
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.
|
// 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))
|
out := make([]arr.Release, 0, len(all))
|
||||||
for _, r := range all {
|
for _, r := range all {
|
||||||
if hideRejected && isRejected(r) {
|
if hideRejected && isRejected(r) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if af.active() && !af.matchFile(r.Attrs()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
slices.SortStableFunc(out, func(a, b arr.Release) int {
|
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)))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestVisibleReleasesSort(t *testing.T) {
|
|||||||
{"hide rejected", true, sortBest, []string{"ok-1080-score", "ok-1080", "ok-720"}},
|
{"hide rejected", true, sortBest, []string{"ok-1080-score", "ok-1080", "ok-720"}},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
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)
|
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,3 +108,20 @@ func TestSummarizeSeasons(t *testing.T) {
|
|||||||
t.Error("seasonLabel")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user