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