Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

690
modules/plex_client.py Normal file
View File

@@ -0,0 +1,690 @@
"""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