diff --git a/README.md b/README.md index 2a9318c..3ec5b01 100644 --- a/README.md +++ b/README.md @@ -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 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 - 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. - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, 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 | | `Enter`, `a` | add selected title | | `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`/`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 | diff --git a/SPECS.md b/SPECS.md index 7e9898b..0554b9c 100644 --- a/SPECS.md +++ b/SPECS.md @@ -69,7 +69,7 @@ Notes: ### 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. ### 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) | | `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 | +| `d` | Library: remove the item from the library (asks first) | | `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download | | `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) | @@ -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] **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. -- [ ] **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. ## 10. Open Questions diff --git a/internal/radarr/radarr.go b/internal/radarr/radarr.go index 154ac95..ad1e6ff 100644 --- a/internal/radarr/radarr.go +++ b/internal/radarr/radarr.go @@ -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) +} diff --git a/internal/radarr/radarr_test.go b/internal/radarr/radarr_test.go index 3876b01..3517f16 100644 --- a/internal/radarr/radarr_test.go +++ b/internal/radarr/radarr_test.go @@ -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) + } +} diff --git a/internal/sonarr/sonarr.go b/internal/sonarr/sonarr.go index 016e75f..2f4bf24 100644 --- a/internal/sonarr/sonarr.go +++ b/internal/sonarr/sonarr.go @@ -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) +} diff --git a/internal/sonarr/sonarr_test.go b/internal/sonarr/sonarr_test.go index 634235b..339f29a 100644 --- a/internal/sonarr/sonarr_test.go +++ b/internal/sonarr/sonarr_test.go @@ -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) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index ec19ff9..ba48729 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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" } diff --git a/internal/ui/help.go b/internal/ui/help.go index bf9eb05..db3f469 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -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 diff --git a/internal/ui/libdelete.go b/internal/ui/libdelete.go new file mode 100644 index 0000000..3f931f1 --- /dev/null +++ b/internal/ui/libdelete.go @@ -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) + }) +} diff --git a/internal/ui/libdelete_test.go b/internal/ui/libdelete_test.go new file mode 100644 index 0000000..0d8764c --- /dev/null +++ b/internal/ui/libdelete_test.go @@ -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) + } +} diff --git a/internal/ui/library.go b/internal/ui/library.go index 9df6b0a..1fb9a43 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -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 }