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/plex_service.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from plexapi.server import PlexServer
|
|
8
|
+
|
|
9
|
+
from .config import AppConfig, config_path
|
|
10
|
+
from .models import LibraryItem, MediaDetails, MediaItem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PLAYABLE_TYPES = {"movie", "episode", "track", "clip"}
|
|
14
|
+
DEFAULT_PAGE_SIZE = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class MediaPage:
|
|
19
|
+
items: list[MediaItem]
|
|
20
|
+
start: int
|
|
21
|
+
total: int
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def next_start(self) -> int:
|
|
25
|
+
return self.start + len(self.items)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def has_more(self) -> bool:
|
|
29
|
+
return self.next_start < self.total
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PlexService:
|
|
33
|
+
def __init__(self, config: AppConfig) -> None:
|
|
34
|
+
if not config.base_url or not config.token:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"missing Plex config. Set PLEX_TUI_BASE_URL and PLEX_TUI_TOKEN, "
|
|
37
|
+
f"or create {config_path()}"
|
|
38
|
+
)
|
|
39
|
+
self.server = PlexServer(config.base_url, config.token)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def friendly_name(self) -> str:
|
|
43
|
+
return getattr(self.server, "friendlyName", "Plex")
|
|
44
|
+
|
|
45
|
+
def libraries(self) -> list[LibraryItem]:
|
|
46
|
+
sections = self.server.library.sections()
|
|
47
|
+
return [
|
|
48
|
+
LibraryItem(
|
|
49
|
+
title=section.title,
|
|
50
|
+
key=str(section.key),
|
|
51
|
+
kind=getattr(section, "TYPE", section.__class__.__name__),
|
|
52
|
+
raw=section,
|
|
53
|
+
)
|
|
54
|
+
for section in sections
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
def library_page(self, library: LibraryItem, start: int = 0, size: int = DEFAULT_PAGE_SIZE) -> MediaPage:
|
|
58
|
+
raw_items = library.raw.all(maxresults=size, container_start=start, container_size=size)
|
|
59
|
+
items = [to_media_item(item) for item in raw_items]
|
|
60
|
+
total = getattr(raw_items, "totalSize", None)
|
|
61
|
+
if total is None:
|
|
62
|
+
total = start + len(items)
|
|
63
|
+
return MediaPage(items=items, start=start, total=int(total))
|
|
64
|
+
|
|
65
|
+
def library_items(self, library: LibraryItem) -> list[MediaItem]:
|
|
66
|
+
return self.library_page(library, 0, DEFAULT_PAGE_SIZE).items
|
|
67
|
+
|
|
68
|
+
def search_page(
|
|
69
|
+
self,
|
|
70
|
+
query: str,
|
|
71
|
+
library: LibraryItem | None = None,
|
|
72
|
+
start: int = 0,
|
|
73
|
+
size: int = DEFAULT_PAGE_SIZE,
|
|
74
|
+
) -> MediaPage:
|
|
75
|
+
if library is not None:
|
|
76
|
+
raw_items = library.raw.search(
|
|
77
|
+
query,
|
|
78
|
+
maxresults=size,
|
|
79
|
+
container_start=start,
|
|
80
|
+
container_size=size,
|
|
81
|
+
)
|
|
82
|
+
items = [to_media_item(item) for item in raw_items]
|
|
83
|
+
total = getattr(raw_items, "totalSize", None)
|
|
84
|
+
if total is None:
|
|
85
|
+
total = start + len(items)
|
|
86
|
+
return MediaPage(items=items, start=start, total=int(total))
|
|
87
|
+
|
|
88
|
+
if start:
|
|
89
|
+
return MediaPage(items=[], start=start, total=start)
|
|
90
|
+
raw_items = self.server.search(query, limit=size)
|
|
91
|
+
items = [to_media_item(item) for item in raw_items]
|
|
92
|
+
return MediaPage(items=items, start=0, total=len(items))
|
|
93
|
+
|
|
94
|
+
def children(self, item: MediaItem) -> list[MediaItem]:
|
|
95
|
+
raw = item.raw
|
|
96
|
+
if hasattr(raw, "seasons"):
|
|
97
|
+
return [to_media_item(child) for child in raw.seasons()]
|
|
98
|
+
if hasattr(raw, "episodes"):
|
|
99
|
+
return [to_media_item(child) for child in raw.episodes()]
|
|
100
|
+
if hasattr(raw, "items"):
|
|
101
|
+
return [to_media_item(child) for child in raw.items()]
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
def search(self, query: str, library: LibraryItem | None = None) -> list[MediaItem]:
|
|
105
|
+
if library is not None:
|
|
106
|
+
return self.search_page(query, library, 0, DEFAULT_PAGE_SIZE).items
|
|
107
|
+
source: Iterable[Any] = self.server.search(query, limit=DEFAULT_PAGE_SIZE)
|
|
108
|
+
return [to_media_item(item) for item in source]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def to_media_item(raw: Any) -> MediaItem:
|
|
112
|
+
kind = str(getattr(raw, "TYPE", raw.__class__.__name__)).lower()
|
|
113
|
+
year = getattr(raw, "year", None)
|
|
114
|
+
duration = format_duration(getattr(raw, "duration", None))
|
|
115
|
+
bits = [str(year) if year else "", duration]
|
|
116
|
+
subtitle = " ".join(bit for bit in bits if bit)
|
|
117
|
+
key = str(getattr(raw, "ratingKey", getattr(raw, "key", "")))
|
|
118
|
+
return MediaItem(
|
|
119
|
+
title=getattr(raw, "title", "Untitled"),
|
|
120
|
+
subtitle=subtitle,
|
|
121
|
+
kind=kind,
|
|
122
|
+
key=key,
|
|
123
|
+
playable=kind in PLAYABLE_TYPES and hasattr(raw, "getStreamURL"),
|
|
124
|
+
raw=raw,
|
|
125
|
+
artwork_path=artwork_path(raw),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def media_details(item: MediaItem) -> MediaDetails:
|
|
130
|
+
raw = item.raw
|
|
131
|
+
facts = [item.kind]
|
|
132
|
+
for value in (
|
|
133
|
+
getattr(raw, "year", None),
|
|
134
|
+
format_duration(getattr(raw, "duration", None)),
|
|
135
|
+
watched_state(raw),
|
|
136
|
+
episode_label(raw),
|
|
137
|
+
subtitle_label(raw),
|
|
138
|
+
getattr(raw, "contentRating", None),
|
|
139
|
+
rating_label(raw),
|
|
140
|
+
):
|
|
141
|
+
if value:
|
|
142
|
+
facts.append(str(value))
|
|
143
|
+
|
|
144
|
+
for label, value in (
|
|
145
|
+
("Show", getattr(raw, "grandparentTitle", None)),
|
|
146
|
+
("Season", getattr(raw, "parentTitle", None)),
|
|
147
|
+
("Studio", getattr(raw, "studio", None)),
|
|
148
|
+
):
|
|
149
|
+
if value:
|
|
150
|
+
facts.append(f"{label}: {value}")
|
|
151
|
+
|
|
152
|
+
return MediaDetails(
|
|
153
|
+
title=item.title,
|
|
154
|
+
kind=item.kind,
|
|
155
|
+
facts=facts,
|
|
156
|
+
metadata=metadata_fields(raw),
|
|
157
|
+
audio=audio_details(raw),
|
|
158
|
+
subtitles=subtitle_details(raw),
|
|
159
|
+
summary=str(getattr(raw, "summary", "") or ""),
|
|
160
|
+
playable=item.playable,
|
|
161
|
+
artwork_path=artwork_path(raw) or item.artwork_path,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def artwork_path(raw: Any) -> str:
|
|
166
|
+
for attr in ("grandparentThumb", "parentThumb", "thumb", "art"):
|
|
167
|
+
value = getattr(raw, attr, None)
|
|
168
|
+
if value:
|
|
169
|
+
return str(value)
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def metadata_fields(raw: Any) -> list[tuple[str, str]]:
|
|
174
|
+
fields: list[tuple[str, str]] = []
|
|
175
|
+
for label, value in (
|
|
176
|
+
("Type", getattr(raw, "TYPE", "")),
|
|
177
|
+
("Year", getattr(raw, "year", None)),
|
|
178
|
+
("Duration", format_duration(getattr(raw, "duration", None))),
|
|
179
|
+
("Status", watched_state(raw)),
|
|
180
|
+
("Progress", progress_label(raw)),
|
|
181
|
+
("Episode", episode_label(raw)),
|
|
182
|
+
("Content Rating", getattr(raw, "contentRating", None)),
|
|
183
|
+
("Rating", rating_label(raw).replace("Rating ", "")),
|
|
184
|
+
("Show", getattr(raw, "grandparentTitle", None)),
|
|
185
|
+
("Season", getattr(raw, "parentTitle", None)),
|
|
186
|
+
("Studio", getattr(raw, "studio", None)),
|
|
187
|
+
):
|
|
188
|
+
if value:
|
|
189
|
+
fields.append((label, str(value)))
|
|
190
|
+
return fields
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def audio_details(raw: Any) -> list[str]:
|
|
194
|
+
audio: list[str] = []
|
|
195
|
+
try:
|
|
196
|
+
if not hasattr(raw, "iterParts"):
|
|
197
|
+
return audio
|
|
198
|
+
for part in raw.iterParts():
|
|
199
|
+
for stream in part.audioStreams():
|
|
200
|
+
audio.append(stream_detail_label(stream, include_location=False))
|
|
201
|
+
except Exception:
|
|
202
|
+
return audio
|
|
203
|
+
return audio
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def subtitle_details(raw: Any) -> list[str]:
|
|
207
|
+
subtitles: list[str] = []
|
|
208
|
+
try:
|
|
209
|
+
if not hasattr(raw, "iterParts"):
|
|
210
|
+
return subtitles
|
|
211
|
+
for part in raw.iterParts():
|
|
212
|
+
for stream in part.subtitleStreams():
|
|
213
|
+
subtitles.append(stream_detail_label(stream, include_location=True))
|
|
214
|
+
except Exception:
|
|
215
|
+
return subtitles
|
|
216
|
+
return subtitles
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def stream_detail_label(stream: Any, include_location: bool = False) -> str:
|
|
220
|
+
label = getattr(stream, "displayTitle", None) or getattr(stream, "language", None) or "Unknown"
|
|
221
|
+
values = []
|
|
222
|
+
codec = getattr(stream, "codec", None)
|
|
223
|
+
if codec:
|
|
224
|
+
values.append(str(codec))
|
|
225
|
+
channels = getattr(stream, "channels", None)
|
|
226
|
+
if channels:
|
|
227
|
+
values.append(f"{channels}ch")
|
|
228
|
+
if include_location:
|
|
229
|
+
values.append("external" if getattr(stream, "key", None) else "embedded")
|
|
230
|
+
if getattr(stream, "selected", False):
|
|
231
|
+
values.append("selected")
|
|
232
|
+
if getattr(stream, "forced", False):
|
|
233
|
+
values.append("forced")
|
|
234
|
+
if getattr(stream, "hearingImpaired", False):
|
|
235
|
+
values.append("SDH")
|
|
236
|
+
suffix = ", ".join(values)
|
|
237
|
+
return f"{label} ({suffix})" if suffix else str(label)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def watched_state(raw: Any) -> str:
|
|
241
|
+
view_count = getattr(raw, "viewCount", None)
|
|
242
|
+
if view_count:
|
|
243
|
+
return "watched"
|
|
244
|
+
if resume_offset(raw):
|
|
245
|
+
return "in progress"
|
|
246
|
+
if hasattr(raw, "isWatched"):
|
|
247
|
+
try:
|
|
248
|
+
value = raw.isWatched() if callable(raw.isWatched) else raw.isWatched
|
|
249
|
+
return "watched" if value else "unwatched"
|
|
250
|
+
except Exception:
|
|
251
|
+
return ""
|
|
252
|
+
return ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def progress_label(raw: Any) -> str:
|
|
256
|
+
offset = resume_offset(raw)
|
|
257
|
+
if not offset:
|
|
258
|
+
return ""
|
|
259
|
+
duration = duration_ms(raw)
|
|
260
|
+
if duration:
|
|
261
|
+
percent = min(99, max(1, round(offset / duration * 100)))
|
|
262
|
+
return f"{format_position(offset)} / {format_position(duration)} ({percent}%)"
|
|
263
|
+
return f"Resume at {format_position(offset)}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def row_progress_marker(raw: Any) -> str:
|
|
267
|
+
state = watched_state(raw)
|
|
268
|
+
if state == "watched":
|
|
269
|
+
return "[watched]"
|
|
270
|
+
if state == "in progress":
|
|
271
|
+
label = progress_label(raw)
|
|
272
|
+
return f"[resume {format_position(resume_offset(raw))}]" if label else "[resume]"
|
|
273
|
+
return ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def resume_offset(raw: Any) -> int:
|
|
277
|
+
try:
|
|
278
|
+
return max(0, int(getattr(raw, "viewOffset", 0) or 0))
|
|
279
|
+
except (TypeError, ValueError):
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def duration_ms(raw: Any) -> int:
|
|
284
|
+
try:
|
|
285
|
+
return max(0, int(getattr(raw, "duration", 0) or 0))
|
|
286
|
+
except (TypeError, ValueError):
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def format_position(milliseconds: int) -> str:
|
|
291
|
+
seconds = max(0, milliseconds // 1000)
|
|
292
|
+
hours, remainder = divmod(seconds, 3600)
|
|
293
|
+
minutes, _ = divmod(remainder, 60)
|
|
294
|
+
if hours:
|
|
295
|
+
return f"{hours}h {minutes:02d}m"
|
|
296
|
+
return f"{minutes}m"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def episode_label(raw: Any) -> str:
|
|
300
|
+
season = getattr(raw, "parentIndex", None)
|
|
301
|
+
episode = getattr(raw, "index", None)
|
|
302
|
+
if season is not None and episode is not None and getattr(raw, "TYPE", "") == "episode":
|
|
303
|
+
return f"S{int(season):02d}E{int(episode):02d}"
|
|
304
|
+
if episode is not None and getattr(raw, "TYPE", "") == "season":
|
|
305
|
+
return f"Season {episode}"
|
|
306
|
+
return ""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def rating_label(raw: Any) -> str:
|
|
310
|
+
rating = getattr(raw, "audienceRating", None) or getattr(raw, "rating", None)
|
|
311
|
+
if rating is None:
|
|
312
|
+
return ""
|
|
313
|
+
try:
|
|
314
|
+
return f"Rating {float(rating):.1f}"
|
|
315
|
+
except (TypeError, ValueError):
|
|
316
|
+
return f"Rating {rating}"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def subtitle_label(raw: Any) -> str:
|
|
320
|
+
count = 0
|
|
321
|
+
try:
|
|
322
|
+
if hasattr(raw, "iterParts"):
|
|
323
|
+
for part in raw.iterParts():
|
|
324
|
+
count += len(part.subtitleStreams())
|
|
325
|
+
except Exception:
|
|
326
|
+
return ""
|
|
327
|
+
if count == 1:
|
|
328
|
+
return "1 subtitle"
|
|
329
|
+
if count > 1:
|
|
330
|
+
return f"{count} subtitles"
|
|
331
|
+
return ""
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def format_duration(milliseconds: Any) -> str:
|
|
335
|
+
if not milliseconds:
|
|
336
|
+
return ""
|
|
337
|
+
try:
|
|
338
|
+
minutes = int(milliseconds) // 60_000
|
|
339
|
+
except (TypeError, ValueError):
|
|
340
|
+
return ""
|
|
341
|
+
hours, mins = divmod(minutes, 60)
|
|
342
|
+
if hours:
|
|
343
|
+
return f"{hours}h {mins}m"
|
|
344
|
+
return f"{mins}m"
|
plextui/smoke.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .app import PlexTuiApp, format_offset
|
|
4
|
+
from .config import config_path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
app = PlexTuiApp()
|
|
9
|
+
if not app.BINDINGS:
|
|
10
|
+
raise SystemExit("missing key bindings")
|
|
11
|
+
if format_offset(65_000) != "1:05":
|
|
12
|
+
raise SystemExit("helper self-check failed")
|
|
13
|
+
print(f"plex-tui smoke ok: {app.__class__.__name__}")
|
|
14
|
+
print(f"config path: {config_path()}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|