propresenter-client 0.1.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.
- propresenter_client/__init__.py +10 -0
- propresenter_client/main.py +583 -0
- propresenter_client-0.1.0.dist-info/METADATA +156 -0
- propresenter_client-0.1.0.dist-info/RECORD +10 -0
- propresenter_client-0.1.0.dist-info/WHEEL +5 -0
- propresenter_client-0.1.0.dist-info/entry_points.txt +2 -0
- propresenter_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- propresenter_client-0.1.0.dist-info/scm_file_list.json +15 -0
- propresenter_client-0.1.0.dist-info/scm_version.json +8 -0
- propresenter_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main module for ProPresenter API interface
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import requests
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProPresenterController:
|
|
15
|
+
"""Interface for controlling ProPresenter via its APIs"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, host: str = "localhost", port: int = 1025, timeout: int = 5):
|
|
18
|
+
"""
|
|
19
|
+
Initialize the ProPresenter controller.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
host: The hostname or IP address of the ProPresenter instance
|
|
23
|
+
port: The port number for the ProPresenter API
|
|
24
|
+
timeout: Request timeout in seconds
|
|
25
|
+
"""
|
|
26
|
+
self.host = host
|
|
27
|
+
self.port = port
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
self.base_url = f"http://{host}:{port}"
|
|
30
|
+
|
|
31
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> Optional[dict]:
|
|
32
|
+
"""
|
|
33
|
+
Make a request to the ProPresenter API.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
method: HTTP method (GET, POST, etc.)
|
|
37
|
+
endpoint: API endpoint path
|
|
38
|
+
**kwargs: Additional arguments to pass to requests
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Response JSON if available, or empty dict if successful with no content, None if request fails
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
url = f"{self.base_url}/{endpoint}"
|
|
45
|
+
response = requests.request(
|
|
46
|
+
method, url, timeout=self.timeout, **kwargs
|
|
47
|
+
)
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
# Try to parse JSON, but some endpoints return no content
|
|
50
|
+
if response.text:
|
|
51
|
+
return response.json()
|
|
52
|
+
else:
|
|
53
|
+
return {}
|
|
54
|
+
except requests.RequestException as e:
|
|
55
|
+
logger.debug(f"Request failed: {e}")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def next_slide(self) -> bool:
|
|
59
|
+
"""Advance to the next slide."""
|
|
60
|
+
result = self._request("GET", "v1/presentation/active/next/trigger")
|
|
61
|
+
return result is not None
|
|
62
|
+
|
|
63
|
+
def previous_slide(self) -> bool:
|
|
64
|
+
"""Go to the previous slide."""
|
|
65
|
+
result = self._request("GET", "v1/presentation/active/previous/trigger")
|
|
66
|
+
return result is not None
|
|
67
|
+
|
|
68
|
+
def get_status(self) -> Optional[dict]:
|
|
69
|
+
"""Get the current presentation status."""
|
|
70
|
+
return self._request("GET", "v1/status/slide")
|
|
71
|
+
|
|
72
|
+
def get_slide_position(self) -> Optional[tuple[int, int]]:
|
|
73
|
+
"""
|
|
74
|
+
Get the current slide position and total slide count.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
A tuple of (current_slide_number, total_slides) in 1-indexed form,
|
|
78
|
+
or None if the slide position cannot be determined from status.
|
|
79
|
+
"""
|
|
80
|
+
status = self.get_status()
|
|
81
|
+
if not status or not isinstance(status, dict):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
current = None
|
|
85
|
+
total = None
|
|
86
|
+
|
|
87
|
+
if "currentSlide" in status and isinstance(status["currentSlide"], dict):
|
|
88
|
+
current = status["currentSlide"].get("index")
|
|
89
|
+
if current is None:
|
|
90
|
+
current = status["currentSlide"].get("number")
|
|
91
|
+
total = status["currentSlide"].get("total")
|
|
92
|
+
if total is None:
|
|
93
|
+
total = status["currentSlide"].get("count")
|
|
94
|
+
|
|
95
|
+
if current is None and "slide" in status and isinstance(status["slide"], dict):
|
|
96
|
+
current = status["slide"].get("index")
|
|
97
|
+
if current is None:
|
|
98
|
+
current = status["slide"].get("number")
|
|
99
|
+
total = status["slide"].get("total")
|
|
100
|
+
if total is None:
|
|
101
|
+
total = status["slide"].get("count")
|
|
102
|
+
|
|
103
|
+
if current is None:
|
|
104
|
+
for key in ("currentSlide", "slideIndex", "currentSlideIndex"):
|
|
105
|
+
if key in status:
|
|
106
|
+
current = status[key]
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
if total is None:
|
|
110
|
+
for key in ("slideCount", "totalSlides", "slideTotal", "totalSlideCount"):
|
|
111
|
+
if key in status:
|
|
112
|
+
total = status[key]
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
if isinstance(current, int) and isinstance(total, int):
|
|
116
|
+
return current + 1, total
|
|
117
|
+
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def go_to_slide(self, slide_number: int) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Go to a specific slide number.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
slide_number: The slide number to navigate to (1-indexed)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if successful, False otherwise
|
|
129
|
+
"""
|
|
130
|
+
if slide_number <= 0:
|
|
131
|
+
slide_number = 1 # Ensure slide number is at least 1
|
|
132
|
+
|
|
133
|
+
api_slide_number = max(slide_number - 1, 0)
|
|
134
|
+
|
|
135
|
+
result = self._request(
|
|
136
|
+
"GET",
|
|
137
|
+
f"v1/presentation/active/{api_slide_number}/trigger"
|
|
138
|
+
)
|
|
139
|
+
return result is not None
|
|
140
|
+
|
|
141
|
+
def get_slide_index(self, chunked: bool = False) -> Optional[int]:
|
|
142
|
+
"""
|
|
143
|
+
Get the zero-based index of the currently active slide.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
chunked: If True, uses chunked slide indexing. Default False
|
|
147
|
+
returns a flat index across the whole presentation.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Zero-based slide index, or None if it cannot be determined.
|
|
151
|
+
"""
|
|
152
|
+
result = self._request(
|
|
153
|
+
"GET", "v1/presentation/slide_index",
|
|
154
|
+
params={"chunked": str(chunked).lower()}
|
|
155
|
+
)
|
|
156
|
+
logger.debug("get_slide_index response: %s", result)
|
|
157
|
+
if result is None:
|
|
158
|
+
return None
|
|
159
|
+
if isinstance(result, int):
|
|
160
|
+
return result
|
|
161
|
+
if isinstance(result, dict):
|
|
162
|
+
# Bare index at top level
|
|
163
|
+
for key in ("slideIndex", "slide_index", "index"):
|
|
164
|
+
val = result.get(key)
|
|
165
|
+
if isinstance(val, int):
|
|
166
|
+
return val
|
|
167
|
+
# Nested one level under a container key — check container first so we
|
|
168
|
+
# don't accidentally pick up a sibling id.index from presentation metadata.
|
|
169
|
+
for container in ("presentation_index", "presentationSlideIndex"):
|
|
170
|
+
nested = result.get(container)
|
|
171
|
+
if isinstance(nested, dict):
|
|
172
|
+
for key in ("slideIndex", "slide_index", "index"):
|
|
173
|
+
val = nested.get(key)
|
|
174
|
+
if isinstance(val, int):
|
|
175
|
+
return val
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def get_active_presentation(self) -> Optional[dict]:
|
|
179
|
+
"""
|
|
180
|
+
Get the currently active presentation.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Presentation details if available, None if request fails
|
|
184
|
+
"""
|
|
185
|
+
return self._request("GET", "v1/presentation/active")
|
|
186
|
+
|
|
187
|
+
def get_active_presentation_uuid(self) -> Optional[str]:
|
|
188
|
+
"""
|
|
189
|
+
Get the UUID of the currently active presentation.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
UUID string if found, None if the active presentation cannot be
|
|
193
|
+
reached or its UUID cannot be parsed from the response.
|
|
194
|
+
"""
|
|
195
|
+
data = self.get_active_presentation()
|
|
196
|
+
if not data:
|
|
197
|
+
return None
|
|
198
|
+
return ProPresenterController._extract_uuid(data)
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _extract_uuid(data: object) -> Optional[str]:
|
|
202
|
+
"""Recursively locate a presentation UUID in a ProPresenter API response."""
|
|
203
|
+
if not isinstance(data, dict):
|
|
204
|
+
return None
|
|
205
|
+
for key in ("uuid", "presentationId", "presentationUUID"):
|
|
206
|
+
val = data.get(key)
|
|
207
|
+
if isinstance(val, str) and val:
|
|
208
|
+
return val
|
|
209
|
+
for key in ("presentation", "id"):
|
|
210
|
+
result = ProPresenterController._extract_uuid(data.get(key))
|
|
211
|
+
if result:
|
|
212
|
+
return result
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def find_slides(details: object) -> list:
|
|
217
|
+
"""
|
|
218
|
+
Recursively collect ALL slides from a presentation details response,
|
|
219
|
+
flattening across every group so the returned list is in presentation order.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
details: The response payload from get_presentation_details()
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Flat list of all slide objects across all groups, or [] if none found.
|
|
226
|
+
"""
|
|
227
|
+
if isinstance(details, dict):
|
|
228
|
+
slides = details.get("slides")
|
|
229
|
+
if isinstance(slides, list):
|
|
230
|
+
return slides # this dict is a group — return its slides directly
|
|
231
|
+
all_slides: list = []
|
|
232
|
+
for value in details.values():
|
|
233
|
+
all_slides.extend(ProPresenterController.find_slides(value))
|
|
234
|
+
return all_slides
|
|
235
|
+
elif isinstance(details, list):
|
|
236
|
+
all_slides = []
|
|
237
|
+
for item in details:
|
|
238
|
+
all_slides.extend(ProPresenterController.find_slides(item))
|
|
239
|
+
return all_slides
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
def get_library(self, library_name: str) -> Optional[dict]:
|
|
243
|
+
"""
|
|
244
|
+
Get a named library's contents.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
library_name: The library name to query
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Library data if available, None if request fails
|
|
251
|
+
"""
|
|
252
|
+
return self._request("GET", f"v1/library/{library_name}")
|
|
253
|
+
|
|
254
|
+
def get_library_default(self) -> Optional[dict]:
|
|
255
|
+
"""
|
|
256
|
+
Get the Default library contents.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Library data if available, None if request fails
|
|
260
|
+
"""
|
|
261
|
+
return self.get_library("Default")
|
|
262
|
+
|
|
263
|
+
def find_presentation_uuid_by_name(
|
|
264
|
+
self, presentation_name: str, library_data: Optional[dict]
|
|
265
|
+
) -> Optional[str]:
|
|
266
|
+
"""
|
|
267
|
+
Find a presentation UUID in the Default library by presentation name.
|
|
268
|
+
|
|
269
|
+
Uses case-insensitive substring matching on common title fields.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
presentation_name: The presentation name to search for
|
|
273
|
+
library_data: The library response payload
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The matching presentation UUID, or None if not found
|
|
277
|
+
"""
|
|
278
|
+
if not library_data:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
items = []
|
|
282
|
+
if isinstance(library_data, dict):
|
|
283
|
+
if "items" in library_data and isinstance(library_data["items"], list):
|
|
284
|
+
items = library_data["items"]
|
|
285
|
+
elif "presentations" in library_data and isinstance(library_data["presentations"], list):
|
|
286
|
+
items = library_data["presentations"]
|
|
287
|
+
else:
|
|
288
|
+
items = [library_data]
|
|
289
|
+
elif isinstance(library_data, list):
|
|
290
|
+
items = library_data
|
|
291
|
+
|
|
292
|
+
search = presentation_name.strip().lower()
|
|
293
|
+
|
|
294
|
+
for entry in items:
|
|
295
|
+
if not isinstance(entry, dict):
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
title = (
|
|
299
|
+
entry.get("name")
|
|
300
|
+
or entry.get("title")
|
|
301
|
+
or entry.get("presentationName")
|
|
302
|
+
or entry.get("presentationTitle")
|
|
303
|
+
or entry.get("songName")
|
|
304
|
+
)
|
|
305
|
+
uuid = (
|
|
306
|
+
entry.get("uuid")
|
|
307
|
+
or entry.get("id")
|
|
308
|
+
or entry.get("presentationId")
|
|
309
|
+
or entry.get("presentationUUID")
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if title and uuid and search in title.lower():
|
|
313
|
+
return uuid
|
|
314
|
+
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
def get_presentation_details(self, uuid: str) -> Optional[dict]:
|
|
318
|
+
"""
|
|
319
|
+
Get details for a presentation by UUID.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
uuid: The presentation UUID to fetch details for
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Presentation details if available, None if request fails
|
|
326
|
+
"""
|
|
327
|
+
return self._request("GET", f"v1/presentation/{uuid}")
|
|
328
|
+
|
|
329
|
+
def activate_presentation(self, uuid: str) -> bool:
|
|
330
|
+
"""
|
|
331
|
+
Activate a presentation by UUID.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
uuid: The presentation UUID to activate
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
True if activation request succeeded, False otherwise
|
|
338
|
+
"""
|
|
339
|
+
result = self._request("GET", f"v1/presentation/{uuid}/trigger")
|
|
340
|
+
return result is not None
|
|
341
|
+
|
|
342
|
+
def activate_first_library_presentation(self, library_name: str) -> bool:
|
|
343
|
+
"""
|
|
344
|
+
Activate the first presentation in the specified library.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
library_name: The library name to activate the first presentation from
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
True if activation request succeeded, False otherwise
|
|
351
|
+
"""
|
|
352
|
+
result = self._request("GET", f"v1/library/{library_name}/0/trigger")
|
|
353
|
+
return result is not None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _get_command() -> str:
|
|
357
|
+
"""Read a command without requiring Enter for single-char keys (n, b, q).
|
|
358
|
+
Digits are buffered until Enter is pressed. Falls back to input() on
|
|
359
|
+
platforms that don't support raw terminal mode."""
|
|
360
|
+
try:
|
|
361
|
+
import tty
|
|
362
|
+
import termios
|
|
363
|
+
except ImportError:
|
|
364
|
+
return input().strip().lower()
|
|
365
|
+
|
|
366
|
+
fd = sys.stdin.fileno()
|
|
367
|
+
old = termios.tcgetattr(fd)
|
|
368
|
+
buf = ""
|
|
369
|
+
try:
|
|
370
|
+
tty.setraw(fd)
|
|
371
|
+
while True:
|
|
372
|
+
ch = sys.stdin.read(1)
|
|
373
|
+
if ch == "\x03": # Ctrl+C
|
|
374
|
+
raise KeyboardInterrupt
|
|
375
|
+
if ch == "\x1b": # Escape — clear digit buffer
|
|
376
|
+
if buf:
|
|
377
|
+
sys.stdout.write("\r" + " " * (len("Enter command: ") + len(buf)))
|
|
378
|
+
sys.stdout.write("\rEnter command: ")
|
|
379
|
+
sys.stdout.flush()
|
|
380
|
+
buf = ""
|
|
381
|
+
continue
|
|
382
|
+
if not buf and ch.lower() in ("n", "b", "q"):
|
|
383
|
+
sys.stdout.write(ch.lower() + "\n")
|
|
384
|
+
sys.stdout.flush()
|
|
385
|
+
return ch.lower()
|
|
386
|
+
if ch.isdigit():
|
|
387
|
+
buf += ch
|
|
388
|
+
sys.stdout.write(ch)
|
|
389
|
+
sys.stdout.flush()
|
|
390
|
+
elif ch in ("\r", "\n") and buf:
|
|
391
|
+
sys.stdout.write("\n")
|
|
392
|
+
sys.stdout.flush()
|
|
393
|
+
return buf
|
|
394
|
+
elif ch in ("\x7f", "\x08") and buf: # Backspace
|
|
395
|
+
buf = buf[:-1]
|
|
396
|
+
sys.stdout.write("\b \b")
|
|
397
|
+
sys.stdout.flush()
|
|
398
|
+
finally:
|
|
399
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def interactive_prompt(controller: ProPresenterController) -> None:
|
|
403
|
+
"""
|
|
404
|
+
Start an interactive prompt for controlling the presentation.
|
|
405
|
+
|
|
406
|
+
Supported commands:
|
|
407
|
+
- 'n': next slide
|
|
408
|
+
- 'b': previous slide
|
|
409
|
+
- <number>: go to specific slide number (1-indexed)
|
|
410
|
+
- 'q': quit
|
|
411
|
+
"""
|
|
412
|
+
print("\n=== ProPresenter Slide Controller ===")
|
|
413
|
+
print("Commands: 'n' (next), 'b' (back), <number> (go to slide number), 'q' (quit)")
|
|
414
|
+
print("Note: Slide numbers are 1-indexed (first slide = 1)")
|
|
415
|
+
print("====================================\n")
|
|
416
|
+
|
|
417
|
+
while True:
|
|
418
|
+
try:
|
|
419
|
+
sys.stdout.write("Enter command: ")
|
|
420
|
+
sys.stdout.flush()
|
|
421
|
+
user_input = _get_command()
|
|
422
|
+
|
|
423
|
+
if not user_input:
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
if user_input == 'q':
|
|
427
|
+
print("Exiting...")
|
|
428
|
+
break
|
|
429
|
+
elif user_input == 'n':
|
|
430
|
+
slide_position = controller.get_slide_position()
|
|
431
|
+
if slide_position:
|
|
432
|
+
current_slide, total_slides = slide_position
|
|
433
|
+
if current_slide >= total_slides:
|
|
434
|
+
print("Cannot go beyond the last slide. Prompt attempted to go beyond the last slide.")
|
|
435
|
+
continue
|
|
436
|
+
if controller.next_slide():
|
|
437
|
+
print("✓ Moved to next slide")
|
|
438
|
+
else:
|
|
439
|
+
print("✗ Failed to move to next slide")
|
|
440
|
+
elif user_input == 'b':
|
|
441
|
+
slide_position = controller.get_slide_position()
|
|
442
|
+
if slide_position:
|
|
443
|
+
current_slide, _ = slide_position
|
|
444
|
+
if current_slide <= 1:
|
|
445
|
+
print("Cannot go before the first slide. Prompt attempted to go beyond the first slide.")
|
|
446
|
+
continue
|
|
447
|
+
if controller.previous_slide():
|
|
448
|
+
print("✓ Moved to previous slide")
|
|
449
|
+
else:
|
|
450
|
+
print("✗ Failed to move to previous slide")
|
|
451
|
+
else:
|
|
452
|
+
# Try to parse as slide number
|
|
453
|
+
try:
|
|
454
|
+
slide_num = int(user_input)
|
|
455
|
+
if slide_num <= 0:
|
|
456
|
+
print("Invalid slide number. Use 1 or greater.")
|
|
457
|
+
continue
|
|
458
|
+
slide_position = controller.get_slide_position()
|
|
459
|
+
if slide_position:
|
|
460
|
+
_, total_slides = slide_position
|
|
461
|
+
if slide_num > total_slides:
|
|
462
|
+
print("Cannot go beyond the last slide. Prompt attempted to go beyond the last slide.")
|
|
463
|
+
continue
|
|
464
|
+
if controller.go_to_slide(slide_num):
|
|
465
|
+
print(f"✓ Moved to slide {slide_num}")
|
|
466
|
+
else:
|
|
467
|
+
print(f"✗ Failed to move to slide {slide_num}")
|
|
468
|
+
except ValueError:
|
|
469
|
+
print("Invalid command. Use 'n', 'b', a slide number (1-based), or 'q' to quit.")
|
|
470
|
+
except KeyboardInterrupt:
|
|
471
|
+
print("\n\nExiting...")
|
|
472
|
+
break
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def main() -> None:
|
|
476
|
+
"""Main entry point for the CLI."""
|
|
477
|
+
parser = argparse.ArgumentParser(
|
|
478
|
+
description="Control ProPresenter presentations from the command line"
|
|
479
|
+
)
|
|
480
|
+
parser.add_argument(
|
|
481
|
+
"--host",
|
|
482
|
+
type=str,
|
|
483
|
+
default="localhost",
|
|
484
|
+
help="ProPresenter host/IP address (default: localhost)"
|
|
485
|
+
)
|
|
486
|
+
parser.add_argument(
|
|
487
|
+
"--port",
|
|
488
|
+
type=int,
|
|
489
|
+
default=1025,
|
|
490
|
+
help="ProPresenter port (default: 1025)"
|
|
491
|
+
)
|
|
492
|
+
parser.add_argument(
|
|
493
|
+
"--timeout",
|
|
494
|
+
type=int,
|
|
495
|
+
default=5,
|
|
496
|
+
help="Request timeout in seconds (default: 5)"
|
|
497
|
+
)
|
|
498
|
+
parser.add_argument(
|
|
499
|
+
"--library",
|
|
500
|
+
type=str,
|
|
501
|
+
default="Default",
|
|
502
|
+
help="Library name to use for presentation lookup and default activation (default: Default)"
|
|
503
|
+
)
|
|
504
|
+
parser.add_argument(
|
|
505
|
+
"--presentation",
|
|
506
|
+
type=str,
|
|
507
|
+
help="Presentation title to activate from the configured library before entering interactive mode"
|
|
508
|
+
)
|
|
509
|
+
parser.add_argument(
|
|
510
|
+
"--list-details",
|
|
511
|
+
action="store_true",
|
|
512
|
+
help="Print details for the specified presentation and exit (requires --presentation)"
|
|
513
|
+
)
|
|
514
|
+
parser.add_argument(
|
|
515
|
+
"--log-level",
|
|
516
|
+
type=str,
|
|
517
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
518
|
+
default="WARNING",
|
|
519
|
+
help="Set logging verbosity for request diagnostics (default: WARNING)"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
args = parser.parse_args()
|
|
523
|
+
|
|
524
|
+
logging.basicConfig(
|
|
525
|
+
level=getattr(logging, args.log_level),
|
|
526
|
+
format="%(asctime)s [%(levelname)s] %(message)s"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
controller = ProPresenterController(
|
|
530
|
+
host=args.host,
|
|
531
|
+
port=args.port,
|
|
532
|
+
timeout=args.timeout
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Test connection
|
|
536
|
+
status = controller.get_status()
|
|
537
|
+
if status is None:
|
|
538
|
+
print(f"Error: Could not connect to ProPresenter at {args.host}:{args.port}")
|
|
539
|
+
sys.exit(1)
|
|
540
|
+
|
|
541
|
+
print(f"Connected to ProPresenter at {args.host}:{args.port}")
|
|
542
|
+
|
|
543
|
+
if args.list_details and not args.presentation:
|
|
544
|
+
print("Error: --list-details requires --presentation")
|
|
545
|
+
sys.exit(1)
|
|
546
|
+
|
|
547
|
+
if args.presentation:
|
|
548
|
+
library = controller.get_library(args.library)
|
|
549
|
+
if library is None:
|
|
550
|
+
print(f"Error: Could not query {args.library} library at {args.host}:{args.port}")
|
|
551
|
+
sys.exit(1)
|
|
552
|
+
|
|
553
|
+
presentation_uuid = controller.find_presentation_uuid_by_name(args.presentation, library)
|
|
554
|
+
if presentation_uuid is None:
|
|
555
|
+
print(f"Error: Presentation '{args.presentation}' not found in {args.library} library")
|
|
556
|
+
sys.exit(1)
|
|
557
|
+
|
|
558
|
+
if args.list_details:
|
|
559
|
+
details = controller.get_presentation_details(presentation_uuid)
|
|
560
|
+
if details is None:
|
|
561
|
+
print(f"Error: Could not fetch details for '{args.presentation}' (UUID: {presentation_uuid})")
|
|
562
|
+
sys.exit(1)
|
|
563
|
+
import json
|
|
564
|
+
print(json.dumps(details, indent=2))
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
if controller.activate_presentation(presentation_uuid):
|
|
568
|
+
print(f"Activated '{args.presentation}' (UUID: {presentation_uuid})")
|
|
569
|
+
else:
|
|
570
|
+
print(f"Error: Failed to activate presentation UUID {presentation_uuid}")
|
|
571
|
+
sys.exit(1)
|
|
572
|
+
else:
|
|
573
|
+
# Default behavior: activate first presentation in configured library
|
|
574
|
+
if controller.activate_first_library_presentation(args.library):
|
|
575
|
+
print(f"Activated first presentation in {args.library} library")
|
|
576
|
+
else:
|
|
577
|
+
print(f"Warning: Could not activate first presentation in {args.library} library")
|
|
578
|
+
|
|
579
|
+
interactive_prompt(controller)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
if __name__ == "__main__":
|
|
583
|
+
main()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: propresenter-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the ProPresenter REST API
|
|
5
|
+
Author-email: scaperothian <scaperoth@berkeley.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/scaperothian/propresenter-client
|
|
8
|
+
Project-URL: Repository, https://github.com/scaperothian/propresenter-client.git
|
|
9
|
+
Project-URL: Issues, https://github.com/scaperothian/propresenter-client/issues
|
|
10
|
+
Keywords: propresenter,api,client,presentation,slides
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: requests>=2.31
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
26
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# propresenter-client
|
|
30
|
+
|
|
31
|
+
[](https://github.com/scaperothian/propresenter-client/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/propresenter-client/)
|
|
33
|
+
|
|
34
|
+
Python client for the ProPresenter REST API.
|
|
35
|
+
|
|
36
|
+
## Description
|
|
37
|
+
|
|
38
|
+
A Python library and CLI for interacting with ProPresenter's REST API. Provides a `ProPresenterController` class for programmatic access to any ProPresenter API endpoint, plus an interactive **presentation mode** CLI for live slide control.
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- `ProPresenterController` class covering common ProPresenter API endpoints
|
|
43
|
+
- Interactive presentation mode for live slide navigation
|
|
44
|
+
- Extensible design — add new API endpoints as methods over time
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install propresenter-client
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Development Setup
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Create a virtual environment and install the package with dev dependencies
|
|
56
|
+
python -m venv .venv
|
|
57
|
+
source .venv/bin/activate
|
|
58
|
+
pip install -e ".[dev]"
|
|
59
|
+
|
|
60
|
+
# Run the CLI
|
|
61
|
+
propresenter-client --host=<your-host>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from propresenter_client import ProPresenterController
|
|
68
|
+
|
|
69
|
+
controller = ProPresenterController(host="localhost", port=1025)
|
|
70
|
+
|
|
71
|
+
# Slide control
|
|
72
|
+
controller.next_slide()
|
|
73
|
+
controller.previous_slide()
|
|
74
|
+
controller.go_to_slide(1) # 1-indexed
|
|
75
|
+
|
|
76
|
+
# Presentations
|
|
77
|
+
controller.activate_presentation("92B5E6E2-5E99-4F54-BAD3-6FBD7D2EE675")
|
|
78
|
+
controller.get_presentation_details("92B5E6E2-5E99-4F54-BAD3-6FBD7D2EE675")
|
|
79
|
+
controller.activate_first_library_presentation("Default")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Interactive Presentation Mode
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# Default: activates first presentation in Default library
|
|
86
|
+
propresenter-client --host=192.168.1.100
|
|
87
|
+
|
|
88
|
+
# Activate a specific presentation by name before entering interactive mode
|
|
89
|
+
propresenter-client --host=192.168.1.100 --presentation="Amazing Grace"
|
|
90
|
+
|
|
91
|
+
# Print presentation details and exit (no interactive mode)
|
|
92
|
+
propresenter-client --host=192.168.1.100 --presentation="Amazing Grace" --list-details
|
|
93
|
+
|
|
94
|
+
# Use a different library
|
|
95
|
+
propresenter-client --host=192.168.1.100 --library="Worship"
|
|
96
|
+
|
|
97
|
+
# Enable request diagnostics
|
|
98
|
+
propresenter-client --host=192.168.1.100 --log-level=DEBUG
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Interactive commands (no Enter required for single-key commands):
|
|
102
|
+
- `n` — Next slide
|
|
103
|
+
- `b` — Back to previous slide
|
|
104
|
+
- `q` — Quit
|
|
105
|
+
- `1`, `2`, `3`, … — Go to specific slide (1-indexed, press Enter to confirm)
|
|
106
|
+
- `Escape` — Cancel a partially typed slide number
|
|
107
|
+
|
|
108
|
+
## Testing
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Run all tests
|
|
112
|
+
pytest
|
|
113
|
+
|
|
114
|
+
# Run specific test file
|
|
115
|
+
pytest tests/test_propresenter_controller.py -v
|
|
116
|
+
|
|
117
|
+
# Run specific test class
|
|
118
|
+
pytest tests/test_propresenter_controller.py::TestProPresenterController -v
|
|
119
|
+
|
|
120
|
+
# Run individual test
|
|
121
|
+
pytest tests/test_propresenter_controller.py::TestProPresenterController::test_get_status_success -v
|
|
122
|
+
|
|
123
|
+
# Run tests matching a pattern
|
|
124
|
+
pytest -k "test_get" -v
|
|
125
|
+
|
|
126
|
+
# Generate coverage report
|
|
127
|
+
pytest --cov=propresenter_client
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Releasing
|
|
131
|
+
|
|
132
|
+
Releases are published to [PyPI](https://pypi.org/project/propresenter-client/)
|
|
133
|
+
automatically by the `Publish` GitHub Actions workflow when a version tag is
|
|
134
|
+
pushed. The version number is derived from the git tag via `setuptools_scm`.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
git tag v1.2.3
|
|
138
|
+
git push origin v1.2.3
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Publishing uses [PyPI Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/),
|
|
142
|
+
so no API tokens are stored in the repository. One-time setup on PyPI:
|
|
143
|
+
|
|
144
|
+
1. Create the project's pending publisher at
|
|
145
|
+
<https://pypi.org/manage/account/publishing/>.
|
|
146
|
+
2. Owner: `scaperothian`, repository: `propresenter-client`,
|
|
147
|
+
workflow: `publish.yml`, environment: `pypi`.
|
|
148
|
+
|
|
149
|
+
## Requirements
|
|
150
|
+
|
|
151
|
+
- Python 3.10+
|
|
152
|
+
- requests
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
propresenter_client/__init__.py,sha256=IBg3Zlw76Z8uld_CQiM682h1jetTugyfldGWb8cofsU,202
|
|
2
|
+
propresenter_client/main.py,sha256=kofJ9zaI8VckdlDdzH4GULcZ0F_44ZOJ9ahXIKqIn9A,20470
|
|
3
|
+
propresenter_client-0.1.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
|
|
4
|
+
propresenter_client-0.1.0.dist-info/METADATA,sha256=Dgi89qPdUHcQFvArlBiRFWVmGVQwWki2WGuVHxIBrDQ,4854
|
|
5
|
+
propresenter_client-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
propresenter_client-0.1.0.dist-info/entry_points.txt,sha256=sXuO0k2yP1p4M84utvYn_CAkzP4yUkV0sUaZv50z9CE,70
|
|
7
|
+
propresenter_client-0.1.0.dist-info/scm_file_list.json,sha256=7RqUiEauulWgg7XKOg_pEr-BxOEInWyUSFiFyBCWTdg,330
|
|
8
|
+
propresenter_client-0.1.0.dist-info/scm_version.json,sha256=ldZmJYrNSqp1X5Ju93qHdKwI6LwTnXs_C0Fh3YZ74rk,160
|
|
9
|
+
propresenter_client-0.1.0.dist-info/top_level.txt,sha256=D0HGQ-zoxnFIewHZQjO5BJcUr4CrNMq0LQxL-Y187tg,20
|
|
10
|
+
propresenter_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
"README.md",
|
|
4
|
+
"LICENSE",
|
|
5
|
+
"pyproject.toml",
|
|
6
|
+
"Claude.md",
|
|
7
|
+
".gitignore",
|
|
8
|
+
"src/propresenter_client/__init__.py",
|
|
9
|
+
"src/propresenter_client/main.py",
|
|
10
|
+
"tests/__init__.py",
|
|
11
|
+
"tests/test_propresenter_controller.py",
|
|
12
|
+
".github/workflows/ci.yml",
|
|
13
|
+
".github/workflows/publish.yml"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
propresenter_client
|