plex-tui 0.3.41__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {plex_tui-0.3.41 → plex_tui-0.4.0}/CHANGELOG.md +11 -0
  2. {plex_tui-0.3.41 → plex_tui-0.4.0}/PKG-INFO +7 -7
  3. {plex_tui-0.3.41 → plex_tui-0.4.0}/README.md +6 -6
  4. {plex_tui-0.3.41 → plex_tui-0.4.0}/ROADMAP.md +2 -5
  5. {plex_tui-0.3.41 → plex_tui-0.4.0}/config.example.toml +2 -2
  6. {plex_tui-0.3.41 → plex_tui-0.4.0}/packaging/aur/.SRCINFO +3 -3
  7. {plex_tui-0.3.41 → plex_tui-0.4.0}/packaging/aur/PKGBUILD +2 -2
  8. {plex_tui-0.3.41 → plex_tui-0.4.0}/pyproject.toml +1 -1
  9. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/__init__.py +1 -1
  10. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/app.py +67 -22
  11. plex_tui-0.4.0/src/plextui/artwork.py +407 -0
  12. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_app_helpers.py +51 -0
  13. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_app_navigation.py +36 -0
  14. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_artwork.py +55 -12
  15. plex_tui-0.3.41/src/plextui/artwork.py +0 -198
  16. {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/aur.yml +0 -0
  17. {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/bump.yml +0 -0
  18. {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/ci.yml +0 -0
  19. {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/post-release-aur.yml +0 -0
  20. {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/post-release-homebrew.yml +0 -0
  21. {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/publish-aur.yml +0 -0
  22. {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/publish-pypi.yml +0 -0
  23. {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/publish-testpypi.yml +0 -0
  24. {plex_tui-0.3.41 → plex_tui-0.4.0}/.gitignore +0 -0
  25. {plex_tui-0.3.41 → plex_tui-0.4.0}/AGENTS.md +0 -0
  26. {plex_tui-0.3.41 → plex_tui-0.4.0}/LICENSE +0 -0
  27. {plex_tui-0.3.41 → plex_tui-0.4.0}/Makefile +0 -0
  28. {plex_tui-0.3.41 → plex_tui-0.4.0}/PACKAGING.md +0 -0
  29. {plex_tui-0.3.41 → plex_tui-0.4.0}/RELEASE.md +0 -0
  30. {plex_tui-0.3.41 → plex_tui-0.4.0}/docs/assets/grid-view.png +0 -0
  31. {plex_tui-0.3.41 → plex_tui-0.4.0}/docs/assets/list-view.png +0 -0
  32. {plex_tui-0.3.41 → plex_tui-0.4.0}/docs/plex-integration-research.md +0 -0
  33. {plex_tui-0.3.41 → plex_tui-0.4.0}/packaging/aur/README.md +0 -0
  34. {plex_tui-0.3.41 → plex_tui-0.4.0}/packaging/homebrew/README.md +0 -0
  35. {plex_tui-0.3.41 → plex_tui-0.4.0}/scripts/check_release.py +0 -0
  36. {plex_tui-0.3.41 → plex_tui-0.4.0}/scripts/stage_release.py +0 -0
  37. {plex_tui-0.3.41 → plex_tui-0.4.0}/scripts/update_aur_package.py +0 -0
  38. {plex_tui-0.3.41 → plex_tui-0.4.0}/scripts/update_homebrew_formula.py +0 -0
  39. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/__main__.py +0 -0
  40. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/auth.py +0 -0
  41. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/config.py +0 -0
  42. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/models.py +0 -0
  43. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/player.py +0 -0
  44. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/plex_service.py +0 -0
  45. {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/smoke.py +0 -0
  46. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_auth.py +0 -0
  47. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_cli.py +0 -0
  48. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_config.py +0 -0
  49. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_player.py +0 -0
  50. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_plex_service.py +0 -0
  51. {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_release_checks.py +0 -0
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 - 2026-06-17
6
+
7
+ - Added native Kitty poster artwork via Unicode placeholders for terminals that
8
+ advertise Kitty graphics support, with higher-resolution native image fetches
9
+ and block art kept as the fallback.
10
+
11
+ ## 0.3.43 - 2026-06-16
12
+
13
+ - Kept the footer command bar focused on core actions while leaving the full
14
+ keyboard reference discoverable from Help.
15
+
5
16
  ## 0.3.41 - 2026-06-16
6
17
 
7
18
  - Fixed Plex-transcoded resume playback by passing the resume offset into the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plex-tui
3
- Version: 0.3.41
3
+ Version: 0.4.0
4
4
  Summary: A small standalone Plex terminal UI.
5
5
  Project-URL: Homepage, https://github.com/so1omon563/plex-tui
6
6
  Project-URL: Issues, https://github.com/so1omon563/plex-tui/issues
@@ -246,12 +246,12 @@ presets.
246
246
 
247
247
  ## Artwork
248
248
 
249
- Poster artwork renders as portable colored block art, so it works in ordinary
250
- terminals without Kitty, iTerm2, or Sixel support. Native Kitty image output is
251
- disabled inside the Textual app because direct Kitty graphics protocol placement
252
- can destabilize the UI. If `artwork_renderer` is set to `auto` or `kitty`, the
253
- app falls back to block art and summarizes the fallback in
254
- `plex-tui --diagnostics`.
249
+ Poster artwork renders as portable colored block art by default, so it works in
250
+ ordinary terminals without native image support. In Kitty, set
251
+ `artwork_renderer` to `auto` or `kitty` to render native terminal images through
252
+ Kitty Unicode placeholders. This keeps Textual in charge of layout and redraws
253
+ while Kitty paints the poster inside those cells. Outside Kitty, `auto` falls
254
+ back to block art; `plex-tui --diagnostics` reports the active renderer status.
255
255
 
256
256
  Grid view prefetches artwork for the visible page immediately and, by default,
257
257
  prepares three pages ahead in the background. `grid_prefetch_pages` can be set
@@ -209,12 +209,12 @@ presets.
209
209
 
210
210
  ## Artwork
211
211
 
212
- Poster artwork renders as portable colored block art, so it works in ordinary
213
- terminals without Kitty, iTerm2, or Sixel support. Native Kitty image output is
214
- disabled inside the Textual app because direct Kitty graphics protocol placement
215
- can destabilize the UI. If `artwork_renderer` is set to `auto` or `kitty`, the
216
- app falls back to block art and summarizes the fallback in
217
- `plex-tui --diagnostics`.
212
+ Poster artwork renders as portable colored block art by default, so it works in
213
+ ordinary terminals without native image support. In Kitty, set
214
+ `artwork_renderer` to `auto` or `kitty` to render native terminal images through
215
+ Kitty Unicode placeholders. This keeps Textual in charge of layout and redraws
216
+ while Kitty paints the poster inside those cells. Outside Kitty, `auto` falls
217
+ back to block art; `plex-tui --diagnostics` reports the active renderer status.
218
218
 
219
219
  Grid view prefetches artwork for the visible page immediately and, by default,
220
220
  prepares three pages ahead in the background. `grid_prefetch_pages` can be set
@@ -64,9 +64,6 @@
64
64
  - Use `PLEX_TUI_PERF_LOG=1` for focused regression checks when changing grid,
65
65
  artwork, pagination, or detail-loading behavior.
66
66
  - Revisit whether verbose `PLEX_TUI_ARTWORK_LOG=1` should expose more structured artwork counters.
67
- - Revisit native terminal image support with a Kitty Unicode-placeholder
68
- prototype. Direct Kitty graphics protocol placement inside Textual caused UI
69
- hangs, so any future implementation should transmit images quietly, render
70
- normal placeholder text that Textual can redraw safely, and keep block art as
71
- the mandatory fallback.
67
+ - Continue validating Kitty Unicode-placeholder artwork in real terminals.
68
+ Block art remains the mandatory fallback outside Kitty.
72
69
  - Add focused regression tests for any real-world Plex media edge cases discovered during use.
@@ -40,8 +40,8 @@ client_identifier = "plex-tui-your-stable-client-id"
40
40
  # Optional artwork settings.
41
41
  # artwork_mode can be "on" or "off".
42
42
  # artwork_mode = "on"
43
- # artwork_renderer can be "block", "auto", or "kitty". Auto/kitty currently
44
- # fall back to block art because direct Kitty image placement is unsafe in Textual.
43
+ # artwork_renderer can be "block", "auto", or "kitty". Auto uses native Kitty
44
+ # images when Kitty is detected, otherwise it falls back to block art.
45
45
  # artwork_renderer = "block"
46
46
  # detail_artwork_mode can be "list_only", "on", or "off".
47
47
  # detail_artwork_mode = "list_only"
@@ -1,6 +1,6 @@
1
1
  pkgbase = plex-tui
2
2
  pkgdesc = Standalone Plex terminal UI with mpv playback
3
- pkgver = 0.3.39
3
+ pkgver = 0.3.43
4
4
  pkgrel = 1
5
5
  url = https://github.com/so1omon563/plex-tui
6
6
  arch = any
@@ -16,7 +16,7 @@ pkgbase = plex-tui
16
16
  depends = python-plexapi
17
17
  depends = python-rich
18
18
  depends = python-textual
19
- source = plex-tui-0.3.39.tar.gz::https://github.com/so1omon563/plex-tui/archive/refs/tags/v0.3.39.tar.gz
20
- sha256sums = 1e799c3f9635dc8e36b1da8ace89a1c3776fb9414a40e74c635f803864c48fad
19
+ source = plex-tui-0.3.43.tar.gz::https://github.com/so1omon563/plex-tui/archive/refs/tags/v0.3.43.tar.gz
20
+ sha256sums = 7e034f82945a4645fd01364c79b1d9bfd2d7820257aef4012eaf9ce5d3e9aa58
21
21
 
22
22
  pkgname = plex-tui
@@ -1,6 +1,6 @@
1
1
  # Maintainer: so1omon
2
2
  pkgname=plex-tui
3
- pkgver=0.3.39
3
+ pkgver=0.3.43
4
4
  pkgrel=1
5
5
  pkgdesc="Standalone Plex terminal UI with mpv playback"
6
6
  arch=("any")
@@ -22,7 +22,7 @@ makedepends=(
22
22
  "python-wheel"
23
23
  )
24
24
  source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
25
- sha256sums=("1e799c3f9635dc8e36b1da8ace89a1c3776fb9414a40e74c635f803864c48fad")
25
+ sha256sums=("7e034f82945a4645fd01364c79b1d9bfd2d7820257aef4012eaf9ce5d3e9aa58")
26
26
 
27
27
  build() {
28
28
  cd "$pkgname-$pkgver"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plex-tui"
7
- version = "0.3.41"
7
+ version = "0.4.0"
8
8
  description = "A small standalone Plex terminal UI."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Plex TUI prototype."""
2
2
 
3
- __version__ = "0.3.41"
3
+ __version__ = "0.4.0"
@@ -26,7 +26,16 @@ from textual.timer import Timer
26
26
  from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, Static
27
27
 
28
28
  from . import __version__
29
- from .artwork import artwork_is_cached, fetch_artwork, protocol_renderer_status, render_artwork, render_protocol_artwork
29
+ from .artwork import (
30
+ KittyImage,
31
+ artwork_is_cached,
32
+ fetch_artwork,
33
+ kitty_pixel_size,
34
+ protocol_renderer_status,
35
+ render_artwork,
36
+ render_protocol_artwork,
37
+ resolve_protocol_renderer,
38
+ )
30
39
  from .auth import LoginSession, ServerChoice, save_server_choice
31
40
  from .config import (
32
41
  DEFAULT_AUTO_LOAD_THRESHOLD,
@@ -431,29 +440,39 @@ class PlexTuiApp(App[None]):
431
440
 
432
441
  BINDINGS = [
433
442
  Binding("q", "quit", "Quit"),
434
- Binding("ctrl+r", "reload", "Reload"),
443
+ Binding("ctrl+r", "reload", "Reload", show=False),
435
444
  Binding("/", "focus_search", "Search"),
436
445
  Binding("g", "focus_global_search", "Global"),
437
- Binding("tab", "focus_next", "Next"),
438
- Binding("shift+tab", "focus_previous", "Prev"),
446
+ Binding("tab", "focus_next", "Next", show=False),
447
+ Binding("shift+tab", "focus_previous", "Prev", show=False),
439
448
  Binding("question_mark", "show_help", "Help"),
440
- Binding("l", "focus_libraries", "Focus libraries"),
441
- Binding("m", "focus_media", "Focus media list"),
442
- Binding("d", "focus_details", "Focus details"),
449
+ Binding("l", "focus_libraries", "Focus libraries", show=False),
450
+ Binding("m", "focus_media", "Focus media list", show=False),
451
+ Binding("d", "focus_details", "Focus details", show=False),
443
452
  Binding("v", "toggle_media_view", "View"),
444
- Binding("left_square_bracket", "jump_alpha_previous", "Prev letter"),
445
- Binding("right_square_bracket", "jump_alpha_next", "Next letter"),
446
- Binding("left", "grid_left", "Left"),
447
- Binding("right", "grid_right", "Right"),
453
+ Binding(
454
+ "left_square_bracket",
455
+ "jump_alpha_previous",
456
+ "Prev letter",
457
+ show=False,
458
+ ),
459
+ Binding(
460
+ "right_square_bracket",
461
+ "jump_alpha_next",
462
+ "Next letter",
463
+ show=False,
464
+ ),
465
+ Binding("left", "grid_left", "Left", show=False),
466
+ Binding("right", "grid_right", "Right", show=False),
448
467
  Binding("comma", "show_settings", "Settings"),
449
468
  Binding("escape", "back_or_clear", "Back"),
450
469
  Binding("p", "play_selected", "Play"),
451
470
  Binding("r", "resume_selected", "Resume"),
452
- Binding("a", "audio_picker", "Audio"),
453
- Binding("s", "subtitle_picker", "Subtitles"),
454
- Binding("A", "clear_audio_preference", "Clear audio"),
455
- Binding("S", "cycle_subtitle_mode", "Sub mode"),
456
- Binding("x", "stop_playback", "Stop"),
471
+ Binding("a", "audio_picker", "Audio", show=False),
472
+ Binding("s", "subtitle_picker", "Subtitles", show=False),
473
+ Binding("A", "clear_audio_preference", "Clear audio", show=False),
474
+ Binding("S", "cycle_subtitle_mode", "Sub mode", show=False),
475
+ Binding("x", "stop_playback", "Stop", show=False),
457
476
  ]
458
477
 
459
478
  service: reactive[PlexService | None] = reactive(None)
@@ -1187,8 +1206,9 @@ class PlexTuiApp(App[None]):
1187
1206
  artwork_path = getattr(details, "artwork_path")
1188
1207
  if detail_size is not None:
1189
1208
  width, height = detail_size
1209
+ fetch_width, fetch_height = artwork_fetch_pixel_size(self.config, width, height)
1190
1210
  detail_fetch_started = time.perf_counter()
1191
- data = fetch_artwork(full_item.raw, artwork_path, self.config, width=width, height=height * 2)
1211
+ data = fetch_artwork(full_item.raw, artwork_path, self.config, width=fetch_width, height=fetch_height)
1192
1212
  detail_fetch_ms = (time.perf_counter() - detail_fetch_started) * 1000
1193
1213
  detail_render_started = time.perf_counter()
1194
1214
  artwork = (
@@ -1202,7 +1222,7 @@ class PlexTuiApp(App[None]):
1202
1222
  if card_artwork is not None:
1203
1223
  card_cache_hit = True
1204
1224
  else:
1205
- card_width, card_height = card_artwork_pixel_size(self.config)
1225
+ card_width, card_height = card_artwork_fetch_size(self.config)
1206
1226
  card_fetch_started = time.perf_counter()
1207
1227
  card_data = fetch_artwork(
1208
1228
  full_item.raw,
@@ -1505,7 +1525,7 @@ class PlexTuiApp(App[None]):
1505
1525
  failed_count = 0
1506
1526
  try:
1507
1527
  prefetch_items = [item for item in items if item.artwork_path]
1508
- width, height = card_artwork_pixel_size(self.config)
1528
+ width, height = card_artwork_fetch_size(self.config)
1509
1529
  cached_count = sum(
1510
1530
  1 for item in prefetch_items if artwork_is_cached(item.artwork_path, self.config, width=width, height=height)
1511
1531
  )
@@ -2570,6 +2590,13 @@ def grid_card_line(value: str, width: int, style: str) -> Text:
2570
2590
 
2571
2591
 
2572
2592
  def center_renderable_lines(renderable: object, width: int) -> object:
2593
+ if isinstance(renderable, KittyImage):
2594
+ plain_width = renderable.columns + renderable.left_padding + renderable.right_padding
2595
+ if plain_width >= width:
2596
+ return renderable.copy()
2597
+ left = (width - plain_width) // 2
2598
+ right = width - plain_width - left
2599
+ return renderable.padded(left, right)
2573
2600
  if isinstance(renderable, Group):
2574
2601
  return Group(*(center_renderable_lines(item, width) for item in renderable.renderables))
2575
2602
  if isinstance(renderable, Text):
@@ -2598,7 +2625,12 @@ def copy_renderable(renderable: object | None) -> object | None:
2598
2625
 
2599
2626
  def render_card_artwork(data: bytes, config: AppConfig) -> object:
2600
2627
  spec = grid_density_spec(config)
2601
- artwork = render_artwork(data, width=int(spec["art_width"]), max_height=int(spec["art_height"]))
2628
+ width = int(spec["art_width"])
2629
+ height = int(spec["art_height"])
2630
+ protocol_artwork = render_protocol_artwork(data, config.artwork_renderer, width=width, max_height=height)
2631
+ if protocol_artwork is not None:
2632
+ return protocol_artwork
2633
+ artwork = render_artwork(data, width=width, max_height=height)
2602
2634
  return Group(*artwork.split("\n"))
2603
2635
 
2604
2636
 
@@ -2607,8 +2639,21 @@ def card_artwork_pixel_size(config: AppConfig) -> tuple[int, int]:
2607
2639
  return int(spec["art_width"]), int(spec["art_height"]) * 2
2608
2640
 
2609
2641
 
2610
- def grid_artwork_cache_key(item: MediaItem, config: AppConfig) -> tuple[str, str]:
2611
- return item.artwork_path, config.grid_density
2642
+ def card_artwork_fetch_size(config: AppConfig) -> tuple[int, int]:
2643
+ spec = grid_density_spec(config)
2644
+ width = int(spec["art_width"])
2645
+ height = int(spec["art_height"])
2646
+ return artwork_fetch_pixel_size(config, width, height)
2647
+
2648
+
2649
+ def artwork_fetch_pixel_size(config: AppConfig, width: int, height: int) -> tuple[int, int]:
2650
+ if resolve_protocol_renderer(config.artwork_renderer) == "kitty":
2651
+ return kitty_pixel_size(width, height)
2652
+ return width, height * 2
2653
+
2654
+
2655
+ def grid_artwork_cache_key(item: MediaItem, config: AppConfig) -> tuple[str, str, str]:
2656
+ return item.artwork_path, config.grid_density, config.artwork_renderer
2612
2657
 
2613
2658
 
2614
2659
  def grid_density_spec(config: AppConfig | None) -> dict[str, int]:
@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import os
6
+ import sys
7
+ import threading
8
+ from dataclasses import dataclass, replace
9
+ from io import BytesIO
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.parse import urlencode, urlsplit, urlunsplit
13
+ from urllib.request import Request, urlopen
14
+
15
+ from PIL import Image, ImageOps
16
+ from rich.measure import Measurement
17
+ from rich.segment import Segment
18
+ from rich.style import Style
19
+ from rich.text import Text
20
+
21
+ from .config import AppConfig, cache_path, write_debug_log
22
+
23
+
24
+ MAX_IMAGE_BYTES = 12 * 1024 * 1024
25
+ ARTWORK_CACHE_LIMIT_BYTES = 100 * 1024 * 1024
26
+ KITTY_PAYLOAD_CHUNK_SIZE = 4096
27
+ KITTY_CELL_WIDTH_PX = 12
28
+ KITTY_CELL_HEIGHT_PX = 24
29
+ KITTY_TRANSMIT_LOCK = threading.Lock()
30
+ KITTY_PLACEHOLDER = "\U0010eeee"
31
+ KITTY_PLACEHOLDER_DIACRITICS = tuple(chr(codepoint) for codepoint in (
32
+ 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F,
33
+ 0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357,
34
+ 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
35
+ 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484,
36
+ 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
37
+ ))
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class KittyImage:
42
+ commands: tuple[str, ...]
43
+ lines: tuple[str, ...]
44
+ image_id: int
45
+ columns: int
46
+ left_padding: int = 0
47
+ right_padding: int = 0
48
+
49
+ @property
50
+ def plain(self) -> str:
51
+ padding_left = " " * self.left_padding
52
+ padding_right = " " * self.right_padding
53
+ return "\n".join(f"{padding_left}{line}{padding_right}" for line in self.lines)
54
+
55
+ def copy(self) -> "KittyImage":
56
+ return self
57
+
58
+ def padded(self, left: int, right: int) -> "KittyImage":
59
+ return replace(
60
+ self,
61
+ left_padding=self.left_padding + max(0, left),
62
+ right_padding=self.right_padding + max(0, right),
63
+ )
64
+
65
+ def __rich_console__(self, console: object, options: object) -> object:
66
+ del console, options
67
+ style = Style(color=f"#{self.image_id:06x}")
68
+ left = " " * self.left_padding
69
+ right = " " * self.right_padding
70
+ for index, line in enumerate(self.lines):
71
+ if left:
72
+ yield Segment(left)
73
+ yield Segment(line, style)
74
+ if right:
75
+ yield Segment(right)
76
+ if index < len(self.lines) - 1:
77
+ yield Segment.line()
78
+
79
+ def __rich_measure__(self, console: object, options: object) -> Measurement:
80
+ del console, options
81
+ width = self.left_padding + self.columns + self.right_padding
82
+ return Measurement(width, width)
83
+
84
+
85
+ def fetch_artwork(raw: Any, path: str, config: AppConfig, width: int | None = None, height: int | None = None) -> bytes:
86
+ cached = cached_artwork_path(path, config, width, height)
87
+ if cached.exists():
88
+ return cached.read_bytes()
89
+
90
+ url = artwork_url(raw, path, config, width, height)
91
+ request = Request(url, headers={"User-Agent": "plex-tui"})
92
+ with urlopen(request, timeout=10) as response:
93
+ status = getattr(response, "status", 200)
94
+ if status != 200:
95
+ raise OSError(f"artwork fetch failed: HTTP {status}")
96
+ data = response.read(MAX_IMAGE_BYTES + 1)
97
+ if len(data) > MAX_IMAGE_BYTES:
98
+ raise OSError("artwork image is too large")
99
+
100
+ cached.parent.mkdir(parents=True, exist_ok=True)
101
+ cached.write_bytes(data)
102
+ prune_artwork_cache()
103
+ return data
104
+
105
+
106
+ def artwork_url(raw: Any, path: str, config: AppConfig, width: int | None = None, height: int | None = None) -> str:
107
+ if width is not None and height is not None:
108
+ return transcode_artwork_url(path, config, width, height)
109
+
110
+ server = getattr(raw, "_server", None)
111
+ if server is not None and hasattr(server, "url"):
112
+ try:
113
+ return str(server.url(path, includeToken=True))
114
+ except Exception:
115
+ pass
116
+
117
+ if path.startswith("http://") or path.startswith("https://"):
118
+ url = path
119
+ else:
120
+ url = f"{config.base_url.rstrip('/')}/{path.lstrip('/')}"
121
+ return add_token(url, config.token)
122
+
123
+
124
+ def transcode_artwork_url(path: str, config: AppConfig, width: int, height: int) -> str:
125
+ base_url = config.base_url.rstrip("/")
126
+ query = urlencode({
127
+ "width": max(1, width),
128
+ "height": max(1, height),
129
+ "minSize": 1,
130
+ "upscale": 1,
131
+ "url": path,
132
+ })
133
+ return add_token(f"{base_url}/photo/:/transcode?{query}", config.token)
134
+
135
+
136
+ def add_token(url: str, token: str) -> str:
137
+ if not token:
138
+ return url
139
+ parts = urlsplit(url)
140
+ query = parts.query
141
+ separator = "&" if query else ""
142
+ query = f"{query}{separator}{urlencode({'X-Plex-Token': token})}"
143
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
144
+
145
+
146
+ def cached_artwork_path(path: str, config: AppConfig, width: int | None = None, height: int | None = None) -> Path:
147
+ source = f"{config.base_url}\0{path}"
148
+ if width is not None or height is not None:
149
+ source = f"{source}\0{width or ''}x{height or ''}"
150
+ key = hashlib.sha256(source.encode("utf-8")).hexdigest()
151
+ return cache_path() / "artwork" / f"{key}.img"
152
+
153
+
154
+ def artwork_is_cached(path: str, config: AppConfig, width: int | None = None, height: int | None = None) -> bool:
155
+ return cached_artwork_path(path, config, width, height).exists()
156
+
157
+
158
+ def prune_artwork_cache(limit_bytes: int = ARTWORK_CACHE_LIMIT_BYTES) -> None:
159
+ directory = cache_path() / "artwork"
160
+ if not directory.exists():
161
+ return
162
+ files = []
163
+ total = 0
164
+ try:
165
+ for path in directory.iterdir():
166
+ if not path.is_file():
167
+ continue
168
+ stat = path.stat()
169
+ files.append((stat.st_mtime, stat.st_size, path))
170
+ total += stat.st_size
171
+ except OSError:
172
+ return
173
+ if total <= limit_bytes:
174
+ return
175
+ for _, size, path in sorted(files):
176
+ try:
177
+ path.unlink()
178
+ except OSError:
179
+ continue
180
+ total -= size
181
+ if total <= limit_bytes:
182
+ break
183
+
184
+
185
+ def render_artwork(data: bytes, width: int = 28, max_height: int = 20) -> Text:
186
+ image = load_image(data)
187
+ image = resize_for_cells(image, width, max_height)
188
+
189
+ text = Text()
190
+ pixels = image.load()
191
+ for y in range(0, image.height, 2):
192
+ for x in range(image.width):
193
+ top = pixels[x, y]
194
+ bottom = pixels[x, y + 1] if y + 1 < image.height else top
195
+ text.append("▀", style=f"{rgb(top)} on {rgb(bottom)}")
196
+ if y + 2 < image.height:
197
+ text.append("\n")
198
+ return text
199
+
200
+
201
+ def render_protocol_artwork(data: bytes, renderer: str, width: int = 28, max_height: int = 20) -> object | None:
202
+ resolved = resolve_protocol_renderer(renderer)
203
+ if resolved != "kitty":
204
+ write_kitty_artwork_log(
205
+ f"kitty renderer skipped requested={renderer!r} resolved={resolved!r} "
206
+ f"env={kitty_environment_status()}"
207
+ )
208
+ return None
209
+ return render_kitty_artwork(data, width=width, max_height=max_height, transmit=True)
210
+
211
+
212
+ def resolve_protocol_renderer(renderer: str) -> str:
213
+ if renderer == "kitty" and kitty_graphics_supported():
214
+ return "kitty"
215
+ if renderer == "auto" and kitty_graphics_supported():
216
+ return "kitty"
217
+ return "block"
218
+
219
+
220
+ def protocol_renderer_status(renderer: str) -> str:
221
+ resolved = resolve_protocol_renderer(renderer)
222
+ if resolved == "kitty":
223
+ return "Kitty native images via Unicode placeholders"
224
+ if renderer == "auto":
225
+ return "Block art; Kitty terminal not detected"
226
+ if renderer == "kitty":
227
+ return "Block fallback; Kitty terminal not detected"
228
+ return "Block art"
229
+
230
+
231
+ def kitty_graphics_supported() -> bool:
232
+ return bool(os.environ.get("KITTY_WINDOW_ID") or os.environ.get("KITTY_PID"))
233
+
234
+
235
+ def kitty_environment_status() -> str:
236
+ fields = [
237
+ f"KITTY_WINDOW_ID={int(bool(os.environ.get('KITTY_WINDOW_ID')))}",
238
+ f"KITTY_PID={int(bool(os.environ.get('KITTY_PID')))}",
239
+ f"TERM={os.environ.get('TERM', '')!r}",
240
+ f"COLORTERM={os.environ.get('COLORTERM', '')!r}",
241
+ ]
242
+ return ",".join(fields)
243
+
244
+
245
+ def render_kitty_artwork(data: bytes, width: int = 28, max_height: int = 20, transmit: bool = False) -> KittyImage:
246
+ image = load_image(data)
247
+ columns, rows = kitty_cell_dimensions(image, width, max_height)
248
+ image = resize_for_kitty_cells(image, columns, rows)
249
+ buffer = BytesIO()
250
+ image.save(buffer, format="PNG")
251
+ payload = base64.b64encode(buffer.getvalue()).decode("ascii")
252
+ image_id = kitty_image_id(buffer.getvalue(), columns, rows)
253
+ commands = kitty_graphics_commands(payload, image_id=image_id, columns=columns, rows=rows)
254
+ if transmit:
255
+ emit_kitty_graphics_commands(commands)
256
+ return KittyImage(
257
+ commands=tuple(commands),
258
+ lines=tuple(kitty_placeholder_lines(image_id, columns, rows)),
259
+ image_id=image_id,
260
+ columns=columns,
261
+ )
262
+
263
+
264
+ def kitty_pixel_size(width: int, max_height: int) -> tuple[int, int]:
265
+ return max(1, width) * KITTY_CELL_WIDTH_PX, max(1, max_height) * KITTY_CELL_HEIGHT_PX
266
+
267
+
268
+ def kitty_cell_dimensions(image: Image.Image, width: int, max_height: int) -> tuple[int, int]:
269
+ source_width, source_height = image.size
270
+ width = max(1, width)
271
+ max_height = max(1, max_height)
272
+ if not source_width or not source_height:
273
+ return 1, 1
274
+ rows = min(max_height, max(1, round(source_height / source_width * width)))
275
+ return width, rows
276
+
277
+
278
+ def kitty_graphics_commands(
279
+ payload: str,
280
+ image_id: int | None = None,
281
+ columns: int | None = None,
282
+ rows: int | None = None,
283
+ ) -> list[str]:
284
+ placement = ""
285
+ if image_id is not None:
286
+ placement = f",i={image_id},U=1,c={max(1, columns or 1)},r={max(1, rows or 1)}"
287
+ chunks = [
288
+ payload[index : index + KITTY_PAYLOAD_CHUNK_SIZE]
289
+ for index in range(0, len(payload), KITTY_PAYLOAD_CHUNK_SIZE)
290
+ ]
291
+ if not chunks:
292
+ return [f"\033_Ga=T,f=100,q=2{placement};\033\\"]
293
+ if len(chunks) == 1:
294
+ return [f"\033_Ga=T,f=100,q=2{placement};{chunks[0]}\033\\"]
295
+
296
+ commands = []
297
+ for index, chunk in enumerate(chunks):
298
+ if index == 0:
299
+ prefix = f"a=T,f=100,q=2{placement},m=1"
300
+ elif index == len(chunks) - 1:
301
+ prefix = "m=0"
302
+ else:
303
+ prefix = "m=1"
304
+ commands.append(f"\033_G{prefix};{chunk}\033\\")
305
+ return commands
306
+
307
+
308
+ def emit_kitty_graphics_commands(commands: list[str]) -> None:
309
+ payload = "".join(commands).encode("ascii")
310
+ with KITTY_TRANSMIT_LOCK:
311
+ emit_kitty_graphics_payload(payload)
312
+
313
+
314
+ def emit_kitty_graphics_payload(payload: bytes) -> None:
315
+ target = "none"
316
+ try:
317
+ fd = os.open("/dev/tty", os.O_WRONLY | os.O_NOCTTY)
318
+ except OSError:
319
+ fd = None
320
+ if fd is not None:
321
+ try:
322
+ write_all(fd, payload)
323
+ target = "/dev/tty"
324
+ return
325
+ except OSError:
326
+ target = "/dev/tty-failed"
327
+ finally:
328
+ try:
329
+ os.close(fd)
330
+ except OSError:
331
+ pass
332
+ write_kitty_artwork_log(f"kitty transmit bytes={len(payload)} target={target}")
333
+ try:
334
+ stdout = getattr(sys, "__stdout__", sys.stdout)
335
+ buffer = getattr(stdout, "buffer", None)
336
+ if buffer is not None:
337
+ buffer.write(payload)
338
+ buffer.flush()
339
+ else:
340
+ stdout.write(payload.decode("ascii"))
341
+ stdout.flush()
342
+ target = "__stdout__"
343
+ except OSError:
344
+ target = "__stdout__-failed"
345
+ finally:
346
+ write_kitty_artwork_log(f"kitty transmit bytes={len(payload)} target={target}")
347
+
348
+
349
+ def write_all(fd: int, payload: bytes) -> None:
350
+ view = memoryview(payload)
351
+ written = 0
352
+ while written < len(payload):
353
+ count = os.write(fd, view[written:])
354
+ if count <= 0:
355
+ raise OSError("short write to terminal")
356
+ written += count
357
+
358
+
359
+ def kitty_image_id(data: bytes, columns: int, rows: int) -> int:
360
+ digest = hashlib.sha256(data + f"\0{columns}x{rows}".encode("ascii")).digest()
361
+ return int.from_bytes(digest[:3], "big") or 1
362
+
363
+
364
+ def kitty_placeholder_lines(image_id: int, columns: int, rows: int) -> list[str]:
365
+ if columns >= len(KITTY_PLACEHOLDER_DIACRITICS) or rows >= len(KITTY_PLACEHOLDER_DIACRITICS):
366
+ raise ValueError("Kitty placeholder dimensions exceed supported diacritics")
367
+ high_byte = image_id >> 24
368
+ high = KITTY_PLACEHOLDER_DIACRITICS[high_byte] if high_byte else ""
369
+ lines = []
370
+ for row in range(rows):
371
+ row_mark = KITTY_PLACEHOLDER_DIACRITICS[row]
372
+ cells = [
373
+ f"{KITTY_PLACEHOLDER}{row_mark}{KITTY_PLACEHOLDER_DIACRITICS[column]}{high}"
374
+ for column in range(columns)
375
+ ]
376
+ lines.append("".join(cells))
377
+ return lines
378
+
379
+
380
+ def write_kitty_artwork_log(message: str) -> None:
381
+ if os.environ.get("PLEX_TUI_ARTWORK_LOG") != "1":
382
+ return
383
+ write_debug_log(message)
384
+
385
+
386
+ def load_image(data: bytes) -> Image.Image:
387
+ return ImageOps.exif_transpose(Image.open(BytesIO(data))).convert("RGB")
388
+
389
+
390
+ def resize_for_cells(image: Image.Image, width: int, max_height: int) -> Image.Image:
391
+ source_width, source_height = image.size
392
+ if not source_width or not source_height:
393
+ return Image.new("RGB", (1, 2), "#000000")
394
+
395
+ width = max(1, width)
396
+ max_height = max(1, max_height)
397
+ pixel_height = min(max_height * 2, max(2, round(source_height / source_width * width * 2)))
398
+ return ImageOps.contain(image, (width, pixel_height), Image.Resampling.LANCZOS)
399
+
400
+
401
+ def resize_for_kitty_cells(image: Image.Image, width: int, max_height: int) -> Image.Image:
402
+ max_width_px, max_height_px = kitty_pixel_size(width, max_height)
403
+ return ImageOps.contain(image, (max_width_px, max_height_px), Image.Resampling.LANCZOS)
404
+
405
+
406
+ def rgb(color: tuple[int, int, int]) -> str:
407
+ return f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}"
@@ -9,6 +9,7 @@ from rich.align import Align
9
9
  from rich.console import Console, Group
10
10
  from rich.text import Text
11
11
 
12
+ from plextui.artwork import KittyImage
12
13
  from plextui.app import (
13
14
  alphabet_group_label,
14
15
  alphabet_jump_index,
@@ -19,6 +20,7 @@ from plextui.app import (
19
20
  MediaGrid,
20
21
  MediaRow,
21
22
  PlexTuiApp,
23
+ card_artwork_fetch_size,
22
24
  card_artwork_pixel_size,
23
25
  context_hint,
24
26
  detect_mpv,
@@ -686,6 +688,16 @@ def test_card_artwork_pixel_size_tracks_terminal_render_size():
686
688
  assert card_artwork_pixel_size(AppConfig("http://plex", "token", "client", grid_density="large")) == (24, 24)
687
689
 
688
690
 
691
+ def test_card_artwork_fetch_size_uses_higher_resolution_for_kitty(monkeypatch):
692
+ monkeypatch.setenv("KITTY_WINDOW_ID", "1")
693
+
694
+ block = AppConfig("http://plex", "token", "client", grid_density="comfortable")
695
+ kitty = AppConfig("http://plex", "token", "client", artwork_renderer="kitty", grid_density="comfortable")
696
+
697
+ assert card_artwork_fetch_size(block) == (18, 18)
698
+ assert card_artwork_fetch_size(kitty) == (216, 216)
699
+
700
+
689
701
  def test_render_card_artwork_uses_per_line_renderables():
690
702
  image = Image.new("RGB", (2, 4), "#ff0000")
691
703
  buffer = BytesIO()
@@ -697,6 +709,22 @@ def test_render_card_artwork_uses_per_line_renderables():
697
709
  assert all(isinstance(line, Text) for line in rendered.renderables)
698
710
 
699
711
 
712
+ def test_render_card_artwork_uses_kitty_placeholders_when_enabled(monkeypatch):
713
+ monkeypatch.setenv("KITTY_WINDOW_ID", "1")
714
+ monkeypatch.setattr("plextui.artwork.emit_kitty_graphics_commands", lambda commands: None)
715
+ image = Image.new("RGB", (2, 4), "#ff0000")
716
+ buffer = BytesIO()
717
+ image.save(buffer, format="PNG")
718
+
719
+ rendered = render_card_artwork(
720
+ buffer.getvalue(),
721
+ AppConfig("http://plex", "token", "client", artwork_renderer="kitty", grid_density="compact"),
722
+ )
723
+
724
+ assert isinstance(rendered, KittyImage)
725
+ assert rendered.commands
726
+
727
+
700
728
  def test_render_subtitle_none_playback_status():
701
729
  config = AppConfig(
702
730
  base_url="http://plex",
@@ -830,6 +858,29 @@ def test_render_help_groups_key_bindings():
830
858
  assert "?: show help" in rendered
831
859
 
832
860
 
861
+ def test_footer_shows_core_bindings_and_help_keeps_full_reference():
862
+ shown = {binding.action for binding in PlexTuiApp.BINDINGS if binding.show}
863
+ hidden = {binding.action for binding in PlexTuiApp.BINDINGS if not binding.show}
864
+ rendered = render_help()
865
+
866
+ assert shown == {
867
+ "quit",
868
+ "focus_search",
869
+ "focus_global_search",
870
+ "show_help",
871
+ "toggle_media_view",
872
+ "show_settings",
873
+ "back_or_clear",
874
+ "play_selected",
875
+ "resume_selected",
876
+ }
877
+ assert "audio_picker" in hidden
878
+ assert "subtitle_picker" in hidden
879
+ assert "a: choose and save audio preference" in rendered
880
+ assert "s: choose and save subtitle preference" in rendered
881
+ assert "ctrl+r: reconnect / reload libraries" in rendered
882
+
883
+
833
884
  def test_performance_log_requires_perf_env(monkeypatch):
834
885
  messages = []
835
886
  monkeypatch.delenv("PLEX_TUI_PERF_LOG", raising=False)
@@ -13,6 +13,7 @@ from plextui.app import (
13
13
  LibraryMenuRow,
14
14
  LoadMoreRow,
15
15
  PlexTuiApp,
16
+ artwork_fetch_pixel_size,
16
17
  card_artwork_pixel_size,
17
18
  grid_artwork_cache_key,
18
19
  grid_page_key,
@@ -293,6 +294,41 @@ def test_detail_artwork_fetches_resized_detail_and_card_artwork(monkeypatch):
293
294
  assert app.rendered_grid_artwork_cache[grid_artwork_cache_key(full_item, app.config)] == "card-art"
294
295
 
295
296
 
297
+ def test_detail_artwork_fetches_higher_resolution_for_kitty(monkeypatch):
298
+ monkeypatch.setenv("KITTY_WINDOW_ID", "1")
299
+ app = PlexTuiApp()
300
+ app.config = AppConfig("http://plex", "token", "client-id", artwork_renderer="kitty", media_view="grid")
301
+ app.detail_refresh_token = 1
302
+ app.rendered_grid_artwork_cache = {}
303
+ full_item = MediaItem("Movie", "", "movie", "1", True, Raw(), artwork_path="/thumb")
304
+ details = SimpleNamespace(artwork_path="/thumb")
305
+ requested_sizes = []
306
+
307
+ def capture_fetch(raw, path, config, width=None, height=None):
308
+ requested_sizes.append((width, height))
309
+ return b"image"
310
+
311
+ monkeypatch.setattr(app_module, "fetch_artwork", capture_fetch)
312
+ monkeypatch.setattr(app_module, "render_protocol_artwork", lambda *args, **kwargs: "detail-art")
313
+ monkeypatch.setattr(app_module, "render_card_artwork", lambda *args, **kwargs: "card-art")
314
+ app.call_from_thread = lambda callback, *args: None
315
+
316
+ PlexTuiApp.fetch_media_detail_artwork.__wrapped__(
317
+ app,
318
+ full_item,
319
+ details,
320
+ token=1,
321
+ detail_size=(30, 20),
322
+ include_card_artwork=True,
323
+ )
324
+
325
+ assert requested_sizes == [
326
+ artwork_fetch_pixel_size(app.config, 30, 20),
327
+ artwork_fetch_pixel_size(app.config, 18, 9),
328
+ ]
329
+ assert app.rendered_grid_artwork_cache[grid_artwork_cache_key(full_item, app.config)] == "card-art"
330
+
331
+
296
332
  def test_detail_artwork_reuses_rendered_grid_card_cache(monkeypatch):
297
333
  app = PlexTuiApp()
298
334
  app.config = AppConfig("http://plex", "token", "client-id", media_view="grid")
@@ -7,15 +7,19 @@ from PIL import Image
7
7
 
8
8
  from plextui import artwork
9
9
  from plextui.artwork import (
10
+ KITTY_PLACEHOLDER,
11
+ KittyImage,
10
12
  add_token,
11
13
  artwork_url,
12
14
  cached_artwork_path,
13
15
  kitty_graphics_commands,
16
+ kitty_placeholder_lines,
14
17
  protocol_renderer_status,
15
18
  prune_artwork_cache,
16
19
  render_kitty_artwork,
17
20
  render_artwork,
18
21
  render_protocol_artwork,
22
+ write_all,
19
23
  )
20
24
  from plextui.config import AppConfig
21
25
 
@@ -76,7 +80,9 @@ def test_render_artwork_returns_halfcell_text():
76
80
  assert rendered.spans
77
81
 
78
82
 
79
- def test_render_protocol_artwork_falls_back_without_explicit_native_opt_in():
83
+ def test_render_protocol_artwork_falls_back_without_explicit_native_opt_in(monkeypatch):
84
+ monkeypatch.delenv("KITTY_WINDOW_ID", raising=False)
85
+ monkeypatch.delenv("KITTY_PID", raising=False)
80
86
  image = Image.new("RGB", (2, 4), "#00ff00")
81
87
  buffer = BytesIO()
82
88
  image.save(buffer, format="PNG")
@@ -86,40 +92,50 @@ def test_render_protocol_artwork_falls_back_without_explicit_native_opt_in():
86
92
  assert rendered is None
87
93
 
88
94
 
89
- def test_render_protocol_artwork_falls_back_even_when_native_opt_in_is_enabled(monkeypatch):
95
+ def test_render_protocol_artwork_uses_unicode_placeholders_when_kitty_is_detected(monkeypatch):
90
96
  monkeypatch.setenv("KITTY_WINDOW_ID", "1")
97
+ transmitted = []
98
+ monkeypatch.setattr(artwork, "emit_kitty_graphics_commands", lambda commands: transmitted.extend(commands))
91
99
  image = Image.new("RGB", (2, 4), "#00ff00")
92
100
  buffer = BytesIO()
93
101
  image.save(buffer, format="PNG")
94
102
 
95
103
  rendered = render_protocol_artwork(buffer.getvalue(), "kitty", width=2, max_height=2)
96
104
 
97
- assert rendered is None
98
- assert protocol_renderer_status("kitty") == "Block fallback; native Kitty images are disabled inside Textual"
105
+ assert isinstance(rendered, KittyImage)
106
+ assert rendered.commands[0].startswith("\033_Ga=T,f=100,q=2,i=")
107
+ assert ",U=1,c=2,r=2;" in rendered.commands[0]
108
+ assert rendered.plain.count(KITTY_PLACEHOLDER) == 4
109
+ assert transmitted == list(rendered.commands)
110
+ assert protocol_renderer_status("kitty") == "Kitty native images via Unicode placeholders"
99
111
 
100
112
 
101
- def test_render_kitty_artwork_builds_protocol_bytes():
113
+ def test_render_kitty_artwork_builds_virtual_placement_and_placeholder_text():
102
114
  image = Image.new("RGB", (2, 4), "#00ff00")
103
115
  buffer = BytesIO()
104
116
  image.save(buffer, format="PNG")
105
117
 
106
118
  rendered = render_kitty_artwork(buffer.getvalue(), width=2, max_height=2)
107
119
 
108
- assert "\033_Ga=T,f=100,q=2;" in rendered.plain
109
- assert rendered.plain.endswith("\033\\")
120
+ assert isinstance(rendered, KittyImage)
121
+ assert rendered.commands[0].startswith("\033_Ga=T,f=100,q=2,i=")
122
+ assert rendered.commands[0].endswith("\033\\")
123
+ assert rendered.plain.count(KITTY_PLACEHOLDER) == 4
124
+ assert len(rendered.lines) == 2
110
125
 
111
126
 
112
127
  def test_kitty_graphics_commands_chunks_large_payload(monkeypatch):
113
128
  monkeypatch.setattr(artwork, "KITTY_PAYLOAD_CHUNK_SIZE", 8)
114
129
 
115
- commands = kitty_graphics_commands("a" * 20)
130
+ commands = kitty_graphics_commands("a" * 20, image_id=42, columns=2, rows=2)
116
131
 
117
- assert commands[0].startswith("\033_Ga=T,f=100,q=2,m=1;")
132
+ assert commands[0].startswith("\033_Ga=T,f=100,q=2,i=42,U=1,c=2,r=2,m=1;")
118
133
  assert commands[-1].startswith("\033_Gm=0;")
119
134
 
120
135
 
121
136
  def test_auto_protocol_renderer_requires_kitty_terminal(monkeypatch):
122
137
  monkeypatch.delenv("KITTY_WINDOW_ID", raising=False)
138
+ monkeypatch.delenv("KITTY_PID", raising=False)
123
139
  monkeypatch.setenv("TERM", "xterm-256color")
124
140
  image = Image.new("RGB", (2, 4), "#00ff00")
125
141
  buffer = BytesIO()
@@ -128,11 +144,38 @@ def test_auto_protocol_renderer_requires_kitty_terminal(monkeypatch):
128
144
  rendered = render_protocol_artwork(buffer.getvalue(), "auto", width=2, max_height=2)
129
145
 
130
146
  assert rendered is None
131
- assert protocol_renderer_status("auto") == "Block fallback; native Kitty images are disabled inside Textual"
147
+ assert protocol_renderer_status("auto") == "Block art; Kitty terminal not detected"
148
+
149
+
150
+ def test_protocol_renderer_status_explains_textual_fallback(monkeypatch):
151
+ monkeypatch.delenv("KITTY_WINDOW_ID", raising=False)
152
+ monkeypatch.delenv("KITTY_PID", raising=False)
153
+
154
+ assert protocol_renderer_status("kitty") == "Block fallback; Kitty terminal not detected"
155
+
156
+
157
+ def test_kitty_placeholder_lines_encode_row_and_column_cells():
158
+ lines = kitty_placeholder_lines(42, columns=2, rows=2)
159
+
160
+ assert lines[0].startswith(f"{KITTY_PLACEHOLDER}\u0305\u0305")
161
+ assert lines[0].endswith(f"{KITTY_PLACEHOLDER}\u0305\u030d")
162
+ assert lines[1].startswith(f"{KITTY_PLACEHOLDER}\u030d\u0305")
163
+
164
+
165
+ def test_write_all_retries_short_terminal_writes(monkeypatch):
166
+ chunks = []
167
+
168
+ def short_write(fd, payload):
169
+ del fd
170
+ chunk = bytes(payload[:3])
171
+ chunks.append(chunk)
172
+ return len(chunk)
173
+
174
+ monkeypatch.setattr(artwork.os, "write", short_write)
132
175
 
176
+ write_all(1, b"abcdefgh")
133
177
 
134
- def test_protocol_renderer_status_explains_textual_fallback():
135
- assert protocol_renderer_status("kitty") == "Block fallback; native Kitty images are disabled inside Textual"
178
+ assert b"".join(chunks) == b"abcdefgh"
136
179
 
137
180
 
138
181
  def test_prune_artwork_cache_removes_oldest_files(tmp_path, monkeypatch):
@@ -1,198 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import hashlib
5
- from io import BytesIO
6
- from pathlib import Path
7
- from typing import Any
8
- from urllib.parse import urlencode, urlsplit, urlunsplit
9
- from urllib.request import Request, urlopen
10
-
11
- from PIL import Image, ImageOps
12
- from rich.text import Text
13
-
14
- from .config import AppConfig, cache_path
15
-
16
-
17
- MAX_IMAGE_BYTES = 12 * 1024 * 1024
18
- ARTWORK_CACHE_LIMIT_BYTES = 100 * 1024 * 1024
19
- KITTY_PAYLOAD_CHUNK_SIZE = 4096
20
-
21
-
22
- def fetch_artwork(raw: Any, path: str, config: AppConfig, width: int | None = None, height: int | None = None) -> bytes:
23
- cached = cached_artwork_path(path, config, width, height)
24
- if cached.exists():
25
- return cached.read_bytes()
26
-
27
- url = artwork_url(raw, path, config, width, height)
28
- request = Request(url, headers={"User-Agent": "plex-tui"})
29
- with urlopen(request, timeout=10) as response:
30
- status = getattr(response, "status", 200)
31
- if status != 200:
32
- raise OSError(f"artwork fetch failed: HTTP {status}")
33
- data = response.read(MAX_IMAGE_BYTES + 1)
34
- if len(data) > MAX_IMAGE_BYTES:
35
- raise OSError("artwork image is too large")
36
-
37
- cached.parent.mkdir(parents=True, exist_ok=True)
38
- cached.write_bytes(data)
39
- prune_artwork_cache()
40
- return data
41
-
42
-
43
- def artwork_url(raw: Any, path: str, config: AppConfig, width: int | None = None, height: int | None = None) -> str:
44
- if width is not None and height is not None:
45
- return transcode_artwork_url(path, config, width, height)
46
-
47
- server = getattr(raw, "_server", None)
48
- if server is not None and hasattr(server, "url"):
49
- try:
50
- return str(server.url(path, includeToken=True))
51
- except Exception:
52
- pass
53
-
54
- if path.startswith("http://") or path.startswith("https://"):
55
- url = path
56
- else:
57
- url = f"{config.base_url.rstrip('/')}/{path.lstrip('/')}"
58
- return add_token(url, config.token)
59
-
60
-
61
- def transcode_artwork_url(path: str, config: AppConfig, width: int, height: int) -> str:
62
- base_url = config.base_url.rstrip("/")
63
- query = urlencode({
64
- "width": max(1, width),
65
- "height": max(1, height),
66
- "minSize": 1,
67
- "upscale": 1,
68
- "url": path,
69
- })
70
- return add_token(f"{base_url}/photo/:/transcode?{query}", config.token)
71
-
72
-
73
- def add_token(url: str, token: str) -> str:
74
- if not token:
75
- return url
76
- parts = urlsplit(url)
77
- query = parts.query
78
- separator = "&" if query else ""
79
- query = f"{query}{separator}{urlencode({'X-Plex-Token': token})}"
80
- return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
81
-
82
-
83
- def cached_artwork_path(path: str, config: AppConfig, width: int | None = None, height: int | None = None) -> Path:
84
- source = f"{config.base_url}\0{path}"
85
- if width is not None or height is not None:
86
- source = f"{source}\0{width or ''}x{height or ''}"
87
- key = hashlib.sha256(source.encode("utf-8")).hexdigest()
88
- return cache_path() / "artwork" / f"{key}.img"
89
-
90
-
91
- def artwork_is_cached(path: str, config: AppConfig, width: int | None = None, height: int | None = None) -> bool:
92
- return cached_artwork_path(path, config, width, height).exists()
93
-
94
-
95
- def prune_artwork_cache(limit_bytes: int = ARTWORK_CACHE_LIMIT_BYTES) -> None:
96
- directory = cache_path() / "artwork"
97
- if not directory.exists():
98
- return
99
- files = []
100
- total = 0
101
- try:
102
- for path in directory.iterdir():
103
- if not path.is_file():
104
- continue
105
- stat = path.stat()
106
- files.append((stat.st_mtime, stat.st_size, path))
107
- total += stat.st_size
108
- except OSError:
109
- return
110
- if total <= limit_bytes:
111
- return
112
- for _, size, path in sorted(files):
113
- try:
114
- path.unlink()
115
- except OSError:
116
- continue
117
- total -= size
118
- if total <= limit_bytes:
119
- break
120
-
121
-
122
- def render_artwork(data: bytes, width: int = 28, max_height: int = 20) -> Text:
123
- image = load_image(data)
124
- image = resize_for_cells(image, width, max_height)
125
-
126
- text = Text()
127
- pixels = image.load()
128
- for y in range(0, image.height, 2):
129
- for x in range(image.width):
130
- top = pixels[x, y]
131
- bottom = pixels[x, y + 1] if y + 1 < image.height else top
132
- text.append("▀", style=f"{rgb(top)} on {rgb(bottom)}")
133
- if y + 2 < image.height:
134
- text.append("\n")
135
- return text
136
-
137
-
138
- def render_protocol_artwork(data: bytes, renderer: str, width: int = 28, max_height: int = 20) -> object | None:
139
- return None
140
-
141
-
142
- def resolve_protocol_renderer(renderer: str) -> str:
143
- return "block"
144
-
145
-
146
- def protocol_renderer_status(renderer: str) -> str:
147
- if renderer in {"auto", "kitty"}:
148
- return "Block fallback; native Kitty images are disabled inside Textual"
149
- return "Block art"
150
-
151
-
152
- def render_kitty_artwork(data: bytes, width: int = 28, max_height: int = 20) -> Text:
153
- image = load_image(data)
154
- image = resize_for_cells(image, width, max_height)
155
- buffer = BytesIO()
156
- image.save(buffer, format="PNG")
157
- payload = base64.b64encode(buffer.getvalue()).decode("ascii")
158
- return Text("".join(kitty_graphics_commands(payload)))
159
-
160
-
161
- def kitty_graphics_commands(payload: str) -> list[str]:
162
- chunks = [
163
- payload[index : index + KITTY_PAYLOAD_CHUNK_SIZE]
164
- for index in range(0, len(payload), KITTY_PAYLOAD_CHUNK_SIZE)
165
- ]
166
- if not chunks:
167
- return ["\033_Ga=T,f=100,q=2;\033\\"]
168
- if len(chunks) == 1:
169
- return [f"\033_Ga=T,f=100,q=2;{chunks[0]}\033\\"]
170
-
171
- commands = []
172
- for index, chunk in enumerate(chunks):
173
- if index == 0:
174
- prefix = "a=T,f=100,q=2,m=1"
175
- elif index == len(chunks) - 1:
176
- prefix = "m=0"
177
- else:
178
- prefix = "m=1"
179
- commands.append(f"\033_G{prefix};{chunk}\033\\")
180
- return commands
181
-
182
- def load_image(data: bytes) -> Image.Image:
183
- return ImageOps.exif_transpose(Image.open(BytesIO(data))).convert("RGB")
184
-
185
-
186
- def resize_for_cells(image: Image.Image, width: int, max_height: int) -> Image.Image:
187
- source_width, source_height = image.size
188
- if not source_width or not source_height:
189
- return Image.new("RGB", (1, 2), "#000000")
190
-
191
- width = max(1, width)
192
- max_height = max(1, max_height)
193
- pixel_height = min(max_height * 2, max(2, round(source_height / source_width * width * 2)))
194
- return ImageOps.contain(image, (width, pixel_height), Image.Resampling.LANCZOS)
195
-
196
-
197
- def rgb(color: tuple[int, int, int]) -> str:
198
- return f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes