Files
arr-tui/internal/ui/cover.go
T
nilsandClaude Opus 5.5 786e783951 Posters: always a 2:3 box
The poster box followed the image's shape, so images Radarr/Sonarr hand
out in another shape came out smaller than the others in a list. The box
is now always 2:3: an image within 12% of that fills it, trimmed at the
long sides (block characters too, which were stretched before); one
further off is shown whole, centred in the box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:15:26 +02:00

646 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
_ "image/gif" // poster formats
_ "image/jpeg" // (Radarr and Sonarr store the provider's bytes under a .jpg name,
_ "image/png" // whatever format they are in)
_ "golang.org/x/image/webp"
"net/url"
"os/exec"
"runtime"
"strings"
"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 block characters
graphicsCoverHeight = 640 // posters shown as real images keep more detail
coverFailureTTL = 2 * time.Minute
maxCoverCache = 120
coverFetchLimit = 4
)
// decodeCover decodes a JPEG or PNG poster and keeps a copy at most
// maxHeight pixels tall.
func decodeCover(data []byte, maxHeight int) (*image.RGBA, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("not a supported image (%s): %w", describeBytes(data), 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() <= maxHeight {
return boxScale(img, b.Dx(), b.Dy()), nil
}
return boxScale(img, max(1, b.Dx()*maxHeight/b.Dy()), maxHeight), 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
}
// coverStyle is a way to draw a poster with block characters: every cell
// shows sw×sh pixels in two colors, the best split of its pixels into a
// foreground and a background group.
type coverStyle struct {
name string
sw, sh int
glyph func(mask int) rune // mask: bit i = pixel i (row-major) is foreground
}
var (
// quadrantStyle (2×2 pixels): the glyphs are in the Block Elements range
// that every terminal font has.
quadrantStyle = coverStyle{"quadrant", 2, 2, func(mask int) rune { return quadrants[mask] }}
// sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols
// for Legacy Computing block, which many fonts lack.
sextantStyle = coverStyle{"sextant", 2, 3, sextantGlyph}
// cellsStyle (1 pixel per cell): only background colors, no glyphs at all.
// Coarse, but exact in terminals that draw block elements from the font
// with the halves off the cell's middle (macOS Terminal).
cellsStyle = coverStyle{"cells", 1, 1, func(int) rune { return ' ' }}
)
// quadrants[mask] is the glyph that fills the quadrants set in mask
// (bit 0 top left, 1 top right, 2 bottom left, 3 bottom right).
var quadrants = [16]rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
// sextantGlyph maps a 2×3 pixel mask (bit 0 top left, 1 top right, 2 middle
// left, ... 5 bottom right) to its character. Unicode lists the 60 sextants
// in mask order but leaves out the empty and full cell and the two halves,
// which already exist as space, full block and the half blocks.
func sextantGlyph(mask int) rune {
switch mask {
case 0:
return ' '
case 63:
return '█'
case 21:
return '▌'
case 42:
return '▐'
}
idx := mask - 1
if mask > 21 {
idx--
}
if mask > 42 {
idx--
}
return rune(0x1FB00 + idx)
}
// coverStyleFor resolves the block-character style for the setting. "auto"
// picks sextants on terminals known to have the glyphs, plain cells in macOS
// Terminal and quadrants everywhere else.
func coverStyleFor(setting string, env func(string) string) coverStyle {
switch setting {
case "sextant":
return sextantStyle
case "quadrant":
return quadrantStyle
case "cells":
return cellsStyle
}
// Terminal.app draws block elements from the font, and their halves do
// not meet at the cell's middle: rows of a poster come out uneven.
if env("TERM_PROGRAM") == "Apple_Terminal" {
return cellsStyle
}
term, program := strings.ToLower(env("TERM")), strings.ToLower(env("TERM_PROGRAM"))
for _, known := range []string{"kitty", "foot", "ghostty", "wezterm"} {
if strings.Contains(term, known) || strings.Contains(program, known) {
return sextantStyle
}
}
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
return sextantStyle
}
// Other terminals (GNOME Terminal, Konsole, Alacritty...) draw glyphs their
// font lacks from a fallback font, so sextants work if some installed font
// has them. Over SSH the installed fonts are the remote machine's, which
// says nothing about the terminal, so keep the safe style there.
// macOS terminals do not draw missing glyphs from fontconfig's fonts, so a
// font found there says nothing about them.
if runtime.GOOS != "darwin" && env("SSH_CONNECTION") == "" && env("SSH_TTY") == "" && sextantFontAvailable() {
return sextantStyle
}
return quadrantStyle
}
// sextantFontAvailable reports whether fontconfig knows a font with the
// sextant characters. It is a variable so tests can replace it.
var sextantFontAvailable = func() bool {
path, err := exec.LookPath("fc-list")
if err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, path, ":charset=1fb00", "family").Output()
return err == nil && len(bytes.TrimSpace(out)) > 0
}
// resolveCovers decides how posters are drawn: as real images through the
// Kitty graphics protocol (graphics) or with block characters in the given
// style. "kitty" asks for the protocol explicitly; "auto" uses it when the
// terminal is known to support it. The block style is also the fallback when
// the protocol cannot be used at draw time (e.g. no true color).
func resolveCovers(setting string, env func(string) string) (blocks coverStyle, graphics bool) {
switch setting {
case "kitty":
// Asked for explicitly, but a terminal without the protocol prints the
// commands as text, so it is still confirmed.
return coverStyleFor("auto", env), kittyGraphicsSupported()
case "auto", "":
return coverStyleFor("auto", env), kittyTerminal(env) && kittyGraphicsSupported()
}
return coverStyleFor(setting, env), false
}
// kittyGraphicsSupported reports whether the terminal confirmed the Kitty
// graphics protocol when it was asked at startup (see probeTerminal). It is a
// variable so tests can replace it.
var kittyGraphicsSupported = func() bool { return terminalAnswers().graphics }
// flatPixelError is the squared RGB distance per pixel below which a cell is
// drawn flat: smooth areas and noise should not turn into patterns.
const flatPixelError = 150
// bestCell picks the glyph and the two colors that best reproduce the pixels
// of one cell. Only masks without the last pixel are tried, because a mask
// and its complement draw the same picture with the colors swapped.
func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
n := len(p)
type sum struct{ r, g, b, n float64 }
var all sum
for _, c := range p {
all.r, all.g, all.b, all.n = all.r+float64(c.R), all.g+float64(c.G), all.b+float64(c.B), all.n+1
}
avg := func(s sum) [3]float64 {
if s.n == 0 {
return [3]float64{}
}
return [3]float64{s.r / s.n, s.g / s.n, s.b / s.n}
}
dist := func(c color.RGBA, m [3]float64) float64 {
dr, dg, db := float64(c.R)-m[0], float64(c.G)-m[1], float64(c.B)-m[2]
return dr*dr + dg*dg + db*db
}
toRGBA := func(m [3]float64) color.RGBA {
return color.RGBA{uint8(m[0] + .5), uint8(m[1] + .5), uint8(m[2] + .5), 255}
}
flat := avg(all)
var flatErr float64
for _, c := range p {
flatErr += dist(c, flat)
}
if flatErr < flatPixelError*float64(n) {
c := toRGBA(flat)
return ' ', c, c
}
best, bestErr := 0, flatErr*0.6 // a split must clearly beat the flat cell
var bestFg, bestBg [3]float64
for mask := 1; mask < 1<<(n-1); mask++ {
var f, b sum
for i, c := range p {
t := &b
if mask>>i&1 == 1 {
t = &f
}
t.r, t.g, t.b, t.n = t.r+float64(c.R), t.g+float64(c.G), t.b+float64(c.B), t.n+1
}
fm, bm := avg(f), avg(b)
var e float64
for i, c := range p {
if mask>>i&1 == 1 {
e += dist(c, fm)
} else {
e += dist(c, bm)
}
if e >= bestErr {
break
}
}
if e < bestErr {
best, bestErr, bestFg, bestBg = mask, e, fm, bm
}
}
if best == 0 {
c := toRGBA(flat)
return ' ', c, c
}
return st.glyph(best), toRGBA(bestFg), toRGBA(bestBg)
}
// coverView draws a poster, see coverStyle. It works in any terminal with
// colors, also over SSH.
type coverView struct {
*tview.Box
style coverStyle
gfx *kittyGraphics // real images; nil for block characters only
lastDrawn [5]int // id, position and size of the last real image drawn, for the debug log
cellAspect func() float64 // height of a terminal cell divided by its width
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
// The cells for the last drawn size.
cols, rows int
cells []styledCell
}
type styledCell struct {
ch rune
fg, bg tcell.Color
}
func newCoverView(style coverStyle, gfx *kittyGraphics, cellAspect func() float64) *coverView {
return &coverView{Box: tview.NewBox(), style: style, gfx: gfx, cellAspect: cellAspect}
}
func (c *coverView) aspectOfCells() float64 {
if c.cellAspect != nil {
if a := c.cellAspect(); a > 0.5 && a < 8 {
return a
}
}
return 2
}
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil }
func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil }
// Every poster gets a box of the usual poster shape, 2:3, so all posters of a
// list have the same size whatever image Radarr/Sonarr hand out.
const (
posterW, posterH = 2, 3
// posterShapeTolerance is how far an image's shape may differ from 2:3 and
// still fill the box (trimmed at the long sides); one further off is shown
// whole inside it.
posterShapeTolerance = 0.12
)
// aspect returns the shape of the box the poster is shown in.
func (c *coverView) aspect() (w, h int) { return posterW, posterH }
// nearPosterShape reports whether an image is close enough to 2:3 to fill the box.
func nearPosterShape(b image.Rectangle) bool {
if b.Dx() <= 0 || b.Dy() <= 0 {
return false
}
d := float64(b.Dx())*posterH/(float64(b.Dy())*posterW) - 1
return d >= -posterShapeTolerance && d <= posterShapeTolerance
}
// coverFitTolerance is how far the shape of the cell box may differ from the
// poster's, as a fraction. The box is chosen to match the poster's shape
// closely; the small remainder is cropped away from the poster before it is
// shown as a real image, because a real image placed in a box of another shape
// is letterboxed (visible bars beside or above it).
const coverFitTolerance = 0.03
// coverCells returns how many columns and rows a poster of the given shape
// needs inside w×h cells. cellAspect is the height of a cell divided by its
// width, about 2 in most terminals. Cell counts are whole numbers, so the
// largest size whose box is within coverFitTolerance of the poster's shape is
// chosen (or the closest one if none is).
func coverCells(imgW, imgH, w, h int, cellAspect float64) (cols, rows int) {
if imgW <= 0 || imgH <= 0 || w <= 0 || h <= 0 || cellAspect <= 0 {
return 0, 0
}
shape := float64(imgW) / float64(imgH) // width / height of the poster
// A box of r rows is r*cellAspect*shape columns wide for the same shape.
maxRows := min(h, int(float64(w)/(cellAspect*shape)))
if maxRows < 1 {
return w, 0
}
bestErr := 2.0
for r := maxRows; r >= max(1, maxRows-6); r-- {
c := int(float64(r)*cellAspect*shape + 0.5)
if c < 1 || c > w {
continue
}
boxShape := float64(c) / (float64(r) * cellAspect)
err := boxShape/shape - 1
if err < 0 {
err = -err
}
if err <= coverFitTolerance {
return c, r
}
if err < bestErr {
bestErr, cols, rows = err, c, r
}
}
return cols, rows
}
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, named("gray"))
}
return
}
// The 2:3 box, right-aligned in the area. An image close to that shape
// fills it (trimmed); one far from it (square, landscape) is shown whole,
// centred in the box.
b := c.img.Bounds()
boxW, boxH := coverCells(posterW, posterH, w, h, c.aspectOfCells())
cols, rows := boxW, boxH
if !nearPosterShape(b) {
cols, rows = coverCells(b.Dx(), b.Dy(), boxW, boxH, c.aspectOfCells())
}
if cols < 1 || rows < 1 {
return
}
x += w - boxW + (boxW-cols)/2
y += (boxH - rows) / 2
if c.drawGraphics(screen, x, y, cols, rows) {
return
}
if c.cells == nil || c.cols != cols || c.rows != rows {
c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows)
}
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
cell := c.cells[row*cols+col]
screen.SetContent(x+col, y+row, cell.ch, nil, tcell.StyleDefault.Foreground(cell.fg).Background(cell.bg))
}
}
}
// cropToBox returns the part of an image of the given bounds that has the same
// shape as a box of cols×rows cells: a few pixels are trimmed from both sides
// of the longer dimension (at most the fit tolerance), so the image fills the
// box exactly and the terminal does not letterbox it.
func cropToBox(b image.Rectangle, cols, rows int, cellAspect float64) image.Rectangle {
box := float64(cols) / (float64(rows) * cellAspect) // width / height of the box
shape := float64(b.Dx()) / float64(b.Dy())
if box < shape { // box is narrower: trim the sides
w := int(float64(b.Dy())*box + 0.5)
trim := (b.Dx() - w) / 2
return image.Rect(b.Min.X+trim, b.Min.Y, b.Min.X+trim+w, b.Max.Y)
}
h := int(float64(b.Dx())/box + 0.5) // box is wider: trim top and bottom
trim := (b.Dy() - h) / 2
return image.Rect(b.Min.X, b.Min.Y+trim, b.Max.X, b.Min.Y+trim+h)
}
// drawGraphics shows the poster as a real image through the Kitty graphics
// protocol: the image is uploaded once and the cells get placeholder
// characters. It reports false when that is not possible (no protocol, no true
// color, too many rows or columns, or the terminal could not be written to), so
// the caller can draw block characters instead.
func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool {
if c.gfx == nil || c.gfx.settling() || screen.Colors() < 1<<24 || cols > maxPlaceholders || rows > maxPlaceholders {
return false
}
id, err := c.gfx.show(c.img, cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells()), cols, rows)
if err != nil {
return false
}
if c.gfx.debug != nil {
if drawn := [5]int{int(id), x, y, cols, rows}; drawn != c.lastDrawn {
c.lastDrawn = drawn
c.gfx.debug(fmt.Sprintf("draw: id %d at column %d, row %d over %dx%d cells", id, x, y, cols, rows))
}
}
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
ch, comb, style := placeholderCell(id, row, col)
screen.SetContent(x+col, y+row, ch, comb, style)
}
}
return true
}
// build renders the poster into cols×rows cells.
func (c *coverView) build(cols, rows int) []styledCell {
st := c.style
// Trim the image to the shape of the cells first, as for real images, so
// it is not stretched.
crop := cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells())
scaled := boxScale(c.img.SubImage(crop), cols*st.sw, rows*st.sh)
cells := make([]styledCell, 0, cols*rows)
px := make([]color.RGBA, st.sw*st.sh)
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
for sy := 0; sy < st.sh; sy++ {
for sx := 0; sx < st.sw; sx++ {
i := scaled.PixOffset(col*st.sw+sx, row*st.sh+sy)
px[sy*st.sw+sx] = color.RGBA{scaled.Pix[i], scaled.Pix[i+1], scaled.Pix[i+2], 255}
}
}
ch, fg, bg := st.bestCell(px)
cells = append(cells, styledCell{ch,
notToken(tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B))),
notToken(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))})
}
}
return cells
}
// 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 downloads the poster; large asks for a bigger copy (for real images).
fetch func(ctx context.Context, large bool) ([]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}
maxHeight := a.coverMaxHeight()
large := a.gfx != nil // real images show the poster at full resolution
go func() {
c.slots <- struct{}{}
defer func() { <-c.slots }()
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
var img *image.RGBA
var err error
// A copy from an earlier run is used while it is fresh enough.
if data, ok := a.disk.readPoster(ref.key, large); ok {
if img, err = decodeCover(data, maxHeight); err != nil {
a.disk.dropPoster(ref.key, large)
img = nil
}
}
if img == nil {
var data []byte
data, err = ref.fetch(ctx, large)
if err == nil {
img, err = decodeCover(data, maxHeight)
}
if err == nil {
a.disk.writePoster(ref.key, large, data)
}
}
a.post(func() {
waiters := c.waiting[ref.key]
delete(c.waiting, ref.key)
if err != nil {
c.failed[ref.key] = time.Now()
// Say why in the log: a poster that silently never shows up is hard to diagnose.
a.logs.add(levelWarn, "Poster not loaded ("+posterSource(ref.key)+"): "+err.Error())
} else {
if len(c.imgs) >= maxCoverCache {
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again
if a.gfx != nil {
a.gfx.deleteAll() // the terminal's copies belong to the dropped images
}
}
c.imgs[ref.key] = img
}
for _, w := range waiters {
w(img, err)
}
})
}()
}
// posterSource describes where a poster key points, for the log: the host and
// file name of a remote image, or the path on the Radarr/Sonarr instance.
func posterSource(key string) string {
if _, local, ok := strings.Cut(key, "|"); ok {
key = local
}
if u, err := url.Parse(key); err == nil && u.Host != "" {
return u.Host + u.Path
}
path, _, _ := strings.Cut(key, "?")
return path
}
// describeBytes says what a blob of unknown data looks like, for error
// messages: a file Radarr/Sonarr stored as poster.jpg can be any format, or an
// error page.
func describeBytes(data []byte) string {
if len(data) == 0 {
return "empty"
}
head := data[:min(len(data), 16)]
switch {
case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP":
return "WebP"
case bytes.HasPrefix(data, []byte("GIF8")):
return "GIF"
case bytes.HasPrefix(data, []byte("\x89PNG")):
return "PNG"
case bytes.HasPrefix(data, []byte("\xff\xd8")):
return "JPEG"
case len(data) >= 12 && string(data[4:8]) == "ftyp":
return "AVIF/HEIC, " + strings.TrimSpace(string(data[8:12]))
case bytes.HasPrefix(data, []byte("BM")):
return "BMP"
case bytes.HasPrefix(data, []byte("II*\x00")) || bytes.HasPrefix(data, []byte("MM\x00*")):
return "TIFF"
case bytes.HasPrefix(bytes.TrimSpace(data), []byte("<")):
return "text, probably an HTML or SVG page: " + printable(bytes.TrimSpace(data), 40)
}
return fmt.Sprintf("%d bytes starting % x", len(data), head)
}
// printable returns up to n bytes of data with control characters replaced.
func printable(data []byte, n int) string {
if len(data) > n {
data = data[:n]
}
return strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return ' '
}
return r
}, string(data))
}