Show poster thumbnails in the detail panes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:47:50 +02:00
co-authored by Claude Sonnet 5.5
parent ff204655d3
commit a4303ed3ca
15 changed files with 724 additions and 22 deletions
+5 -1
View File
@@ -8,6 +8,10 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
optionally start the automatic search.
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
- **Posters**: a small poster thumbnail in the detail panes, drawn with colored half-block
characters (needs a terminal with colors; works over SSH). Library items use the copy stored in
Radarr/Sonarr; search results are downloaded from the metadata provider's image host
(TMDb, TheTVDB). Turn it off with `"ui": {"covers": false}`.
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle
monitoring, trigger searches. Series open into seasons and episodes with per-season and
per-episode monitoring, automatic search and release search.
@@ -38,7 +42,7 @@ Settings → General.
"radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" },
"sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" },
"sabnzbd": { "url": "http://localhost:8080", "apikey": "..." },
"ui": { "refresh_seconds": 3 }
"ui": { "refresh_seconds": 3, "covers": true }
}
```
+1
View File
@@ -63,6 +63,7 @@ Notes:
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference.
- **Posters:** the detail panes show a poster thumbnail above the text, rendered as half-block characters with 24-bit colors (each cell is two pixels; JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
- **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
### 4.2 v0.2 (done, M8)
+2 -1
View File
@@ -16,6 +16,7 @@
"apikey": "YOUR_SABNZBD_API_KEY"
},
"ui": {
"refresh_seconds": 3
"refresh_seconds": 3,
"covers": true
}
}
+120
View File
@@ -0,0 +1,120 @@
package arr
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const maxImageBytes = 8 << 20
// Poster returns a key identifying the poster among cached images and a
// function that downloads it, or ok=false when there is none.
//
// Library items have a copy on the Radarr/Sonarr server, which is fetched
// with the API key (preferring the small -250 variant). Titles that are not
// in the library yet only have the remote URL of the metadata provider; that
// is fetched without the API key.
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context) ([]byte, error), ok bool) {
for _, img := range images {
if img.CoverType != "poster" {
continue
}
if inLibrary && img.URL != "" {
local := img.URL
return c.baseURL + "|" + local, func(ctx context.Context) ([]byte, error) {
// MediaCover has poster-250.jpg next to poster.jpg; fall back to the original.
if small := smallVariant(local); small != local {
if data, err := c.GetBytes(ctx, small); err == nil {
return data, nil
}
}
return c.GetBytes(ctx, local)
}, true
}
if img.RemoteURL != "" {
remote := img.RemoteURL
return remote, func(ctx context.Context) ([]byte, error) { return FetchURL(ctx, remote) }, true
}
}
return "", nil, false
}
// smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x.
func smallVariant(p string) string {
path, query, hasQuery := strings.Cut(p, "?")
if !strings.HasSuffix(path, "/poster.jpg") {
return p
}
path = strings.TrimSuffix(path, ".jpg") + "-250.jpg"
if hasQuery {
return path + "?" + query
}
return path
}
// GetBytes downloads a server-relative path such as /MediaCover/1/poster.jpg
// from the instance, authenticated with the API key.
func (c *Client) GetBytes(ctx context.Context, ref string) ([]byte, error) {
u, err := c.resolve(ref)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.apiKey)
return doImage(c.http, req)
}
// resolve joins a server-relative path to the base URL. Radarr/Sonarr include
// their URL base in image paths, so it must not be added twice.
func (c *Client) resolve(ref string) (string, error) {
base, err := url.Parse(c.baseURL)
if err != nil {
return "", err
}
if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") {
return base.Scheme + "://" + base.Host + ref, nil
}
return c.baseURL + ref, nil
}
var imageClient = &http.Client{Timeout: 20 * time.Second}
// FetchURL downloads a public image. No credentials are sent.
func FetchURL(ctx context.Context, rawURL string) ([]byte, error) {
u, err := url.Parse(rawURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return nil, fmt.Errorf("unsupported image URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
return doImage(imageClient, req)
}
func doImage(client *http.Client, req *http.Request) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, stripURL(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("image: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxImageBytes {
return nil, fmt.Errorf("image larger than %d MB", maxImageBytes>>20)
}
return data, nil
}
+96
View File
@@ -0,0 +1,96 @@
package arr
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestSmallVariant(t *testing.T) {
for in, want := range map[string]string{
"/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster-250.jpg?lastWrite=5",
"/MediaCover/1/poster.jpg": "/MediaCover/1/poster-250.jpg",
"/MediaCover/1/fanart.jpg": "/MediaCover/1/fanart.jpg",
} {
if got := smallVariant(in); got != want {
t.Errorf("smallVariant(%q) = %q, want %q", in, got, want)
}
}
}
func TestPosterLocalUsesAPIKey(t *testing.T) {
var paths []string
var keys []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.RequestURI())
keys = append(keys, r.Header.Get("X-Api-Key"))
if r.URL.Path == "/MediaCover/1/poster-250.jpg" {
http.NotFound(w, r) // no small variant: fall back to the original
return
}
w.Write([]byte("IMG"))
}))
defer srv.Close()
c := NewClient(srv.URL, "secret")
imgs := []Image{{CoverType: "fanart", URL: "/MediaCover/1/fanart.jpg"}, {CoverType: "poster", URL: "/MediaCover/1/poster.jpg?lastWrite=1", RemoteURL: "https://remote/p.jpg"}}
key, fetch, ok := c.Poster(imgs, true)
if !ok || key == "" {
t.Fatal("no poster")
}
data, err := fetch(context.Background())
if err != nil || string(data) != "IMG" {
t.Fatalf("data=%q err=%v", data, err)
}
if len(paths) != 2 || paths[0] != "/MediaCover/1/poster-250.jpg?lastWrite=1" || paths[1] != "/MediaCover/1/poster.jpg?lastWrite=1" {
t.Errorf("requests = %v", paths)
}
for _, k := range keys {
if k != "secret" {
t.Errorf("api key header = %q", k)
}
}
}
func TestPosterRemoteSendsNoAPIKey(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("X-Api-Key")
w.Write([]byte("IMG"))
}))
defer srv.Close()
c := NewClient("http://radarr.invalid", "secret")
key, fetch, ok := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/0/poster.jpg", RemoteURL: srv.URL + "/p.jpg"}}, false)
if !ok || key != srv.URL+"/p.jpg" {
t.Fatalf("key=%q ok=%v", key, ok)
}
if _, err := fetch(context.Background()); err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("the API key leaked to a remote image host: %q", got)
}
if _, _, ok := c.Poster([]Image{{CoverType: "banner", RemoteURL: "x"}}, false); ok {
t.Error("only posters count")
}
if _, err := FetchURL(context.Background(), "file:///etc/passwd"); err == nil {
t.Error("non-http schemes must be refused")
}
}
func TestResolveURLBase(t *testing.T) {
c := NewClient("http://host:7878/radarr", "k")
for ref, want := range map[string]string{
"/radarr/MediaCover/1/poster.jpg": "http://host:7878/radarr/MediaCover/1/poster.jpg",
"/MediaCover/1/poster.jpg": "http://host:7878/radarr/MediaCover/1/poster.jpg",
} {
if got, _ := c.resolve(ref); got != want {
t.Errorf("resolve(%q) = %q, want %q", ref, got, want)
}
}
if got, _ := NewClient("http://host", "k").resolve("/MediaCover/1/poster.jpg"); got != "http://host/MediaCover/1/poster.jpg" {
t.Errorf("no url base: %q", got)
}
}
+7
View File
@@ -28,8 +28,15 @@ type Arr struct {
// UI holds interface settings.
type UI struct {
RefreshSeconds int `json:"refresh_seconds,omitempty"`
// Covers shows poster thumbnails in the detail panes (default true).
// Posters of titles that are not in the library yet are downloaded from
// the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB).
Covers *bool `json:"covers,omitempty"`
}
// ShowCovers reports whether poster thumbnails are enabled.
func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers }
// Config is the full application configuration.
type Config struct {
Radarr Arr `json:"radarr"`
+21
View File
@@ -76,3 +76,24 @@ func TestLoadPermissionWarning(t *testing.T) {
t.Fatalf("unexpected warnings: %v", warnings)
}
}
func TestShowCovers(t *testing.T) {
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}`
for _, tc := range []struct {
ui string
want bool
}{
{``, true},
{`,"ui":{}`, true},
{`,"ui":{"covers":true}`, true},
{`,"ui":{"covers":false}`, false},
} {
cfg, err := Parse([]byte(base + tc.ui + `}`))
if err != nil {
t.Fatal(err)
}
if got := cfg.UI.ShowCovers(); got != tc.want {
t.Errorf("%q: ShowCovers = %v, want %v", tc.ui, got, tc.want)
}
}
}
+6
View File
@@ -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
View File
@@ -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
}
+204
View File
@@ -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)
}
})
}()
}
+149
View File
@@ -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")
}
}
+82
View File
@@ -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)
}
+2 -2
View File
@@ -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
View File
@@ -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).
+5 -6
View File
@@ -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.