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:
2026-10-21 15:31:56 +02:00
co-authored by Claude Opus 5.5
parent b259e22458
commit 2798977867
7 changed files with 71 additions and 35 deletions
+3
View File
@@ -2,6 +2,9 @@
## Unreleased ## Unreleased
- **Sharper library posters as real images:** titles in the library used Radarr's/Sonarr's
250×375 copy of the poster, which real images (kitty, Ghostty) enlarge to up to 640 pixels and
so looked soft. Real images now use the 500×750 copy; block-character posters keep the small one.
- **First start without a config file:** arr-tui creates the file (private, with its directory) - **First start without a config file:** arr-tui creates the file (private, with its directory)
and opens the Config view to enter the services, instead of refusing to start. Services may be and opens the Config view to enter the services, instead of refusing to start. Services may be
left out: one without address and key is not used, its indicator stays grey and its lists say left out: one without address and key is not used, its indicator stays grey and its lists say
+1 -1
View File
@@ -66,7 +66,7 @@ Notes:
- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. Two ways to draw the poster, chosen by `ui.cover_style`: - **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. Two ways to draw the poster, chosen by `ui.cover_style`:
- **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is uploaded as PNG (`a=t,f=100,q=2`, 4096-byte base64 chunks) and placed virtually with the cell footprint (`a=p,U=1,c=,r=`) right away. Every footprint (crop and cell size) is its own image id, placed once and never changed: Ghostty kept showing the old, small placement when the size of an existing one was changed, so a new size uploads under a new id and deletes the old one. The poster is shown by writing U+10EEEE placeholder characters whose combining marks give row and column (the 297-entry table from the protocol) and whose foreground color is the image id. Since it is ordinary text, popups, dialogs and view changes cover it correctly and tcell's diffing keeps it in place. The footprint uses the real cell size in pixels (the window's pixel size divided by its cell count, rounded down to whole pixels so padding around the grid does not skew it; all images are deleted whenever the window or cell size changes, block characters stand in while the size keeps changing, and real images are uploaded afresh once it has been stable for 300 ms, because uploading and placing for every intermediate size of a window drag raced with the terminal's own resizing) and a box whose shape is within 3% of the poster's; the remaining sliver is cropped from the poster's edges before upload (one upload per size, the previous one is deleted), because the terminal letterboxes an image that does not fit its box exactly. As a second safeguard the placeholder cells use the panes' background color, so anything the picture does not cover blends in (without it the terminal's own, slightly different, default background showed as grey bars beside the poster). Writes to the terminal go through the screen's tty on the UI goroutine, so they cannot interleave with drawing. Requires 24-bit color; otherwise, and inside tmux/screen, the block fallback is used. Terminals are recognised from `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`, and then asked before the screen starts (a Kitty graphics query and a background-color query followed by a device-attributes request, 300 ms timeout, waiting with select(2) because a read deadline does not work on a terminal device on macOS): only a terminal that answers the query gets real images, also with `cover_style: kitty`, because a terminal without the protocol prints the commands as text. The Log says which way posters are drawn. With `ARR_TUI_DEBUG_GFX=1` the Log (F7) lists every window size, the cell shape, every upload (poster size, crop, cells) and every change of what is drawn (image id, position, cells); with a path (`ARR_TUI_DEBUG_GFX=/tmp/arr-gfx.log`) the same lines also go to that file with millisecond timestamps, so they can be read without leaving the view that shows the problem. The go-termimg library was looked at and not used: its placeholder support is marked experimental, its diacritic table does not match the protocol's, and it writes images straight to the terminal instead of through the screen. - **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is uploaded as PNG (`a=t,f=100,q=2`, 4096-byte base64 chunks) and placed virtually with the cell footprint (`a=p,U=1,c=,r=`) right away. Every footprint (crop and cell size) is its own image id, placed once and never changed: Ghostty kept showing the old, small placement when the size of an existing one was changed, so a new size uploads under a new id and deletes the old one. The poster is shown by writing U+10EEEE placeholder characters whose combining marks give row and column (the 297-entry table from the protocol) and whose foreground color is the image id. Since it is ordinary text, popups, dialogs and view changes cover it correctly and tcell's diffing keeps it in place. The footprint uses the real cell size in pixels (the window's pixel size divided by its cell count, rounded down to whole pixels so padding around the grid does not skew it; all images are deleted whenever the window or cell size changes, block characters stand in while the size keeps changing, and real images are uploaded afresh once it has been stable for 300 ms, because uploading and placing for every intermediate size of a window drag raced with the terminal's own resizing) and a box whose shape is within 3% of the poster's; the remaining sliver is cropped from the poster's edges before upload (one upload per size, the previous one is deleted), because the terminal letterboxes an image that does not fit its box exactly. As a second safeguard the placeholder cells use the panes' background color, so anything the picture does not cover blends in (without it the terminal's own, slightly different, default background showed as grey bars beside the poster). Writes to the terminal go through the screen's tty on the UI goroutine, so they cannot interleave with drawing. Requires 24-bit color; otherwise, and inside tmux/screen, the block fallback is used. Terminals are recognised from `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`, and then asked before the screen starts (a Kitty graphics query and a background-color query followed by a device-attributes request, 300 ms timeout, waiting with select(2) because a read deadline does not work on a terminal device on macOS): only a terminal that answers the query gets real images, also with `cover_style: kitty`, because a terminal without the protocol prints the commands as text. The Log says which way posters are drawn. With `ARR_TUI_DEBUG_GFX=1` the Log (F7) lists every window size, the cell shape, every upload (poster size, crop, cells) and every change of what is drawn (image id, position, cells); with a path (`ARR_TUI_DEBUG_GFX=/tmp/arr-gfx.log`) the same lines also go to that file with millisecond timestamps, so they can be read without leaving the view that shows the problem. The go-termimg library was looked at and not used: its placeholder support is marked experimental, its diacritic table does not match the protocol's, and it writes images straight to the terminal instead of through the screen.
- **Block characters** (`quadrant`, `sextant`, `cells`, and the fallback): every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing; `cells`: 1 pixel per cell drawn as background color only, because macOS Terminal draws block elements from the font with the halves off the cell's middle). `auto` picks cells in macOS Terminal (`TERM_PROGRAM=Apple_Terminal`), sextants on kitty, WezTerm, foot and Ghostty, and on other terminals (not on macOS, whose terminals do not use fontconfig's fonts) when fontconfig lists a font with the sextant characters (`fc-list :charset=1fb00`, about 20 ms at startup; terminals draw glyphs their font lacks from fallback fonts; skipped over SSH because the installed fonts are the remote machine's), and quadrants otherwise. - **Block characters** (`quadrant`, `sextant`, `cells`, and the fallback): every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing; `cells`: 1 pixel per cell drawn as background color only, because macOS Terminal draws block elements from the font with the halves off the cell's middle). `auto` picks cells in macOS Terminal (`TERM_PROGRAM=Apple_Terminal`), sextants on kitty, WezTerm, foot and Ghostty, and on other terminals (not on macOS, whose terminals do not use fontconfig's fonts) when fontconfig lists a font with the sextant characters (`fc-list :charset=1fb00`, about 20 ms at startup; terminals draw glyphs their font lacks from fallback fonts; skipped over SSH because the installed fonts are the remote machine's), and quadrants otherwise.
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` (250×375; `poster-500.jpg` when posters are real images, which otherwise enlarge the small copy) from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search. - **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that. - **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live. - **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
+23 -15
View File
@@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -17,10 +18,11 @@ const maxImageBytes = 8 << 20
// function that downloads it, or ok=false when there is none. // function that downloads it, or ok=false when there is none.
// //
// Library items have a copy on the Radarr/Sonarr server, which is fetched // 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 // with the API key, preferring a scaled variant: -250 (250×375), or -500
// in the library yet only have the remote URL of the metadata provider; that // (500×750) when fetch is asked for a large poster. Titles that are not in
// is fetched without the API key. // the library yet only have the remote URL of the metadata provider; that is
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context) ([]byte, error), ok bool) { // 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 { for _, img := range images {
if img.CoverType != "poster" { if img.CoverType != "poster" {
continue continue
@@ -28,8 +30,8 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
if inLibrary && img.URL != "" { if inLibrary && img.URL != "" {
local, remote := img.URL, img.RemoteURL local, remote := img.URL, img.RemoteURL
base, _ := c.settings() base, _ := c.settings()
return base + "|" + local, func(ctx context.Context) ([]byte, error) { return base + "|" + local, func(ctx context.Context, large bool) ([]byte, error) {
data, err := c.localPoster(ctx, local) data, err := c.localPoster(ctx, local, large)
if err != nil && remote != "" { if err != nil && remote != "" {
// The instance has no usable copy (not cached, or behind a login): // The instance has no usable copy (not cached, or behind a login):
// use the metadata provider's, as for titles not in the library. // 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 != "" { if img.RemoteURL != "" {
remote := 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 return "", nil, false
} }
// localPoster fetches a poster from the instance: the small variant, then the // localPoster fetches a poster from the instance: the scaled variant (-500
// original. When the instance answers with a page instead of an image (it // 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 // 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. // 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) { try := func(ref string) ([]byte, error) {
data, err := c.GetBytes(ctx, ref) data, err := c.GetBytes(ctx, ref)
var page *notImageError var page *notImageError
@@ -63,8 +65,12 @@ func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error)
} }
return data, err return data, err
} }
if small := smallVariant(local); small != local { width := 250
data, err := try(small) if large {
width = 500
}
if scaled := scaledVariant(local, width); scaled != local {
data, err := try(scaled)
if err == nil { if err == nil {
return data, nil return data, nil
} }
@@ -76,13 +82,15 @@ func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error)
return try(local) return try(local)
} }
// smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x. // scaledVariant turns /MediaCover/1/poster.jpg?x into
func smallVariant(p string) string { // /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, "?") path, query, hasQuery := strings.Cut(p, "?")
if !strings.HasSuffix(path, "/poster.jpg") { if !strings.HasSuffix(path, "/poster.jpg") {
return p return p
} }
path = strings.TrimSuffix(path, ".jpg") + "-250.jpg" path = strings.TrimSuffix(path, ".jpg") + "-" + strconv.Itoa(width) + ".jpg"
if hasQuery { if hasQuery {
return path + "?" + query return path + "?" + query
} }
+38 -14
View File
@@ -8,18 +8,42 @@ import (
"testing" "testing"
) )
func TestSmallVariant(t *testing.T) { func TestScaledVariant(t *testing.T) {
for in, want := range map[string]string{ for _, tc := range []struct {
"/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster-250.jpg?lastWrite=5", in string
"/MediaCover/1/poster.jpg": "/MediaCover/1/poster-250.jpg", width int
"/MediaCover/1/fanart.jpg": "/MediaCover/1/fanart.jpg", 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 { if got := scaledVariant(tc.in, tc.width); got != tc.want {
t.Errorf("smallVariant(%q) = %q, want %q", in, got, 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) { func TestPosterLocalUsesAPIKey(t *testing.T) {
var paths []string var paths []string
var keys []string var keys []string
@@ -40,7 +64,7 @@ func TestPosterLocalUsesAPIKey(t *testing.T) {
if !ok || key == "" { if !ok || key == "" {
t.Fatal("no poster") t.Fatal("no poster")
} }
data, err := fetch(context.Background()) data, err := fetch(context.Background(), false)
if err != nil || string(data) != "IMG" { if err != nil || string(data) != "IMG" {
t.Fatalf("data=%q err=%v", data, err) t.Fatalf("data=%q err=%v", data, err)
} }
@@ -67,7 +91,7 @@ func TestPosterRemoteSendsNoAPIKey(t *testing.T) {
if !ok || key != srv.URL+"/p.jpg" { if !ok || key != srv.URL+"/p.jpg" {
t.Fatalf("key=%q ok=%v", key, ok) 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) t.Fatal(err)
} }
if got != "" { if got != "" {
@@ -111,7 +135,7 @@ func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
if !ok { if !ok {
t.Fatal("no poster") t.Fatal("no poster")
} }
data, err := fetch(context.Background()) data, err := fetch(context.Background(), false)
if err != nil || string(data) != "REMOTE" { if err != nil || string(data) != "REMOTE" {
t.Fatalf("data=%q err=%v", data, err) 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. // Without a remote address the local failure is reported.
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg"}}, true) _, 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") t.Error("expected the local error")
} }
} }
@@ -148,7 +172,7 @@ func TestPosterTriesTheKeyInTheQueryWhenGivenALoginPage(t *testing.T) {
c := NewClient(srv.URL, "secret") c := NewClient(srv.URL, "secret")
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true) _, 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" { if err != nil || string(data) != "IMAGE" {
t.Fatalf("data=%q err=%v, hits=%v", data, err, hits) 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") c := NewClient(instance.URL, "secret")
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true) _, 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" { if err != nil || string(data) != "WEBP" {
t.Fatalf("data=%q err=%v", data, err) 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. // Without a provider address the failure says what came back.
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true) _, 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") { if err == nil || !strings.Contains(err.Error(), "text/html") || !strings.Contains(err.Error(), "login") {
t.Errorf("error = %v", err) t.Errorf("error = %v", err)
} }
+3 -2
View File
@@ -468,7 +468,7 @@ func (c *coverView) build(cols, rows int) []styledCell {
// coverRef says where a poster comes from. The zero value means "no poster". // coverRef says where a poster comes from. The zero value means "no poster".
type coverRef struct { type coverRef struct {
key string // identifies the poster in the cache key string // identifies the poster in the cache
fetch func(ctx context.Context) ([]byte, error) fetch func(ctx context.Context, large bool) ([]byte, error) // large: for real images
} }
// coverFor builds the poster reference of a title from its image list. // coverFor builds the poster reference of a title from its image list.
@@ -516,6 +516,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
} }
c.waiting[ref.key] = []func(*image.RGBA, error){done} c.waiting[ref.key] = []func(*image.RGBA, error){done}
maxHeight := a.coverMaxHeight() maxHeight := a.coverMaxHeight()
large := a.gfx != nil // real images show the poster at full resolution
go func() { go func() {
c.slots <- struct{}{} c.slots <- struct{}{}
@@ -523,7 +524,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel() defer cancel()
var img *image.RGBA var img *image.RGBA
data, err := ref.fetch(ctx) data, err := ref.fetch(ctx, large)
if err == nil { if err == nil {
img, err = decodeCover(data, maxHeight) img, err = decodeCover(data, maxHeight)
} }
+2 -2
View File
@@ -165,7 +165,7 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
var fetches atomic.Int32 var fetches atomic.Int32
var buf bytes.Buffer var buf bytes.Buffer
png.Encode(&buf, stripes(10, 20)) png.Encode(&buf, stripes(10, 20))
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) { ref := coverRef{key: "k", fetch: func(context.Context, bool) ([]byte, error) {
fetches.Add(1) fetches.Add(1)
return buf.Bytes(), nil return buf.Bytes(), nil
}} }}
@@ -186,7 +186,7 @@ func TestLoadCoverRemembersFailures(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.coverCache.failed["bad"] = time.Now() a.coverCache.failed["bad"] = time.Now()
var gotErr error var gotErr error
a.loadCover(coverRef{key: "bad", fetch: func(context.Context) ([]byte, error) { a.loadCover(coverRef{key: "bad", fetch: func(context.Context, bool) ([]byte, error) {
t.Error("a recent failure must not be retried") t.Error("a recent failure must not be retried")
return nil, nil return nil, nil
}}, func(_ *image.RGBA, err error) { gotErr = err }) }}, func(_ *image.RGBA, err error) { gotErr = err })
+1 -1
View File
@@ -116,7 +116,7 @@ func TestPosterWaitsWhileTheSelectionMoves(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 20, 30)) img := image.NewRGBA(image.Rect(0, 0, 20, 30))
a.coverCache.imgs["one"], a.coverCache.imgs["two"] = img, img a.coverCache.imgs["one"], a.coverCache.imgs["two"] = img, img
ref := func(key string) coverRef { ref := func(key string) coverRef {
return coverRef{key: key, fetch: func(context.Context) ([]byte, error) { return nil, nil }} return coverRef{key: key, fetch: func(context.Context, bool) ([]byte, error) { return nil, nil }}
} }
d := newDetailPane(a) d := newDetailPane(a)