Widgets copy their colors when they are created, so they now get role tokens (one unique RGB value per role) instead of colors, and a wrapper around the tcell screen replaces the tokens with the current theme's colors in SetContent. Changing the theme in the Config view swaps that table; the next draw recolors everything. Poster colors that equal a token are nudged off it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
607 lines
20 KiB
Go
607 lines
20 KiB
Go
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 }
|
||
|
||
// aspect returns the width and height of the poster (2:3 until one is loaded).
|
||
func (c *coverView) aspect() (w, h int) {
|
||
if c.img == nil {
|
||
return 2, 3
|
||
}
|
||
b := c.img.Bounds()
|
||
return b.Dx(), b.Dy()
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
b := c.img.Bounds()
|
||
cols, rows := coverCells(b.Dx(), b.Dy(), w, h, c.aspectOfCells())
|
||
if cols < 1 || rows < 1 {
|
||
return
|
||
}
|
||
if c.drawGraphics(screen, x+w-cols, 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)
|
||
}
|
||
// Right-align the poster inside its area, so a narrower poster sits against the edge.
|
||
x += w - cols
|
||
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
|
||
scaled := boxScale(c.img, 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 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}
|
||
maxHeight := a.coverMaxHeight()
|
||
|
||
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, maxHeight)
|
||
}
|
||
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))
|
||
}
|