Remove titles from the library with a confirmation (M10)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 22:36:19 +02:00
co-authored by Claude Sonnet 5.5
parent ebbb3cd178
commit 7582504268
11 changed files with 174 additions and 5 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
terminals known to support it, quadrant elsewhere). If you see boxes or question marks in the terminals known to support it, quadrant elsewhere). If you see boxes or question marks in the
poster, set `quadrant`. poster, set `quadrant`.
- **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, audio format, language), 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, audio format, language), toggle
monitoring, trigger searches. Series open into seasons and episodes with per-season and monitoring, trigger searches, remove titles (the files stay on disk unless you tick "Delete files"). Series open into seasons and episodes with per-season and
per-episode monitoring, automatic search and release search. per-episode monitoring, automatic search and release search.
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
retry failed downloads, remove from the arr queue and blocklist. retry failed downloads, remove from the arr queue and blocklist.
@@ -69,7 +69,7 @@ Keep the file private (`chmod 600`); it holds your API keys.
| `/` | focus search box | | `/` | focus search box |
| `Enter`, `a` | add selected title | | `Enter`, `a` | add selected title |
| `i` | interactive release search | | `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 | | `Tab`, `f`, `F`, `/`, `t`, `s`, `d`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, remove, releases/episodes, reload |
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor | | `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
| `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties | | `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 | | `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
+3 -2
View File
@@ -69,7 +69,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 (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). - **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, `d` removes the item from the library (a dialog with "Delete files from disk" and "import exclusion" checkboxes, both off by default and Cancel focused first; `DELETE /movie|series/{id}?deleteFiles=&addImport(List)Exclusion=`), `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
@@ -117,6 +117,7 @@ Keybindings (the status bar shows the ones that apply to the current view):
| `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) | | `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) |
| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) | | `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) |
| `f` | Library: cycle the status filter; release list: hide rejected releases | | `f` | Library: cycle the status filter; release list: hide rejected releases |
| `d` | Library: remove the item from the library (asks first) |
| `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download | | `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download |
| `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) | | `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) | | `Enter` / `g` | Release list: grab the selected release (rejected ones ask for confirmation) |
@@ -202,7 +203,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API
- [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`. - [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`.
- [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search). - [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search).
- [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar. - [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar.
- [ ] **M10** — Download completion notifications (done), delete from library, calendar view. - [ ] **M10** — Download completion notifications (done), delete from library (done), calendar view.
- [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title. Multiple instances per service are explicitly out of scope. - [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title. Multiple instances per service are explicitly out of scope.
## 10. Open Questions ## 10. Open Questions
+11
View File
@@ -170,3 +170,14 @@ func (c *Client) SetMonitored(ctx context.Context, movieID int, monitored bool)
return nil return nil
}) })
} }
// DeleteMovie removes a movie from the library. With deleteFiles the movie's
// files are deleted from disk too; addExclusion keeps it from coming back
// through import lists.
func (c *Client) DeleteMovie(ctx context.Context, id int, deleteFiles, addExclusion bool) error {
q := url.Values{
"deleteFiles": {strconv.FormatBool(deleteFiles)},
"addImportExclusion": {strconv.FormatBool(addExclusion)},
}
return c.Do(ctx, http.MethodDelete, "movie/"+strconv.Itoa(id), q, nil, nil)
}
+15
View File
@@ -161,3 +161,18 @@ func TestReleases(t *testing.T) {
t.Fatalf("releases = %+v, %v", rels, err) t.Fatalf("releases = %+v, %v", rels, err)
} }
} }
func TestDeleteMovie(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/movie/12" {
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
q := r.URL.Query()
if q.Get("deleteFiles") != "true" || q.Get("addImportExclusion") != "false" {
t.Errorf("query = %v", q)
}
})
if err := c.DeleteMovie(context.Background(), 12, true, false); err != nil {
t.Fatal(err)
}
}
+11
View File
@@ -246,3 +246,14 @@ func (c *Client) EpisodeFiles(ctx context.Context, seriesID int) ([]arr.MediaFil
err := c.Do(ctx, http.MethodGet, "episodefile", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &files) err := c.Do(ctx, http.MethodGet, "episodefile", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &files)
return files, err return files, err
} }
// DeleteSeries removes a series from the library. With deleteFiles all its
// episode files are deleted from disk too; addExclusion keeps it from coming
// back through import lists.
func (c *Client) DeleteSeries(ctx context.Context, id int, deleteFiles, addExclusion bool) error {
q := url.Values{
"deleteFiles": {strconv.FormatBool(deleteFiles)},
"addImportListExclusion": {strconv.FormatBool(addExclusion)},
}
return c.Do(ctx, http.MethodDelete, "series/"+strconv.Itoa(id), q, nil, nil)
}
+15
View File
@@ -164,3 +164,18 @@ func TestEpisodeFiles(t *testing.T) {
t.Errorf("attrs = %+v", a) t.Errorf("attrs = %+v", a)
} }
} }
func TestDeleteSeries(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/series/3" {
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
q := r.URL.Query()
if q.Get("deleteFiles") != "false" || q.Get("addImportListExclusion") != "true" {
t.Errorf("query = %v", q)
}
})
if err := c.DeleteSeries(context.Background(), 3, false, true); err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -419,7 +419,7 @@ func searchHints() string {
} }
func libraryHints() string { func libraryHints() string {
return "Tab list · f status · F file props · / text · t monitor · s search" return "Tab list · f status · F props · / text · t monitor · s search · d remove"
} }
func logHints() string { return "↑↓ scroll · G follow · c clear" } func logHints() string { return "↑↓ scroll · G follow · c clear" }
+2
View File
@@ -31,6 +31,8 @@ const helpText = `[yellow]Global[-]
/ text filter for the focused list only; each list keeps its / text filter for the focused list only; each list keeps its
own text (Enter or Esc returns to the list) own text (Enter or Esc returns to the list)
t toggle monitored t toggle monitored
d remove from the library (files are kept unless you tick
"Delete files")
s automatic search (series: all missing, asks first) s automatic search (series: all missing, asks first)
i, Enter movie: release search; series: seasons and episodes i, Enter movie: release search; series: seasons and episodes
r reload r reload
+66
View File
@@ -0,0 +1,66 @@
package ui
import (
"context"
"github.com/rivo/tview"
)
// confirmDelete asks before removing a title from the library. Deleting the
// files is a separate, unticked choice: by default only the library entry goes.
func (a *App) confirmDelete(label, summary string, onDelete func(deleteFiles, exclude bool)) {
box, _ := a.newDeleteDialog(label, summary, onDelete)
a.openModal(box, 66, 14)
}
func (a *App) newDeleteDialog(label, summary string, onDelete func(deleteFiles, exclude bool)) (*tview.Flex, *tview.Form) {
const (
lFiles = "Delete files from disk"
lExclude = "Don't let import lists re-add it"
)
text := tview.NewTextView().SetDynamicColors(true).SetWordWrap(true).
SetText("[::b]" + label + "[::-]\n" + summary)
form := tview.NewForm()
form.AddCheckbox(lFiles, false, nil)
form.AddCheckbox(lExclude, false, nil)
files := form.GetFormItemByLabel(lFiles).(*tview.Checkbox)
exclude := form.GetFormItemByLabel(lExclude).(*tview.Checkbox)
// Cancel first so a stray Enter is safe.
form.AddButton("Cancel", a.closeModal)
form.AddButton("Delete", func() {
a.closeModal()
onDelete(files.IsChecked(), exclude.IsChecked())
})
form.SetFocus(form.GetFormItemCount()) // the Cancel button
arrowNavigation(form, a.closeModal, nil)
box := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(text, 4, 0, false).
AddItem(form, 0, 1, true)
box.SetBorder(true).SetTitle(" Remove from library ")
return box, form
}
// remove asks to delete the selected library item.
func (v *libraryView) remove() {
it, ok := v.selected()
if !ok {
return
}
summary := "[gray]Untick “Delete files” to keep the files on disk.[-]"
if it.have {
summary = "[yellow]" + it.size + " on disk.[-] Files are kept unless you tick “Delete files”."
}
v.app.confirmDelete(it.label, summary, func(deleteFiles, exclude bool) {
what := "Removed " + it.label
if deleteFiles {
what = "Removed " + it.label + " and its files"
}
v.run("Removing "+it.label, what, func(ctx context.Context) error {
if it.movie != nil {
return v.radarr.DeleteMovie(ctx, it.id, deleteFiles, exclude)
}
return v.sonarr.DeleteSeries(ctx, it.id, deleteFiles, exclude)
}, v.reload)
})
}
+46
View File
@@ -0,0 +1,46 @@
package ui
import (
"testing"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func TestDeleteDialogDefaultsAreSafe(t *testing.T) {
a := newTestApp(t)
var calls [][2]bool
_, form := a.newDeleteDialog("Heat (1995)", "3 GB on disk", func(files, exclude bool) { calls = append(calls, [2]bool{files, exclude}) })
var focus func(p tview.Primitive)
focus = func(p tview.Primitive) { p.Focus(focus) }
form.Focus(focus)
press := func(k tcell.Key) { form.InputHandler()(tcell.NewEventKey(k, 0, 0), focus) }
space := func() { form.InputHandler()(tcell.NewEventKey(tcell.KeyRune, ' ', 0), focus) }
// Both boxes start unticked and the focus starts on Cancel: a stray Enter does nothing.
press(tcell.KeyEnter)
if len(calls) != 0 {
t.Fatalf("Enter on the initial focus deleted: %v", calls)
}
// Moving to Delete and confirming removes the entry only.
form.SetFocus(form.GetFormItemCount())
press(tcell.KeyRight)
press(tcell.KeyEnter)
if len(calls) != 1 || calls[0] != [2]bool{false, false} {
t.Fatalf("calls = %v, want one call without files and exclusion", calls)
}
// Ticking the boxes is passed on.
form.SetFocus(0)
space() // delete files
press(tcell.KeyDown)
space() // exclusion
press(tcell.KeyDown)
press(tcell.KeyDown) // Delete button
press(tcell.KeyEnter)
if len(calls) != 2 || calls[1] != [2]bool{true, true} {
t.Errorf("calls = %v, want the second with both ticked", calls)
}
}
+2
View File
@@ -293,6 +293,8 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
v.search() v.search()
case 't': case 't':
v.toggleMonitored() v.toggleMonitored()
case 'd':
v.remove()
default: default:
return ev return ev
} }