Add library view and series drill-down (M8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Update reads <path>/<id>, 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
+15
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user