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/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)
|