diff --git a/README.md b/README.md index 3e480a0..566f9df 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/SPECS.md b/SPECS.md index 4c62027..8c53b83 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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) | diff --git a/internal/arr/mediafile.go b/internal/arr/mediafile.go new file mode 100644 index 0000000..534abe8 --- /dev/null +++ b/internal/arr/mediafile.go @@ -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 +} diff --git a/internal/arr/mediafile_test.go b/internal/arr/mediafile_test.go new file mode 100644 index 0000000..7ec9d9d --- /dev/null +++ b/internal/arr/mediafile_test.go @@ -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)) + } + } +} diff --git a/internal/radarr/radarr.go b/internal/radarr/radarr.go index ea26a76..154ac95 100644 --- a/internal/radarr/radarr.go +++ b/internal/radarr/radarr.go @@ -39,37 +39,27 @@ type Rating struct { // Movie is a lookup result or library movie. ID is 0 when the movie is not // in the library yet. type Movie struct { - ID int `json:"id"` - Title string `json:"title"` - OriginalTitle string `json:"originalTitle,omitempty"` - Year int `json:"year"` - Overview string `json:"overview,omitempty"` - Runtime int `json:"runtime"` - Genres []string `json:"genres,omitempty"` - Studio string `json:"studio,omitempty"` - Status string `json:"status,omitempty"` - TMDBID int `json:"tmdbId"` - IMDBID string `json:"imdbId,omitempty"` - TitleSlug string `json:"titleSlug,omitempty"` - Images []arr.Image `json:"images,omitempty"` - Ratings Ratings `json:"ratings"` - Monitored bool `json:"monitored"` - HasFile bool `json:"hasFile"` - QualityProfileID int `json:"qualityProfileId"` - 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"` + ID int `json:"id"` + Title string `json:"title"` + OriginalTitle string `json:"originalTitle,omitempty"` + Year int `json:"year"` + Overview string `json:"overview,omitempty"` + Runtime int `json:"runtime"` + Genres []string `json:"genres,omitempty"` + Studio string `json:"studio,omitempty"` + Status string `json:"status,omitempty"` + TMDBID int `json:"tmdbId"` + IMDBID string `json:"imdbId,omitempty"` + TitleSlug string `json:"titleSlug,omitempty"` + Images []arr.Image `json:"images,omitempty"` + Ratings Ratings `json:"ratings"` + Monitored bool `json:"monitored"` + HasFile bool `json:"hasFile"` + QualityProfileID int `json:"qualityProfileId"` + SizeOnDisk int64 `json:"sizeOnDisk"` + IsAvailable bool `json:"isAvailable"` + Path string `json:"path,omitempty"` + MovieFile *arr.MediaFile `json:"movieFile,omitempty"` } // QualityName returns the quality of the downloaded file, or "". diff --git a/internal/sonarr/sonarr.go b/internal/sonarr/sonarr.go index afc38d8..016e75f 100644 --- a/internal/sonarr/sonarr.go +++ b/internal/sonarr/sonarr.go @@ -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 +} diff --git a/internal/sonarr/sonarr_test.go b/internal/sonarr/sonarr_test.go index 50c4a08..634235b 100644 --- a/internal/sonarr/sonarr_test.go +++ b/internal/sonarr/sonarr_test.go @@ -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) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 2b63397..847cd16 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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" } diff --git a/internal/ui/help.go b/internal/ui/help.go index 3b58507..9af2b0e 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -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) diff --git a/internal/ui/libfilter.go b/internal/ui/libfilter.go new file mode 100644 index 0000000..fafbaf1 --- /dev/null +++ b/internal/ui/libfilter.go @@ -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) +} diff --git a/internal/ui/library.go b/internal/ui/library.go index 7e0420e..7d5d996 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -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{ - id: m.ID, title: m.Title, monitored: m.Monitored, have: m.HasFile, + 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() + } + }) + } + }() +} diff --git a/internal/ui/library_test.go b/internal/ui/library_test.go index 2793dad..1ca1c4d 100644 --- a/internal/ui/library_test.go +++ b/internal/ui/library_test.go @@ -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") + } +}