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.
- plex_tui-0.2.0.dist-info/METADATA +226 -0
- plex_tui-0.2.0.dist-info/RECORD +15 -0
- plex_tui-0.2.0.dist-info/WHEEL +4 -0
- plex_tui-0.2.0.dist-info/entry_points.txt +2 -0
- plex_tui-0.2.0.dist-info/licenses/LICENSE +21 -0
- plextui/__init__.py +3 -0
- plextui/__main__.py +40 -0
- plextui/app.py +2248 -0
- plextui/artwork.py +158 -0
- plextui/auth.py +93 -0
- plextui/config.py +200 -0
- plextui/models.py +36 -0
- plextui/player.py +519 -0
- plextui/plex_service.py +344 -0
- plextui/smoke.py +18 -0
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 = ""
|