Show poster thumbnails in the detail panes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@ type App struct {
|
||||
services *tview.TextView
|
||||
state map[string]serviceState // keyed by service name
|
||||
probes []probe
|
||||
|
||||
covers bool // poster thumbnails enabled
|
||||
coverCache *coverCache
|
||||
}
|
||||
|
||||
// probe checks that one service answers; it returns its version.
|
||||
@@ -84,6 +87,9 @@ func New(cfg *config.Config) *App {
|
||||
|
||||
services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight),
|
||||
state: map[string]serviceState{},
|
||||
|
||||
covers: cfg.UI.ShowCovers(),
|
||||
coverCache: newCoverCache(),
|
||||
}
|
||||
|
||||
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||||
|
||||
+13
-4
@@ -19,6 +19,7 @@ type result interface {
|
||||
details() string
|
||||
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.
|
||||
@@ -50,13 +51,17 @@ type backend interface {
|
||||
|
||||
// --- Radarr ---
|
||||
|
||||
type movieResult struct{ radarr.Movie }
|
||||
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() string { 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
|
||||
@@ -73,7 +78,7 @@ 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{m}
|
||||
out[i] = movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, m.InLibrary())}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
@@ -97,13 +102,17 @@ func (b radarrBackend) interactive(v *searchView, r result) {
|
||||
|
||||
// --- Sonarr ---
|
||||
|
||||
type seriesResult struct{ sonarr.Series }
|
||||
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() string { 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
|
||||
@@ -130,7 +139,7 @@ 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{s}
|
||||
out[i] = seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, s.InLibrary())}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg" // poster formats
|
||||
_ "image/png"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCoverPixels = 25_000_000 // refuse decompression bombs
|
||||
storedCoverHeight = 160 // a small copy is plenty for a terminal thumbnail
|
||||
coverFailureTTL = 2 * time.Minute
|
||||
maxCoverCache = 300
|
||||
coverFetchLimit = 4
|
||||
)
|
||||
|
||||
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
|
||||
func decodeCover(data []byte) (*image.RGBA, error) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not a supported image: %w", err)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxCoverPixels {
|
||||
return nil, fmt.Errorf("image dimensions %dx%d not supported", cfg.Width, cfg.Height)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := img.Bounds()
|
||||
if b.Dy() <= storedCoverHeight {
|
||||
return boxScale(img, b.Dx(), b.Dy()), nil
|
||||
}
|
||||
return boxScale(img, max(1, b.Dx()*storedCoverHeight/b.Dy()), storedCoverHeight), nil
|
||||
}
|
||||
|
||||
// boxScale resizes src to w×h by averaging the source pixels under every
|
||||
// target pixel (good for shrinking; upscaling degrades to nearest neighbour).
|
||||
func boxScale(src image.Image, w, h int) *image.RGBA {
|
||||
b := src.Bounds()
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
y0 := b.Min.Y + y*b.Dy()/h
|
||||
y1 := max(b.Min.Y+(y+1)*b.Dy()/h, y0+1)
|
||||
for x := 0; x < w; x++ {
|
||||
x0 := b.Min.X + x*b.Dx()/w
|
||||
x1 := max(b.Min.X+(x+1)*b.Dx()/w, x0+1)
|
||||
var r, g, bl, n uint64
|
||||
for sy := y0; sy < y1; sy++ {
|
||||
for sx := x0; sx < x1; sx++ {
|
||||
pr, pg, pb, _ := src.At(sx, sy).RGBA()
|
||||
r, g, bl, n = r+uint64(pr), g+uint64(pg), bl+uint64(pb), n+1
|
||||
}
|
||||
}
|
||||
i := dst.PixOffset(x, y)
|
||||
dst.Pix[i], dst.Pix[i+1], dst.Pix[i+2], dst.Pix[i+3] = uint8(r/n>>8), uint8(g/n>>8), uint8(bl/n>>8), 255
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// coverView draws a poster with half-block characters: every cell shows two
|
||||
// vertically stacked pixels (foreground = upper, background = lower), so it
|
||||
// works in any terminal with colors, also over SSH.
|
||||
type coverView struct {
|
||||
*tview.Box
|
||||
img *image.RGBA
|
||||
note string // shown instead of the image: "loading…", "no cover"
|
||||
|
||||
scaled *image.RGBA // cache of img at the last drawn size
|
||||
}
|
||||
|
||||
func newCoverView() *coverView { return &coverView{Box: tview.NewBox()} }
|
||||
|
||||
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.scaled = img, "", nil }
|
||||
func (c *coverView) setNote(note string) { c.img, c.note, c.scaled = nil, note, nil }
|
||||
|
||||
func (c *coverView) Draw(screen tcell.Screen) {
|
||||
c.Box.DrawForSubclass(screen, c)
|
||||
x, y, w, h := c.GetInnerRect()
|
||||
if w < 1 || h < 1 {
|
||||
return
|
||||
}
|
||||
if c.img == nil {
|
||||
if c.note != "" {
|
||||
tview.Print(screen, "[gray]"+c.note, x, y, w, tview.AlignLeft, tcell.ColorGray)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fit the poster into w columns by 2h pixel rows, keeping its aspect.
|
||||
b := c.img.Bounds()
|
||||
tw := w
|
||||
th := tw * b.Dy() / b.Dx()
|
||||
if th > 2*h {
|
||||
th = 2 * h
|
||||
tw = th * b.Dx() / b.Dy()
|
||||
}
|
||||
th &^= 1
|
||||
if tw < 1 || th < 2 {
|
||||
return
|
||||
}
|
||||
if c.scaled == nil || c.scaled.Bounds().Dx() != tw || c.scaled.Bounds().Dy() != th {
|
||||
c.scaled = boxScale(c.img, tw, th)
|
||||
}
|
||||
px := func(px, py int) tcell.Color {
|
||||
i := c.scaled.PixOffset(px, py)
|
||||
return tcell.NewRGBColor(int32(c.scaled.Pix[i]), int32(c.scaled.Pix[i+1]), int32(c.scaled.Pix[i+2]))
|
||||
}
|
||||
for row := 0; row < th/2; row++ {
|
||||
for col := 0; col < tw; col++ {
|
||||
style := tcell.StyleDefault.Foreground(px(col, 2*row)).Background(px(col, 2*row+1))
|
||||
screen.SetContent(x+col, y+row, '▀', nil, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coverRef says where a poster comes from. The zero value means "no poster".
|
||||
type coverRef struct {
|
||||
key string // identifies the poster in the cache
|
||||
fetch func(ctx context.Context) ([]byte, error)
|
||||
}
|
||||
|
||||
// coverFor builds the poster reference of a title from its image list.
|
||||
func coverFor(c *arr.Client, images []arr.Image, inLibrary bool) coverRef {
|
||||
key, fetch, ok := c.Poster(images, inLibrary)
|
||||
if !ok {
|
||||
return coverRef{}
|
||||
}
|
||||
return coverRef{key: key, fetch: fetch}
|
||||
}
|
||||
|
||||
// coverCache holds decoded posters and the downloads in flight. UI goroutine only.
|
||||
type coverCache struct {
|
||||
imgs map[string]*image.RGBA
|
||||
failed map[string]time.Time
|
||||
waiting map[string][]func(*image.RGBA, error)
|
||||
slots chan struct{}
|
||||
}
|
||||
|
||||
func newCoverCache() *coverCache {
|
||||
return &coverCache{
|
||||
imgs: map[string]*image.RGBA{},
|
||||
failed: map[string]time.Time{},
|
||||
waiting: map[string][]func(*image.RGBA, error){},
|
||||
slots: make(chan struct{}, coverFetchLimit),
|
||||
}
|
||||
}
|
||||
|
||||
// loadCover delivers the poster to done on the UI goroutine, downloading it in
|
||||
// the background when it is not cached. Failures are remembered for a while so
|
||||
// an unreachable image host is not retried on every selection.
|
||||
func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
||||
c := a.coverCache
|
||||
if img, ok := c.imgs[ref.key]; ok {
|
||||
done(img, nil)
|
||||
return
|
||||
}
|
||||
if t, ok := c.failed[ref.key]; ok && time.Since(t) < coverFailureTTL {
|
||||
done(nil, fmt.Errorf("no cover"))
|
||||
return
|
||||
}
|
||||
if _, inflight := c.waiting[ref.key]; inflight {
|
||||
c.waiting[ref.key] = append(c.waiting[ref.key], done)
|
||||
return
|
||||
}
|
||||
c.waiting[ref.key] = []func(*image.RGBA, error){done}
|
||||
|
||||
go func() {
|
||||
c.slots <- struct{}{}
|
||||
defer func() { <-c.slots }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
var img *image.RGBA
|
||||
data, err := ref.fetch(ctx)
|
||||
if err == nil {
|
||||
img, err = decodeCover(data)
|
||||
}
|
||||
a.post(func() {
|
||||
waiters := c.waiting[ref.key]
|
||||
delete(c.waiting, ref.key)
|
||||
if err != nil {
|
||||
c.failed[ref.key] = time.Now()
|
||||
} else {
|
||||
if len(c.imgs) >= maxCoverCache {
|
||||
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again
|
||||
}
|
||||
c.imgs[ref.key] = img
|
||||
}
|
||||
for _, w := range waiters {
|
||||
w(img, err)
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// stripes returns a w×h image whose top half is red and bottom half blue.
|
||||
func stripes(w, h int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
c := color.RGBA{255, 0, 0, 255}
|
||||
if y >= h/2 {
|
||||
c = color.RGBA{0, 0, 255, 255}
|
||||
}
|
||||
img.SetRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func TestBoxScaleAverages(t *testing.T) {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
src.SetRGBA(0, 0, color.RGBA{200, 0, 0, 255})
|
||||
src.SetRGBA(1, 0, color.RGBA{0, 0, 0, 255})
|
||||
src.SetRGBA(0, 1, color.RGBA{0, 0, 0, 255})
|
||||
src.SetRGBA(1, 1, color.RGBA{0, 0, 0, 255})
|
||||
got := boxScale(src, 1, 1).RGBAAt(0, 0)
|
||||
if got.R < 49 || got.R > 51 || got.G != 0 || got.A != 255 {
|
||||
t.Errorf("average = %+v, want R≈50", got)
|
||||
}
|
||||
// Upscaling must not leave holes.
|
||||
up := boxScale(stripes(1, 2), 3, 4)
|
||||
if up.RGBAAt(2, 0).R != 255 || up.RGBAAt(2, 3).B != 255 {
|
||||
t.Errorf("upscaled corners: %+v %+v", up.RGBAAt(2, 0), up.RGBAAt(2, 3))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCover(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := decodeCover(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b := img.Bounds(); b.Dy() != storedCoverHeight || b.Dx() != 106 {
|
||||
t.Errorf("stored size %v, want 106x%d", b, storedCoverHeight)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := png.Encode(&buf, stripes(20, 30)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img, err := decodeCover(buf.Bytes()); err != nil || img.Bounds().Dy() != 30 {
|
||||
t.Errorf("small png kept as is: %v %v", img, err)
|
||||
}
|
||||
|
||||
if _, err := decodeCover([]byte("<html>not an image</html>")); err == nil {
|
||||
t.Error("garbage must be rejected")
|
||||
}
|
||||
// A header that promises a huge image is refused before decoding it.
|
||||
var huge bytes.Buffer
|
||||
huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00"))
|
||||
if _, err := decodeCover(huge.Bytes()); err == nil {
|
||||
t.Error("oversized image must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverViewDrawsHalfBlocks(t *testing.T) {
|
||||
screen := tcell.NewSimulationScreen("")
|
||||
if err := screen.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer screen.Fini()
|
||||
screen.SetSize(20, 10)
|
||||
|
||||
c := newCoverView()
|
||||
c.setImage(stripes(40, 60)) // 2:3 poster
|
||||
c.SetRect(0, 0, 20, 10) // 20 columns × 20 pixel rows: a 13×20 pixel poster
|
||||
c.Draw(screen)
|
||||
|
||||
// Row 0 holds pixel rows 0 (top) and 1: both red. The middle cell row
|
||||
// (index 4/5) crosses into blue; the last row is blue on both halves.
|
||||
rgba := func(c tcell.Color) color.RGBA {
|
||||
r, g, b := c.RGB()
|
||||
return color.RGBA{uint8(r), uint8(g), uint8(b), 255}
|
||||
}
|
||||
check := func(row int, wantFg, wantBg color.RGBA) {
|
||||
t.Helper()
|
||||
ch, _, style, _ := screen.GetContent(0, row)
|
||||
fg, bg, _ := style.Decompose()
|
||||
if ch != '▀' || rgba(fg) != wantFg || rgba(bg) != wantBg {
|
||||
t.Errorf("row %d: %q fg=%v bg=%v", row, ch, rgba(fg), rgba(bg))
|
||||
}
|
||||
}
|
||||
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
|
||||
check(0, red, red)
|
||||
check(9, blue, blue)
|
||||
// Beyond the poster's width nothing is drawn.
|
||||
if ch, _, _, _ := screen.GetContent(19, 0); ch == '▀' {
|
||||
t.Error("poster stretched beyond its aspect ratio")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
fetches := 0
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, stripes(10, 20))
|
||||
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
|
||||
fetches++
|
||||
return buf.Bytes(), nil
|
||||
}}
|
||||
|
||||
// Run posted callbacks inline instead of through the (not running) event loop.
|
||||
delivered := make(chan error, 2)
|
||||
a.loadCover(ref, func(_ *image.RGBA, err error) { delivered <- err })
|
||||
a.loadCover(ref, func(_ *image.RGBA, err error) { delivered <- err }) // joins the request in flight
|
||||
if len(a.coverCache.waiting["k"]) != 2 {
|
||||
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
|
||||
}
|
||||
if fetches > 1 {
|
||||
t.Errorf("fetched %d times", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCoverRemembersFailures(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
a.coverCache.failed["bad"] = time.Now()
|
||||
var gotErr error
|
||||
a.loadCover(coverRef{key: "bad", fetch: func(context.Context) ([]byte, error) {
|
||||
t.Error("a recent failure must not be retried")
|
||||
return nil, nil
|
||||
}}, func(_ *image.RGBA, err error) { gotErr = err })
|
||||
if gotErr == nil {
|
||||
t.Error("expected an error for a recently failed cover")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const (
|
||||
coverRows = 18 // poster height in terminal rows (36 pixels)
|
||||
minCoverRows = 6 // a smaller poster is not worth showing
|
||||
minTextRows = 10 // the poster never takes these rows from the text
|
||||
)
|
||||
|
||||
// detailPane shows a poster above a text. The poster shrinks on short panes
|
||||
// and is dropped when the text would get too little room.
|
||||
type detailPane struct {
|
||||
*tview.Flex
|
||||
app *App
|
||||
cover *coverView
|
||||
text *tview.TextView
|
||||
|
||||
want string // key of the poster that should be shown
|
||||
hasCover bool // a poster (or its loading note) is part of the content
|
||||
coverSize int // rows currently given to the poster
|
||||
}
|
||||
|
||||
func newDetailPane(app *App) *detailPane {
|
||||
d := &detailPane{
|
||||
Flex: tview.NewFlex().SetDirection(tview.FlexRow),
|
||||
app: app,
|
||||
cover: newCoverView(),
|
||||
text: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
||||
}
|
||||
d.AddItem(d.cover, 0, 0, false).AddItem(d.text, 0, 1, false)
|
||||
d.SetBorder(true).SetTitle(" Details ")
|
||||
return d
|
||||
}
|
||||
|
||||
// Show displays the text and, if there is one, the poster.
|
||||
func (d *detailPane) Show(text string, ref coverRef) {
|
||||
d.text.SetText(text).ScrollToBeginning()
|
||||
if !d.app.covers || ref.fetch == nil {
|
||||
d.want, d.hasCover = "", false
|
||||
d.cover.setImage(nil)
|
||||
return
|
||||
}
|
||||
d.want, d.hasCover = ref.key, true
|
||||
d.cover.setNote("loading cover…")
|
||||
want := ref.key
|
||||
d.app.loadCover(ref, func(img *image.RGBA, err error) {
|
||||
if d.want != want {
|
||||
return // the selection moved on
|
||||
}
|
||||
if err != nil {
|
||||
d.hasCover = false // nothing to show: give the room back to the text
|
||||
d.cover.setImage(nil)
|
||||
return
|
||||
}
|
||||
d.cover.setImage(img)
|
||||
})
|
||||
}
|
||||
|
||||
// Clear empties the pane.
|
||||
func (d *detailPane) Clear() { d.Show("", coverRef{}) }
|
||||
|
||||
func (d *detailPane) Draw(screen tcell.Screen) {
|
||||
_, _, _, h := d.GetInnerRect()
|
||||
rows := 0
|
||||
if d.hasCover {
|
||||
rows = min(coverRows, h-minTextRows)
|
||||
if rows < minCoverRows {
|
||||
rows = 0
|
||||
}
|
||||
}
|
||||
if rows != d.coverSize {
|
||||
d.coverSize = rows
|
||||
d.ResizeItem(d.cover, rows, 0)
|
||||
}
|
||||
d.Flex.Draw(screen)
|
||||
}
|
||||
@@ -75,14 +75,14 @@ func TestSeriesLabelAndDetails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResultKeysAndWithID(t *testing.T) {
|
||||
var r result = movieResult{radarr.Movie{TMDBID: 603, Title: "x"}}
|
||||
var r result = movieResult{Movie: radarr.Movie{TMDBID: 603, Title: "x"}}
|
||||
if r.key() != "tmdb:603" || r.inLibrary() {
|
||||
t.Fatalf("movie key/inLibrary: %q %v", r.key(), r.inLibrary())
|
||||
}
|
||||
if added := r.withID(5); !added.inLibrary() || r.inLibrary() {
|
||||
t.Error("withID must return a flagged copy and leave the original alone")
|
||||
}
|
||||
r = seriesResult{sonarr.Series{TVDBID: 81189}}
|
||||
r = seriesResult{Series: sonarr.Series{TVDBID: 81189}}
|
||||
if r.key() != "tvdb:81189" {
|
||||
t.Errorf("series key = %q", r.key())
|
||||
}
|
||||
|
||||
+11
-8
@@ -47,6 +47,7 @@ type libItem struct {
|
||||
status string
|
||||
size string
|
||||
details string
|
||||
cover coverRef
|
||||
movie *radarr.Movie
|
||||
series *sonarr.Series
|
||||
// File properties for the attribute filter. For series they are fetched on
|
||||
@@ -199,7 +200,7 @@ type libraryView struct {
|
||||
root *tview.Flex
|
||||
header *tview.TextView
|
||||
input *tview.InputField
|
||||
details *tview.TextView
|
||||
details *detailPane
|
||||
panes [2]*libPane
|
||||
|
||||
// UI-goroutine state.
|
||||
@@ -225,11 +226,10 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
||||
app: app, radarr: r, sonarr: s,
|
||||
header: tview.NewTextView().SetDynamicColors(true),
|
||||
input: tview.NewInputField().SetLabel("Filter Movies: ").SetFieldWidth(0),
|
||||
details: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
||||
details: newDetailPane(app),
|
||||
|
||||
seriesFiles: map[int]seriesFiles{},
|
||||
}
|
||||
v.details.SetBorder(true).SetTitle(" Details ")
|
||||
|
||||
lists := tview.NewFlex().SetDirection(tview.FlexRow) // movies above series
|
||||
for _, kind := range []libKind{kindMovies, kindSeries} {
|
||||
@@ -326,14 +326,18 @@ func (v *libraryView) reloadPane(p *libPane) {
|
||||
var movies []radarr.Movie
|
||||
if movies, err = v.radarr.Movies(ctx); err == nil {
|
||||
for _, m := range movies {
|
||||
items = append(items, newMovieItem(m))
|
||||
it := newMovieItem(m)
|
||||
it.cover = coverFor(v.radarr.Client, m.Images, true)
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var series []sonarr.Series
|
||||
if series, err = v.sonarr.Series(ctx); err == nil {
|
||||
for _, s := range series {
|
||||
items = append(items, v.withCachedFiles(newSeriesItem(s)))
|
||||
it := newSeriesItem(s)
|
||||
it.cover = coverFor(v.sonarr.Client, s.Images, true)
|
||||
items = append(items, v.withCachedFiles(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,11 +427,10 @@ func (v *libraryView) renderPane(p *libPane) {
|
||||
|
||||
func (v *libraryView) renderDetails() {
|
||||
if it, ok := v.selected(); ok {
|
||||
v.details.SetText(it.details)
|
||||
v.details.ScrollToBeginning()
|
||||
v.details.Show(it.details, it.cover)
|
||||
return
|
||||
}
|
||||
v.details.SetText("")
|
||||
v.details.Clear()
|
||||
}
|
||||
|
||||
// open starts the interactive search (movie) or the episode drill-down (series).
|
||||
|
||||
@@ -30,7 +30,7 @@ type searchView struct {
|
||||
root *tview.Flex
|
||||
input *tview.InputField
|
||||
list *tview.List
|
||||
details *tview.TextView
|
||||
details *detailPane
|
||||
|
||||
// UI-goroutine state.
|
||||
results []result
|
||||
@@ -53,10 +53,9 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
|
||||
cfg: cfg,
|
||||
input: tview.NewInputField().SetLabel("Search: "),
|
||||
list: tview.NewList().ShowSecondaryText(false),
|
||||
details: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
||||
details: newDetailPane(app),
|
||||
}
|
||||
v.list.SetBorder(true).SetTitle(" " + be.noun() + " ")
|
||||
v.details.SetBorder(true).SetTitle(" Details ")
|
||||
|
||||
left := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(v.input, 1, 0, true).
|
||||
@@ -231,7 +230,7 @@ func (v *searchView) render(sel int) {
|
||||
v.list.AddItem(r.label(), "", 0, nil)
|
||||
}
|
||||
if len(v.results) == 0 {
|
||||
v.details.SetText("")
|
||||
v.details.Clear()
|
||||
return
|
||||
}
|
||||
sel = min(max(sel, 0), len(v.results)-1)
|
||||
@@ -241,10 +240,10 @@ func (v *searchView) render(sel int) {
|
||||
|
||||
func (v *searchView) showDetails(i int) {
|
||||
if i < 0 || i >= len(v.results) {
|
||||
v.details.SetText("")
|
||||
v.details.Clear()
|
||||
return
|
||||
}
|
||||
v.details.SetText(v.results[i].details()).ScrollToBeginning()
|
||||
v.details.Show(v.results[i].details(), v.results[i].cover())
|
||||
}
|
||||
|
||||
// interactive opens the release list for the result at index i.
|
||||
|
||||
Reference in New Issue
Block a user