plex-tui 0.2.0__py3-none-any.whl

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.
plextui/artwork.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
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
+ NATIVE_IMAGE_ENV = "PLEX_TUI_ENABLE_NATIVE_IMAGES"
20
+
21
+
22
+ def fetch_artwork(raw: Any, path: str, config: AppConfig) -> bytes:
23
+ cached = cached_artwork_path(path, config)
24
+ if cached.exists():
25
+ return cached.read_bytes()
26
+
27
+ url = artwork_url(raw, path, config)
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) -> str:
44
+ server = getattr(raw, "_server", None)
45
+ if server is not None and hasattr(server, "url"):
46
+ try:
47
+ return str(server.url(path, includeToken=True))
48
+ except Exception:
49
+ pass
50
+
51
+ if path.startswith("http://") or path.startswith("https://"):
52
+ url = path
53
+ else:
54
+ url = f"{config.base_url.rstrip('/')}/{path.lstrip('/')}"
55
+ return add_token(url, config.token)
56
+
57
+
58
+ def add_token(url: str, token: str) -> str:
59
+ if not token:
60
+ return url
61
+ parts = urlsplit(url)
62
+ query = parts.query
63
+ separator = "&" if query else ""
64
+ query = f"{query}{separator}{urlencode({'X-Plex-Token': token})}"
65
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
66
+
67
+
68
+ def cached_artwork_path(path: str, config: AppConfig) -> Path:
69
+ key = hashlib.sha256(f"{config.base_url}\0{path}".encode("utf-8")).hexdigest()
70
+ return cache_path() / "artwork" / f"{key}.img"
71
+
72
+
73
+ def artwork_is_cached(path: str, config: AppConfig) -> bool:
74
+ return cached_artwork_path(path, config).exists()
75
+
76
+
77
+ def prune_artwork_cache(limit_bytes: int = ARTWORK_CACHE_LIMIT_BYTES) -> None:
78
+ directory = cache_path() / "artwork"
79
+ if not directory.exists():
80
+ return
81
+ files = []
82
+ total = 0
83
+ try:
84
+ for path in directory.iterdir():
85
+ if not path.is_file():
86
+ continue
87
+ stat = path.stat()
88
+ files.append((stat.st_mtime, stat.st_size, path))
89
+ total += stat.st_size
90
+ except OSError:
91
+ return
92
+ if total <= limit_bytes:
93
+ return
94
+ for _, size, path in sorted(files):
95
+ try:
96
+ path.unlink()
97
+ except OSError:
98
+ continue
99
+ total -= size
100
+ if total <= limit_bytes:
101
+ break
102
+
103
+
104
+ def render_artwork(data: bytes, width: int = 28, max_height: int = 20) -> Text:
105
+ image = load_image(data)
106
+ image = resize_for_cells(image, width, max_height)
107
+
108
+ text = Text()
109
+ pixels = image.load()
110
+ for y in range(0, image.height, 2):
111
+ for x in range(image.width):
112
+ top = pixels[x, y]
113
+ bottom = pixels[x, y + 1] if y + 1 < image.height else top
114
+ text.append("▀", style=f"{rgb(top)} on {rgb(bottom)}")
115
+ if y + 2 < image.height:
116
+ text.append("\n")
117
+ return text
118
+
119
+
120
+ def render_protocol_artwork(data: bytes, renderer: str, width: int = 28, max_height: int = 20) -> object | None:
121
+ return None
122
+
123
+
124
+ def resolve_protocol_renderer(renderer: str) -> str:
125
+ if not native_images_enabled():
126
+ return "block"
127
+ if renderer == "kitty":
128
+ return "kitty"
129
+ if renderer == "auto" and is_kitty_terminal():
130
+ return "kitty"
131
+ return "block"
132
+
133
+
134
+ def native_images_enabled() -> bool:
135
+ return os.environ.get(NATIVE_IMAGE_ENV) == "1"
136
+
137
+
138
+ def is_kitty_terminal() -> bool:
139
+ return bool(os.environ.get("KITTY_WINDOW_ID") or "kitty" in os.environ.get("TERM", "").lower())
140
+
141
+
142
+ def load_image(data: bytes) -> Image.Image:
143
+ return ImageOps.exif_transpose(Image.open(BytesIO(data))).convert("RGB")
144
+
145
+
146
+ def resize_for_cells(image: Image.Image, width: int, max_height: int) -> Image.Image:
147
+ source_width, source_height = image.size
148
+ if not source_width or not source_height:
149
+ return Image.new("RGB", (1, 2), "#000000")
150
+
151
+ width = max(1, width)
152
+ max_height = max(1, max_height)
153
+ pixel_height = min(max_height * 2, max(2, round(source_height / source_width * width * 2)))
154
+ return ImageOps.contain(image, (width, pixel_height), Image.Resampling.LANCZOS)
155
+
156
+
157
+ def rgb(color: tuple[int, int, int]) -> str:
158
+ return f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}"
plextui/auth.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import webbrowser
4
+ from dataclasses import dataclass
5
+
6
+ from plexapi.myplex import MyPlexAccount, MyPlexPinLogin, MyPlexResource
7
+
8
+ from . import __version__
9
+ from .config import APP_NAME, AppConfig, save_config
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ServerChoice:
14
+ name: str
15
+ uri: str
16
+ source: str
17
+ resource: MyPlexResource
18
+
19
+
20
+ class LoginSession:
21
+ def __init__(self, config: AppConfig) -> None:
22
+ self.config = config
23
+ self.pin_login = MyPlexPinLogin(headers=plex_headers(config), oauth=True)
24
+
25
+ def start(self, timeout: int = 300) -> str:
26
+ self.pin_login.run(timeout=timeout)
27
+ url = self.pin_login.oauthUrl()
28
+ try:
29
+ webbrowser.open(url)
30
+ except Exception:
31
+ pass
32
+ return url
33
+
34
+ def wait(self) -> tuple[str, list[ServerChoice]]:
35
+ if not self.pin_login.waitForLogin() or not self.pin_login.token:
36
+ raise RuntimeError("Plex login timed out or was cancelled")
37
+
38
+ account_token = self.pin_login.token
39
+ account = MyPlexAccount(token=account_token)
40
+ choices: list[ServerChoice] = []
41
+ for resource in account.resources():
42
+ if "server" not in str(resource.provides):
43
+ continue
44
+ for uri in resource.preferred_connections():
45
+ choices.append(
46
+ ServerChoice(
47
+ name=resource.name,
48
+ uri=uri,
49
+ source=resource.sourceTitle or "owned",
50
+ resource=resource,
51
+ )
52
+ )
53
+ if not choices:
54
+ raise RuntimeError("No Plex Media Server resources found for this account")
55
+ return account_token, choices
56
+
57
+ def stop(self) -> None:
58
+ self.pin_login.stop()
59
+
60
+
61
+ def save_server_choice(config: AppConfig, account_token: str, choice: ServerChoice) -> AppConfig:
62
+ saved = AppConfig(
63
+ base_url=choice.uri,
64
+ token=choice.resource.accessToken,
65
+ client_identifier=config.client_identifier,
66
+ account_token=account_token,
67
+ preferred_audio_language=config.preferred_audio_language,
68
+ preferred_subtitle_language=config.preferred_subtitle_language,
69
+ subtitle_mode=config.subtitle_mode,
70
+ artwork_mode=config.artwork_mode,
71
+ artwork_renderer=config.artwork_renderer,
72
+ detail_artwork_mode=config.detail_artwork_mode,
73
+ grid_density=config.grid_density,
74
+ media_view=config.media_view,
75
+ theme=config.theme,
76
+ mpv_window_size=config.mpv_window_size,
77
+ page_size=config.page_size,
78
+ auto_load_threshold=config.auto_load_threshold,
79
+ )
80
+ save_config(saved)
81
+ return saved
82
+
83
+
84
+ def plex_headers(config: AppConfig) -> dict[str, str]:
85
+ return {
86
+ "X-Plex-Product": APP_NAME,
87
+ "X-Plex-Version": __version__,
88
+ "X-Plex-Client-Identifier": config.client_identifier,
89
+ "X-Plex-Platform": "Python",
90
+ "X-Plex-Platform-Version": "3",
91
+ "X-Plex-Device": "terminal",
92
+ "X-Plex-Device-Name": APP_NAME,
93
+ }
plextui/config.py ADDED
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import tomllib
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from platformdirs import user_cache_dir, user_config_dir
11
+
12
+
13
+ APP_NAME = "plex-tui"
14
+ DEFAULT_PAGE_SIZE = 40
15
+ MIN_PAGE_SIZE = 25
16
+ MAX_PAGE_SIZE = 500
17
+ DEFAULT_AUTO_LOAD_THRESHOLD = 10
18
+ MIN_AUTO_LOAD_THRESHOLD = 1
19
+ MAX_AUTO_LOAD_THRESHOLD = 100
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AppConfig:
24
+ base_url: str
25
+ token: str
26
+ client_identifier: str
27
+ account_token: str = ""
28
+ preferred_audio_language: str = ""
29
+ preferred_subtitle_language: str = ""
30
+ subtitle_mode: str = "auto"
31
+ artwork_mode: str = "on"
32
+ artwork_renderer: str = "block"
33
+ detail_artwork_mode: str = "list_only"
34
+ grid_density: str = "comfortable"
35
+ media_view: str = "list"
36
+ theme: str = "textual-dark"
37
+ mpv_window_size: str = ""
38
+ page_size: int = DEFAULT_PAGE_SIZE
39
+ auto_load_threshold: int = DEFAULT_AUTO_LOAD_THRESHOLD
40
+
41
+
42
+ def config_path() -> Path:
43
+ return Path(user_config_dir(APP_NAME)) / "config.toml"
44
+
45
+
46
+ def cache_path() -> Path:
47
+ return Path(user_cache_dir(APP_NAME))
48
+
49
+
50
+ def debug_log_path() -> Path:
51
+ return Path(user_config_dir(APP_NAME)) / "debug.log"
52
+
53
+
54
+ def load_config() -> AppConfig:
55
+ data: dict[str, str] = {}
56
+ path = config_path()
57
+ if path.exists():
58
+ with path.open("rb") as fh:
59
+ raw = tomllib.load(fh)
60
+ data = {k: str(v) for k, v in raw.items() if isinstance(v, str | int)}
61
+
62
+ base_url = os.environ.get("PLEX_TUI_BASE_URL") or data.get("base_url", "")
63
+ token = os.environ.get("PLEX_TUI_TOKEN") or data.get("token", "")
64
+ client_identifier = data.get("client_identifier") or f"plex-tui-{uuid.uuid4()}"
65
+ account_token = data.get("account_token", "")
66
+ preferred_audio_language = data.get("preferred_audio_language", "")
67
+ preferred_subtitle_language = data.get("preferred_subtitle_language", "")
68
+ subtitle_mode = data.get("subtitle_mode", "auto")
69
+ if subtitle_mode not in {"auto", "none", "preferred"}:
70
+ write_debug_log(f"invalid subtitle_mode {subtitle_mode!r}; using 'auto'")
71
+ subtitle_mode = "auto"
72
+ artwork_mode = data.get("artwork_mode", "on")
73
+ if artwork_mode not in {"on", "off"}:
74
+ write_debug_log(f"invalid artwork_mode {artwork_mode!r}; using 'on'")
75
+ artwork_mode = "on"
76
+ artwork_renderer = data.get("artwork_renderer", "block")
77
+ if artwork_renderer not in {"block", "auto", "kitty"}:
78
+ write_debug_log(f"invalid artwork_renderer {artwork_renderer!r}; using 'block'")
79
+ artwork_renderer = "block"
80
+ detail_artwork_mode = data.get("detail_artwork_mode", "list_only")
81
+ if detail_artwork_mode not in {"list_only", "on", "off"}:
82
+ write_debug_log(f"invalid detail_artwork_mode {detail_artwork_mode!r}; using 'list_only'")
83
+ detail_artwork_mode = "list_only"
84
+ grid_density = data.get("grid_density", "comfortable")
85
+ if grid_density not in {"compact", "comfortable", "large"}:
86
+ write_debug_log(f"invalid grid_density {grid_density!r}; using 'comfortable'")
87
+ grid_density = "comfortable"
88
+ media_view = data.get("media_view", "list")
89
+ if media_view == "poster":
90
+ write_debug_log("media_view 'poster' is deprecated; using 'list'")
91
+ media_view = "list"
92
+ if media_view not in {"list", "grid"}:
93
+ write_debug_log(f"invalid media_view {media_view!r}; using 'list'")
94
+ media_view = "list"
95
+ theme = data.get("theme", "textual-dark")
96
+ mpv_window_size = data.get("mpv_window_size", "")
97
+ if mpv_window_size and not valid_mpv_window_size(mpv_window_size):
98
+ write_debug_log(f"invalid mpv_window_size {mpv_window_size!r}; using default")
99
+ mpv_window_size = ""
100
+ page_size = bounded_int(
101
+ data.get("page_size", ""),
102
+ DEFAULT_PAGE_SIZE,
103
+ MIN_PAGE_SIZE,
104
+ MAX_PAGE_SIZE,
105
+ "page_size",
106
+ )
107
+ auto_load_threshold = bounded_int(
108
+ data.get("auto_load_threshold", ""),
109
+ DEFAULT_AUTO_LOAD_THRESHOLD,
110
+ MIN_AUTO_LOAD_THRESHOLD,
111
+ MAX_AUTO_LOAD_THRESHOLD,
112
+ "auto_load_threshold",
113
+ )
114
+ return AppConfig(
115
+ base_url=base_url.strip(),
116
+ token=token.strip(),
117
+ client_identifier=client_identifier.strip(),
118
+ account_token=account_token.strip(),
119
+ preferred_audio_language=preferred_audio_language.strip(),
120
+ preferred_subtitle_language=preferred_subtitle_language.strip(),
121
+ subtitle_mode=subtitle_mode.strip(),
122
+ artwork_mode=artwork_mode.strip(),
123
+ artwork_renderer=artwork_renderer.strip(),
124
+ detail_artwork_mode=detail_artwork_mode.strip(),
125
+ grid_density=grid_density.strip(),
126
+ media_view=media_view.strip(),
127
+ theme=theme.strip() or "textual-dark",
128
+ mpv_window_size=mpv_window_size.strip(),
129
+ page_size=page_size,
130
+ auto_load_threshold=auto_load_threshold,
131
+ )
132
+
133
+
134
+ def save_config(config: AppConfig) -> None:
135
+ path = config_path()
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ lines = [
138
+ f'base_url = "{_toml_escape(config.base_url)}"',
139
+ f'token = "{_toml_escape(config.token)}"',
140
+ f'client_identifier = "{_toml_escape(config.client_identifier)}"',
141
+ ]
142
+ if config.account_token:
143
+ lines.append(f'account_token = "{_toml_escape(config.account_token)}"')
144
+ if config.preferred_audio_language:
145
+ lines.append(f'preferred_audio_language = "{_toml_escape(config.preferred_audio_language)}"')
146
+ if config.preferred_subtitle_language:
147
+ lines.append(f'preferred_subtitle_language = "{_toml_escape(config.preferred_subtitle_language)}"')
148
+ if config.subtitle_mode != "auto":
149
+ lines.append(f'subtitle_mode = "{_toml_escape(config.subtitle_mode)}"')
150
+ if config.artwork_mode != "on":
151
+ lines.append(f'artwork_mode = "{_toml_escape(config.artwork_mode)}"')
152
+ if config.artwork_renderer != "block":
153
+ lines.append(f'artwork_renderer = "{_toml_escape(config.artwork_renderer)}"')
154
+ if config.detail_artwork_mode != "list_only":
155
+ lines.append(f'detail_artwork_mode = "{_toml_escape(config.detail_artwork_mode)}"')
156
+ if config.grid_density != "comfortable":
157
+ lines.append(f'grid_density = "{_toml_escape(config.grid_density)}"')
158
+ if config.media_view != "list":
159
+ lines.append(f'media_view = "{_toml_escape(config.media_view)}"')
160
+ if config.theme != "textual-dark":
161
+ lines.append(f'theme = "{_toml_escape(config.theme)}"')
162
+ if config.mpv_window_size:
163
+ lines.append(f'mpv_window_size = "{_toml_escape(config.mpv_window_size)}"')
164
+ if config.page_size != DEFAULT_PAGE_SIZE:
165
+ lines.append(f"page_size = {config.page_size}")
166
+ if config.auto_load_threshold != DEFAULT_AUTO_LOAD_THRESHOLD:
167
+ lines.append(f"auto_load_threshold = {config.auto_load_threshold}")
168
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
169
+
170
+
171
+ def valid_mpv_window_size(value: str) -> bool:
172
+ return bool(re.fullmatch(r"(?:\d{2,5}x\d{2,5}|\d{1,3}%x\d{1,3}%|\d{1,3}%)", value.strip()))
173
+
174
+
175
+ def bounded_int(value: str, default: int, minimum: int, maximum: int, name: str) -> int:
176
+ if value == "":
177
+ return default
178
+ try:
179
+ parsed = int(value)
180
+ except (TypeError, ValueError):
181
+ write_debug_log(f"invalid {name} {value!r}; using {default}")
182
+ return default
183
+ if parsed < minimum or parsed > maximum:
184
+ write_debug_log(f"invalid {name} {value!r}; using {default}")
185
+ return default
186
+ return parsed
187
+
188
+
189
+ def _toml_escape(value: str) -> str:
190
+ return value.replace("\\", "\\\\").replace('"', '\\"')
191
+
192
+
193
+ def write_debug_log(message: str) -> None:
194
+ try:
195
+ path = debug_log_path()
196
+ path.parent.mkdir(parents=True, exist_ok=True)
197
+ with path.open("a", encoding="utf-8") as fh:
198
+ fh.write(f"{message}\n")
199
+ except OSError:
200
+ return
plextui/models.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class LibraryItem:
9
+ title: str
10
+ key: str
11
+ kind: str
12
+ raw: Any
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MediaItem:
17
+ title: str
18
+ subtitle: str
19
+ kind: str
20
+ key: str
21
+ playable: bool
22
+ raw: Any
23
+ artwork_path: str = ""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class MediaDetails:
28
+ title: str
29
+ kind: str
30
+ facts: list[str]
31
+ metadata: list[tuple[str, str]]
32
+ audio: list[str]
33
+ subtitles: list[str]
34
+ summary: str
35
+ playable: bool
36
+ artwork_path: str = ""