Library: filter by file properties (quality, codec, HDR, DV, language)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
||||
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.
|
||||
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by missing / downloaded / monitored, 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
|
||||
per-episode monitoring, automatic search and release search.
|
||||
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
||||
@@ -56,7 +56,7 @@ Keep the file private (`chmod 600`); it holds your API keys.
|
||||
| `/` | focus search box |
|
||||
| `Enter`, `a` | add selected title |
|
||||
| `i` | interactive release search |
|
||||
| `Tab`, `f`, `/`, `t`, `s`, `i`/`Enter`, `r` (library) | movies/series list, 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`/`g`, `s`, `f` (release list) | grab, cycle sort, hide rejected |
|
||||
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
||||
|
||||
@@ -67,7 +67,7 @@ Notes:
|
||||
|
||||
### 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) and a text filter apply to both lists. `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 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).
|
||||
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search.
|
||||
|
||||
### 4.3 Later / ideas
|
||||
@@ -113,7 +113,8 @@ 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: cycle the filter; release list: hide rejected releases |
|
||||
| `F` | Library: filter by file 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) |
|
||||
| `Enter` / `g` | Release list: grab the selected release (rejected ones ask for confirmation) |
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MediaFile is a downloaded file as Radarr (movieFile) and Sonarr
|
||||
// (episodeFile) describe it.
|
||||
type MediaFile struct {
|
||||
Size int64 `json:"size"`
|
||||
SceneName string `json:"sceneName,omitempty"`
|
||||
RelativePath string `json:"relativePath,omitempty"`
|
||||
Quality struct {
|
||||
Quality struct {
|
||||
Name string `json:"name"`
|
||||
Resolution int `json:"resolution"`
|
||||
} `json:"quality"`
|
||||
} `json:"quality"`
|
||||
MediaInfo struct {
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
VideoDynamicRange string `json:"videoDynamicRange"`
|
||||
VideoDynamicRangeType string `json:"videoDynamicRangeType"`
|
||||
} `json:"mediaInfo"`
|
||||
Languages []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"languages"`
|
||||
}
|
||||
|
||||
// Codec names used for filtering.
|
||||
const (
|
||||
CodecX265 = "x265"
|
||||
CodecX264 = "x264"
|
||||
CodecAV1 = "AV1"
|
||||
)
|
||||
|
||||
// FileAttrs are the properties of a file the library can be filtered by.
|
||||
type FileAttrs struct {
|
||||
Resolution int // 2160, 1080, 720, 480 ... or 0 if unknown
|
||||
Codec string // CodecX265, CodecX264, CodecAV1 or ""
|
||||
HDR bool // HDR10, HDR10+, HLG, PQ (also with Dolby Vision on top)
|
||||
DV bool // Dolby Vision
|
||||
Languages []string // audio languages
|
||||
}
|
||||
|
||||
var (
|
||||
reRes = regexp.MustCompile(`(?i)\b(2160|1080|720|576|480)p\b`)
|
||||
re265 = regexp.MustCompile(`(?i)(x\.?265|h\.?265|hevc)`)
|
||||
re264 = regexp.MustCompile(`(?i)(x\.?264|h\.?264|\bavc\b)`)
|
||||
reAV1 = regexp.MustCompile(`(?i)\bav1\b`)
|
||||
reDV = regexp.MustCompile(`(?i)(\bdv\b|dovi|dolby[ .]?vision)`)
|
||||
reHDR = regexp.MustCompile(`(?i)\b(hdr(10(\+|plus)?)?|hlg|pq)\b`)
|
||||
)
|
||||
|
||||
// Attrs derives the filterable properties. Radarr/Sonarr's media info is used
|
||||
// when present, otherwise the names (scene name, path, quality) are searched.
|
||||
func (f MediaFile) Attrs() FileAttrs {
|
||||
names := strings.Join([]string{f.SceneName, f.RelativePath, f.Quality.Quality.Name}, " ")
|
||||
a := FileAttrs{Resolution: f.Quality.Quality.Resolution}
|
||||
if a.Resolution == 0 {
|
||||
if m := reRes.FindStringSubmatch(names); m != nil {
|
||||
a.Resolution = atoi(m[1])
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
dr := f.MediaInfo.VideoDynamicRangeType + " " + f.MediaInfo.VideoDynamicRange
|
||||
if strings.TrimSpace(dr) == "" {
|
||||
dr = names
|
||||
}
|
||||
a.DV = reDV.MatchString(dr)
|
||||
a.HDR = reHDR.MatchString(dr)
|
||||
for _, l := range f.Languages {
|
||||
if l.Name != "" {
|
||||
a.Languages = append(a.Languages, l.Name)
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func atoi(s string) (n int) {
|
||||
for _, c := range s {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func attrs(t *testing.T, raw string) FileAttrs {
|
||||
t.Helper()
|
||||
var f MediaFile
|
||||
if err := json.Unmarshal([]byte(raw), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.Attrs()
|
||||
}
|
||||
|
||||
func TestAttrsFromMediaInfo(t *testing.T) {
|
||||
a := attrs(t, `{"quality":{"quality":{"name":"Bluray-2160p","resolution":2160}},
|
||||
"mediaInfo":{"videoCodec":"x265","videoDynamicRange":"HDR","videoDynamicRangeType":"DV HDR10"},
|
||||
"languages":[{"name":"English"},{"name":"German"}]}`)
|
||||
want := FileAttrs{Resolution: 2160, Codec: CodecX265, HDR: true, DV: true, Languages: []string{"English", "German"}}
|
||||
if !reflect.DeepEqual(a, want) {
|
||||
t.Errorf("got %+v, want %+v", a, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttrsVariants(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, raw string
|
||||
check func(FileAttrs) bool
|
||||
}{
|
||||
{"hevc codec", `{"mediaInfo":{"videoCodec":"HEVC"}}`, func(a FileAttrs) bool { return a.Codec == CodecX265 }},
|
||||
{"h264 codec", `{"mediaInfo":{"videoCodec":"h264"}}`, func(a FileAttrs) bool { return a.Codec == CodecX264 }},
|
||||
{"avc codec", `{"mediaInfo":{"videoCodec":"AVC"}}`, func(a FileAttrs) bool { return a.Codec == CodecX264 }},
|
||||
{"av1 codec", `{"mediaInfo":{"videoCodec":"AV1"}}`, func(a FileAttrs) bool { return a.Codec == CodecAV1 }},
|
||||
{"unoffered codec is not guessed from the name", `{"sceneName":"x264.Movie","mediaInfo":{"videoCodec":"VC-1"}}`, func(a FileAttrs) bool { return a.Codec == "" }},
|
||||
{"codec from scene name", `{"sceneName":"Movie.2020.1080p.BluRay.x265-GRP"}`, func(a FileAttrs) bool { return a.Codec == CodecX265 && a.Resolution == 1080 }},
|
||||
{"dv from path", `{"relativePath":"Movie.2160p.DV.HDR10.mkv"}`, func(a FileAttrs) bool { return a.DV && a.HDR && a.Resolution == 2160 }},
|
||||
{"dv only", `{"mediaInfo":{"videoDynamicRangeType":"DV"}}`, func(a FileAttrs) bool { return a.DV && !a.HDR }},
|
||||
{"hlg is hdr", `{"mediaInfo":{"videoDynamicRangeType":"HLG"}}`, func(a FileAttrs) bool { return a.HDR && !a.DV }},
|
||||
{"sdr", `{"mediaInfo":{"videoCodec":"x264"},"sceneName":"Movie.HDRip.x264"}`, func(a FileAttrs) bool { return !a.HDR && !a.DV }},
|
||||
{"hdr word in a title is not enough with media info", `{"relativePath":"Hdr.Story.mkv","mediaInfo":{"videoDynamicRange":"SDR"}}`, func(a FileAttrs) bool { return !a.HDR }},
|
||||
} {
|
||||
if !tc.check(attrs(t, tc.raw)) {
|
||||
t.Errorf("%s: got %+v", tc.name, attrs(t, tc.raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,17 +59,7 @@ type Movie struct {
|
||||
SizeOnDisk int64 `json:"sizeOnDisk"`
|
||||
IsAvailable bool `json:"isAvailable"`
|
||||
Path string `json:"path,omitempty"`
|
||||
MovieFile *MovieFile `json:"movieFile,omitempty"`
|
||||
}
|
||||
|
||||
// MovieFile is the downloaded file of a library movie.
|
||||
type MovieFile struct {
|
||||
Size int64 `json:"size"`
|
||||
Quality struct {
|
||||
Quality struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"quality"`
|
||||
} `json:"quality"`
|
||||
MovieFile *arr.MediaFile `json:"movieFile,omitempty"`
|
||||
}
|
||||
|
||||
// QualityName returns the quality of the downloaded file, or "".
|
||||
|
||||
@@ -239,3 +239,10 @@ func (c *Client) SeasonReleases(ctx context.Context, seriesID, season int) ([]ar
|
||||
"seasonNumber": {strconv.Itoa(season)},
|
||||
})
|
||||
}
|
||||
|
||||
// EpisodeFiles lists the downloaded files of a library series.
|
||||
func (c *Client) EpisodeFiles(ctx context.Context, seriesID int) ([]arr.MediaFile, error) {
|
||||
var files []arr.MediaFile
|
||||
err := c.Do(ctx, http.MethodGet, "episodefile", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &files)
|
||||
return files, err
|
||||
}
|
||||
|
||||
@@ -148,3 +148,19 @@ func TestEpisodesAndReleaseQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeFiles(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v3/episodefile" || r.URL.Query().Get("seriesId") != "4" {
|
||||
t.Errorf("unexpected request %s", r.URL)
|
||||
}
|
||||
w.Write([]byte(`[{"size":5,"quality":{"quality":{"name":"WEBDL-1080p","resolution":1080}},"mediaInfo":{"videoCodec":"x264"}}]`))
|
||||
})
|
||||
files, err := c.EpisodeFiles(context.Background(), 4)
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("files=%v err=%v", files, err)
|
||||
}
|
||||
if a := files[0].Attrs(); a.Resolution != 1080 || a.Codec != "x264" {
|
||||
t.Errorf("attrs = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -341,7 +341,7 @@ func searchHints() string {
|
||||
}
|
||||
|
||||
func libraryHints() string {
|
||||
return "Tab movies/series · f filter · / text · t monitor · s search · Enter open"
|
||||
return "Tab list · f status · F file props · / text · t monitor · s search"
|
||||
}
|
||||
|
||||
func logHints() string { return "↑↓ scroll · G follow · c clear" }
|
||||
|
||||
@@ -22,6 +22,8 @@ const helpText = `[yellow]Global[-]
|
||||
Tab switch between the movies and series lists
|
||||
f cycle filter (all, missing, downloaded, monitored, unmonitored);
|
||||
filters apply to both lists
|
||||
F filter by file properties: quality, codec (x265, x264, AV1),
|
||||
HDR / DV / SDR, language. Series are read on demand.
|
||||
/ text filter (Enter or Esc returns to the list)
|
||||
t toggle monitored
|
||||
s automatic search (series: all missing, asks first)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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, 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"}
|
||||
)
|
||||
|
||||
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.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.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)
|
||||
}
|
||||
|
||||
// languagesOf collects the languages of all known files, sorted.
|
||||
func languagesOf(items ...[]libItem) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, list := range items {
|
||||
for _, it := range list {
|
||||
for _, a := range it.attrs {
|
||||
for _, l := range a.Languages {
|
||||
if !seen[l] {
|
||||
seen[l] = true
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func indexOf(options []string, v string) int {
|
||||
if i := slices.Index(options, v); i >= 0 {
|
||||
return i + 1 // option 0 is "any"
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// openAttrFilter shows the dialog to pick quality, codec, HDR and language.
|
||||
func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attrFilter)) {
|
||||
if cur.lang != "" && !slices.Contains(languages, cur.lang) {
|
||||
languages = append([]string{cur.lang}, languages...)
|
||||
}
|
||||
withAny := func(o []string) []string { return append([]string{anyOption}, o...) }
|
||||
const (
|
||||
lRes = "Quality"
|
||||
lCodec = "Codec"
|
||||
lHDR = "HDR"
|
||||
lLang = "Language"
|
||||
)
|
||||
form := tview.NewForm().
|
||||
AddDropDown(lRes, withAny(resOptions), indexOf(resOptions, cur.res), nil).
|
||||
AddDropDown(lCodec, withAny(codecOptions), indexOf(codecOptions, cur.codec), nil).
|
||||
AddDropDown(lHDR, withAny(hdrOptions), indexOf(hdrOptions, cur.hdr), nil).
|
||||
AddDropDown(lLang, withAny(languages), indexOf(languages, cur.lang), nil)
|
||||
|
||||
pick := func(label string, options []string) string {
|
||||
i := selected(form, label)
|
||||
if i <= 0 {
|
||||
return ""
|
||||
}
|
||||
return options[i-1]
|
||||
}
|
||||
form.AddButton("Apply", func() {
|
||||
a.closeModal()
|
||||
apply(attrFilter{
|
||||
res: pick(lRes, resOptions),
|
||||
codec: pick(lCodec, codecOptions),
|
||||
hdr: pick(lHDR, hdrOptions),
|
||||
lang: pick(lLang, languages),
|
||||
})
|
||||
})
|
||||
form.AddButton("Reset", func() {
|
||||
a.closeModal()
|
||||
apply(attrFilter{})
|
||||
})
|
||||
form.AddButton("Cancel", a.closeModal)
|
||||
form.SetCancelFunc(a.closeModal)
|
||||
arrowNavigation(form)
|
||||
form.SetBorder(true).SetTitle(" Filter by file properties ")
|
||||
a.openModal(form, 50, 13)
|
||||
}
|
||||
+144
-7
@@ -49,6 +49,10 @@ type libItem struct {
|
||||
details string
|
||||
movie *radarr.Movie
|
||||
series *sonarr.Series
|
||||
// File properties for the attribute filter. For series they are fetched on
|
||||
// demand, attrsKnown tells whether that has happened.
|
||||
attrs []arr.FileAttrs
|
||||
attrsKnown bool
|
||||
}
|
||||
|
||||
var libFilters = []struct {
|
||||
@@ -78,10 +82,14 @@ func sizeOrBlank(b int64) string {
|
||||
|
||||
func newMovieItem(m radarr.Movie) libItem {
|
||||
it := libItem{
|
||||
attrsKnown: true,
|
||||
id: m.ID, title: m.Title, monitored: m.Monitored, have: m.HasFile,
|
||||
missing: m.Monitored && !m.HasFile && m.IsAvailable,
|
||||
size: sizeOrBlank(m.SizeOnDisk), movie: &m,
|
||||
}
|
||||
if m.HasFile && m.MovieFile != nil {
|
||||
it.attrs = []arr.FileAttrs{m.MovieFile.Attrs()}
|
||||
}
|
||||
it.label = tview.Escape(m.Title)
|
||||
if m.Year > 0 {
|
||||
it.label += fmt.Sprintf(" (%d)", m.Year)
|
||||
@@ -107,6 +115,7 @@ func newSeriesItem(s sonarr.Series) libItem {
|
||||
missing: s.Monitored && st.EpisodeFileCount < st.EpisodeCount,
|
||||
size: sizeOrBlank(st.SizeOnDisk), series: &s,
|
||||
}
|
||||
it.attrsKnown = st.EpisodeFileCount == 0 // nothing to fetch for series without files
|
||||
it.label = tview.Escape(s.Title)
|
||||
if s.Year > 0 {
|
||||
it.label += fmt.Sprintf(" (%d)", s.Year)
|
||||
@@ -145,12 +154,17 @@ func pathLine(p string) string {
|
||||
return "Path: " + tview.Escape(p) + "\n"
|
||||
}
|
||||
|
||||
// filterItems applies the filter preset and the text filter, sorted by title.
|
||||
func filterItems(items []libItem, filter int, text string) []libItem {
|
||||
// filterItems applies the filter preset, the text filter and the file
|
||||
// property filter, sorted by title. Items whose file properties are not known
|
||||
// yet do not match an active property filter.
|
||||
func filterItems(items []libItem, filter int, text string, af attrFilter) []libItem {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
var out []libItem
|
||||
for _, it := range items {
|
||||
if libFilters[filter].match(it) && strings.Contains(strings.ToLower(it.title), text) {
|
||||
if !libFilters[filter].match(it) || !strings.Contains(strings.ToLower(it.title), text) {
|
||||
continue
|
||||
}
|
||||
if !af.active() || af.match(it.attrs) {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
@@ -187,6 +201,19 @@ type libraryView struct {
|
||||
// UI-goroutine state.
|
||||
cur libKind // the pane that has (or last had) focus
|
||||
filter int
|
||||
attr attrFilter
|
||||
|
||||
// File properties of series, fetched on demand and cached. An entry is
|
||||
// reused while the series' file count and size stay the same.
|
||||
seriesFiles map[int]seriesFiles
|
||||
fileGen int // invalidates running fetches
|
||||
filePending int
|
||||
}
|
||||
|
||||
type seriesFiles struct {
|
||||
count int
|
||||
size int64
|
||||
attrs []arr.FileAttrs
|
||||
}
|
||||
|
||||
func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
||||
@@ -195,6 +222,8 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
||||
header: tview.NewTextView().SetDynamicColors(true),
|
||||
input: tview.NewInputField().SetLabel("Filter: ").SetFieldWidth(0),
|
||||
details: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
||||
|
||||
seriesFiles: map[int]seriesFiles{},
|
||||
}
|
||||
v.details.SetBorder(true).SetTitle(" Details ")
|
||||
|
||||
@@ -241,7 +270,10 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||
case 'f':
|
||||
v.filter = (v.filter + 1) % len(libFilters)
|
||||
v.render()
|
||||
case 'F':
|
||||
v.app.openAttrFilter(v.attr, languagesOf(v.panes[kindMovies].all, v.panes[kindSeries].all), v.setAttrFilter)
|
||||
case 'r':
|
||||
v.seriesFiles = map[int]seriesFiles{} // a manual reload also refreshes file properties
|
||||
v.reload()
|
||||
case 'i':
|
||||
v.open()
|
||||
@@ -291,7 +323,7 @@ func (v *libraryView) reloadPane(p *libPane) {
|
||||
var series []sonarr.Series
|
||||
if series, err = v.sonarr.Series(ctx); err == nil {
|
||||
for _, s := range series {
|
||||
items = append(items, newSeriesItem(s))
|
||||
items = append(items, v.withCachedFiles(newSeriesItem(s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +333,9 @@ func (v *libraryView) reloadPane(p *libPane) {
|
||||
return
|
||||
}
|
||||
p.err = err
|
||||
if err == nil && kind == kindSeries {
|
||||
defer v.loadSeriesFiles()
|
||||
}
|
||||
if err != nil {
|
||||
// Keep showing the last good list; say why it is stale.
|
||||
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
|
||||
@@ -325,10 +360,17 @@ func (v *libraryView) render() {
|
||||
for _, p := range v.panes {
|
||||
v.renderPane(p)
|
||||
}
|
||||
v.header.SetText(fmt.Sprintf("[::b]Library[::-] · %s %d/%d · %s %d/%d · filter: [yellow]%s[-]",
|
||||
filters := "[yellow]" + libFilters[v.filter].name + "[-]"
|
||||
if v.attr.active() {
|
||||
filters += " + [yellow]" + v.attr.String() + "[-]"
|
||||
if v.filePending > 0 {
|
||||
filters += fmt.Sprintf(" [gray](reading %d series…)[-]", v.filePending)
|
||||
}
|
||||
}
|
||||
v.header.SetText(fmt.Sprintf("[::b]Library[::-] · %s %d/%d · %s %d/%d · filter: %s",
|
||||
kindMovies, len(v.panes[kindMovies].shown), len(v.panes[kindMovies].all),
|
||||
kindSeries, len(v.panes[kindSeries].shown), len(v.panes[kindSeries].all),
|
||||
libFilters[v.filter].name))
|
||||
filters))
|
||||
v.renderDetails()
|
||||
}
|
||||
|
||||
@@ -337,7 +379,7 @@ func (v *libraryView) renderPane(p *libPane) {
|
||||
if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) {
|
||||
selID = p.shown[r-1].id
|
||||
}
|
||||
p.shown = filterItems(p.all, v.filter, v.input.GetText())
|
||||
p.shown = filterItems(p.all, v.filter, v.input.GetText(), v.attr)
|
||||
|
||||
p.table.Clear()
|
||||
header(p.table, 0, "Title", "Status", "Size")
|
||||
@@ -448,3 +490,98 @@ func (v *libraryView) run(doing, done string, fn func(ctx context.Context) error
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (v *libraryView) setAttrFilter(f attrFilter) {
|
||||
v.attr = f
|
||||
v.render()
|
||||
v.loadSeriesFiles()
|
||||
}
|
||||
|
||||
// withCachedFiles fills in the file properties of a series from the cache.
|
||||
func (v *libraryView) withCachedFiles(it libItem) libItem {
|
||||
if it.attrsKnown || it.series == nil {
|
||||
return it
|
||||
}
|
||||
st := it.series.Statistics
|
||||
if c, ok := v.seriesFiles[it.id]; ok && c.count == st.EpisodeFileCount && c.size == st.SizeOnDisk {
|
||||
it.attrs, it.attrsKnown = c.attrs, true
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
// loadSeriesFiles fetches the file properties of all series that lack them,
|
||||
// but only while a property filter is active (it costs one request per series).
|
||||
func (v *libraryView) loadSeriesFiles() {
|
||||
if !v.attr.active() {
|
||||
return
|
||||
}
|
||||
p := v.panes[kindSeries]
|
||||
var todo []sonarr.Series
|
||||
for i, it := range p.all {
|
||||
p.all[i] = v.withCachedFiles(it)
|
||||
if !p.all[i].attrsKnown {
|
||||
todo = append(todo, *p.all[i].series)
|
||||
}
|
||||
}
|
||||
v.render()
|
||||
if len(todo) == 0 {
|
||||
return
|
||||
}
|
||||
v.fileGen++
|
||||
gen := v.fileGen
|
||||
v.filePending = len(todo)
|
||||
v.render()
|
||||
|
||||
type fetched struct {
|
||||
s sonarr.Series
|
||||
attrs []arr.FileAttrs
|
||||
err error
|
||||
}
|
||||
jobs := make(chan sonarr.Series)
|
||||
results := make(chan fetched)
|
||||
const workers = 6
|
||||
for range workers {
|
||||
go func() {
|
||||
for s := range jobs {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
files, err := v.sonarr.EpisodeFiles(ctx, s.ID)
|
||||
cancel()
|
||||
f := fetched{s: s, err: err}
|
||||
for _, mf := range files {
|
||||
f.attrs = append(f.attrs, mf.Attrs())
|
||||
}
|
||||
results <- f
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
for _, s := range todo {
|
||||
jobs <- s
|
||||
}
|
||||
close(jobs)
|
||||
}()
|
||||
go func() {
|
||||
for range todo {
|
||||
f := <-results
|
||||
v.app.post(func() {
|
||||
if gen != v.fileGen {
|
||||
return
|
||||
}
|
||||
v.filePending--
|
||||
if f.err != nil {
|
||||
v.app.SetService("Sonarr", "", f.err)
|
||||
} else {
|
||||
v.seriesFiles[f.s.ID] = seriesFiles{f.s.Statistics.EpisodeFileCount, f.s.Statistics.SizeOnDisk, f.attrs}
|
||||
for i, it := range p.all {
|
||||
if it.id == f.s.ID {
|
||||
p.all[i].attrs, p.all[i].attrsKnown = f.attrs, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.filePending%10 == 0 { // keep redraws cheap for big libraries
|
||||
v.render()
|
||||
}
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||||
)
|
||||
@@ -49,7 +50,7 @@ func TestFilterItems(t *testing.T) {
|
||||
}
|
||||
ids := func(f int, text string) string {
|
||||
var s string
|
||||
for _, it := range filterItems(items, f, text) {
|
||||
for _, it := range filterItems(items, f, text, attrFilter{}) {
|
||||
s += itoa(it.id)
|
||||
}
|
||||
return s
|
||||
@@ -90,3 +91,66 @@ func TestEpisodeStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttrFilter(t *testing.T) {
|
||||
uhd := arr.FileAttrs{Resolution: 2160, Codec: arr.CodecX265, HDR: true, DV: true, Languages: []string{"English", "German"}}
|
||||
hd := arr.FileAttrs{Resolution: 1080, Codec: arr.CodecX264, Languages: []string{"French"}}
|
||||
sd := arr.FileAttrs{Resolution: 480}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
f attrFilter
|
||||
files []arr.FileAttrs
|
||||
want bool
|
||||
}{
|
||||
{"inactive matches any file", attrFilter{}, []arr.FileAttrs{hd}, true},
|
||||
{"no files never matches an active filter", attrFilter{res: "1080p"}, nil, false},
|
||||
{"resolution", attrFilter{res: "1080p"}, []arr.FileAttrs{hd}, true},
|
||||
{"resolution mismatch", attrFilter{res: "2160p"}, []arr.FileAttrs{hd}, false},
|
||||
{"SD", attrFilter{res: "SD"}, []arr.FileAttrs{sd}, true},
|
||||
{"SD excludes HD", attrFilter{res: "SD"}, []arr.FileAttrs{hd}, false},
|
||||
{"codec", attrFilter{codec: "x265"}, []arr.FileAttrs{uhd}, true},
|
||||
{"hdr includes DV+HDR", attrFilter{hdr: "HDR"}, []arr.FileAttrs{uhd}, true},
|
||||
{"dv", attrFilter{hdr: "DV"}, []arr.FileAttrs{hd}, false},
|
||||
{"sdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{hd}, true},
|
||||
{"sdr excludes hdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{uhd}, false},
|
||||
{"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},
|
||||
{"any file of a series", attrFilter{res: "1080p", codec: "x264"}, []arr.FileAttrs{uhd, hd}, true},
|
||||
} {
|
||||
if got := tc.f.match(tc.files); got != tc.want {
|
||||
t.Errorf("%s: match = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
if s := (attrFilter{res: "1080p", hdr: "DV"}).String(); s != "1080p DV" {
|
||||
t.Errorf("String = %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterItemsByAttrs(t *testing.T) {
|
||||
items := []libItem{
|
||||
{id: 1, title: "A", attrs: []arr.FileAttrs{{Resolution: 1080, Codec: arr.CodecX265}}, attrsKnown: true},
|
||||
{id: 2, title: "B", attrs: []arr.FileAttrs{{Resolution: 1080, Codec: arr.CodecX264}}, attrsKnown: true},
|
||||
{id: 3, title: "C"}, // series whose files were not read yet
|
||||
}
|
||||
got := filterItems(items, 0, "", attrFilter{codec: arr.CodecX265})
|
||||
if len(got) != 1 || got[0].id != 1 {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
if got := filterItems(items, 0, "", attrFilter{}); len(got) != 3 {
|
||||
t.Errorf("without filter: %d items", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovieItemAttrs(t *testing.T) {
|
||||
m := radarr.Movie{ID: 1, Title: "M", HasFile: true, MovieFile: &arr.MediaFile{SceneName: "M.2160p.DV.x265-G"}}
|
||||
it := newMovieItem(m)
|
||||
if !it.attrsKnown || len(it.attrs) != 1 || !it.attrs[0].DV || it.attrs[0].Codec != arr.CodecX265 {
|
||||
t.Errorf("attrs = %+v", it.attrs)
|
||||
}
|
||||
if it := newMovieItem(radarr.Movie{ID: 2, Title: "N"}); !it.attrsKnown || len(it.attrs) != 0 {
|
||||
t.Errorf("movie without file: %+v", it)
|
||||
}
|
||||
if it := newSeriesItem(sonarr.Series{ID: 3, Title: "S", Statistics: sonarr.Statistics{EpisodeFileCount: 2}}); it.attrsKnown {
|
||||
t.Error("series with files must fetch its attributes")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user