Files
arr-tui/internal/ui/backend.go
T
nilsandClaude Opus 5.5 f0bc284e82 Trakt lists: keep the Radarr/Sonarr lookups of their titles on disk
Library membership is asked every time via movie?tmdbId= / series?tvdbId=;
only the lookups of titles not in the library are stored (3 days).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:48:20 +02:00

244 lines
7.3 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
lookups *lookupStore
}
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) {
id := e.IDs.TMDB
if id == 0 {
return nil, errNotListed
}
if m, ok, err := b.c.MovieByTMDB(ctx, id); err != nil {
return nil, err
} else if ok {
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, true)}, nil
}
// Not in the library: the lookup from an earlier run, if fresh.
key := radarr.TMDBTerm(id)
var m radarr.Movie
if b.lookups.get(key, &m) {
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, false)}, nil
}
movies, err := b.c.Lookup(ctx, key)
if err != nil || len(movies) == 0 {
return nil, err
}
m = movies[0]
if !m.InLibrary() {
b.lookups.put(key, m)
}
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, m.InLibrary())}, 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
lookups *lookupStore
}
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) {
id := e.IDs.TVDB
if id == 0 {
return nil, errNotListed
}
if s, ok, err := b.c.SeriesByTVDB(ctx, id); err != nil {
return nil, err
} else if ok {
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, true)}, nil
}
// Not in the library: the lookup from an earlier run, if fresh.
key := "tvdb:" + itoa(id)
var s sonarr.Series
if b.lookups.get(key, &s) {
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, false)}, nil
}
series, err := b.c.Lookup(ctx, key)
if err != nil || len(series) == 0 {
return nil, err
}
s = series[0]
if !s.InLibrary() {
b.lookups.put(key, s)
}
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, s.InLibrary())}, 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)
})
}()
}