Library: status and property filters follow the focused list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:04:30 +02:00
co-authored by Claude Sonnet 5.5
parent b0a2c3b4fd
commit 77c25b2c92
3 changed files with 50 additions and 46 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ Notes:
### 4.2 v0.2 (done, M8) ### 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), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter; each dropdown only lists options that, together with the other chosen criteria, still match at least one item (series files are read when the dialog opens so this is accurate)) apply to both lists; the text filter (`/`) only acts on the focused list, and each list keeps its own text. 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). - **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (status: all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter; each dropdown only lists options that, together with the other chosen criteria, still match at least one item (series files are read when the dialog opens so this is accurate)) act only on the focused list, and each list keeps its own status filter, property filter and text; the lists show their active filters in their titles. 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. - **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search.
### 4.3 Later / ideas ### 4.3 Later / ideas
+1 -1
View File
@@ -21,7 +21,7 @@ const helpText = `[yellow]Global[-]
[yellow]Library[-] [yellow]Library[-]
Tab switch between the movies and series lists Tab switch between the movies and series lists
f cycle filter (all, missing, downloaded, monitored, unmonitored); f cycle filter (all, missing, downloaded, monitored, unmonitored);
the status and property filters apply to both lists every list has its own filters
F filter by file properties: quality, codec (x265, x264, AV1), F filter by file properties: quality, codec (x265, x264, AV1),
HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language.
Changes apply live behind the dialog, also while you move through an Changes apply live behind the dialog, also while you move through an
+48 -44
View File
@@ -184,6 +184,9 @@ type libPane struct {
err error err error
gen int // invalidates stale loads gen int // invalidates stale loads
text string // text filter, set with / while this pane is the current one text string // text filter, set with / while this pane is the current one
// Each list has its own filters.
filter int // status preset, index into libFilters
attr attrFilter // file properties
} }
// libraryView shows the movies of Radarr above the series of Sonarr, with the // libraryView shows the movies of Radarr above the series of Sonarr, with the
@@ -200,9 +203,7 @@ type libraryView struct {
panes [2]*libPane panes [2]*libPane
// UI-goroutine state. // UI-goroutine state.
cur libKind // the pane that has (or last had) focus 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 // File properties of series, fetched on demand and cached. An entry is
// reused while the series' file count and size stay the same. // reused while the series' file count and size stay the same.
@@ -276,7 +277,8 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
case '/': case '/':
v.app.app.SetFocus(v.input) v.app.app.SetFocus(v.input)
case 'f': case 'f':
v.filter = (v.filter + 1) % len(libFilters) p := v.pane()
p.filter = (p.filter + 1) % len(libFilters)
v.render() v.render()
case 'F': case 'F':
v.openFilterDialog() v.openFilterDialog()
@@ -368,17 +370,7 @@ func (v *libraryView) render() {
for _, p := range v.panes { for _, p := range v.panes {
v.renderPane(p) v.renderPane(p)
} }
filters := "[yellow]" + libFilters[v.filter].name + "[-]" v.header.SetText("[::b]Library[::-] · Tab switches list · each list has its own filters")
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),
filters))
v.renderDetails() v.renderDetails()
} }
@@ -387,7 +379,7 @@ func (v *libraryView) renderPane(p *libPane) {
if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) { if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) {
selID = p.shown[r-1].id selID = p.shown[r-1].id
} }
p.shown = filterItems(p.all, v.filter, p.text, v.attr) p.shown = filterItems(p.all, p.filter, p.text, p.attr)
p.table.Clear() p.table.Clear()
header(p.table, 0, "Title", "Status", "Size") header(p.table, 0, "Title", "Status", "Size")
@@ -413,11 +405,20 @@ func (v *libraryView) renderPane(p *libPane) {
if selRow == 0 { if selRow == 0 {
p.table.ScrollToBeginning() p.table.ScrollToBeginning()
} }
title := fmt.Sprintf(" %s ", p.kind) title := fmt.Sprintf(" %s %d/%d", p.kind, len(p.shown), len(p.all))
if p.err != nil && p.loaded { if p.filter > 0 {
title = fmt.Sprintf(" %s [red](outdated)[-] ", p.kind) title += " · [yellow]" + libFilters[p.filter].name + "[-]"
} }
p.table.SetTitle(title) if p.attr.active() {
title += " · [yellow]" + p.attr.String() + "[-]"
if p.kind == kindSeries && v.filePending > 0 {
title += fmt.Sprintf(" [gray](reading %d…)[-]", v.filePending)
}
}
if p.err != nil && p.loaded {
title += " [red](outdated)[-]"
}
p.table.SetTitle(title + " ")
} }
func (v *libraryView) renderDetails() { func (v *libraryView) renderDetails() {
@@ -499,32 +500,35 @@ func (v *libraryView) run(doing, done string, fn func(ctx context.Context) error
}() }()
} }
func (v *libraryView) setAttrFilter(f attrFilter) { // openFilterDialog opens the property filter for the focused list. For series
v.attr = f // the files of all series are read so the dialog can leave out options that
v.render() // would match nothing.
v.loadSeriesFiles(false)
}
// openFilterDialog opens the property filter. The files of all series are read
// so the dialog can leave out options that would match nothing.
func (v *libraryView) openFilterDialog() { func (v *libraryView) openFilterDialog() {
v.refreshDialog = v.app.openAttrFilter(v.attr, v.filterPool, v.setAttrFilter, func() { v.refreshDialog = nil }) p := v.pane()
v.loadSeriesFiles(true) set := func(f attrFilter) {
} p.attr = f
v.render()
// filterPool lists the files of the items that pass the status and text if p.kind == kindSeries {
// filters, for the filter dialog. v.loadSeriesFiles(false)
func (v *libraryView) filterPool() filterPool {
var p filterPool
for _, pane := range v.panes {
for _, it := range filterItems(pane.all, v.filter, pane.text, attrFilter{}) {
if !it.attrsKnown {
p.unknown = true
}
p.files = append(p.files, it.attrs)
} }
} }
return p v.refreshDialog = v.app.openAttrFilter(p.attr, func() filterPool { return v.filterPool(p) }, set, func() { v.refreshDialog = nil })
if p.kind == kindSeries {
v.loadSeriesFiles(true)
}
}
// filterPool lists the files of the items of one list that pass its status
// and text filters, for the filter dialog.
func (v *libraryView) filterPool(p *libPane) filterPool {
var pool filterPool
for _, it := range filterItems(p.all, p.filter, p.text, attrFilter{}) {
if !it.attrsKnown {
pool.unknown = true
}
pool.files = append(pool.files, it.attrs)
}
return pool
} }
// withCachedFiles fills in the file properties of a series from the cache. // withCachedFiles fills in the file properties of a series from the cache.
@@ -543,7 +547,7 @@ func (v *libraryView) withCachedFiles(it libItem) libItem {
// but only while a property filter is active or the filter dialog is open // but only while a property filter is active or the filter dialog is open
// (it costs one request per series). // (it costs one request per series).
func (v *libraryView) loadSeriesFiles(force bool) { func (v *libraryView) loadSeriesFiles(force bool) {
if !force && !v.attr.active() { if !force && !v.panes[kindSeries].attr.active() {
return return
} }
p := v.panes[kindSeries] p := v.panes[kindSeries]