Files
nilsandClaude Sonnet 5.5 f928427993 Add interactive release search (M6)
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>
2026-10-20 18:09:19 +02:00

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)
})
}