Ghostty shows an image smaller than its placement at its own size, so library posters (Radarr's poster-500 is 333x500, not 500x750) came out smaller than the 426x640 remote ones. Images are now scaled (bilinear) to exactly cols*cellWidth x rows*cellHeight pixels before upload, and library titles use the original poster for real images. The disk cache stores posters as decoded (JPEG) instead of the possibly large originals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
7.7 KiB
Go
218 lines
7.7 KiB
Go
package arr
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestScaledVariant(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in string
|
|
width int
|
|
want string
|
|
}{
|
|
{"/MediaCover/1/poster.jpg?lastWrite=5", 250, "/MediaCover/1/poster-250.jpg?lastWrite=5"},
|
|
{"/MediaCover/1/poster.jpg", 500, "/MediaCover/1/poster-500.jpg"},
|
|
{"/MediaCover/1/fanart.jpg", 250, "/MediaCover/1/fanart.jpg"},
|
|
} {
|
|
if got := scaledVariant(tc.in, tc.width); got != tc.want {
|
|
t.Errorf("scaledVariant(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLargePosterUsesTheOriginal(t *testing.T) {
|
|
var paths []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
paths = append(paths, r.URL.Path)
|
|
w.Write([]byte("IMG"))
|
|
}))
|
|
defer srv.Close()
|
|
c := NewClient(srv.URL, "secret")
|
|
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/1/poster.jpg"}}, true)
|
|
if _, err := fetch(context.Background(), true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := fetch(context.Background(), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(paths, " ") != "/MediaCover/1/poster.jpg /MediaCover/1/poster-250.jpg" {
|
|
t.Errorf("requests = %v", paths)
|
|
}
|
|
}
|
|
|
|
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(), false)
|
|
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(), false); 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)
|
|
}
|
|
}
|
|
|
|
func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
|
|
var remoteKey string
|
|
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
remoteKey = r.Header.Get("X-Api-Key")
|
|
w.Write([]byte("REMOTE"))
|
|
}))
|
|
defer remote.Close()
|
|
instance := httptest.NewServer(http.NotFoundHandler()) // MediaCover has nothing cached
|
|
defer instance.Close()
|
|
|
|
c := NewClient(instance.URL, "secret")
|
|
_, fetch, ok := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true)
|
|
if !ok {
|
|
t.Fatal("no poster")
|
|
}
|
|
data, err := fetch(context.Background(), false)
|
|
if err != nil || string(data) != "REMOTE" {
|
|
t.Fatalf("data=%q err=%v", data, err)
|
|
}
|
|
if remoteKey != "" {
|
|
t.Errorf("the API key went to the provider: %q", remoteKey)
|
|
}
|
|
|
|
// Without a remote address the local failure is reported.
|
|
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg"}}, true)
|
|
if _, err := fetch(context.Background(), false); err == nil {
|
|
t.Error("expected the local error")
|
|
}
|
|
}
|
|
|
|
// loginWall answers like an instance that requires a login for /MediaCover:
|
|
// a 200 with an HTML page, unless the request carries the key as a query parameter.
|
|
func loginWall(acceptQuery bool, hits *[]string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
*hits = append(*hits, r.URL.RequestURI()+" key-header="+r.Header.Get("X-Api-Key"))
|
|
if acceptQuery && r.URL.Query().Get("apikey") == "secret" {
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Write([]byte("IMAGE"))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte("<!DOCTYPE html><html><head><title>Login</title>"))
|
|
}
|
|
}
|
|
|
|
func TestPosterTriesTheKeyInTheQueryWhenGivenALoginPage(t *testing.T) {
|
|
var hits []string
|
|
srv := httptest.NewServer(loginWall(true, &hits))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL, "secret")
|
|
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
|
data, err := fetch(context.Background(), false)
|
|
if err != nil || string(data) != "IMAGE" {
|
|
t.Fatalf("data=%q err=%v, hits=%v", data, err, hits)
|
|
}
|
|
// Small variant with the header (a page), then the same file with the key in the query.
|
|
if len(hits) != 2 || !strings.Contains(hits[0], "poster-250.jpg") || !strings.Contains(hits[0], "key-header=secret") ||
|
|
!strings.Contains(hits[1], "poster-250.jpg?apikey=secret") {
|
|
t.Errorf("requests = %v", hits)
|
|
}
|
|
}
|
|
|
|
func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
|
|
var hits []string
|
|
instance := httptest.NewServer(loginWall(false, &hits))
|
|
defer instance.Close()
|
|
var remoteKey, remoteQuery string
|
|
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
remoteKey, remoteQuery = r.Header.Get("X-Api-Key"), r.URL.RawQuery
|
|
w.Header().Set("Content-Type", "image/webp")
|
|
w.Write([]byte("WEBP"))
|
|
}))
|
|
defer remote.Close()
|
|
|
|
c := NewClient(instance.URL, "secret")
|
|
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true)
|
|
data, err := fetch(context.Background(), false)
|
|
if err != nil || string(data) != "WEBP" {
|
|
t.Fatalf("data=%q err=%v", data, err)
|
|
}
|
|
if remoteKey != "" || remoteQuery != "" {
|
|
t.Errorf("credentials reached the provider: header %q query %q", remoteKey, remoteQuery)
|
|
}
|
|
if len(hits) != 2 { // the small variant with the header and with the query; the original is skipped
|
|
t.Errorf("instance requests = %v", hits)
|
|
}
|
|
|
|
// Without a provider address the failure says what came back.
|
|
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
|
_, err = fetch(context.Background(), false)
|
|
if err == nil || !strings.Contains(err.Error(), "text/html") || !strings.Contains(err.Error(), "login") {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
}
|