Remove titles from the library with a confirmation (M10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -170,3 +170,14 @@ func (c *Client) SetMonitored(ctx context.Context, movieID int, monitored bool)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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" }
|
||||
|
||||
@@ -31,6 +31,8 @@ const helpText = `[yellow]Global[-]
|
||||
/ text filter for the focused list only; each list keeps its
|
||||
own text (Enter or Esc returns to the list)
|
||||
t toggle monitored
|
||||
d remove from the library (files are kept unless you tick
|
||||
"Delete files")
|
||||
s automatic search (series: all missing, asks first)
|
||||
i, Enter movie: release search; series: seasons and episodes
|
||||
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()
|
||||
case 't':
|
||||
v.toggleMonitored()
|
||||
case 'd':
|
||||
v.remove()
|
||||
default:
|
||||
return ev
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user