diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3bcbc..bb59547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- **Real-image posters fill their box in every terminal:** they are scaled to the exact pixel size + of their cells before they are sent. Ghostty shows an image that is smaller than its place at its + own size, so library posters (Radarr's copy is only 333×500, not 500×750 as assumed in 0.4.0) + came out smaller than the others. Library titles now also use the original poster for real + images. The disk cache keeps posters at the size shown, not as downloaded. - **Posters all the same size:** the details pane always shows a poster in a 2:3 box. Images slightly off that shape (some Radarr/Sonarr hand out) fill it, trimmed at the long sides; images far from it (square, landscape) are shown whole and centred. Before, such posters came out diff --git a/SPECS.md b/SPECS.md index 5ddca63..e35f2a0 100644 --- a/SPECS.md +++ b/SPECS.md @@ -64,14 +64,14 @@ Notes: - Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job. - **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `F9` opens the full key reference. - **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 2:3 (every poster gets the same 2:3 box: an image within 12% of that shape fills it, trimmed at its long sides, also with block characters; one further off, e.g. square or landscape, is shown whole at the largest size that fits, centred in the box); 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 scaled (bilinear) to exactly the pixel size of its cells (cell size from the terminal's window size; terminals do not all enlarge a smaller image: Ghostty shows it at its own size) and 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 2:3 (every poster gets the same 2:3 box: an image within 12% of that shape fills it, trimmed at its long sides, also with block characters; one further off, e.g. square or landscape, is shown whole at the largest size that fits, centred in the box); 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. - 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//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. + 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//poster-250.jpg` (250 pixels tall; for real images the original `poster.jpg`, because even Radarr's `poster-500.jpg` is only 333×500) 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. - **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. - **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:`) or Sonarr (`tvdb:`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`). - **Downloading in the Library:** while the Library is visible it reads the Radarr and Sonarr queues (`/queue`, matched to titles by `movieId` / `seriesId`) when it opens, every 10 s, and 3 s after a grab. A title with queue entries gets a blue mark (filled when monitored, hollow when not) instead of the green or grey one, and its details a line "Downloading 45% · 0:06:12 left" (series: "Downloading N episodes"). A queue that cannot be read leaves the marks as they were. After a successful grab the release list closes (if nothing was opened on top of it). -- **Disk cache:** posters and casts are kept between runs in `os.UserCacheDir()/arr-tui` (`$XDG_CACHE_HOME` or `~/.cache` on Linux, `~/Library/Caches` on macOS; directories `0700`, files written atomically). Posters are the downloaded bytes, one file per poster and size (small copy for block characters, large for real images) under `posters/.img`; a file is used for 30 days (by its modification time) and dropped when it cannot be decoded; at startup files older than that are deleted, then the oldest until the rest fit 200 MB. Casts are one `people.json` (key = `movie:tmdb:` / `show:tvdb:`, with the time it was fetched); entries are used for 7 days, at most 2000 are kept (the oldest go first), titles Trakt does not know are kept as having no cast; new casts are written 5 s after the last one and when the program quits. Changing the Trakt client ID keeps the casts and only forgets failed lookups. +- **Disk cache:** posters and casts are kept between runs in `os.UserCacheDir()/arr-tui` (`$XDG_CACHE_HOME` or `~/.cache` on Linux, `~/Library/Caches` on macOS; directories `0700`, files written atomically). Posters are stored as decoded (at most 640 or 160 pixels tall, JPEG quality 90), one file per poster and size (small copy for block characters, large for real images) under `posters/.img`; a file is used for 30 days (by its modification time) and dropped when it cannot be decoded; at startup files older than that are deleted, then the oldest until the rest fit 200 MB. Casts are one `people.json` (key = `movie:tmdb:` / `show:tvdb:`, with the time it was fetched); entries are used for 7 days, at most 2000 are kept (the oldest go first), titles Trakt does not know are kept as having no cast; new casts are written 5 s after the last one and when the program quits. Changing the Trakt client ID keeps the casts and only forgets failed lookups. - **Season posters:** the series drill-down reads the series with `includeSeasonImages=true` (Sonarr only sends season posters when asked, and keeps no copy of them: they are TheTVDB links, fetched without credentials). A selected season or episode shows its season's poster; seasons without one, or whose poster cannot be loaded, show the series poster. - **Cast:** with a Trakt client ID, every details pane (search results, Trakt lists, Library, Calendar, the series drill-down) shows the director(s) of a movie or the "created by" credits of a series and up to 8 cast members with their characters below the plot ("and N more" after that). The title is found on Trakt by its TMDb id (movies) or TVDB id (series), else IMDb id (`/search//?type=movie|show`), then `/movies|shows//people` is read. Casts are cached, also on disk (see Disk cache; failures are retried after two minutes) and, like posters, only loaded once the selection has rested for 120 ms, so a held arrow key does not spend Trakt's allowance (500 requests per five minutes). - **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, Trakt 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. diff --git a/internal/arr/image.go b/internal/arr/image.go index 79f25f4..dd0ad7b 100644 --- a/internal/arr/image.go +++ b/internal/arr/image.go @@ -18,8 +18,8 @@ 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 a scaled variant: -250 (250×375), or -500 -// (500×750) when fetch is asked for a large poster. Titles that are not in +// with the API key: the copy 250 pixels tall (-250), or the original 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) { @@ -50,10 +50,11 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func( return "", nil, false } -// 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. +// localPoster fetches a poster from the instance: the small variant (-250), +// then the original; when large, only 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, large bool) ([]byte, error) { try := func(ref string) ([]byte, error) { data, err := c.GetBytes(ctx, ref) @@ -65,11 +66,12 @@ func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]b } return data, err } - width := 250 + // Radarr/Sonarr keep copies 250 and 500 pixels tall; even the larger one + // is smaller than a real image is shown, so those take the original. if large { - width = 500 + return try(local) } - if scaled := scaledVariant(local, width); scaled != local { + if scaled := scaledVariant(local, 250); scaled != local { data, err := try(scaled) if err == nil { return data, nil diff --git a/internal/arr/image_test.go b/internal/arr/image_test.go index 2b67c02..84b6a0a 100644 --- a/internal/arr/image_test.go +++ b/internal/arr/image_test.go @@ -24,7 +24,7 @@ func TestScaledVariant(t *testing.T) { } } -func TestLargePosterUsesThe500Variant(t *testing.T) { +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) @@ -39,7 +39,7 @@ func TestLargePosterUsesThe500Variant(t *testing.T) { 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" { + if strings.Join(paths, " ") != "/MediaCover/1/poster.jpg /MediaCover/1/poster-250.jpg" { t.Errorf("requests = %v", paths) } } diff --git a/internal/ui/app.go b/internal/ui/app.go index f884558..7bef2f4 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -32,11 +32,12 @@ type view struct { // App is the top-level arr-tui application. type App struct { - app *tview.Application - theme theme // the colors in use - trakt *trakt.Client // trending, most watched and anticipated lists, casts - people *peopleCache // casts loaded from Trakt - disk *diskCache // posters and casts kept between runs + cellW, cellH int // size of a terminal cell in pixels, 0 if unknown + app *tview.Application + theme theme // the colors in use + trakt *trakt.Client // trending, most watched and anticipated lists, casts + people *peopleCache // casts loaded from Trakt + disk *diskCache // posters and casts kept between runs // Called (on the UI goroutine) a moment after a release was grabbed. afterGrab []func() // The Movies and Series views, to refresh their Trakt lists. @@ -218,6 +219,7 @@ func New(cfg *config.Config) *App { s.HideCursor() a.screen = s a.cellAspect = cellAspectOf(s) + a.cellW, a.cellH = cellPixelsOf(s) a.noteWindow(s) return false }) @@ -240,6 +242,7 @@ func New(cfg *config.Config) *App { // helps to find out why a poster is drawn wrongly in some terminal. func (a *App) newGraphics() *kittyGraphics { k := newKittyGraphics(a.writeTerminal) + k.cellPixels = func() (int, int) { return a.cellW, a.cellH } switch v := os.Getenv("ARR_TUI_DEBUG_GFX"); { case v == "": case strings.Contains(v, "/"): @@ -298,6 +301,17 @@ func (a *App) writeTerminal(b []byte) error { // cellAspectOf measures the height of a terminal cell divided by its width in // pixels; 2 when the terminal does not report pixel sizes. +// cellPixelsOf returns the size of a terminal cell in pixels, 0, 0 when the +// terminal does not say. +func cellPixelsOf(s tcell.Screen) (w, h int) { + if tty, ok := s.Tty(); ok { + if ws, err := tty.WindowSize(); err == nil && ws.Width > 0 && ws.Height > 0 { + return ws.PixelWidth / ws.Width, ws.PixelHeight / ws.Height + } + } + return 0, 0 +} + func cellAspectOf(s tcell.Screen) float64 { if tty, ok := s.Tty(); ok { if ws, err := tty.WindowSize(); err == nil { diff --git a/internal/ui/cover.go b/internal/ui/cover.go index fdf7034..97f5d7c 100644 --- a/internal/ui/cover.go +++ b/internal/ui/cover.go @@ -6,9 +6,9 @@ import ( "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) + _ "image/gif" // poster formats (Radarr and Sonarr store the provider's bytes + "image/jpeg" // under a .jpg name, whatever format they are in); jpeg also + _ "image/png" // encodes the copies kept on disk _ "golang.org/x/image/webp" "net/url" @@ -563,7 +563,12 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) { img, err = decodeCover(data, maxHeight) } if err == nil { - a.disk.writePoster(ref.key, large, data) + // Stored as decoded (at most maxHeight tall), not as downloaded: + // originals can be megabytes. + var shrunk bytes.Buffer + if jpeg.Encode(&shrunk, img, &jpeg.Options{Quality: 90}) == nil { + a.disk.writePoster(ref.key, large, shrunk.Bytes()) + } } } a.post(func() { diff --git a/internal/ui/kitty.go b/internal/ui/kitty.go index d67250e..b653d40 100644 --- a/internal/ui/kitty.go +++ b/internal/ui/kitty.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "fmt" + xdraw "golang.org/x/image/draw" "image" "image/png" "strings" @@ -43,6 +44,11 @@ type kittyGraphics struct { holdUntil time.Time // no real images until then (the window is being resized) debug func(string) // reports what is uploaded, if set + // cellPixels returns the size of a cell in pixels (0 if unknown): images are + // scaled to exactly fill their cells, because terminals do not all enlarge + // an image that is smaller than its placement (Ghostty shows it at its own + // size, so a small poster came out smaller than the others). + cellPixels func() (w, h int) } // settleDelay is how long the window size must stay the same before real @@ -92,9 +98,15 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i if id, ok := k.ids[key]; ok { return id, nil } + var src image.Image = img.SubImage(rect) + if k.cellPixels != nil { + if cw, ch := k.cellPixels(); cw > 0 && ch > 0 { + src = scaleImage(src, cols*cw, rows*ch) + } + } var data bytes.Buffer enc := png.Encoder{CompressionLevel: png.BestSpeed} - if err := enc.Encode(&data, img.SubImage(rect)); err != nil { + if err := enc.Encode(&data, src); err != nil { return 0, err } encoded := base64.StdEncoding.EncodeToString(data.Bytes()) @@ -130,7 +142,8 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i k.ids[key] = id k.latest[img] = key if k.debug != nil { - k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, placed over %dx%d cells", id, img.Bounds().Dx(), img.Bounds().Dy(), rect, cols, rows)) + k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, sent as %dx%d px, placed over %dx%d cells", + id, img.Bounds().Dx(), img.Bounds().Dy(), rect, src.Bounds().Dx(), src.Bounds().Dy(), cols, rows)) } return id, nil } @@ -175,3 +188,15 @@ func kittyTerminal(env func(string) string) bool { } return false } + +// scaleImage resizes src to w×h pixels, smoothly (bilinear) both up and down. +// Sizes beyond 4000 pixels are not made (a misreported cell size). +func scaleImage(src image.Image, w, h int) image.Image { + b := src.Bounds() + if w <= 0 || h <= 0 || w > 4000 || h > 4000 || (w == b.Dx() && h == b.Dy()) { + return src + } + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + xdraw.BiLinear.Scale(dst, dst.Bounds(), src, b, xdraw.Src, nil) + return dst +} diff --git a/internal/ui/kitty_test.go b/internal/ui/kitty_test.go index 9ed4e2d..6f01c93 100644 --- a/internal/ui/kitty_test.go +++ b/internal/ui/kitty_test.go @@ -393,3 +393,27 @@ func TestGraphicsNeedTheTerminalsConfirmation(t *testing.T) { t.Error("a known terminal that answers gets real images") } } + +func TestPostersAreSentAtTheBoxSize(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 333, 500)) // Radarr's "500" copy, smaller than the box + var msgs []string + k := newKittyGraphics(func([]byte) error { return nil }) + k.debug = func(m string) { msgs = append(msgs, m) } + k.cellPixels = func() (int, int) { return 22, 48 } // a high-resolution screen + if _, err := k.show(img, img.Bounds(), 31, 21); err != nil { + t.Fatal(err) + } + if len(msgs) != 1 || !strings.Contains(msgs[0], "sent as 682x1008 px") { + t.Errorf("upload: %q", msgs) + } + // Without the cell size the image goes as it is. + k.cellPixels = func() (int, int) { return 0, 0 } + img2 := image.NewRGBA(image.Rect(0, 0, 426, 640)) + msgs = nil + if _, err := k.show(img2, img2.Bounds(), 31, 21); err != nil { + t.Fatal(err) + } + if len(msgs) != 1 || !strings.Contains(msgs[0], "sent as 426x640 px") { + t.Errorf("upload without cell size: %q", msgs) + } +}