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:
2026-10-20 20:22:06 +02:00
co-authored by Claude Sonnet 5.5
parent a7d74e2097
commit b34f88ef3e
12 changed files with 564 additions and 45 deletions
+145 -8
View File
@@ -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()
}
})
}
}()
}