"""Plex Media Server client for linking appearances to library items""" import asyncio import uuid from typing import Dict, List, Optional, Any from web.backend.core.http_client import http_client from modules.universal_logger import get_logger logger = get_logger('Plex') # Plex API constants PLEX_TV_API = "https://plex.tv/api/v2" PLEX_AUTH_URL = "https://app.plex.tv/auth" CLIENT_IDENTIFIER = "media-downloader-appearances" PRODUCT_NAME = "Media Downloader" class PlexOAuth: """Handle Plex OAuth PIN-based authentication flow""" def __init__(self): self._headers = { 'Accept': 'application/json', 'X-Plex-Client-Identifier': CLIENT_IDENTIFIER, 'X-Plex-Product': PRODUCT_NAME, 'X-Plex-Version': '1.0.0', 'X-Plex-Device': 'Web', 'X-Plex-Platform': 'Web', } async def create_pin(self) -> Optional[Dict]: """ Create a new PIN for authentication. Returns: Dict with 'id', 'code', and 'auth_url' or None on failure """ try: url = f"{PLEX_TV_API}/pins" response = await http_client.post( url, headers=self._headers, data={'strong': 'true'} ) data = response.json() pin_id = data.get('id') pin_code = data.get('code') if pin_id and pin_code: # Build the auth URL for the user to visit auth_url = ( f"{PLEX_AUTH_URL}#?" f"clientID={CLIENT_IDENTIFIER}&" f"code={pin_code}&" f"context%5Bdevice%5D%5Bproduct%5D={PRODUCT_NAME.replace(' ', '%20')}" ) logger.info(f"Created Plex PIN {pin_id}") return { 'id': pin_id, 'code': pin_code, 'auth_url': auth_url, 'expires_at': data.get('expiresAt'), } return None except Exception as e: logger.error(f"Failed to create Plex PIN: {e}") return None async def check_pin(self, pin_id: int) -> Optional[str]: """ Check if the user has authenticated with the PIN. Args: pin_id: The PIN ID returned from create_pin Returns: The auth token if authenticated, None if still pending or expired """ try: url = f"{PLEX_TV_API}/pins/{pin_id}" response = await http_client.get(url, headers=self._headers) data = response.json() auth_token = data.get('authToken') if auth_token: logger.info("Plex authentication successful") return auth_token return None except Exception as e: logger.error(f"Failed to check Plex PIN: {e}") return None async def wait_for_auth(self, pin_id: int, timeout: int = 120, poll_interval: int = 2) -> Optional[str]: """ Poll for authentication completion. Args: pin_id: The PIN ID to check timeout: Maximum seconds to wait poll_interval: Seconds between checks Returns: The auth token if successful, None on timeout/failure """ elapsed = 0 while elapsed < timeout: token = await self.check_pin(pin_id) if token: return token await asyncio.sleep(poll_interval) elapsed += poll_interval logger.warning(f"Plex authentication timed out after {timeout}s") return None async def get_user_info(self, token: str) -> Optional[Dict]: """ Get information about the authenticated user. Args: token: Plex auth token Returns: User info dict or None """ try: url = f"{PLEX_TV_API}/user" headers = {**self._headers, 'X-Plex-Token': token} response = await http_client.get(url, headers=headers) data = response.json() return { 'username': data.get('username'), 'email': data.get('email'), 'thumb': data.get('thumb'), 'title': data.get('title'), } except Exception as e: logger.error(f"Failed to get Plex user info: {e}") return None async def get_user_servers(self, token: str) -> List[Dict]: """ Get list of Plex servers available to the user. Args: token: Plex auth token Returns: List of server dictionaries """ try: url = f"{PLEX_TV_API}/resources" headers = {**self._headers, 'X-Plex-Token': token} params = {'includeHttps': 1, 'includeRelay': 1} response = await http_client.get(url, headers=headers, params=params) data = response.json() servers = [] for resource in data: if resource.get('provides') == 'server': connections = resource.get('connections', []) # Prefer non-local (relay/remote) connections for server-to-server communication # Local connections often use internal IPs that aren't reachable externally remote_conn = next((c for c in connections if not c.get('local') and c.get('relay')), None) https_conn = next((c for c in connections if not c.get('local') and 'https' in c.get('uri', '')), None) any_remote = next((c for c in connections if not c.get('local')), None) local_conn = next((c for c in connections if c.get('local')), None) # Try in order: relay, https remote, any remote, local best_conn = remote_conn or https_conn or any_remote or local_conn or (connections[0] if connections else None) if best_conn: # Also include all connection URLs for debugging/manual selection all_urls = [{'url': c.get('uri'), 'local': c.get('local', False), 'relay': c.get('relay', False)} for c in connections] servers.append({ 'name': resource.get('name'), 'machineIdentifier': resource.get('clientIdentifier'), 'owned': resource.get('owned', False), 'url': best_conn.get('uri'), 'local': best_conn.get('local', False), 'relay': best_conn.get('relay', False), 'accessToken': resource.get('accessToken'), 'all_connections': all_urls, }) return servers except Exception as e: logger.error(f"Failed to get Plex servers: {e}") return [] class PlexClient: """Client for interacting with Plex Media Server API""" def __init__(self, base_url: str, token: str): """ Initialize Plex client. Args: base_url: Plex server URL (e.g., 'http://192.168.1.100:32400') token: Plex authentication token """ self.base_url = base_url.rstrip('/') self.token = token self._headers = { 'X-Plex-Token': token, 'Accept': 'application/json' } async def test_connection(self) -> bool: """ Test connection to Plex server. Returns: True if connection successful, False otherwise """ try: url = f"{self.base_url}/identity" response = await http_client.get(url, headers=self._headers) data = response.json() server_name = data.get('MediaContainer', {}).get('friendlyName', 'Unknown') logger.info(f"Connected to Plex server: {server_name}") return True except Exception as e: logger.error(f"Plex connection test failed: {e}") return False async def get_libraries(self) -> List[Dict]: """ Get list of Plex libraries. Returns: List of library dictionaries with id, title, type """ try: url = f"{self.base_url}/library/sections" response = await http_client.get(url, headers=self._headers) data = response.json() libraries = [] for section in data.get('MediaContainer', {}).get('Directory', []): libraries.append({ 'id': section.get('key'), 'title': section.get('title'), 'type': section.get('type'), # 'movie', 'show', 'artist', etc. 'uuid': section.get('uuid'), }) return libraries except Exception as e: logger.error(f"Failed to get Plex libraries: {e}") return [] async def search_by_tmdb_id(self, tmdb_id: int, media_type: str = 'movie') -> Optional[Dict]: """ Search for an item in Plex library by TMDB ID. Args: tmdb_id: The Movie Database ID media_type: 'movie' or 'show' Returns: Plex item dict with ratingKey, title, etc. or None if not found """ try: # Plex uses guid format like: tmdb://12345 guid = f"tmdb://{tmdb_id}" # Search across all libraries url = f"{self.base_url}/library/all" params = { 'guid': guid, 'type': 1 if media_type == 'movie' else 2 # 1=movie, 2=show } response = await http_client.get(url, headers=self._headers, params=params) data = response.json() items = data.get('MediaContainer', {}).get('Metadata', []) if items: item = items[0] return { 'ratingKey': item.get('ratingKey'), 'title': item.get('title'), 'year': item.get('year'), 'thumb': item.get('thumb'), 'type': item.get('type'), 'librarySectionID': item.get('librarySectionID'), } return None except Exception as e: logger.debug(f"TMDB search failed for {tmdb_id}: {e}") return None async def search_by_title(self, title: str, year: Optional[int] = None, media_type: str = 'movie') -> Optional[Dict]: """ Search for an item in Plex library by title. Args: title: Movie or show title year: Optional release year for more accurate matching media_type: 'movie' or 'show' Returns: Plex item dict or None if not found """ try: url = f"{self.base_url}/search" params = { 'query': title, 'type': 1 if media_type == 'movie' else 2 } response = await http_client.get(url, headers=self._headers, params=params) data = response.json() items = data.get('MediaContainer', {}).get('Metadata', []) # If year provided, filter for matching year if year and items: for item in items: if item.get('year') == year: return { 'ratingKey': item.get('ratingKey'), 'title': item.get('title'), 'year': item.get('year'), 'thumb': item.get('thumb'), 'type': item.get('type'), 'librarySectionID': item.get('librarySectionID'), } # Return first result if no exact year match if items: item = items[0] return { 'ratingKey': item.get('ratingKey'), 'title': item.get('title'), 'year': item.get('year'), 'thumb': item.get('thumb'), 'type': item.get('type'), 'librarySectionID': item.get('librarySectionID'), } return None except Exception as e: logger.debug(f"Title search failed for '{title}': {e}") return None async def get_episode(self, show_rating_key: str, season: int, episode: int) -> Optional[Dict]: """ Get a specific episode from a TV show. Args: show_rating_key: Plex ratingKey for the show season: Season number episode: Episode number Returns: Episode dict with ratingKey, title, etc. or None if not found """ try: # Get all episodes of the show url = f"{self.base_url}/library/metadata/{show_rating_key}/allLeaves" response = await http_client.get(url, headers=self._headers) data = response.json() episodes = data.get('MediaContainer', {}).get('Metadata', []) for ep in episodes: if ep.get('parentIndex') == season and ep.get('index') == episode: return { 'ratingKey': ep.get('ratingKey'), 'title': ep.get('title'), 'season': season, 'episode': episode, 'show_rating_key': show_rating_key, 'type': 'episode', } return None except Exception as e: logger.debug(f"Episode search failed for S{season}E{episode}: {e}") return None async def get_all_episodes(self, show_rating_key: str) -> Dict[tuple, Dict]: """ Get all episodes for a TV show, indexed by (season, episode) tuple. Args: show_rating_key: Plex ratingKey for the show Returns: Dict mapping (season_num, episode_num) to episode info """ episodes_map = {} try: url = f"{self.base_url}/library/metadata/{show_rating_key}/allLeaves" response = await http_client.get(url, headers=self._headers) data = response.json() episodes = data.get('MediaContainer', {}).get('Metadata', []) for ep in episodes: season = ep.get('parentIndex') episode = ep.get('index') if season is not None and episode is not None: episodes_map[(season, episode)] = { 'ratingKey': ep.get('ratingKey'), 'title': ep.get('title'), 'season': season, 'episode': episode, 'show_rating_key': show_rating_key, 'air_date': ep.get('originallyAvailableAt'), } logger.debug(f"Found {len(episodes_map)} episodes for show {show_rating_key}") return episodes_map except Exception as e: logger.debug(f"Failed to get episodes for show {show_rating_key}: {e}") return {} def get_watch_url(self, rating_key: str) -> str: """ Generate a direct watch URL for a Plex item. Args: rating_key: Plex ratingKey for the item Returns: URL to open the item in Plex Web """ # Extract server machine identifier from base URL or use a generic format # Plex Web URL format: /web/index.html#!/server/{machineId}/details?key=/library/metadata/{ratingKey} return f"{self.base_url}/web/index.html#!/server/1/details?key=%2Flibrary%2Fmetadata%2F{rating_key}" async def get_server_identity(self) -> Optional[Dict]: """ Get Plex server identity including machine identifier. Returns: Server identity dict or None """ try: url = f"{self.base_url}/identity" response = await http_client.get(url, headers=self._headers) data = response.json() container = data.get('MediaContainer', {}) return { 'machineIdentifier': container.get('machineIdentifier'), 'friendlyName': container.get('friendlyName'), 'version': container.get('version'), } except Exception as e: logger.error(f"Failed to get server identity: {e}") return None def get_full_watch_url(self, rating_key: str, machine_id: str) -> str: """ Generate a complete Plex watch URL with machine identifier. Args: rating_key: Plex ratingKey for the item machine_id: Plex server machine identifier Returns: Complete Plex Web URL """ encoded_key = f"%2Flibrary%2Fmetadata%2F{rating_key}" return f"{self.base_url}/web/index.html#!/server/{machine_id}/details?key={encoded_key}" async def search_by_actor(self, actor_name: str) -> List[Dict]: """ Search Plex library for all movies and TV shows featuring an actor. Uses Plex's actor filter to find all content with the actor in cast. Args: actor_name: Name of the actor to search for Returns: List of appearances with show/movie info and role details """ appearances = [] seen_keys = set() # Track to avoid duplicates actor_name_lower = actor_name.lower() try: # Get all libraries libraries = await self.get_libraries() for library in libraries: lib_key = library.get('id') lib_type = library.get('type') # Only search movie and show libraries if lib_type not in ('movie', 'show'): continue try: # Use actor filter to find all content featuring this actor # This is the most reliable method in Plex url = f"{self.base_url}/library/sections/{lib_key}/all" params = { 'type': 1 if lib_type == 'movie' else 2, # 1=movie, 2=show 'actor': actor_name, # Plex accepts actor name directly } response = await http_client.get(url, headers=self._headers, params=params) data = response.json() items = data.get('MediaContainer', {}).get('Metadata', []) logger.debug(f"Found {len(items)} {lib_type}s for '{actor_name}' in library {library.get('title')}") for item in items: rating_key = item.get('ratingKey') if not rating_key or rating_key in seen_keys: continue seen_keys.add(rating_key) # Get detailed metadata for character name detail_url = f"{self.base_url}/library/metadata/{rating_key}" detail_response = await http_client.get(detail_url, headers=self._headers) detail_data = detail_response.json() detail_items = detail_data.get('MediaContainer', {}).get('Metadata', []) if not detail_items: continue detail = detail_items[0] # Find the actor's role/character name character_name = None roles = detail.get('Role', []) for role in roles: role_tag = (role.get('tag') or '').lower() if actor_name_lower in role_tag or role_tag in actor_name_lower: character_name = role.get('role') break # Build poster URL with auth token thumb = detail.get('thumb') poster_url = None if thumb: poster_url = f"{self.base_url}{thumb}?X-Plex-Token={self.token}" # Build appearance data appearance = { 'appearance_type': 'Movie' if lib_type == 'movie' else 'TV', 'show_name': detail.get('title'), 'episode_title': None, 'network': detail.get('studio'), 'appearance_date': detail.get('originallyAvailableAt'), 'year': detail.get('year'), 'status': 'aired', 'description': detail.get('summary'), 'poster_url': poster_url, 'credit_type': 'acting', 'character_name': character_name, 'plex_rating_key': rating_key, 'plex_library_id': lib_key, 'source': 'plex', } # For TV shows, get episode count if lib_type == 'show': appearance['episode_count'] = detail.get('leafCount', 1) appearances.append(appearance) logger.info(f"Found Plex appearance: {actor_name} in '{detail.get('title')}'" + (f" as {character_name}" if character_name else "")) # Small delay between detail requests await asyncio.sleep(0.02) except Exception as e: logger.debug(f"Error searching library {lib_key}: {e}") continue logger.info(f"Found {len(appearances)} Plex appearances for {actor_name}") return appearances except Exception as e: logger.error(f"Failed to search Plex by actor: {e}") return [] async def batch_match_appearances(self, appearances: List[Dict], on_match=None) -> Dict[int, Dict]: """ Match multiple appearances to Plex library items. Args: appearances: List of appearance dicts with tmdb_show_id or tmdb_movie_id on_match: Optional async callback(appearance_id, match_info) called for each match Returns: Dict mapping appearance ID to Plex match info {rating_key, library_id} """ matches = {} server_info = await self.get_server_identity() machine_id = server_info.get('machineIdentifier') if server_info else None # Dedupe by TMDB ID to avoid redundant searches tmdb_cache: Dict[tuple, Optional[Dict]] = {} # Cache episode lookups per show episode_cache: Dict[str, Dict[tuple, Optional[Dict]]] = {} for appearance in appearances: appearance_id = appearance.get('id') if not appearance_id: continue # Determine media type and TMDB ID tmdb_id = appearance.get('tmdb_movie_id') or appearance.get('tmdb_show_id') is_movie = appearance.get('appearance_type') == 'Movie' media_type = 'movie' if is_movie else 'show' if not tmdb_id: continue cache_key = (tmdb_id, media_type) # Check cache first if cache_key in tmdb_cache: plex_item = tmdb_cache[cache_key] else: # Rate limiting await asyncio.sleep(0.1) # Try TMDB ID first plex_item = await self.search_by_tmdb_id(tmdb_id, media_type) # Fall back to title search if no TMDB match if not plex_item: title = appearance.get('movie_name') or appearance.get('show_name') year = None if appearance.get('release_date'): try: year = int(appearance['release_date'][:4]) except (ValueError, TypeError): pass if title: plex_item = await self.search_by_title(title, year, media_type) tmdb_cache[cache_key] = plex_item if plex_item: show_rating_key = plex_item.get('ratingKey') # Always the show/movie key rating_key = show_rating_key if is_movie else None # Movies get the key, TV starts with None library_id = plex_item.get('librarySectionID') # For TV shows with season/episode data, try to match the specific episode season = appearance.get('season_number') episode = appearance.get('episode_number') if not is_movie and season and episode: # Check episode cache first show_key = str(show_rating_key) ep_key = (season, episode) if show_key not in episode_cache: episode_cache[show_key] = {} if ep_key in episode_cache[show_key]: episode_item = episode_cache[show_key][ep_key] else: episode_item = await self.get_episode(show_rating_key, season, episode) episode_cache[show_key][ep_key] = episode_item if episode_item: rating_key = episode_item.get('ratingKey') # Episode-specific key # If episode not found, rating_key stays None - episode not in Plex match_info = { 'plex_rating_key': rating_key, # Episode key if found, movie key for movies, None for missing TV episodes 'plex_show_rating_key': show_rating_key if not is_movie else None, # Show key for TV (for series-level navigation) 'plex_library_id': library_id, 'plex_watch_url': self.get_full_watch_url(rating_key, machine_id) if (rating_key and machine_id) else (self.get_watch_url(rating_key) if rating_key else None), } matches[appearance_id] = match_info # Call the on_match callback for real-time updates if on_match: await on_match(appearance_id, match_info) logger.info(f"Matched {len(matches)} of {len(appearances)} appearances to Plex library") return matches