diff --git a/README.md b/README.md index 575265f..35170a6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video optionally start the automatic search. - **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), sort and filter them, and grab one. Series: pick a season or a single episode first. +- **Library**: browse your movies and series, filter by missing / downloaded / monitored, toggle + monitoring, trigger searches. 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. - Connection indicators for all three services in the top right; `?` shows every key. @@ -52,6 +55,8 @@ Keep the file private (`chmod 600`); it holds your API keys. | `/` | focus search box | | `Enter`, `a` | add selected title | | `i` | interactive release search | +| `m`, `f`, `/`, `t`, `s`, `i`/`Enter`, `r` (library) | movies/series, filter, text filter, monitor, search, releases/episodes, reload | +| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor | | `Enter`/`g`, `s`, `f` (release list) | grab, cycle sort, hide rejected | | `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh | | `Esc` | back / close dialog | diff --git a/SPECS.md b/SPECS.md index 337912c..db8dfca 100644 --- a/SPECS.md +++ b/SPECS.md @@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and - **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell) - **License:** MIT - **Module:** `git.stinnesbeck.com/nils/arr-tui` -- **Status:** M7 done +- **Status:** M8 done ## 1. Goals @@ -194,7 +194,8 @@ The file should be created with mode `0600`; warn if it is more permissive. API - [x] **M5** — Radarr/Sonarr queues merged into the Downloads view. - [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab. - [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`. -- [ ] **M8+** — v0.2 features (library view, series drill-down). +- [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search). +- [ ] **M9+** — ideas: calendar, notifications, delete from library, multiple instances. ## 10. Open Questions diff --git a/internal/arr/update.go b/internal/arr/update.go new file mode 100644 index 0000000..14feee2 --- /dev/null +++ b/internal/arr/update.go @@ -0,0 +1,55 @@ +package arr + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" +) + +// Update reads /, lets mutate change the raw JSON object and PUTs it +// back. Working on raw fields keeps everything the client does not model +// (Radarr and Sonarr replace the whole resource on PUT). +func (c *Client) Update(ctx context.Context, path string, id int, mutate func(obj map[string]json.RawMessage) error) error { + p := path + "/" + strconv.Itoa(id) + var obj map[string]json.RawMessage + if err := c.Do(ctx, http.MethodGet, p, nil, nil, &obj); err != nil { + return err + } + if err := mutate(obj); err != nil { + return err + } + return c.Do(ctx, http.MethodPut, p, nil, obj, nil) +} + +// SetBool sets a boolean field of a raw object. +func SetBool(obj map[string]json.RawMessage, key string, v bool) { + obj[key], _ = json.Marshal(v) +} + +// Command posts a command ("MoviesSearch", "SeasonSearch", ...) to /command. +func (c *Client) Command(ctx context.Context, body map[string]any) error { + return c.Do(ctx, http.MethodPost, "command", nil, body, nil) +} + +// SetSeasonMonitored flips one season in a raw series object. +func SetSeasonMonitored(obj map[string]json.RawMessage, season int, monitored bool) error { + var seasons []map[string]json.RawMessage + if err := json.Unmarshal(obj["seasons"], &seasons); err != nil { + return fmt.Errorf("decoding seasons: %w", err) + } + found := false + for _, s := range seasons { + var n int + if json.Unmarshal(s["seasonNumber"], &n) == nil && n == season { + SetBool(s, "monitored", monitored) + found = true + } + } + if !found { + return fmt.Errorf("season %d not found", season) + } + obj["seasons"], _ = json.Marshal(seasons) + return nil +} diff --git a/internal/arr/update_test.go b/internal/arr/update_test.go new file mode 100644 index 0000000..dddd7f2 --- /dev/null +++ b/internal/arr/update_test.go @@ -0,0 +1,48 @@ +package arr + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestUpdateKeepsUnknownFields(t *testing.T) { + var put map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/movie/7" { + t.Errorf("path %s", r.URL.Path) + } + switch r.Method { + case http.MethodGet: + io.WriteString(w, `{"id":7,"monitored":true,"customThing":{"a":[1,2]},"seasons":[{"seasonNumber":1,"monitored":true,"x":1},{"seasonNumber":2,"monitored":true}]}`) + case http.MethodPut: + json.NewDecoder(r.Body).Decode(&put) + } + })) + defer srv.Close() + + err := NewClient(srv.URL, "k").Update(context.Background(), "movie", 7, func(o map[string]json.RawMessage) error { + SetBool(o, "monitored", false) + return SetSeasonMonitored(o, 2, false) + }) + if err != nil { + t.Fatal(err) + } + if put["monitored"] != false || put["customThing"] == nil { + t.Errorf("put = %v", put) + } + seasons := put["seasons"].([]any) + if seasons[0].(map[string]any)["monitored"] != true || seasons[1].(map[string]any)["monitored"] != false || seasons[0].(map[string]any)["x"] == nil { + t.Errorf("seasons = %v", seasons) + } +} + +func TestSetSeasonMonitoredUnknownSeason(t *testing.T) { + obj := map[string]json.RawMessage{"seasons": json.RawMessage(`[{"seasonNumber":1,"monitored":true}]`)} + if err := SetSeasonMonitored(obj, 5, true); err == nil { + t.Error("want error") + } +} diff --git a/internal/radarr/radarr.go b/internal/radarr/radarr.go index 0c5ecff..ea26a76 100644 --- a/internal/radarr/radarr.go +++ b/internal/radarr/radarr.go @@ -3,6 +3,7 @@ package radarr import ( "context" + "encoding/json" "net/http" "net/url" "strconv" @@ -55,6 +56,28 @@ type Movie struct { Monitored bool `json:"monitored"` HasFile bool `json:"hasFile"` QualityProfileID int `json:"qualityProfileId"` + SizeOnDisk int64 `json:"sizeOnDisk"` + IsAvailable bool `json:"isAvailable"` + Path string `json:"path,omitempty"` + MovieFile *MovieFile `json:"movieFile,omitempty"` +} + +// MovieFile is the downloaded file of a library movie. +type MovieFile struct { + Size int64 `json:"size"` + Quality struct { + Quality struct { + Name string `json:"name"` + } `json:"quality"` + } `json:"quality"` +} + +// QualityName returns the quality of the downloaded file, or "". +func (m Movie) QualityName() string { + if m.MovieFile == nil { + return "" + } + return m.MovieFile.Quality.Quality.Name } // InLibrary reports whether the movie has already been added to Radarr. @@ -134,8 +157,7 @@ func (c *Client) AddMovie(ctx context.Context, m Movie, opts AddOptions) (Movie, // SearchMovie triggers an automatic search for an existing library movie. func (c *Client) SearchMovie(ctx context.Context, movieID int) error { - body := map[string]any{"name": "MoviesSearch", "movieIds": []int{movieID}} - return c.Do(ctx, http.MethodPost, "command", nil, body, nil) + return c.Command(ctx, map[string]any{"name": "MoviesSearch", "movieIds": []int{movieID}}) } // TMDBTerm builds a lookup term for a TMDb id. @@ -150,3 +172,11 @@ func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) { func (c *Client) Releases(ctx context.Context, movieID int) ([]arr.Release, error) { return c.Client.Releases(ctx, url.Values{"movieId": {strconv.Itoa(movieID)}}) } + +// SetMonitored changes the monitored flag of a library movie. +func (c *Client) SetMonitored(ctx context.Context, movieID int, monitored bool) error { + return c.Update(ctx, "movie", movieID, func(o map[string]json.RawMessage) error { + arr.SetBool(o, "monitored", monitored) + return nil + }) +} diff --git a/internal/sonarr/sonarr.go b/internal/sonarr/sonarr.go index 513e141..afc38d8 100644 --- a/internal/sonarr/sonarr.go +++ b/internal/sonarr/sonarr.go @@ -3,6 +3,7 @@ package sonarr import ( "context" + "encoding/json" "net/http" "net/url" "strconv" @@ -41,16 +42,18 @@ type Rating struct { // Season is a season entry of a series. type Season struct { - SeasonNumber int `json:"seasonNumber"` - Monitored bool `json:"monitored"` + SeasonNumber int `json:"seasonNumber"` + Monitored bool `json:"monitored"` + Statistics Statistics `json:"statistics"` } // Statistics are the episode counts Sonarr reports for library series. type Statistics struct { - SeasonCount int `json:"seasonCount"` - EpisodeFileCount int `json:"episodeFileCount"` - EpisodeCount int `json:"episodeCount"` - TotalEpisodeCount int `json:"totalEpisodeCount"` + SeasonCount int `json:"seasonCount"` + EpisodeFileCount int `json:"episodeFileCount"` + EpisodeCount int `json:"episodeCount"` + TotalEpisodeCount int `json:"totalEpisodeCount"` + SizeOnDisk int64 `json:"sizeOnDisk"` } // Series is a lookup result or library series. ID is 0 when the series is @@ -159,8 +162,45 @@ func (c *Client) AddSeries(ctx context.Context, s Series, opts AddOptions) (Seri // SearchSeries triggers an automatic search for all missing episodes of a library series. func (c *Client) SearchSeries(ctx context.Context, seriesID int) error { - body := map[string]any{"name": "SeriesSearch", "seriesId": seriesID} - return c.Do(ctx, http.MethodPost, "command", nil, body, nil) + return c.Command(ctx, map[string]any{"name": "SeriesSearch", "seriesId": seriesID}) +} + +// SearchSeason triggers an automatic search for the missing episodes of one season. +func (c *Client) SearchSeason(ctx context.Context, seriesID, season int) error { + return c.Command(ctx, map[string]any{"name": "SeasonSearch", "seriesId": seriesID, "seasonNumber": season}) +} + +// SearchEpisode triggers an automatic search for one episode. +func (c *Client) SearchEpisode(ctx context.Context, episodeID int) error { + return c.Command(ctx, map[string]any{"name": "EpisodeSearch", "episodeIds": []int{episodeID}}) +} + +// GetSeries fetches one library series. +func (c *Client) GetSeries(ctx context.Context, id int) (Series, error) { + var s Series + err := c.Do(ctx, http.MethodGet, "series/"+strconv.Itoa(id), nil, nil, &s) + return s, err +} + +// SetMonitored changes the monitored flag of a library series. +func (c *Client) SetMonitored(ctx context.Context, seriesID int, monitored bool) error { + return c.Update(ctx, "series", seriesID, func(o map[string]json.RawMessage) error { + arr.SetBool(o, "monitored", monitored) + return nil + }) +} + +// SetSeasonMonitored changes the monitored flag of one season of a library series. +func (c *Client) SetSeasonMonitored(ctx context.Context, seriesID, season int, monitored bool) error { + return c.Update(ctx, "series", seriesID, func(o map[string]json.RawMessage) error { + return arr.SetSeasonMonitored(o, season, monitored) + }) +} + +// SetEpisodesMonitored changes the monitored flag of episodes. +func (c *Client) SetEpisodesMonitored(ctx context.Context, episodeIDs []int, monitored bool) error { + body := map[string]any{"episodeIds": episodeIDs, "monitored": monitored} + return c.Do(ctx, http.MethodPut, "episode/monitor", nil, body, nil) } // Queue lists Sonarr's download queue, including series and episode info. diff --git a/internal/ui/app.go b/internal/ui/app.go index b40f70f..8c4e6f8 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -69,6 +69,7 @@ func New(cfg *config.Config) *App { radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey) sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey) + library := newLibraryView(a, radarrClient, sonarrClient) downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey), []arrSource{ {name: "Radarr", queue: radarrClient.Queue, remove: radarrClient.RemoveQueueItem}, @@ -79,7 +80,7 @@ func New(cfg *config.Config) *App { {key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root}, {key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root}, {key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate}, - {key: '4', name: "Library", body: placeholder("Library (v0.2)")}, + {key: '4', name: "Library", body: library.root, onShow: library.activate}, } for i, v := range a.views { a.pages.AddPage(v.name, v.body, true, i == 0) @@ -306,7 +307,3 @@ func (a *App) renderTabs() { } a.tabs.SetText(s) } - -func placeholder(text string) tview.Primitive { - return tview.NewTextView().SetText(text).SetTextAlign(tview.AlignCenter) -} diff --git a/internal/ui/episodes.go b/internal/ui/episodes.go new file mode 100644 index 0000000..21da8e7 --- /dev/null +++ b/internal/ui/episodes.go @@ -0,0 +1,355 @@ +package ui + +import ( + "cmp" + "context" + "fmt" + "slices" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "git.stinnesbeck.com/nils/arr-tui/internal/arr" + "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" +) + +// epRow is one table row of the drill-down: a season (ep == nil) or an episode. +type epRow struct { + season int + ep *sonarr.Episode +} + +func (r epRow) key() [2]int { + if r.ep == nil { + return [2]int{r.season, -1} + } + return [2]int{r.season, r.ep.ID} +} + +// seriesView shows the seasons and episodes of one library series. +type seriesView struct { + app *App + c *sonarr.Client + series sonarr.Series + onChange func() // called after changes so the library list can refresh + + root *tview.Flex + header *tview.TextView + table *tview.Table + detail *tview.TextView + + episodes []sonarr.Episode + expanded map[int]bool + rows []epRow + loaded bool +} + +// openSeries opens the season/episode drill-down above the current view. +func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) { + v := &seriesView{ + app: a, c: c, series: s, onChange: onChange, + header: tview.NewTextView().SetDynamicColors(true), + table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0), + detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), + expanded: map[int]bool{}, + } + foot := tview.NewTextView().SetDynamicColors(true). + SetText("[gray]Enter expand / releases · i releases · s search · t monitor · r reload · Esc back") + v.table.SetBorder(true) + v.root = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(v.header, 1, 0, false). + AddItem(v.table, 0, 1, true). + AddItem(v.detail, 2, 0, false). + AddItem(foot, 1, 0, false) + + v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() }) + v.table.SetSelectedFunc(func(int, int) { v.enter() }) + v.table.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + if ev.Key() == tcell.KeyEscape { + a.closeModal() + return nil + } + if ev.Key() != tcell.KeyRune { + return ev + } + switch ev.Rune() { + case 'i': + v.releases() + case 's': + v.search() + case 't': + v.toggleMonitored() + case 'r': + v.reload() + default: + return ev + } + return nil + }) + + v.render() + a.openFull(v.root) + v.reload() +} + +// reload fetches the series and its episodes. +func (v *seriesView) reload() { + id := v.series.ID + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + s, err := v.c.GetSeries(ctx, id) + var eps []sonarr.Episode + if err == nil { + eps, err = v.c.Episodes(ctx, id) + } + v.app.post(func() { + v.app.SetService("Sonarr", "", err) + if err != nil { + v.app.SetMessage("[red]" + tview.Escape(err.Error())) + return + } + if !v.loaded { + // Open the newest regular season first. + if seasons := summarizeSeasons(eps); len(seasons) > 0 { + v.expanded[seasons[0].number] = true + } + } + v.series, v.episodes, v.loaded = s, eps, true + v.render() + }) + }() +} + +func (v *seriesView) seasonMonitored(n int) bool { + for _, s := range v.series.Seasons { + if s.SeasonNumber == n { + return s.Monitored + } + } + return false +} + +func (v *seriesView) buildRows() { + v.rows = v.rows[:0] + for _, se := range summarizeSeasons(v.episodes) { + v.rows = append(v.rows, epRow{season: se.number}) + if !v.expanded[se.number] { + continue + } + var eps []*sonarr.Episode + for i := range v.episodes { + if v.episodes[i].SeasonNumber == se.number { + eps = append(eps, &v.episodes[i]) + } + } + slices.SortFunc(eps, func(a, b *sonarr.Episode) int { return cmp.Compare(a.EpisodeNumber, b.EpisodeNumber) }) + for _, e := range eps { + v.rows = append(v.rows, epRow{season: se.number, ep: e}) + } + } +} + +func (v *seriesView) selected() (epRow, bool) { + r, _ := v.table.GetSelection() + if r < 1 || r > len(v.rows) { + return epRow{}, false + } + return v.rows[r-1], true +} + +func (v *seriesView) render() { + var selKey [2]int + hasSel := false + if r, ok := v.selected(); ok { + selKey, hasSel = r.key(), true + } + v.buildRows() + + v.table.Clear() + header(v.table, 2, "", "", "Title", "Aired", "Status") + today := time.Now().Format("2006-01-02") + selRow := 0 + summary := map[int]seasonSummary{} + for _, se := range summarizeSeasons(v.episodes) { + summary[se.number] = se + } + for i, r := range v.rows { + row := i + 1 + if r.ep == nil { + se := summary[r.season] + arrow := "▶" + if v.expanded[r.season] { + arrow = "▼" + } + status := fmt.Sprintf("%d/%d", se.have, se.total) + if se.have >= se.total { + status = "[green]" + status + } else if v.seasonMonitored(r.season) { + status = "[yellow]" + status + } else { + status = "[gray]" + status + } + v.table.SetCell(row, 0, tview.NewTableCell(monMark(v.seasonMonitored(r.season)))) + v.table.SetCell(row, 1, tview.NewTableCell(arrow)) + v.table.SetCell(row, 2, tview.NewTableCell("[::b]"+seasonLabel(r.season)).SetExpansion(1)) + v.table.SetCell(row, 3, tview.NewTableCell("")) + v.table.SetCell(row, 4, tview.NewTableCell(status)) + } else { + e := r.ep + v.table.SetCell(row, 0, tview.NewTableCell(monMark(e.Monitored))) + v.table.SetCell(row, 1, tview.NewTableCell(fmt.Sprintf("E%02d", e.EpisodeNumber))) + v.table.SetCell(row, 2, tview.NewTableCell(tview.Escape(e.Title)).SetExpansion(1)) + v.table.SetCell(row, 3, tview.NewTableCell(e.AirDate)) + v.table.SetCell(row, 4, tview.NewTableCell(episodeStatus(*e, today))) + } + if hasSel && r.key() == selKey { + selRow = row + } + } + placeholder := "Loading…" + if v.loaded { + placeholder = "Sonarr has no episodes for this series" + } + restoreSelection(v.table, len(v.rows), selRow, placeholder) + if selRow == 0 { + v.table.ScrollToBeginning() + } + + st := v.series.Statistics + v.header.SetText(fmt.Sprintf("[::b]%s[::-] · %s · %d/%d episodes", + tview.Escape(v.series.Title), monitoredWord(v.series.Monitored), st.EpisodeFileCount, st.EpisodeCount)) + v.renderDetail() +} + +func monitoredWord(m bool) string { + if m { + return "monitored" + } + return "[gray]unmonitored[-]" +} + +// episodeStatus describes an episode's download state for the table. +func episodeStatus(e sonarr.Episode, today string) string { + switch { + case e.HasFile: + return "[green]✓ downloaded" + case e.AirDate == "" || e.AirDate > today: + return "[gray]unaired" + case !e.Monitored: + return "[gray]unmonitored" + default: + return "[yellow]missing" + } +} + +func (v *seriesView) renderDetail() { + r, ok := v.selected() + switch { + case !ok: + v.detail.SetText("") + case r.ep == nil: + v.detail.SetText("[gray]Enter expands the season · t toggles monitoring for the whole season · i searches for releases") + default: + v.detail.SetText(fmt.Sprintf("[gray]S%02dE%02d[-] %s", r.season, r.ep.EpisodeNumber, tview.Escape(r.ep.Title))) + } +} + +// enter expands or collapses a season, or opens the release search for an episode. +func (v *seriesView) enter() { + r, ok := v.selected() + if !ok { + return + } + if r.ep == nil { + v.expanded[r.season] = !v.expanded[r.season] + v.render() + return + } + v.releases() +} + +func (v *seriesView) releases() { + r, ok := v.selected() + if !ok { + return + } + s := v.series + if r.ep == nil { + title := tview.Escape(s.Title) + ", " + seasonLabel(r.season) + v.app.openReleases("Releases for "+title, + func(ctx context.Context) ([]arr.Release, error) { return v.c.SeasonReleases(ctx, s.ID, r.season) }, + v.c.Grab) + return + } + e := *r.ep + v.app.openReleases(fmt.Sprintf("Releases for %s S%02dE%02d", tview.Escape(s.Title), r.season, e.EpisodeNumber), + func(ctx context.Context) ([]arr.Release, error) { return v.c.EpisodeReleases(ctx, e.ID) }, + v.c.Grab) +} + +func (v *seriesView) search() { + r, ok := v.selected() + if !ok { + return + } + id := v.series.ID + if r.ep == nil { + season := r.season + v.run("Searching "+seasonLabel(season), func(ctx context.Context) error { return v.c.SearchSeason(ctx, id, season) }, false) + return + } + epID := r.ep.ID + v.run(fmt.Sprintf("Searching S%02dE%02d", r.season, r.ep.EpisodeNumber), + func(ctx context.Context) error { return v.c.SearchEpisode(ctx, epID) }, false) +} + +func (v *seriesView) toggleMonitored() { + r, ok := v.selected() + if !ok { + return + } + id := v.series.ID + if r.ep == nil { + season, want := r.season, !v.seasonMonitored(r.season) + v.run(seasonLabel(season)+": "+monitorVerb(want), func(ctx context.Context) error { + return v.c.SetSeasonMonitored(ctx, id, season, want) + }, true) + return + } + epID, want := r.ep.ID, !r.ep.Monitored + v.run(fmt.Sprintf("S%02dE%02d: %s", r.season, r.ep.EpisodeNumber, monitorVerb(want)), func(ctx context.Context) error { + return v.c.SetEpisodesMonitored(ctx, []int{epID}, want) + }, true) +} + +func monitorVerb(want bool) string { + if want { + return "monitored" + } + return "unmonitored" +} + +// run executes fn off the UI goroutine; with refresh it reloads the data afterwards. +func (v *seriesView) run(what string, fn func(ctx context.Context) error, refresh bool) { + v.app.SetMessage(what + "…") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err := fn(ctx) + v.app.post(func() { + if err != nil { + v.app.SetMessage("[red]" + tview.Escape(err.Error())) + return + } + v.app.SetMessage("[green]Done: " + what) + if refresh { + v.reload() + if v.onChange != nil { + v.onChange() + } + } + }) + }() +} diff --git a/internal/ui/help.go b/internal/ui/help.go index f7f3460..cfdbc5b 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -17,6 +17,20 @@ const helpText = `[yellow]Global[-] Enter, a add the selected title i interactive release search (library titles) +[yellow]Library[-] + m switch between movies and series + f cycle filter (all, missing, downloaded, monitored, unmonitored) + /, Tab text filter (Enter/Esc/Tab returns to the list) + t toggle monitored + s automatic search (series: all missing, asks first) + i, Enter movie: release search; series: seasons and episodes + r reload + +[yellow]Seasons and episodes[-] + Enter season: expand/collapse; episode: release search + i, s, t release search, automatic search, toggle monitored + (on a season they apply to the whole season) + [yellow]Release list[-] Enter, g grab the selected release s cycle sort (best, size, age) @@ -44,5 +58,5 @@ func (a *App) showHelp() { } return ev }) - a.openModal(tv, 76, 34) + a.openModal(tv, 80, 40) } diff --git a/internal/ui/library.go b/internal/ui/library.go new file mode 100644 index 0000000..92855c8 --- /dev/null +++ b/internal/ui/library.go @@ -0,0 +1,425 @@ +package ui + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "git.stinnesbeck.com/nils/arr-tui/internal/arr" + "git.stinnesbeck.com/nils/arr-tui/internal/radarr" + "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" +) + +type libKind int + +const ( + kindMovies libKind = iota + kindSeries +) + +func (k libKind) String() string { + if k == kindMovies { + return "Movies" + } + return "Series" +} + +func (k libKind) service() string { + if k == kindMovies { + return "Radarr" + } + return "Sonarr" +} + +// libItem is one row of the library: a movie or a series in display form. +type libItem struct { + id int + title string // plain, used for sorting and filtering + label string // escaped title with year + monitored bool + have bool // at least one file on disk + missing bool // monitored and not complete + status string + size string + details string + movie *radarr.Movie + series *sonarr.Series +} + +var libFilters = []struct { + name string + match func(libItem) bool +}{ + {"all", func(libItem) bool { return true }}, + {"missing", func(i libItem) bool { return i.missing }}, + {"downloaded", func(i libItem) bool { return i.have }}, + {"monitored", func(i libItem) bool { return i.monitored }}, + {"unmonitored", func(i libItem) bool { return !i.monitored }}, +} + +func monMark(monitored bool) string { + if monitored { + return "[green]●[-]" + } + return "[gray]○[-]" +} + +func sizeOrBlank(b int64) string { + if b <= 0 { + return "" + } + return formatSize(b) +} + +func newMovieItem(m radarr.Movie) libItem { + it := libItem{ + id: m.ID, title: m.Title, monitored: m.Monitored, have: m.HasFile, + missing: m.Monitored && !m.HasFile && m.IsAvailable, + size: sizeOrBlank(m.SizeOnDisk), movie: &m, + } + it.label = tview.Escape(m.Title) + if m.Year > 0 { + it.label += fmt.Sprintf(" (%d)", m.Year) + } + switch { + case m.HasFile: + it.status = "[green]✓ " + tview.Escape(m.QualityName()) + case !m.Monitored: + it.status = "[gray]unmonitored" + case !m.IsAvailable: + it.status = "[gray]not released" + default: + it.status = "[yellow]missing" + } + it.details = movieDetails(m) + monitoredLine(m.Monitored) + sizeLine(m.SizeOnDisk) + pathLine(m.Path) + return it +} + +func newSeriesItem(s sonarr.Series) libItem { + st := s.Statistics + it := libItem{ + id: s.ID, title: s.Title, monitored: s.Monitored, have: st.EpisodeFileCount > 0, + missing: s.Monitored && st.EpisodeFileCount < st.EpisodeCount, + size: sizeOrBlank(st.SizeOnDisk), series: &s, + } + it.label = tview.Escape(s.Title) + if s.Year > 0 { + it.label += fmt.Sprintf(" (%d)", s.Year) + } + counts := fmt.Sprintf("%d/%d", st.EpisodeFileCount, st.EpisodeCount) + switch { + case !s.Monitored: + it.status = "[gray]" + counts + " unmonitored" + case st.EpisodeFileCount >= st.EpisodeCount: + it.status = "[green]" + counts + default: + it.status = "[yellow]" + counts + } + it.details = seriesDetails(s) + monitoredLine(s.Monitored) + sizeLine(st.SizeOnDisk) + return it +} + +func monitoredLine(m bool) string { + if m { + return "Monitored: yes\n" + } + return "Monitored: [gray]no[-]\n" +} + +func sizeLine(b int64) string { + if b <= 0 { + return "" + } + return "Size on disk: " + formatSize(b) + "\n" +} + +func pathLine(p string) string { + if p == "" { + return "" + } + return "Path: " + tview.Escape(p) + "\n" +} + +// filterItems applies the filter preset and the text filter, sorted by title. +func filterItems(items []libItem, filter int, text string) []libItem { + text = strings.ToLower(strings.TrimSpace(text)) + var out []libItem + for _, it := range items { + if libFilters[filter].match(it) && strings.Contains(strings.ToLower(it.title), text) { + out = append(out, it) + } + } + slices.SortFunc(out, func(a, b libItem) int { + return strings.Compare(strings.ToLower(a.title), strings.ToLower(b.title)) + }) + return out +} + +// libraryView lists the movies of Radarr or the series of Sonarr. +type libraryView struct { + app *App + radarr *radarr.Client + sonarr *sonarr.Client + + root *tview.Flex + header *tview.TextView + input *tview.InputField + table *tview.Table + details *tview.TextView + + // UI-goroutine state. + kind libKind + filter int + all map[libKind][]libItem + loaded map[libKind]bool + loadErr map[libKind]error + shown []libItem + gen int // invalidates stale loads +} + +func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView { + v := &libraryView{ + app: app, radarr: r, sonarr: s, + header: tview.NewTextView().SetDynamicColors(true), + input: tview.NewInputField().SetLabel("Filter: ").SetFieldWidth(0), + table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0), + details: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), + all: map[libKind][]libItem{}, + loaded: map[libKind]bool{}, + loadErr: map[libKind]error{}, + } + v.table.SetBorder(true) + v.details.SetBorder(true).SetTitle(" Details ") + v.root = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(v.header, 1, 0, false). + AddItem(v.input, 1, 0, false). + AddItem(tview.NewFlex(). + AddItem(v.table, 0, 3, true). + AddItem(v.details, 0, 2, false), 0, 1, true). + AddItem(tview.NewTextView().SetDynamicColors(true). + SetText("[gray]m movies/series · f filter · / text · t monitor · s search · Enter releases/episodes · r reload"), 1, 0, false) + + v.input.SetChangedFunc(func(string) { v.render() }) + v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.table) }) + v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() }) + v.table.SetSelectedFunc(func(int, int) { v.open() }) + v.table.SetInputCapture(v.handleKey) + v.render() + return v +} + +func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey { + switch ev.Key() { + case tcell.KeyTab, tcell.KeyBacktab: + v.app.app.SetFocus(v.input) + return nil + case tcell.KeyRune: + switch ev.Rune() { + case '/': + v.app.app.SetFocus(v.input) + case 'm': + v.kind = (v.kind + 1) % 2 + v.activate() + case 'f': + v.filter = (v.filter + 1) % len(libFilters) + v.render() + case 'r': + v.reload() + case 'i': + v.open() + case 's': + v.search() + case 't': + v.toggleMonitored() + default: + return ev + } + return nil + } + return ev +} + +// activate is called when the view becomes visible or the kind changes; it +// always refreshes so changes made elsewhere show up. +func (v *libraryView) activate() { + v.render() + v.reload() +} + +func (v *libraryView) reload() { + v.gen++ + gen, kind := v.gen, v.kind + wasLoaded := v.loaded[kind] + if !wasLoaded { + v.app.SetMessage("Loading " + strings.ToLower(kind.String()) + "…") + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + var items []libItem + var err error + if kind == kindMovies { + var movies []radarr.Movie + if movies, err = v.radarr.Movies(ctx); err == nil { + for _, m := range movies { + items = append(items, newMovieItem(m)) + } + } + } else { + var series []sonarr.Series + if series, err = v.sonarr.Series(ctx); err == nil { + for _, s := range series { + items = append(items, newSeriesItem(s)) + } + } + } + v.app.post(func() { + v.app.SetService(kind.service(), "", err) + if gen != v.gen { + return + } + v.loadErr[kind] = err + if err != nil { + v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error())) + } else { + v.all[kind], v.loaded[kind] = items, true + if !wasLoaded { + v.app.SetMessage("") + } + } + if kind == v.kind { + v.render() + } + }) + }() +} + +func (v *libraryView) selected() (libItem, bool) { + r, _ := v.table.GetSelection() + if r < 1 || r > len(v.shown) { + return libItem{}, false + } + return v.shown[r-1], true +} + +func (v *libraryView) render() { + selID := -1 + if it, ok := v.selected(); ok { + selID = it.id + } + v.shown = filterItems(v.all[v.kind], v.filter, v.input.GetText()) + + v.table.Clear() + header(v.table, 0, "Title", "Status", "Size") + selRow := 0 + for i, it := range v.shown { + v.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1)) + v.table.SetCell(i+1, 1, tview.NewTableCell(it.status)) + v.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight)) + if it.id == selID { + selRow = i + 1 + } + } + placeholder := "Nothing matches the filter" + switch { + case v.loadErr[v.kind] != nil && !v.loaded[v.kind]: + placeholder = "Could not load the library, press r to retry" + case !v.loaded[v.kind]: + placeholder = "Loading…" + case len(v.all[v.kind]) == 0: + placeholder = "The library is empty" + } + restoreSelection(v.table, len(v.shown), selRow, placeholder) + if selRow == 0 { + v.table.ScrollToBeginning() + } + v.table.SetTitle(fmt.Sprintf(" %s ", v.kind)) + + v.header.SetText(fmt.Sprintf("[::b]%s[::-] · %d of %d · filter: [yellow]%s[-]", + v.kind, len(v.shown), len(v.all[v.kind]), libFilters[v.filter].name)) + v.renderDetails() +} + +func (v *libraryView) renderDetails() { + if it, ok := v.selected(); ok { + v.details.SetText(it.details) + v.details.ScrollToBeginning() + return + } + v.details.SetText("") +} + +// open starts the interactive search (movie) or the episode drill-down (series). +func (v *libraryView) open() { + it, ok := v.selected() + if !ok { + return + } + if it.movie != nil { + m := *it.movie + v.app.openReleases("Releases for "+tview.Escape(movieTitleYear(m)), + func(ctx context.Context) ([]arr.Release, error) { return v.radarr.Releases(ctx, m.ID) }, + v.radarr.Grab) + return + } + v.app.openSeries(v.sonarr, *it.series, v.reload) +} + +func (v *libraryView) search() { + it, ok := v.selected() + if !ok { + return + } + if it.movie != nil { + v.run("Searching for "+it.label, "Search started for "+it.label, + func(ctx context.Context) error { return v.radarr.SearchMovie(ctx, it.id) }, nil) + return + } + v.app.confirm("Search for all missing episodes of "+it.label+"?", func() { + v.run("Searching for "+it.label, "Search started for "+it.label, + func(ctx context.Context) error { return v.sonarr.SearchSeries(ctx, it.id) }, nil) + }) +} + +func (v *libraryView) toggleMonitored() { + it, ok := v.selected() + if !ok { + return + } + want := !it.monitored + doing, done := "Monitoring ", "Monitored " + if !want { + doing, done = "Unmonitoring ", "Unmonitored " + } + v.run(doing+it.label, done+it.label, func(ctx context.Context) error { + if it.movie != nil { + return v.radarr.SetMonitored(ctx, it.id, want) + } + return v.sonarr.SetMonitored(ctx, it.id, want) + }, v.reload) +} + +// run executes fn off the UI goroutine, reports the outcome and then calls after. +func (v *libraryView) run(doing, done string, fn func(ctx context.Context) error, after func()) { + v.app.SetMessage(doing + "…") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err := fn(ctx) + v.app.post(func() { + if err != nil { + v.app.SetMessage("[red]" + tview.Escape(err.Error())) + return + } + v.app.SetMessage("[green]" + done) + if after != nil { + after() + } + }) + }() +} diff --git a/internal/ui/library_test.go b/internal/ui/library_test.go new file mode 100644 index 0000000..2793dad --- /dev/null +++ b/internal/ui/library_test.go @@ -0,0 +1,92 @@ +package ui + +import ( + "strings" + "testing" + + "git.stinnesbeck.com/nils/arr-tui/internal/radarr" + "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" +) + +func TestMovieItemStatus(t *testing.T) { + tests := []struct { + name string + m radarr.Movie + want string + have, miss bool + }{ + {"missing", radarr.Movie{ID: 1, Title: "A", Monitored: true, IsAvailable: true}, "missing", false, true}, + {"unreleased", radarr.Movie{ID: 2, Title: "B", Monitored: true}, "not released", false, false}, + {"unmonitored", radarr.Movie{ID: 3, Title: "C", IsAvailable: true}, "unmonitored", false, false}, + {"downloaded", radarr.Movie{ID: 4, Title: "D", Monitored: true, HasFile: true, IsAvailable: true}, "✓", true, false}, + } + for _, tc := range tests { + it := newMovieItem(tc.m) + if !strings.Contains(it.status, tc.want) || it.have != tc.have || it.missing != tc.miss { + t.Errorf("%s: status=%q have=%v missing=%v", tc.name, it.status, it.have, it.missing) + } + } +} + +func TestSeriesItemStatus(t *testing.T) { + s := sonarr.Series{ID: 1, Title: "S", Year: 2008, Monitored: true, + Statistics: sonarr.Statistics{EpisodeFileCount: 3, EpisodeCount: 10}} + it := newSeriesItem(s) + if !it.have || !it.missing || !strings.Contains(it.status, "3/10") || it.label != "S (2008)" { + t.Errorf("item = %+v", it) + } + s.Statistics.EpisodeFileCount = 10 + if it := newSeriesItem(s); it.missing || !strings.Contains(it.status, "green") { + t.Errorf("complete series: %+v", it) + } +} + +func TestFilterItems(t *testing.T) { + items := []libItem{ + {id: 1, title: "Zulu", monitored: true, missing: true}, + {id: 2, title: "alpha", monitored: true, have: true}, + {id: 3, title: "Beta"}, + } + ids := func(f int, text string) string { + var s string + for _, it := range filterItems(items, f, text) { + s += itoa(it.id) + } + return s + } + for _, tc := range []struct { + filter int + text string + want string + }{ + {0, "", "231"}, // sorted case-insensitively by title + {1, "", "1"}, + {2, "", "2"}, + {3, "", "21"}, + {4, "", "3"}, + {0, " ALP ", "2"}, + {1, "alpha", ""}, + } { + if got := ids(tc.filter, tc.text); got != tc.want { + t.Errorf("filter %d %q = %q, want %q", tc.filter, tc.text, got, tc.want) + } + } +} + +func TestEpisodeStatus(t *testing.T) { + const today = "2026-06-01" + for _, tc := range []struct { + e sonarr.Episode + want string + }{ + {sonarr.Episode{HasFile: true}, "downloaded"}, + {sonarr.Episode{AirDate: "2027-01-01", Monitored: true}, "unaired"}, + {sonarr.Episode{Monitored: true}, "unaired"}, + {sonarr.Episode{AirDate: "2020-01-01"}, "unmonitored"}, + {sonarr.Episode{AirDate: "2020-01-01", Monitored: true}, "missing"}, + } { + if got := episodeStatus(tc.e, today); !strings.Contains(got, tc.want) { + t.Errorf("%+v = %q, want %q", tc.e, got, tc.want) + } + } +}