From a4303ed3cac61492d7a94c17ffca167fc3a3b1e3 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 21:47:50 +0200 Subject: [PATCH] Show poster thumbnails in the detail panes Co-Authored-By: Claude Sonnet 5.5 --- README.md | 6 +- SPECS.md | 1 + config.example.json | 3 +- internal/arr/image.go | 120 +++++++++++++++++++ internal/arr/image_test.go | 96 ++++++++++++++++ internal/config/config.go | 7 ++ internal/config/config_test.go | 21 ++++ internal/ui/app.go | 6 + internal/ui/backend.go | 17 ++- internal/ui/cover.go | 204 +++++++++++++++++++++++++++++++++ internal/ui/cover_test.go | 149 ++++++++++++++++++++++++ internal/ui/detail.go | 82 +++++++++++++ internal/ui/format_test.go | 4 +- internal/ui/library.go | 19 +-- internal/ui/search.go | 11 +- 15 files changed, 724 insertions(+), 22 deletions(-) create mode 100644 internal/arr/image.go create mode 100644 internal/arr/image_test.go create mode 100644 internal/ui/cover.go create mode 100644 internal/ui/cover_test.go create mode 100644 internal/ui/detail.go diff --git a/README.md b/README.md index 3b6dd38..b013210 100644 --- a/README.md +++ b/README.md @@ -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 } } ``` diff --git a/SPECS.md b/SPECS.md index 7802e46..c03acf1 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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//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) diff --git a/config.example.json b/config.example.json index 4bec46a..11c2c80 100644 --- a/config.example.json +++ b/config.example.json @@ -16,6 +16,7 @@ "apikey": "YOUR_SABNZBD_API_KEY" }, "ui": { - "refresh_seconds": 3 + "refresh_seconds": 3, + "covers": true } } diff --git a/internal/arr/image.go b/internal/arr/image.go new file mode 100644 index 0000000..3a0ecbc --- /dev/null +++ b/internal/arr/image.go @@ -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 +} diff --git a/internal/arr/image_test.go b/internal/arr/image_test.go new file mode 100644 index 0000000..1e08f47 --- /dev/null +++ b/internal/arr/image_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index aed7b87..13236c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5ddff2a..90893c1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index c0e1113..b87dd4f 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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) diff --git a/internal/ui/backend.go b/internal/ui/backend.go index 773f44d..f26d742 100644 --- a/internal/ui/backend.go +++ b/internal/ui/backend.go @@ -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 } diff --git a/internal/ui/cover.go b/internal/ui/cover.go new file mode 100644 index 0000000..87ab185 --- /dev/null +++ b/internal/ui/cover.go @@ -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) + } + }) + }() +} diff --git a/internal/ui/cover_test.go b/internal/ui/cover_test.go new file mode 100644 index 0000000..a2a7736 --- /dev/null +++ b/internal/ui/cover_test.go @@ -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("not an image")); 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") + } +} diff --git a/internal/ui/detail.go b/internal/ui/detail.go new file mode 100644 index 0000000..dfe3443 --- /dev/null +++ b/internal/ui/detail.go @@ -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) +} diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go index 1c4d2f9..5e66360 100644 --- a/internal/ui/format_test.go +++ b/internal/ui/format_test.go @@ -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()) } diff --git a/internal/ui/library.go b/internal/ui/library.go index 25c1d07..d26ba66 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -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). diff --git a/internal/ui/search.go b/internal/ui/search.go index 472ee91..17e0c28 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -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.