Real-image posters of library titles: use the 500 px copy
Library titles fetched Radarr's/Sonarr's 250×375 poster copy, which real images show at up to 640 pixels, so movie posters looked soft next to series posters that came from the provider's full-size original (Sonarr behind a login). With real images the 500×750 copy is used; block-character posters keep the small one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
+23
-15
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -17,10 +18,11 @@ const maxImageBytes = 8 << 20
|
||||
// function that downloads it, or ok=false when there is none.
|
||||
//
|
||||
// Library items have a copy on the Radarr/Sonarr server, which is fetched
|
||||
// with the API key (preferring the small -250 variant). Titles that are not
|
||||
// in the library yet only have the remote URL of the metadata provider; that
|
||||
// is fetched without the API key.
|
||||
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context) ([]byte, error), ok bool) {
|
||||
// with the API key, preferring a scaled variant: -250 (250×375), or -500
|
||||
// (500×750) when fetch is asked for a large poster. Titles that are not in
|
||||
// the library yet only have the remote URL of the metadata provider; that is
|
||||
// fetched without the API key.
|
||||
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context, large bool) ([]byte, error), ok bool) {
|
||||
for _, img := range images {
|
||||
if img.CoverType != "poster" {
|
||||
continue
|
||||
@@ -28,8 +30,8 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
|
||||
if inLibrary && img.URL != "" {
|
||||
local, remote := img.URL, img.RemoteURL
|
||||
base, _ := c.settings()
|
||||
return base + "|" + local, func(ctx context.Context) ([]byte, error) {
|
||||
data, err := c.localPoster(ctx, local)
|
||||
return base + "|" + local, func(ctx context.Context, large bool) ([]byte, error) {
|
||||
data, err := c.localPoster(ctx, local, large)
|
||||
if err != nil && remote != "" {
|
||||
// The instance has no usable copy (not cached, or behind a login):
|
||||
// use the metadata provider's, as for titles not in the library.
|
||||
@@ -42,17 +44,17 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
|
||||
}
|
||||
if img.RemoteURL != "" {
|
||||
remote := img.RemoteURL
|
||||
return remote, func(ctx context.Context) ([]byte, error) { return FetchURL(ctx, remote) }, true
|
||||
return remote, func(ctx context.Context, _ bool) ([]byte, error) { return FetchURL(ctx, remote) }, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// localPoster fetches a poster from the instance: the small variant, then the
|
||||
// original. When the instance answers with a page instead of an image (it
|
||||
// localPoster fetches a poster from the instance: the scaled variant (-500
|
||||
// when large, else -250), then the original. When the instance answers with a page instead of an image (it
|
||||
// wants a login for /MediaCover) the API key is also tried as a query
|
||||
// parameter, which is the other way Radarr and Sonarr accept it.
|
||||
func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error) {
|
||||
func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]byte, error) {
|
||||
try := func(ref string) ([]byte, error) {
|
||||
data, err := c.GetBytes(ctx, ref)
|
||||
var page *notImageError
|
||||
@@ -63,8 +65,12 @@ func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
if small := smallVariant(local); small != local {
|
||||
data, err := try(small)
|
||||
width := 250
|
||||
if large {
|
||||
width = 500
|
||||
}
|
||||
if scaled := scaledVariant(local, width); scaled != local {
|
||||
data, err := try(scaled)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
@@ -76,13 +82,15 @@ func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error)
|
||||
return try(local)
|
||||
}
|
||||
|
||||
// smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x.
|
||||
func smallVariant(p string) string {
|
||||
// scaledVariant turns /MediaCover/1/poster.jpg?x into
|
||||
// /MediaCover/1/poster-250.jpg?x (for width 250; Radarr and Sonarr keep 250
|
||||
// and 500).
|
||||
func scaledVariant(p string, width int) string {
|
||||
path, query, hasQuery := strings.Cut(p, "?")
|
||||
if !strings.HasSuffix(path, "/poster.jpg") {
|
||||
return p
|
||||
}
|
||||
path = strings.TrimSuffix(path, ".jpg") + "-250.jpg"
|
||||
path = strings.TrimSuffix(path, ".jpg") + "-" + strconv.Itoa(width) + ".jpg"
|
||||
if hasQuery {
|
||||
return path + "?" + query
|
||||
}
|
||||
|
||||
+38
-14
@@ -8,18 +8,42 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSmallVariant(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster-250.jpg?lastWrite=5",
|
||||
"/MediaCover/1/poster.jpg": "/MediaCover/1/poster-250.jpg",
|
||||
"/MediaCover/1/fanart.jpg": "/MediaCover/1/fanart.jpg",
|
||||
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 := smallVariant(in); got != want {
|
||||
t.Errorf("smallVariant(%q) = %q, want %q", in, got, want)
|
||||
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 TestLargePosterUsesThe500Variant(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-500.jpg /MediaCover/1/poster-250.jpg" {
|
||||
t.Errorf("requests = %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPosterLocalUsesAPIKey(t *testing.T) {
|
||||
var paths []string
|
||||
var keys []string
|
||||
@@ -40,7 +64,7 @@ func TestPosterLocalUsesAPIKey(t *testing.T) {
|
||||
if !ok || key == "" {
|
||||
t.Fatal("no poster")
|
||||
}
|
||||
data, err := fetch(context.Background())
|
||||
data, err := fetch(context.Background(), false)
|
||||
if err != nil || string(data) != "IMG" {
|
||||
t.Fatalf("data=%q err=%v", data, err)
|
||||
}
|
||||
@@ -67,7 +91,7 @@ func TestPosterRemoteSendsNoAPIKey(t *testing.T) {
|
||||
if !ok || key != srv.URL+"/p.jpg" {
|
||||
t.Fatalf("key=%q ok=%v", key, ok)
|
||||
}
|
||||
if _, err := fetch(context.Background()); err != nil {
|
||||
if _, err := fetch(context.Background(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
@@ -111,7 +135,7 @@ func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("no poster")
|
||||
}
|
||||
data, err := fetch(context.Background())
|
||||
data, err := fetch(context.Background(), false)
|
||||
if err != nil || string(data) != "REMOTE" {
|
||||
t.Fatalf("data=%q err=%v", data, err)
|
||||
}
|
||||
@@ -121,7 +145,7 @@ func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
|
||||
|
||||
// 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()); err == nil {
|
||||
if _, err := fetch(context.Background(), false); err == nil {
|
||||
t.Error("expected the local error")
|
||||
}
|
||||
}
|
||||
@@ -148,7 +172,7 @@ func TestPosterTriesTheKeyInTheQueryWhenGivenALoginPage(t *testing.T) {
|
||||
|
||||
c := NewClient(srv.URL, "secret")
|
||||
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
||||
data, err := fetch(context.Background())
|
||||
data, err := fetch(context.Background(), false)
|
||||
if err != nil || string(data) != "IMAGE" {
|
||||
t.Fatalf("data=%q err=%v, hits=%v", data, err, hits)
|
||||
}
|
||||
@@ -173,7 +197,7 @@ func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
|
||||
|
||||
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())
|
||||
data, err := fetch(context.Background(), false)
|
||||
if err != nil || string(data) != "WEBP" {
|
||||
t.Fatalf("data=%q err=%v", data, err)
|
||||
}
|
||||
@@ -186,7 +210,7 @@ func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
|
||||
|
||||
// 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())
|
||||
_, err = fetch(context.Background(), false)
|
||||
if err == nil || !strings.Contains(err.Error(), "text/html") || !strings.Contains(err.Error(), "login") {
|
||||
t.Errorf("error = %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user