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" "sync" "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 ' ' }} // halvesStyle (2×1 pixels): left and right half blocks only. Twice the // detail of cells across, for terminals whose upper and lower halves do not // meet at the middle but whose left and right ones do. halvesStyle = coverStyle{"halves", 2, 1, func(mask int) rune { return [4]rune{' ', '▌', '▐', '█'}[mask] }} ) // 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 case "halves": return halvesStyle } // 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 } var ( kittyProbeOnce sync.Once kittyProbeResult bool ) // kittyGraphicsSupported asks the terminal once (see probeKittyGraphics) and // remembers the answer. It is a variable so tests can replace it. var kittyGraphicsSupported = func() bool { kittyProbeOnce.Do(func() { kittyProbeResult = probeKittyGraphics(300 * time.Millisecond) }) return kittyProbeResult } // 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, tcell.ColorGray) } 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, tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B)), 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)) }