diff --git a/README.md b/README.md index 566f9df..a8f8a96 100644 --- a/README.md +++ b/README.md @@ -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, optionally start the automatic search. - **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 monitoring, trigger searches. Series open into seasons and episodes with per-season and 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 | | `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`/`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 | | `Esc` | back / close dialog | | `q`, `Ctrl+C` | quit | diff --git a/SPECS.md b/SPECS.md index 8c53b83..2977fed 100644 --- a/SPECS.md +++ b/SPECS.md @@ -57,7 +57,7 @@ Notes: - **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. - **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: - 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. @@ -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 | | `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) | | `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 | | `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download | | `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) | diff --git a/internal/arr/mediafile.go b/internal/arr/mediafile.go index 534abe8..a180de4 100644 --- a/internal/arr/mediafile.go +++ b/internal/arr/mediafile.go @@ -63,22 +63,12 @@ func (f MediaFile) Attrs() FileAttrs { } } - codec := f.MediaInfo.VideoCodec + " " + names - switch { - case re265.MatchString(f.MediaInfo.VideoCodec): - a.Codec = CodecX265 - case re264.MatchString(f.MediaInfo.VideoCodec): - a.Codec = CodecX264 - 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 + if f.MediaInfo.VideoCodec != "" { + // A codec we do not offer as a filter (VC-1, MPEG-2...) stays empty + // rather than being guessed from the name. + a.Codec = codecOf(f.MediaInfo.VideoCodec) + } else { + a.Codec = codecOf(names) } dr := f.MediaInfo.VideoDynamicRangeType + " " + f.MediaInfo.VideoDynamicRange @@ -101,3 +91,16 @@ func atoi(s string) (n int) { } 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 "" +} diff --git a/internal/arr/mediafile_test.go b/internal/arr/mediafile_test.go index 7ec9d9d..4250f2f 100644 --- a/internal/arr/mediafile_test.go +++ b/internal/arr/mediafile_test.go @@ -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) + } +} diff --git a/internal/arr/release.go b/internal/arr/release.go index c047d24..5a2b0fd 100644 --- a/internal/arr/release.go +++ b/internal/arr/release.go @@ -27,12 +27,16 @@ type Release struct { FullSeason bool `json:"fullSeason"` Quality struct { Quality struct { - Name string `json:"name"` + Name string `json:"name"` + Resolution int `json:"resolution"` } `json:"quality"` } `json:"quality"` Languages []struct { Name string `json:"name"` } `json:"languages"` + CustomFormats []struct { + Name string `json:"name"` + } `json:"customFormats"` } // 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} 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 +} diff --git a/internal/ui/help.go b/internal/ui/help.go index 9af2b0e..058fed8 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -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 diff --git a/internal/ui/libfilter.go b/internal/ui/libfilter.go index fafbaf1..d3438d1 100644 --- a/internal/ui/libfilter.go +++ b/internal/ui/libfilter.go @@ -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) } diff --git a/internal/ui/releases.go b/internal/ui/releases.go index 69e5876..c1904b3 100644 --- a/internal/ui/releases.go +++ b/internal/ui/releases.go @@ -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 +} diff --git a/internal/ui/releases_test.go b/internal/ui/releases_test.go index e95b278..d29d280 100644 --- a/internal/ui/releases_test.go +++ b/internal/ui/releases_test.go @@ -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) + } +}