Remove titles from the library with a confirmation (M10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user