Release listing and grab in the shared arr client, a full-screen release list (sort, hide rejected, rejection reasons, confirmation before grabbing rejected releases; Usenet only), and a season/episode picker for Sonarr. Overlays are now a stack so dialogs can open above the release list. Add a fake Newznab indexer to the dev setup. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
115 lines
3.3 KiB
Go
115 lines
3.3 KiB
Go
package ui
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/rivo/tview"
|
|
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
|
)
|
|
|
|
// seasonSummary counts the episodes of one season.
|
|
type seasonSummary struct {
|
|
number, total, have int
|
|
}
|
|
|
|
// summarizeSeasons groups episodes by season, newest season first and specials last.
|
|
func summarizeSeasons(episodes []sonarr.Episode) []seasonSummary {
|
|
byNumber := map[int]*seasonSummary{}
|
|
for _, e := range episodes {
|
|
s := byNumber[e.SeasonNumber]
|
|
if s == nil {
|
|
s = &seasonSummary{number: e.SeasonNumber}
|
|
byNumber[e.SeasonNumber] = s
|
|
}
|
|
s.total++
|
|
if e.HasFile {
|
|
s.have++
|
|
}
|
|
}
|
|
out := make([]seasonSummary, 0, len(byNumber))
|
|
for _, s := range byNumber {
|
|
out = append(out, *s)
|
|
}
|
|
slices.SortFunc(out, func(a, b seasonSummary) int {
|
|
// specials (season 0) last, otherwise descending
|
|
return cmp.Or(cmp.Compare(boolInt(a.number == 0), boolInt(b.number == 0)), cmp.Compare(b.number, a.number))
|
|
})
|
|
return out
|
|
}
|
|
|
|
func seasonLabel(n int) string {
|
|
if n == 0 {
|
|
return "Specials"
|
|
}
|
|
return fmt.Sprintf("Season %d", n)
|
|
}
|
|
|
|
// pickList shows a selectable list in a dialog; Esc closes it.
|
|
func (a *App) pickList(title string, labels []string, onPick func(i int)) {
|
|
list := tview.NewList().ShowSecondaryText(false)
|
|
for _, l := range labels {
|
|
list.AddItem(l, "", 0, nil)
|
|
}
|
|
list.SetSelectedFunc(func(i int, _, _ string, _ rune) {
|
|
a.closeModal()
|
|
onPick(i)
|
|
})
|
|
list.SetDoneFunc(a.closeModal)
|
|
list.SetBorder(true).SetTitle(" " + title + " ")
|
|
a.openModal(list, 56, min(len(labels)+2, 22))
|
|
}
|
|
|
|
// pickSeason lets the user choose a season, then an episode or the whole season,
|
|
// and opens the interactive search for it.
|
|
func (a *App) pickSeason(c *sonarr.Client, s sonarr.Series, episodes []sonarr.Episode) {
|
|
seasons := summarizeSeasons(episodes)
|
|
if len(seasons) == 0 {
|
|
a.SetMessage("[yellow]Sonarr has no episodes for " + tview.Escape(s.Title))
|
|
return
|
|
}
|
|
labels := make([]string, len(seasons))
|
|
for i, se := range seasons {
|
|
labels[i] = fmt.Sprintf("%s (%d/%d downloaded)", seasonLabel(se.number), se.have, se.total)
|
|
}
|
|
a.pickList("Search releases for "+tview.Escape(s.Title), labels, func(i int) {
|
|
a.pickEpisode(c, s, seasons[i].number, episodes)
|
|
})
|
|
}
|
|
|
|
func (a *App) pickEpisode(c *sonarr.Client, s sonarr.Series, season int, all []sonarr.Episode) {
|
|
var episodes []sonarr.Episode
|
|
for _, e := range all {
|
|
if e.SeasonNumber == season {
|
|
episodes = append(episodes, e)
|
|
}
|
|
}
|
|
slices.SortFunc(episodes, func(x, y sonarr.Episode) int { return cmp.Compare(x.EpisodeNumber, y.EpisodeNumber) })
|
|
|
|
labels := []string{"Whole season"}
|
|
for _, e := range episodes {
|
|
mark := ""
|
|
if e.HasFile {
|
|
mark = " ✓"
|
|
}
|
|
labels = append(labels, fmt.Sprintf("E%02d %s%s", e.EpisodeNumber, tview.Escape(e.Title), mark))
|
|
}
|
|
title := tview.Escape(s.Title) + ", " + seasonLabel(season)
|
|
a.pickList(title, labels, func(i int) {
|
|
if i == 0 {
|
|
a.openReleases("Releases for "+title,
|
|
func(ctx context.Context) ([]arr.Release, error) { return c.SeasonReleases(ctx, s.ID, season) },
|
|
c.Grab)
|
|
return
|
|
}
|
|
e := episodes[i-1]
|
|
a.openReleases(fmt.Sprintf("Releases for %s S%02dE%02d", tview.Escape(s.Title), season, e.EpisodeNumber),
|
|
func(ctx context.Context) ([]arr.Release, error) { return c.EpisodeReleases(ctx, e.ID) },
|
|
c.Grab)
|
|
})
|
|
}
|