Show poster thumbnails in the detail panes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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