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.
- {plex_tui-0.3.41 → plex_tui-0.4.0}/CHANGELOG.md +11 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/PKG-INFO +7 -7
- {plex_tui-0.3.41 → plex_tui-0.4.0}/README.md +6 -6
- {plex_tui-0.3.41 → plex_tui-0.4.0}/ROADMAP.md +2 -5
- {plex_tui-0.3.41 → plex_tui-0.4.0}/config.example.toml +2 -2
- {plex_tui-0.3.41 → plex_tui-0.4.0}/packaging/aur/.SRCINFO +3 -3
- {plex_tui-0.3.41 → plex_tui-0.4.0}/packaging/aur/PKGBUILD +2 -2
- {plex_tui-0.3.41 → plex_tui-0.4.0}/pyproject.toml +1 -1
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/__init__.py +1 -1
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/app.py +67 -22
- plex_tui-0.4.0/src/plextui/artwork.py +407 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_app_helpers.py +51 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_app_navigation.py +36 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_artwork.py +55 -12
- plex_tui-0.3.41/src/plextui/artwork.py +0 -198
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/aur.yml +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/bump.yml +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/ci.yml +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/post-release-aur.yml +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/post-release-homebrew.yml +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/publish-aur.yml +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/publish-pypi.yml +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.github/workflows/publish-testpypi.yml +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/.gitignore +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/AGENTS.md +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/LICENSE +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/Makefile +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/PACKAGING.md +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/RELEASE.md +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/docs/assets/grid-view.png +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/docs/assets/list-view.png +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/docs/plex-integration-research.md +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/packaging/aur/README.md +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/packaging/homebrew/README.md +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/scripts/check_release.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/scripts/stage_release.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/scripts/update_aur_package.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/scripts/update_homebrew_formula.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/__main__.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/auth.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/config.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/models.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/player.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/plex_service.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/src/plextui/smoke.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_auth.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_cli.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_config.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_player.py +0 -0
- {plex_tui-0.3.41 → plex_tui-0.4.0}/tests/test_plex_service.py +0 -0
- {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
|
+
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
|
|
250
|
-
terminals without
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
213
|
-
terminals without
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
-
|
|
68
|
-
|
|
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
|
|
44
|
-
#
|
|
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.
|
|
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.
|
|
20
|
-
sha256sums =
|
|
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.
|
|
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=("
|
|
25
|
+
sha256sums=("7e034f82945a4645fd01364c79b1d9bfd2d7820257aef4012eaf9ce5d3e9aa58")
|
|
26
26
|
|
|
27
27
|
build() {
|
|
28
28
|
cd "$pkgname-$pkgver"
|
|
@@ -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
|
|
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(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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=
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2611
|
-
|
|
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
|
|
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
|
|
98
|
-
assert
|
|
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
|
|
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
|
|
109
|
-
assert rendered.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|