-
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathplex_api.py
More file actions
506 lines (432 loc) · 26.8 KB
/
plex_api.py
File metadata and controls
506 lines (432 loc) · 26.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
import os
from plexapi.server import PlexServer
from plexapi.exceptions import BadRequest, NotFound
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class PlexAPI:
def __init__(self, db_settings=None):
if db_settings and db_settings.plex_url and db_settings.plex_token:
self.base_url = db_settings.plex_url
self.token = db_settings.plex_token
else:
self.base_url = os.getenv('PLEX_URL')
self.token = os.getenv('PLEX_TOKEN')
if not self.base_url or not self.token:
raise ValueError("Plex URL and Token must be configured in Settings or environment variables")
try:
self.plex = PlexServer(self.base_url, self.token)
logger.info(f"Connected to Plex server: {self.plex.friendlyName}")
except Exception as e:
logger.error(f"Failed to connect to Plex: {e}")
raise
def get_movie_libraries(self):
"""
Get list of all available movie library names.
Returns a list of library names (strings).
"""
try:
library_names = []
for section in self.plex.library.sections():
if section.type == 'movie':
library_names.append(section.title)
logger.info(f"Found movie library: {section.title}")
if not library_names:
logger.warning("No movie libraries found in Plex")
else:
logger.info(f"Total movie libraries found: {len(library_names)}")
return library_names
except Exception as e:
logger.error(f"Error fetching movie libraries: {e}")
return []
def fetch_movies(self, selected_libraries=None):
"""
Fetch movies from Plex libraries.
Args:
selected_libraries (list, optional): List of library names to fetch from.
If None or empty, fetches from ALL movie libraries.
Returns:
list: List of movie dictionaries with library_name field included.
"""
try:
# Get all movie sections
movie_sections = []
for section in self.plex.library.sections():
if section.type == 'movie':
# If selected_libraries is specified, filter by those names
if selected_libraries and section.title not in selected_libraries:
logger.info(f"Skipping library '{section.title}' (not in selected libraries)")
continue
movie_sections.append(section)
if not movie_sections:
if selected_libraries:
logger.warning(f"No movie libraries found matching selected libraries: {selected_libraries}")
else:
logger.error("No movie libraries found in Plex")
return []
# Fetch movies from all selected libraries
movie_data = []
for movies_section in movie_sections:
logger.info(f"Fetching movies from library: {movies_section.title}")
movies = movies_section.all()
library_name = movies_section.title
for movie in movies:
genres = [g.tag for g in movie.genres] if movie.genres else ['Unknown']
poster_url = None
if hasattr(movie, 'thumb') and movie.thumb:
poster_url = f"{self.base_url}{movie.thumb}?X-Plex-Token={self.token}"
# Get landscape art (banner/backdrop)
art_url = None
if hasattr(movie, 'art') and movie.art:
art_url = f"{self.base_url}{movie.art}?X-Plex-Token={self.token}"
# Get audience rating (IMDb, Rotten Tomatoes, etc.)
audience_rating = None
if hasattr(movie, 'audienceRating') and movie.audienceRating:
audience_rating = float(movie.audienceRating)
elif hasattr(movie, 'rating') and movie.rating:
audience_rating = float(movie.rating)
# Get cast (top 5 actors)
cast = []
if hasattr(movie, 'roles') and movie.roles:
cast = [role.tag for role in movie.roles[:5]]
cast_str = ', '.join(cast) if cast else None
movie_info = {
'title': movie.title,
'plex_id': str(movie.ratingKey),
'duration': int(movie.duration / 60000) if movie.duration else 0,
'genres': genres,
'year': movie.year if hasattr(movie, 'year') else None,
'rating': movie.contentRating if hasattr(movie, 'contentRating') else None,
'content_rating': movie.contentRating if hasattr(movie, 'contentRating') else None,
'audience_rating': audience_rating,
'summary': movie.summary if hasattr(movie, 'summary') else '',
'poster_url': poster_url,
'art_url': art_url,
'cast': cast_str,
'library_name': library_name
}
movie_data.append(movie_info)
logger.info(f"Fetched {len(movie_data)} movies from {len(movie_sections)} libraries")
return movie_data
except Exception as e:
logger.error(f"Error fetching movies: {e}")
return []
def play_movie(self, plex_id, offset_ms=0, playback_mode='web_player', client_id=None):
try:
movie = self.plex.fetchItem(int(plex_id))
logger.info(f"Found movie: {movie.title}")
if playback_mode == 'web_player':
server_id = self.plex.machineIdentifier
# Use custom Plex URL if available, otherwise fall back to app.plex.tv
if self.base_url and not self.base_url.startswith('http://127.0.0.1') and not self.base_url.startswith('http://localhost'):
# Custom domain - use /web/index.html format with URL-encoded key parameter
base_web_url = self.base_url.rstrip('/')
web_url = f"{base_web_url}/web/index.html#!/server/{server_id}/details?key=%2Flibrary%2Fmetadata%2F{plex_id}"
else:
# Default to app.plex.tv for local or unset URLs (keep context parameter for proper deep linking)
web_url = f"https://app.plex.tv/desktop#!/server/{server_id}/details?key=/library/metadata/{plex_id}&context=content.browse.metadata"
if offset_ms > 0:
offset_min = offset_ms // 60000
try:
movie.updateTimeline(offset_ms, state='stopped', duration=int(movie.duration))
logger.info(f"Set resume position to {offset_min} min ({offset_ms}ms) for '{movie.title}'")
except Exception as e:
logger.warning(f"Failed to set resume position: {e}")
logger.info(f"Generated web player URL for '{movie.title}' with {offset_min} min offset")
logger.info(f"FULL URL: {web_url}")
return True, web_url, offset_min
else:
logger.info(f"Generated web player URL for '{movie.title}'")
logger.info(f"FULL URL: {web_url}")
return True, web_url, 0
else:
# Only use user's client_id - no admin fallback
if not client_id:
logger.error("Plex client not configured - user must set their client in Profile settings")
return False, "Plex client not configured. Please set your Plex Client ID in your Profile settings.", 0
try:
client = self.plex.client(client_id)
logger.info(f"Found Plex client: {client.title}")
except NotFound:
try:
available_clients = [c.title for c in self.plex.clients()]
logger.error(f"Client '{client_id}' not found. Available: {available_clients}")
if available_clients:
return False, f"Client '{client_id}' not found. Available: {', '.join(available_clients)}. Update your Plex Client ID in Profile settings.", 0
else:
return False, "No Plex clients found. Make sure a Plex player is running and connected to your server.", 0
except Exception as e:
logger.error(f"Failed to list clients: {e}")
return False, f"Cannot connect to Plex server. Check your network and Plex settings.", 0
try:
client.playMedia(movie)
logger.info(f"Sent playMedia command to {client.title}")
except Exception as e:
logger.error(f"Failed to start playback: {e}", exc_info=True)
return False, "Playback failed. Make sure your Plex client is responding.", 0
if offset_ms > 0:
import time
time.sleep(1)
try:
client.seekTo(int(offset_ms))
logger.info(f"Sought to {offset_ms}ms ({offset_ms // 60000} min) in '{movie.title}'")
except Exception as e:
logger.warning(f"Failed to seek to {offset_ms}ms: {e}")
logger.info(f"Movie is playing from beginning instead")
offset_min = offset_ms // 60000
logger.info(f"Successfully playing '{movie.title}' on {client.title}")
if offset_min > 0:
return True, f"Now playing: {movie.title} (starting at {offset_min} min)", offset_min
else:
return True, f"Now playing: {movie.title}", 0
except NotFound:
logger.error(f"Movie with plex_id {plex_id} not found in Plex library")
return False, "Movie not found in Plex library. Try syncing your library in Settings.", 0
except Exception as e:
logger.error(f"Unexpected error playing movie: {e}", exc_info=True)
return False, "An error occurred while trying to play the movie. Please try again.", 0
def get_available_clients(self):
"""
Get list of available Plex clients using multiple discovery methods.
This combines several approaches to find as many devices as possible:
1. Active GDM clients (clients() - actively responding on network)
2. Currently playing sessions (sessions() - currently streaming)
3. MyPlex account resources (resources() - all registered devices)
"""
try:
logger.info(f"Discovering Plex clients using multiple methods...")
client_map = {} # Use dict to deduplicate by machine_identifier
# Method 1: Active GDM clients (most reliable for controlling playback)
try:
clients = self.plex.clients()
logger.info(f"Method 1 (GDM): Found {len(clients)} active client(s)")
for c in clients:
platform = 'Unknown'
if hasattr(c, 'platform') and c.platform:
platform = c.platform
client_map[c.machineIdentifier] = {
'name': c.title or 'Unknown Device',
'product': c.product or 'Unknown',
'identifier': c.machineIdentifier,
'platform': platform,
'source': 'Active (GDM)'
}
logger.info(f" → Active: {c.title} ({c.product})")
except Exception as e:
logger.warning(f"Could not get GDM clients: {e}")
# Method 2: Currently playing sessions
# Store session metadata for later matching with MyPlex resources
active_sessions = {}
try:
sessions = self.plex.sessions()
logger.info(f"Method 2 (Sessions): Found {len(sessions)} active session(s)")
for session in sessions:
if hasattr(session, 'player') and session.player:
player = session.player
# Get what's currently playing
playing_title = session.title if hasattr(session, 'title') else 'Unknown'
platform = player.platform if hasattr(player, 'platform') and player.platform else 'Unknown'
# If session has machineIdentifier, mark it directly
if hasattr(player, 'machineIdentifier') and player.machineIdentifier:
identifier = player.machineIdentifier
if identifier in client_map:
# Update existing client
client_map[identifier]['is_active_session'] = True
client_map[identifier]['playing_title'] = playing_title
client_map[identifier]['source'] = 'Currently Playing'
logger.info(f" → Updated to Active Session: {player.title} (playing: {playing_title})")
else:
# Add new client
client_map[identifier] = {
'name': player.title or 'Unknown Player',
'product': player.product if hasattr(player, 'product') else 'Unknown',
'identifier': identifier,
'platform': platform,
'source': 'Currently Playing',
'is_active_session': True,
'playing_title': playing_title
}
logger.info(f" → Playing: {player.title} (playing: {playing_title})")
else:
# No machineIdentifier - store for matching with MyPlex resources
# Use multiple matching keys for better success rate
player_name = (player.title or 'Unknown Player').lower().strip()
player_product = (player.product if hasattr(player, 'product') else 'Unknown').lower().strip()
# Create both exact and normalized keys
exact_key = f"{player.title}_{player.product if hasattr(player, 'product') else 'Unknown'}"
normalized_key = f"{player_name}_{player_product}"
session_info = {
'name': player.title or 'Unknown Player',
'product': player.product if hasattr(player, 'product') else 'Unknown',
'playing_title': playing_title,
'platform': platform,
'exact_key': exact_key,
'normalized_key': normalized_key
}
# Store with both keys for flexible matching
active_sessions[exact_key] = session_info
active_sessions[normalized_key] = session_info
logger.info(f" → Pending match: {player.title} (no machineID, will match with MyPlex)")
logger.debug(f" Match keys: exact={exact_key}, normalized={normalized_key}")
except Exception as e:
logger.warning(f"Could not get active sessions: {e}")
# Method 3: MyPlex account resources (all registered devices)
try:
from plexapi.myplex import MyPlexAccount
if hasattr(self.plex, '_token'):
logger.info("Method 3 (MyPlex): Fetching account resources...")
account = MyPlexAccount(token=self.plex._token)
resources = account.resources()
logger.debug(f"Found {len(resources)} total resources from MyPlex")
# Filter for client devices (not servers)
# r.provides can be a string or list depending on plexapi version
client_resources = []
for r in resources:
# Normalize provides to a set of lowercase strings
if isinstance(r.provides, str):
# Split by comma and strip whitespace from each value
provides_set = {p.strip().lower() for p in r.provides.split(',')}
elif isinstance(r.provides, (list, tuple)):
# Handle list or tuple types
provides_set = {str(p).strip().lower() for p in r.provides}
else:
logger.warning(f"Unexpected provides type for {r.name}: {type(r.provides)}")
continue
logger.debug(f"Resource: {r.name} | Provides: {provides_set} | Product: {r.product}")
# Include resources that have known client capabilities
# Known client capabilities from Plex ecosystem:
# - player: Standard playback client
# - client: Generic client capability
# - controller: Remote control capability (Plex for iOS/Android acting as remote)
# - companion: Companion app functionality
# - pubsub-player: Real-time playback updates
client_capabilities = {'player', 'client', 'controller', 'companion', 'pubsub-player'}
has_client_capability = bool(provides_set & client_capabilities)
if has_client_capability:
client_resources.append(r)
logger.debug(f" → INCLUDED: {r.name} (capabilities: {provides_set})")
else:
logger.debug(f" → SKIPPED: {r.name} (no client capabilities: {provides_set})")
logger.info(f"Method 3 (MyPlex): Found {len(client_resources)} registered client(s)")
for resource in client_resources:
# Try to match this resource with a pending active session
# Try both exact and normalized matching
exact_key = f"{resource.name}_{resource.product if hasattr(resource, 'product') else 'Unknown'}"
normalized_key = f"{(resource.name or '').lower().strip()}_{(resource.product if hasattr(resource, 'product') else 'Unknown').lower().strip()}"
session_data = active_sessions.get(exact_key) or active_sessions.get(normalized_key)
if resource.clientIdentifier not in client_map:
platform = resource.platform if hasattr(resource, 'platform') and resource.platform else 'Unknown'
# If we have a matching active session, apply those flags to this resource
if session_data:
client_map[resource.clientIdentifier] = {
'name': resource.name or 'Unknown Device',
'product': resource.product if hasattr(resource, 'product') else 'Unknown',
'identifier': resource.clientIdentifier,
'platform': platform,
'source': 'Currently Playing', # Promote to active since it's playing
'is_active_session': True,
'playing_title': session_data['playing_title']
}
logger.info(f" → Matched active session to resource: {resource.name} (playing: {session_data['playing_title']})")
# Remove matched keys from pending list
if exact_key in active_sessions:
del active_sessions[exact_key]
if normalized_key in active_sessions:
del active_sessions[normalized_key]
else:
# Regular registered device, not currently playing
client_map[resource.clientIdentifier] = {
'name': resource.name or 'Unknown Device',
'product': resource.product if hasattr(resource, 'product') else 'Unknown',
'identifier': resource.clientIdentifier,
'platform': platform,
'source': 'Registered (may be offline)',
'is_active_session': False,
'playing_title': None
}
logger.debug(f" → Registered: {resource.name} ({resource.product})")
# Log any unmatched sessions (these won't be exposed to avoid broken identifiers)
# These are sessions playing on devices that couldn't be matched to MyPlex resources
unmatched = {}
for key, data in active_sessions.items():
if key == data.get('exact_key'): # Only log once per session
unmatched[key] = data
if unmatched:
logger.warning(f"Found {len(unmatched)} active session(s) that couldn't be matched to MyPlex resources:")
for key, data in unmatched.items():
logger.warning(f" → Unmatched: {data['name']} ({data['product']}) - playing: {data['playing_title']}")
logger.warning(f" This device is streaming but couldn't be matched. Check that it's registered in your Plex account.")
except Exception as e:
logger.warning(f"Could not fetch MyPlex resources: {e}")
# Convert to list and ensure all clients have is_active_session flag
client_list = list(client_map.values())
# Ensure all clients have the is_active_session flag
for client in client_list:
if 'is_active_session' not in client:
client['is_active_session'] = False
if 'playing_title' not in client:
client['playing_title'] = None
# Sort: Currently Playing first (most reliable for reverse proxy users), then Active GDM, then Registered
# Within each category, sort alphabetically by name
source_priority = {'Currently Playing': 0, 'Active (GDM)': 1, 'Registered (may be offline)': 2}
client_list.sort(key=lambda x: (source_priority.get(x['source'], 3), x['name']))
logger.info(f"Total unique clients discovered: {len(client_list)}")
if len(client_list) == 0:
logger.warning("No clients found via any method. Troubleshooting tips:")
logger.warning(" 1. Ensure Plex app is open on at least one device")
logger.warning(" 2. Check that devices are on the same network")
logger.warning(" 3. Verify using local Plex URL (not app.plex.tv)")
logger.warning(" 4. Enable GDM in Plex Settings → Network")
logger.warning(" 5. Check firewall allows UDP port 32414")
return client_list
except Exception as e:
logger.error(f"Error fetching clients from Plex API: {e}", exc_info=True)
return []
def get_server_info(self):
try:
return {
'machine_identifier': self.plex.machineIdentifier,
'friendly_name': self.plex.friendlyName,
'version': self.plex.version
}
except Exception as e:
logger.error(f"Error fetching server info: {e}")
return None
def get_movie_deep_link(self, plex_id):
try:
server_id = self.plex.machineIdentifier
plex_uri = f"plex://library/metadata/{plex_id}"
# Use custom Plex URL if available, otherwise fall back to app.plex.tv
if self.base_url and not self.base_url.startswith('http://127.0.0.1') and not self.base_url.startswith('http://localhost'):
# Custom domain - use /web/index.html format with URL-encoded key parameter
base_web_url = self.base_url.rstrip('/')
web_url = f"{base_web_url}/web/index.html#!/server/{server_id}/details?key=%2Flibrary%2Fmetadata%2F{plex_id}"
else:
# Default to app.plex.tv for local or unset URLs
web_url = f"https://app.plex.tv/desktop#!/server/{server_id}/details?key=/library/metadata/{plex_id}"
return {
'plex_uri': plex_uri,
'web_url': web_url,
'rating_key': plex_id
}
except Exception as e:
logger.error(f"Error generating deep link: {e}")
return None
@staticmethod
def verify_library_access(plex_url, user_token):
"""
Verify if a user token has access to the Plex server's movie library.
Returns (has_access: bool, error_message: str or None)
"""
try:
user_plex = PlexServer(plex_url, user_token)
# Check if user can access any movie library
for section in user_plex.library.sections():
if section.type == 'movie':
logger.info(f"User has access to movie library: {section.title}")
return True, None
logger.warning("User authenticated but has no movie library access")
return False, "No movie library access found"
except Exception as e:
logger.error(f"Failed to verify library access: {e}", exc_info=True)
return False, "Cannot access Plex server. Please check your connection."