Posters: decode WebP and GIF; say what unreadable poster data looks like
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+48
-3
@@ -6,8 +6,11 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg" // poster formats
|
||||
_ "image/png"
|
||||
_ "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"
|
||||
"strings"
|
||||
@@ -33,7 +36,7 @@ const (
|
||||
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: %w", err)
|
||||
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)
|
||||
@@ -534,3 +537,45 @@ func posterSource(key string) string {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package ui
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -303,3 +306,44 @@ func TestCoverCellsKeepTheShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCoverOtherFormats(t *testing.T) {
|
||||
// A WebP stored under a .jpg name, as Sonarr keeps what the provider sends.
|
||||
webp, _ := base64.StdEncoding.DecodeString("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==")
|
||||
img, err := decodeCover(webp, storedCoverHeight)
|
||||
if err != nil || img.Bounds().Dx() != 1 || img.Bounds().Dy() != 1 {
|
||||
t.Fatalf("webp: %v %v", img, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
pal := image.NewPaletted(image.Rect(0, 0, 20, 30), []color.Color{color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}})
|
||||
if err := gif.Encode(&buf, pal, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
|
||||
t.Errorf("gif: %v %v", img, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeBytesAndTheErrorItGives(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, "empty"},
|
||||
{"avif", []byte("\x00\x00\x00\x1cftypavif\x00\x00"), "AVIF/HEIC, avif"},
|
||||
{"html", []byte(" <html><body>Not Found</body></html>"), "HTML or SVG"},
|
||||
{"bmp", []byte("BM\x00\x00\x00\x00"), "BMP"},
|
||||
{"tiff", []byte("II*\x00\x08\x00"), "TIFF"},
|
||||
{"binary", []byte{1, 2, 3, 4, 5}, "5 bytes starting 01 02 03 04 05"},
|
||||
} {
|
||||
if got := describeBytes(tc.data); !strings.Contains(got, tc.want) {
|
||||
t.Errorf("%s: %q, want it to mention %q", tc.name, got, tc.want)
|
||||
}
|
||||
_, err := decodeCover(tc.data, storedCoverHeight)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: the error %v should say what the data is (%q)", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user