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/player.py ADDED
@@ -0,0 +1,519 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ import re
6
+ import shutil
7
+ import signal
8
+ import socket
9
+ import subprocess
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
17
+
18
+ from .config import write_debug_log
19
+
20
+
21
+ class PlayerError(RuntimeError):
22
+ pass
23
+
24
+
25
+ @dataclass
26
+ class PlayerHandle:
27
+ title: str
28
+ subtitle_count: int
29
+ stream_mode: str
30
+ start_offset_ms: int
31
+ socket_path: Path
32
+ command: list[str]
33
+ monitor: "ProgressMonitor"
34
+ process: subprocess.Popen[bytes]
35
+
36
+ @property
37
+ def active(self) -> bool:
38
+ return self.process.poll() is None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class StreamChoice:
43
+ stream_id: int | None
44
+ label: str
45
+ stream: Any = None
46
+
47
+
48
+ def play_with_mpv(
49
+ item: Any,
50
+ subtitle_choice: StreamChoice | None = None,
51
+ audio_choice: StreamChoice | None = None,
52
+ window_size: str = "",
53
+ ) -> PlayerHandle:
54
+ if shutil.which("mpv") is None:
55
+ log_debug("playback error: mpv was not found in PATH")
56
+ raise PlayerError("mpv was not found in PATH. Install mpv and make sure it is available on PATH.")
57
+
58
+ item = full_metadata(item)
59
+ selected_subtitle = resolve_subtitle_choice(item, subtitle_choice)
60
+ selected_audio = resolve_audio_choice(item, audio_choice)
61
+ subtitles = external_subtitle_urls(item, selected_subtitle)
62
+ stream_kwargs = {}
63
+ direct_url = direct_play_url(item, selected_subtitle)
64
+ fallback_subtitle_id = selected_subtitle_stream_id(item, selected_subtitle) if not subtitles and not direct_url else None
65
+ if fallback_subtitle_id is not None:
66
+ stream_kwargs["subtitleStreamID"] = fallback_subtitle_id
67
+ if selected_audio is not None:
68
+ stream_kwargs["audioStreamID"] = getattr(selected_audio, "id", selected_audio)
69
+
70
+ try:
71
+ url = direct_url or item.getStreamURL(**stream_kwargs)
72
+ except Exception as exc:
73
+ log_debug(f"playback error: could not get stream URL: {exc}")
74
+ raise PlayerError(f"could not get stream URL from Plex: {exc}") from exc
75
+ if not url:
76
+ log_debug("playback error: Plex returned an empty stream URL")
77
+ raise PlayerError("Plex returned an empty stream URL")
78
+
79
+ title = getattr(item, "title", "Plex")
80
+ start_offset = resume_offset_ms(item)
81
+ socket_path = Path(tempfile.gettempdir()) / f"plex-tui-mpv-{os.getpid()}-{int(time.time() * 1000)}.sock"
82
+ args = [
83
+ "mpv",
84
+ "--force-media-title=" + title,
85
+ "--input-ipc-server=" + str(socket_path),
86
+ ]
87
+ if start_offset:
88
+ args.append(f"--start={start_offset / 1000:.3f}")
89
+ if window_size:
90
+ args.append(f"--autofit={window_size}")
91
+ args.extend(direct_track_args(direct_url, item, selected_audio, selected_subtitle))
92
+ for subtitle in subtitles:
93
+ args.append("--sub-file=" + subtitle)
94
+ args.append(url)
95
+ command = sanitize_command(args)
96
+ stream_mode = "direct" if direct_url else "transcode"
97
+ log_debug(
98
+ "launching mpv: "
99
+ f"title={title!r} mode={stream_mode} "
100
+ f"audio={stream_debug_label(selected_audio)} "
101
+ f"subtitle={stream_debug_label(selected_subtitle)} "
102
+ f"args={command!r}"
103
+ )
104
+ try:
105
+ process = subprocess.Popen(
106
+ args,
107
+ stdout=subprocess.DEVNULL,
108
+ stderr=subprocess.DEVNULL,
109
+ start_new_session=True,
110
+ )
111
+ except OSError as exc:
112
+ log_debug(f"playback error: failed to launch mpv: {exc}; args={command!r}")
113
+ raise PlayerError(f"failed to launch mpv: {exc}") from exc
114
+ subtitle_count = active_subtitle_count(item, selected_subtitle)
115
+ monitor = ProgressMonitor(item, process, socket_path, start_offset)
116
+ monitor.start()
117
+ return PlayerHandle(
118
+ title=title,
119
+ subtitle_count=subtitle_count,
120
+ stream_mode=stream_mode,
121
+ start_offset_ms=start_offset,
122
+ socket_path=socket_path,
123
+ command=command,
124
+ monitor=monitor,
125
+ process=process,
126
+ )
127
+
128
+
129
+ class ProgressMonitor:
130
+ def __init__(self, item: Any, process: subprocess.Popen[bytes], socket_path: Path, start_offset: int) -> None:
131
+ self.item = item
132
+ self.process = process
133
+ self.socket_path = socket_path
134
+ self.last_ms = start_offset
135
+ self._stop = threading.Event()
136
+ self._thread = threading.Thread(target=self._run, name="plex-tui-progress", daemon=True)
137
+
138
+ def start(self) -> None:
139
+ self._thread.start()
140
+
141
+ def stop(self) -> None:
142
+ self._stop.set()
143
+ self.report("stopped")
144
+
145
+ def _run(self) -> None:
146
+ deadline = time.time() + 5
147
+ while time.time() < deadline and not self.socket_path.exists() and self.process.poll() is None:
148
+ time.sleep(0.1)
149
+
150
+ self.report("playing")
151
+ last_report = 0.0
152
+ while not self._stop.is_set() and self.process.poll() is None:
153
+ ms = self.current_time_ms()
154
+ if ms is not None:
155
+ self.last_ms = ms
156
+ now = time.time()
157
+ if self.last_ms and now - last_report >= 10:
158
+ self.report("playing")
159
+ last_report = now
160
+ time.sleep(2)
161
+
162
+ if not self._stop.is_set():
163
+ ms = self.current_time_ms()
164
+ if ms is not None:
165
+ self.last_ms = ms
166
+ self.report("stopped")
167
+ cleanup_socket(self.socket_path)
168
+
169
+ def current_time_ms(self) -> int | None:
170
+ value = mpv_get_property(self.socket_path, "time-pos")
171
+ if value is None:
172
+ return None
173
+ try:
174
+ return max(0, int(float(value) * 1000))
175
+ except (TypeError, ValueError):
176
+ return None
177
+
178
+ def report(self, state: str) -> None:
179
+ if not self.last_ms:
180
+ return
181
+ try:
182
+ self.item.updateProgress(self.last_ms, state=state)
183
+ self.item.updateTimeline(self.last_ms, state=state)
184
+ except Exception:
185
+ return
186
+
187
+
188
+ def mpv_get_property(socket_path: Path, property_name: str) -> Any:
189
+ if not socket_path.exists():
190
+ return None
191
+ try:
192
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
193
+ sock.settimeout(1)
194
+ sock.connect(str(socket_path))
195
+ payload = {"command": ["get_property", property_name]}
196
+ sock.sendall((json.dumps(payload) + "\n").encode("utf-8"))
197
+ data = sock.recv(4096)
198
+ except OSError:
199
+ return None
200
+ try:
201
+ response = json.loads(data.decode("utf-8").splitlines()[0])
202
+ except (IndexError, json.JSONDecodeError, UnicodeDecodeError):
203
+ return None
204
+ if response.get("error") != "success":
205
+ return None
206
+ return response.get("data")
207
+
208
+
209
+ def cleanup_socket(socket_path: Path) -> None:
210
+ try:
211
+ socket_path.unlink(missing_ok=True)
212
+ except OSError:
213
+ return
214
+
215
+
216
+ def resume_offset_ms(item: Any) -> int:
217
+ try:
218
+ return max(0, int(getattr(item, "viewOffset", 0) or 0))
219
+ except (TypeError, ValueError):
220
+ return 0
221
+
222
+
223
+ def full_metadata(item: Any) -> Any:
224
+ if not hasattr(item, "reload"):
225
+ return item
226
+ try:
227
+ return item.reload()
228
+ except Exception:
229
+ return item
230
+
231
+
232
+ def resolve_subtitle_choice(item: Any, choice: StreamChoice | None) -> Any:
233
+ if choice is None:
234
+ return None
235
+ if choice.stream_id == 0:
236
+ return 0
237
+ return find_stream_by_id(subtitle_streams(item), choice.stream_id) or choice.stream
238
+
239
+
240
+ def resolve_audio_choice(item: Any, choice: StreamChoice | None) -> Any:
241
+ if choice is None:
242
+ return None
243
+ return find_stream_by_id(audio_streams(item), choice.stream_id) or choice.stream
244
+
245
+
246
+ def external_subtitle_urls(item: Any, selected_subtitle: Any = None) -> list[str]:
247
+ urls: list[str] = []
248
+ if selected_subtitle == 0:
249
+ return urls
250
+ for part in iter_parts(item):
251
+ for stream in part.subtitleStreams():
252
+ if selected_subtitle is not None and not same_stream(stream, selected_subtitle):
253
+ continue
254
+ key = getattr(stream, "key", None)
255
+ if not key:
256
+ continue
257
+ server = getattr(stream, "_server", None) or getattr(part, "_server", None)
258
+ if server is None:
259
+ continue
260
+ urls.append(server.url(key, includeToken=True))
261
+ return urls
262
+
263
+
264
+ def direct_play_url(item: Any, selected_subtitle: Any = None) -> str | None:
265
+ if not has_embedded_subtitles(item, selected_subtitle):
266
+ return None
267
+ parts = iter_parts(item)
268
+ if not parts:
269
+ return None
270
+ part = parts[0]
271
+ key = getattr(part, "key", None)
272
+ server = getattr(part, "_server", None)
273
+ if not key or server is None:
274
+ return None
275
+ return server.url(key, includeToken=True)
276
+
277
+
278
+ def has_embedded_subtitles(item: Any, selected_subtitle: Any = None) -> bool:
279
+ if selected_subtitle == 0:
280
+ return False
281
+ embedded_codecs = {"pgs", "vobsub", "idx"}
282
+ for stream in subtitle_streams(item):
283
+ if selected_subtitle is not None and not same_stream(stream, selected_subtitle):
284
+ continue
285
+ codec = str(getattr(stream, "codec", "")).lower()
286
+ if codec in embedded_codecs and not getattr(stream, "key", None):
287
+ return True
288
+ return False
289
+
290
+
291
+ def selected_subtitle_stream_id(item: Any, selected_subtitle: Any = None) -> int | None:
292
+ if selected_subtitle == 0:
293
+ return 0
294
+ if selected_subtitle is not None:
295
+ return getattr(selected_subtitle, "id", None)
296
+ streams = subtitle_streams(item)
297
+ selected = [stream for stream in streams if getattr(stream, "selected", False)]
298
+ candidates = selected or preferred_subtitle_streams(streams)
299
+ if not candidates:
300
+ return None
301
+ return getattr(candidates[0], "id", None)
302
+
303
+
304
+ def direct_track_args(
305
+ direct_url: str | None,
306
+ item: Any,
307
+ selected_audio: Any = None,
308
+ selected_subtitle: Any = None,
309
+ ) -> list[str]:
310
+ if not direct_url:
311
+ return []
312
+ args: list[str] = []
313
+ if selected_audio is not None:
314
+ track_id = mpv_track_id(audio_streams(item), selected_audio)
315
+ if track_id is not None:
316
+ args.append(f"--aid={track_id}")
317
+ if selected_subtitle == 0:
318
+ args.append("--sid=no")
319
+ elif selected_subtitle is not None:
320
+ track_id = mpv_track_id(subtitle_streams(item), selected_subtitle)
321
+ if track_id is not None:
322
+ args.append(f"--sid={track_id}")
323
+ return args
324
+
325
+
326
+ def mpv_track_id(streams: list[Any], selected_stream: Any) -> int | None:
327
+ for index, stream in enumerate(streams, start=1):
328
+ if same_stream(stream, selected_stream):
329
+ return index
330
+ return None
331
+
332
+
333
+ def preferred_subtitle_streams(streams: list[Any]) -> list[Any]:
334
+ preferred_codecs = {"srt", "ass", "ssa", "vtt", "idx", "sub"}
335
+ preferred = [
336
+ stream
337
+ for stream in streams
338
+ if str(getattr(stream, "codec", "")).lower() in preferred_codecs
339
+ ]
340
+ return preferred or streams
341
+
342
+
343
+ def subtitle_streams(item: Any) -> list[Any]:
344
+ streams: list[Any] = []
345
+ for part in iter_parts(item):
346
+ streams.extend(part.subtitleStreams())
347
+ return streams
348
+
349
+
350
+ def active_subtitle_count(item: Any, selected_subtitle: Any = None) -> int:
351
+ if selected_subtitle == 0:
352
+ return 0
353
+ if selected_subtitle is not None:
354
+ return 1
355
+ return len(subtitle_streams(item))
356
+
357
+
358
+ def audio_streams(item: Any) -> list[Any]:
359
+ streams: list[Any] = []
360
+ for part in iter_parts(item):
361
+ streams.extend(part.audioStreams())
362
+ return streams
363
+
364
+
365
+ def subtitle_choices(item: Any) -> list[StreamChoice]:
366
+ item = full_metadata(item)
367
+ choices = [StreamChoice(None, "Auto (Plex/default)"), StreamChoice(0, "None (disable subtitles)")]
368
+ choices.extend(StreamChoice(getattr(stream, "id", None), stream_label(stream), stream) for stream in subtitle_streams(item))
369
+ return choices
370
+
371
+
372
+ def audio_choices(item: Any) -> list[StreamChoice]:
373
+ item = full_metadata(item)
374
+ return [StreamChoice(getattr(stream, "id", None), stream_label(stream), stream) for stream in audio_streams(item)]
375
+
376
+
377
+ def stream_label(stream: Any) -> str:
378
+ label = getattr(stream, "displayTitle", None) or getattr(stream, "language", None) or "Unknown"
379
+ codec = getattr(stream, "codec", None)
380
+ flags = []
381
+ if getattr(stream, "selected", False):
382
+ flags.append("selected")
383
+ if getattr(stream, "forced", False):
384
+ flags.append("forced")
385
+ if getattr(stream, "hearingImpaired", False):
386
+ flags.append("SDH")
387
+ channels = getattr(stream, "channels", None)
388
+ if channels:
389
+ flags.append(f"{channels}ch")
390
+ values = [str(codec)] if codec else []
391
+ values.extend(flags)
392
+ suffix = ", ".join(values)
393
+ return f"{label} ({suffix})" if suffix else str(label)
394
+
395
+
396
+ def preferred_audio_choice(item: Any, preferred_language: str) -> StreamChoice | None:
397
+ return preferred_stream_choice(audio_choices(item), preferred_language)
398
+
399
+
400
+ def preferred_subtitle_choice(item: Any, preferred_language: str, subtitle_mode: str) -> StreamChoice | None:
401
+ if subtitle_mode == "none":
402
+ return StreamChoice(0, "None (disable subtitles)")
403
+ if subtitle_mode != "preferred" or not preferred_language:
404
+ return None
405
+ return preferred_stream_choice(subtitle_choices(item), preferred_language)
406
+
407
+
408
+ def preferred_stream_choice(choices: list[StreamChoice], preferred_language: str) -> StreamChoice | None:
409
+ preferred = normalize_language(preferred_language)
410
+ if not preferred:
411
+ return None
412
+ for choice in choices:
413
+ if choice.stream is not None and stream_language_key(choice.stream) == preferred:
414
+ return choice
415
+ return None
416
+
417
+
418
+ def stream_language_key(stream: Any) -> str:
419
+ for attr in ("languageCode", "language", "displayTitle"):
420
+ value = normalize_language(getattr(stream, attr, ""))
421
+ if value:
422
+ return value
423
+ return ""
424
+
425
+
426
+ def stream_language_label(stream: Any) -> str:
427
+ for attr in ("language", "displayTitle", "languageCode"):
428
+ value = str(getattr(stream, attr, "") or "").strip()
429
+ if value:
430
+ return value
431
+ return "unknown"
432
+
433
+
434
+ def normalize_language(value: Any) -> str:
435
+ return str(value or "").strip().lower()
436
+
437
+
438
+ def find_stream_by_id(streams: list[Any], stream_id: int | None) -> Any:
439
+ if stream_id is None:
440
+ return None
441
+ for stream in streams:
442
+ if getattr(stream, "id", None) == stream_id:
443
+ return stream
444
+ return None
445
+
446
+
447
+ def same_stream(left: Any, right: Any) -> bool:
448
+ left_id = getattr(left, "id", None)
449
+ right_id = getattr(right, "id", None)
450
+ if left_id is not None and right_id is not None:
451
+ return left_id == right_id
452
+ return left is right
453
+
454
+
455
+ def iter_parts(item: Any) -> list[Any]:
456
+ if hasattr(item, "iterParts"):
457
+ return list(item.iterParts())
458
+ parts: list[Any] = []
459
+ for media in getattr(item, "media", []) or []:
460
+ parts.extend(getattr(media, "parts", []) or [])
461
+ return parts
462
+
463
+
464
+ def stop_mpv(handle: PlayerHandle | None) -> None:
465
+ if handle is None or not handle.active:
466
+ return
467
+ handle.monitor.stop()
468
+ try:
469
+ if os.name != "nt":
470
+ os.killpg(handle.process.pid, signal.SIGTERM)
471
+ else:
472
+ handle.process.terminate()
473
+ except ProcessLookupError:
474
+ return
475
+ finally:
476
+ cleanup_socket(handle.socket_path)
477
+
478
+
479
+ def sanitize_command(args: list[str]) -> list[str]:
480
+ return [sanitize_arg(arg) for arg in args]
481
+
482
+
483
+ def sanitize_arg(arg: str) -> str:
484
+ if arg.startswith("--sub-file="):
485
+ return "--sub-file=" + sanitize_url(arg.removeprefix("--sub-file="))
486
+ if "://" in arg:
487
+ return sanitize_url(arg)
488
+ return arg
489
+
490
+
491
+ def sanitize_url(url: str) -> str:
492
+ parts = urlsplit(url)
493
+ if not parts.scheme or not parts.netloc:
494
+ return url
495
+ query = []
496
+ for key, value in parse_qsl(parts.query, keep_blank_values=True):
497
+ if key.lower() in {"token", "x-plex-token"}:
498
+ query.append((key, "REDACTED"))
499
+ else:
500
+ query.append((key, value))
501
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
502
+
503
+
504
+ def stream_debug_label(stream: Any) -> str:
505
+ if stream is None:
506
+ return "auto"
507
+ if stream == 0:
508
+ return "none"
509
+ label = getattr(stream, "displayTitle", None) or getattr(stream, "language", None) or getattr(stream, "id", None)
510
+ return str(label or "unknown")
511
+
512
+
513
+ def log_debug(message: str) -> None:
514
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
515
+ write_debug_log(f"{timestamp} {redact_tokens(message)}")
516
+
517
+
518
+ def redact_tokens(text: str) -> str:
519
+ return re.sub(r"(?i)(x-plex-token|token)=([^&\s]+)", r"\1=REDACTED", text)