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.
@@ -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()