With an empty search box, Movies and Series show one of Trakt's public lists: trending now, most watched this week, anticipated, popular, and for movies the US box office. [ ] switch lists, r reloads. Each entry is looked up in Radarr (tmdb:) or Sonarr (tvdb:), so it behaves like a search result: details, poster, tick when in the library, add, releases. Needs a Trakt client ID: new optional trakt.client_id setting (Config view section "Trakt", ARR_TUI_TRAKT_CLIENT_ID), masked like the API keys and checked by Test. ARR_TUI_TRAKT_URL points at another server for development. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
6.2 KiB
Go
206 lines
6.2 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"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"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
|
)
|
|
|
|
// result is one search hit, independent of which *arr produced it.
|
|
type result interface {
|
|
key() string // stable identity, e.g. "tmdb:603"
|
|
title() string
|
|
label() string
|
|
details() detailText
|
|
inLibrary() bool
|
|
withID(id int) result // copy flagged as added to the library
|
|
cover() coverRef
|
|
}
|
|
|
|
// monitorMode is a Sonarr-style "which episodes to monitor" choice.
|
|
type monitorMode struct {
|
|
label string
|
|
value string
|
|
}
|
|
|
|
// addChoice is what the add dialog collects.
|
|
type addChoice struct {
|
|
profileID int
|
|
folder string
|
|
monitored bool
|
|
searchNow bool
|
|
monitor string // empty for backends without monitor modes
|
|
}
|
|
|
|
// backend adapts Radarr or Sonarr to the generic search view.
|
|
type backend interface {
|
|
name() string // "Radarr"
|
|
noun() string // "Movies"
|
|
arr() *arr.Client
|
|
search(ctx context.Context, term string) ([]result, error)
|
|
monitorModes() []monitorMode // nil if not applicable
|
|
add(ctx context.Context, r result, c addChoice) (id int, err error)
|
|
// interactive starts an interactive release search for a library item.
|
|
interactive(v *searchView, r result)
|
|
// lists are the Trakt lists offered while the search box is empty, and
|
|
// resolve looks a list entry up (nil without error if it is not found).
|
|
lists() []trakt.List
|
|
resolve(ctx context.Context, e trakt.Entry) (result, error)
|
|
}
|
|
|
|
// --- Radarr ---
|
|
|
|
type movieResult struct {
|
|
radarr.Movie
|
|
poster coverRef
|
|
}
|
|
|
|
func (m movieResult) key() string { return radarr.TMDBTerm(m.TMDBID) }
|
|
func (m movieResult) title() string { return m.Title }
|
|
func (m movieResult) label() string { return movieLabel(m.Movie) }
|
|
func (m movieResult) details() detailText { return movieDetails(m.Movie) }
|
|
func (m movieResult) inLibrary() bool { return m.InLibrary() }
|
|
func (m movieResult) cover() coverRef { return m.poster }
|
|
func (m movieResult) withID(id int) result {
|
|
m.ID = id
|
|
return m
|
|
}
|
|
|
|
type radarrBackend struct{ c *radarr.Client }
|
|
|
|
func (b radarrBackend) name() string { return "Radarr" }
|
|
func (b radarrBackend) noun() string { return "Movies" }
|
|
func (b radarrBackend) arr() *arr.Client { return b.c.Client }
|
|
func (radarrBackend) monitorModes() []monitorMode { return nil }
|
|
|
|
func (b radarrBackend) search(ctx context.Context, term string) ([]result, error) {
|
|
movies, err := b.c.Lookup(ctx, term)
|
|
out := make([]result, len(movies))
|
|
for i, m := range movies {
|
|
out[i] = movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, m.InLibrary())}
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
func (radarrBackend) lists() []trakt.List { return trakt.MovieLists }
|
|
|
|
func (b radarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
|
|
if e.IDs.TMDB == 0 {
|
|
return nil, errNotListed
|
|
}
|
|
found, err := b.search(ctx, radarr.TMDBTerm(e.IDs.TMDB))
|
|
if err != nil || len(found) == 0 {
|
|
return nil, err
|
|
}
|
|
return found[0], nil
|
|
}
|
|
|
|
func (b radarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
|
|
created, err := b.c.AddMovie(ctx, r.(movieResult).Movie, radarr.AddOptions{
|
|
QualityProfileID: c.profileID,
|
|
RootFolderPath: c.folder,
|
|
Monitored: c.monitored,
|
|
SearchNow: c.searchNow,
|
|
})
|
|
return created.ID, err
|
|
}
|
|
|
|
func (b radarrBackend) interactive(v *searchView, r result) {
|
|
m := r.(movieResult)
|
|
v.app.openReleases("Releases for "+tview.Escape(movieTitleYear(m.Movie)),
|
|
func(ctx context.Context) ([]arr.Release, error) { return b.c.Releases(ctx, m.ID) },
|
|
b.c.Grab)
|
|
}
|
|
|
|
// --- Sonarr ---
|
|
|
|
type seriesResult struct {
|
|
sonarr.Series
|
|
poster coverRef
|
|
}
|
|
|
|
func (s seriesResult) key() string { return "tvdb:" + itoa(s.TVDBID) }
|
|
func (s seriesResult) title() string { return s.Title }
|
|
func (s seriesResult) label() string { return seriesLabel(s.Series) }
|
|
func (s seriesResult) details() detailText { return seriesDetails(s.Series) }
|
|
func (s seriesResult) inLibrary() bool { return s.InLibrary() }
|
|
func (s seriesResult) cover() coverRef { return s.poster }
|
|
func (s seriesResult) withID(id int) result {
|
|
s.ID = id
|
|
return s
|
|
}
|
|
|
|
type sonarrBackend struct{ c *sonarr.Client }
|
|
|
|
func (b sonarrBackend) name() string { return "Sonarr" }
|
|
func (b sonarrBackend) noun() string { return "Series" }
|
|
func (b sonarrBackend) arr() *arr.Client { return b.c.Client }
|
|
|
|
func (sonarrBackend) monitorModes() []monitorMode {
|
|
return []monitorMode{
|
|
{"All episodes", sonarr.MonitorAll},
|
|
{"Future episodes", sonarr.MonitorFuture},
|
|
{"Missing episodes", sonarr.MonitorMissing},
|
|
{"First season", sonarr.MonitorFirstSeason},
|
|
{"Latest season", sonarr.MonitorLatestSeason},
|
|
{"None", sonarr.MonitorNone},
|
|
}
|
|
}
|
|
|
|
func (b sonarrBackend) search(ctx context.Context, term string) ([]result, error) {
|
|
series, err := b.c.Lookup(ctx, term)
|
|
out := make([]result, len(series))
|
|
for i, s := range series {
|
|
out[i] = seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, s.InLibrary())}
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
func (sonarrBackend) lists() []trakt.List { return trakt.ShowLists }
|
|
|
|
func (b sonarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
|
|
if e.IDs.TVDB == 0 {
|
|
return nil, errNotListed
|
|
}
|
|
found, err := b.search(ctx, "tvdb:"+itoa(e.IDs.TVDB))
|
|
if err != nil || len(found) == 0 {
|
|
return nil, err
|
|
}
|
|
return found[0], nil
|
|
}
|
|
|
|
func (b sonarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
|
|
created, err := b.c.AddSeries(ctx, r.(seriesResult).Series, sonarr.AddOptions{
|
|
QualityProfileID: c.profileID,
|
|
RootFolderPath: c.folder,
|
|
Monitored: c.monitored,
|
|
Monitor: c.monitor,
|
|
SearchNow: c.searchNow,
|
|
})
|
|
return created.ID, err
|
|
}
|
|
|
|
func (b sonarrBackend) interactive(v *searchView, r result) {
|
|
s := r.(seriesResult)
|
|
v.app.SetMessage("Loading episodes…")
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
episodes, err := b.c.Episodes(ctx, s.ID)
|
|
v.app.post(func() {
|
|
if err != nil {
|
|
v.app.SetMessage("[red]Loading episodes failed: " + tview.Escape(err.Error()))
|
|
return
|
|
}
|
|
v.app.SetMessage("")
|
|
v.app.pickSeason(b.c, s.Series, episodes)
|
|
})
|
|
}()
|
|
}
|