Improved Plex integration to simplify the libraries selection, webhook creation and do selective content refresh instead of the whole library

This commit is contained in:
Daniel Hansson
2025-08-25 01:03:12 +02:00
committed by GitHub
parent 02b5aad0ae
commit c6ddee1432
18 changed files with 1311 additions and 102 deletions

View File

@@ -487,6 +487,128 @@ class PlexServers(Resource):
return {'data': []}
@api_ns_plex.route('plex/oauth/libraries')
class PlexLibraries(Resource):
def get(self):
try:
decrypted_token = get_decrypted_token()
if not decrypted_token:
logging.warning("No decrypted token available for Plex library fetching")
return {'data': []}
# Get the selected server URL
server_url = settings.plex.get('server_url')
if not server_url:
logging.warning("No Plex server selected")
return {'data': []}
logging.info(f"Fetching Plex libraries from server: {server_url}")
headers = {
'X-Plex-Token': decrypted_token,
'Accept': 'application/json'
}
# Get libraries from the selected server
response = requests.get(
f"{server_url}/library/sections",
headers=headers,
timeout=10,
verify=False
)
if response.status_code in (401, 403):
logging.warning(f"Plex authentication failed: {response.status_code}")
return {'data': []}
elif response.status_code != 200:
logging.error(f"Plex API error: {response.status_code}")
raise PlexConnectionError(f"Failed to get libraries: HTTP {response.status_code}")
response.raise_for_status()
# Parse the response - it could be JSON or XML depending on the server
content_type = response.headers.get('content-type', '')
logging.debug(f"Plex libraries response content-type: {content_type}")
if 'application/json' in content_type:
data = response.json()
logging.debug(f"Plex libraries JSON response: {data}")
if 'MediaContainer' in data and 'Directory' in data['MediaContainer']:
sections = data['MediaContainer']['Directory']
else:
sections = []
elif 'application/xml' in content_type or 'text/xml' in content_type:
import xml.etree.ElementTree as ET
root = ET.fromstring(response.text)
sections = []
for directory in root.findall('Directory'):
sections.append({
'key': directory.get('key'),
'title': directory.get('title'),
'type': directory.get('type'),
'count': int(directory.get('count', 0)),
'agent': directory.get('agent', ''),
'scanner': directory.get('scanner', ''),
'language': directory.get('language', ''),
'uuid': directory.get('uuid', ''),
'updatedAt': int(directory.get('updatedAt', 0)),
'createdAt': int(directory.get('createdAt', 0))
})
else:
raise PlexConnectionError(f"Unexpected response format: {content_type}")
# Filter and format libraries for movie and show types only
libraries = []
for section in sections:
if isinstance(section, dict) and section.get('type') in ['movie', 'show']:
# Get the actual count of items in this library section
try:
section_key = section.get('key')
count_response = requests.get(
f"{server_url}/library/sections/{section_key}/all",
headers={'X-Plex-Token': decrypted_token, 'Accept': 'application/json'},
timeout=5,
verify=False
)
actual_count = 0
if count_response.status_code == 200:
count_data = count_response.json()
if 'MediaContainer' in count_data:
container = count_data['MediaContainer']
# The 'size' field contains the number of items in the library
actual_count = int(container.get('size', len(container.get('Metadata', []))))
logging.info(f"Library '{section.get('title')}' has {actual_count} items")
except Exception as e:
logging.warning(f"Failed to get count for library {section.get('title')}: {e}")
actual_count = 0
libraries.append({
'key': str(section.get('key', '')),
'title': section.get('title', ''),
'type': section.get('type', ''),
'count': actual_count,
'agent': section.get('agent', ''),
'scanner': section.get('scanner', ''),
'language': section.get('language', ''),
'uuid': section.get('uuid', ''),
'updatedAt': int(section.get('updatedAt', 0)),
'createdAt': int(section.get('createdAt', 0))
})
logging.debug(f"Filtered Plex libraries: {libraries}")
return {'data': libraries}
except requests.exceptions.RequestException as e:
logging.warning(f"Failed to connect to Plex server: {type(e).__name__}: {str(e)}")
return {'data': []}
except Exception as e:
logging.warning(f"Unexpected error getting Plex libraries: {type(e).__name__}: {str(e)}")
return {'data': []}
@api_ns_plex.route('plex/oauth/logout')
class PlexLogout(Resource):
post_request_parser = reqparse.RequestParser()
@@ -661,3 +783,172 @@ class PlexSelectServer(Resource):
}
}
}
@api_ns_plex.route('plex/webhook/create')
class PlexWebhookCreate(Resource):
post_request_parser = reqparse.RequestParser()
@api_ns_plex.doc(parser=post_request_parser)
def post(self):
try:
decrypted_token = get_decrypted_token()
if not decrypted_token:
raise UnauthorizedError()
# Import MyPlexAccount here to avoid circular imports
from plexapi.myplex import MyPlexAccount
# Create account instance with OAuth token
account = MyPlexAccount(token=decrypted_token)
# Build webhook URL for this Bazarr instance
# Try to get base URL from settings first, then fall back to request host
configured_base_url = getattr(settings.general, 'base_url', '').rstrip('/')
# Get the API key for webhook authentication
apikey = getattr(settings.auth, 'apikey', '')
if not apikey:
logging.error("No API key configured - cannot create webhook")
return {'error': 'No API key configured. Set up API key in Settings > General first.'}, 400
if configured_base_url:
webhook_url = f"{configured_base_url}/api/webhooks/plex?apikey={apikey}"
logging.info(f"Using configured base URL for webhook: {configured_base_url}/api/webhooks/plex")
else:
# Fall back to using the current request's host
scheme = 'https' if request.is_secure else 'http'
host = request.host
webhook_url = f"{scheme}://{host}/api/webhooks/plex?apikey={apikey}"
logging.info(f"Using request host for webhook (no base URL configured): {scheme}://{host}/api/webhooks/plex")
logging.info("Note: If Bazarr is behind a reverse proxy, configure Base URL in General Settings for better reliability")
# Get existing webhooks
existing_webhooks = account.webhooks()
existing_urls = []
for webhook in existing_webhooks:
try:
if hasattr(webhook, 'url'):
existing_urls.append(webhook.url)
elif isinstance(webhook, str):
existing_urls.append(webhook)
elif isinstance(webhook, dict) and 'url' in webhook:
existing_urls.append(webhook['url'])
except Exception as e:
logging.warning(f"Failed to process existing webhook {webhook}: {e}")
continue
if webhook_url in existing_urls:
return {
'data': {
'success': True,
'message': 'Webhook already exists',
'webhook_url': webhook_url
}
}
# Add the webhook
updated_webhooks = account.addWebhook(webhook_url)
logging.info(f"Successfully created Plex webhook: {webhook_url}")
return {
'data': {
'success': True,
'message': 'Webhook created successfully',
'webhook_url': webhook_url,
'total_webhooks': len(updated_webhooks)
}
}
except Exception as e:
logging.error(f"Failed to create Plex webhook: {e}")
return {'error': f'Failed to create webhook: {str(e)}'}, 500
@api_ns_plex.route('plex/webhook/list')
class PlexWebhookList(Resource):
def get(self):
try:
decrypted_token = get_decrypted_token()
if not decrypted_token:
raise UnauthorizedError()
from plexapi.myplex import MyPlexAccount
account = MyPlexAccount(token=decrypted_token)
webhooks = account.webhooks()
webhook_list = []
for webhook in webhooks:
try:
# Handle different webhook object types
if hasattr(webhook, 'url'):
webhook_url = webhook.url
elif isinstance(webhook, str):
webhook_url = webhook
elif isinstance(webhook, dict) and 'url' in webhook:
webhook_url = webhook['url']
else:
logging.warning(f"Unknown webhook type: {type(webhook)}, value: {webhook}")
continue
webhook_list.append({'url': webhook_url})
except Exception as e:
logging.warning(f"Failed to process webhook {webhook}: {e}")
continue
return {
'data': {
'webhooks': webhook_list,
'count': len(webhook_list)
}
}
except Exception as e:
logging.error(f"Failed to list Plex webhooks: {e}")
return {'error': f'Failed to list webhooks: {str(e)}'}, 500
@api_ns_plex.route('plex/webhook/delete')
class PlexWebhookDelete(Resource):
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('webhook_url', type=str, required=True, help='Webhook URL to delete')
@api_ns_plex.doc(parser=post_request_parser)
def post(self):
try:
args = self.post_request_parser.parse_args()
webhook_url = args.get('webhook_url')
logging.info(f"Attempting to delete Plex webhook: {webhook_url}")
decrypted_token = get_decrypted_token()
if not decrypted_token:
raise UnauthorizedError()
from plexapi.myplex import MyPlexAccount
account = MyPlexAccount(token=decrypted_token)
# First, let's see what webhooks actually exist
existing_webhooks = account.webhooks()
logging.info(f"Existing webhooks before deletion: {[str(w) for w in existing_webhooks]}")
# Delete the webhook
account.deleteWebhook(webhook_url)
logging.info(f"Successfully deleted Plex webhook: {webhook_url}")
return {
'data': {
'success': True,
'message': 'Webhook deleted successfully'
}
}
except Exception as e:
logging.error(f"Failed to delete Plex webhook: {e}")
return {'error': f'Failed to delete webhook: {str(e)}'}, 500

View File

@@ -25,11 +25,10 @@ class TokenManager:
return None
salt = secrets.token_hex(16)
timestamp = int(time.time())
payload = {
'token': token,
'salt': salt,
'timestamp': timestamp
'timestamp': int(time.time())
}
return self.serializer.dumps(payload)

View File

@@ -8,6 +8,8 @@ import re
import secrets
import threading
import time
from datetime import datetime
import random
import configparser
import yaml
@@ -253,6 +255,11 @@ validators = [
Validator('plex.server_name', must_exist=True, default='', is_type_of=str),
Validator('plex.server_url', must_exist=True, default='', is_type_of=str),
Validator('plex.server_local', must_exist=True, default=False, is_type_of=bool),
# Migration fields
Validator('plex.migration_attempted', must_exist=True, default=False, is_type_of=bool),
Validator('plex.migration_successful', must_exist=True, default=False, is_type_of=bool),
Validator('plex.migration_timestamp', must_exist=True, default='', is_type_of=(int, float, str)),
Validator('plex.disable_auto_migration', must_exist=True, default=False, is_type_of=bool),
# proxy section
Validator('proxy.type', must_exist=True, default=None, is_type_of=(NoneType, str),
@@ -977,6 +984,421 @@ def migrate_plex_config():
settings.plex.encryption_key = key
write_config()
logging.info("Plex encryption key generated")
# Check if user needs seamless migration from API key to OAuth
migrate_apikey_to_oauth()
def migrate_apikey_to_oauth():
"""
Seamlessly migrate users from API key authentication to OAuth.
This preserves their existing configuration while enabling OAuth features.
Safety features:
- Creates backup before migration
- Validates before committing changes
- Implements graceful rollback on failure
- Handles rate limiting and network issues
- Delays startup to avoid race conditions
"""
try:
# Add startup delay to avoid race conditions with other Plex connections
logging.info("Starting Plex OAuth migration with 5-second delay to avoid conflicts...")
time.sleep(5)
auth_method = settings.plex.get('auth_method', 'apikey')
api_key = settings.plex.get('apikey', '')
# Only migrate if currently using API key method and have a key
if auth_method != 'apikey' or not api_key:
return
# Check if already migrated (has OAuth token)
if settings.plex.get('token'):
logging.debug("Plex OAuth token already exists, skipping migration")
return
# Check if migration is disabled (for emergency rollback)
if settings.plex.get('disable_auto_migration', False):
logging.info("Plex auto-migration disabled, skipping")
return
# Create backup of current configuration
backup_config = {
'auth_method': auth_method,
'apikey': api_key,
'apikey_encrypted': settings.plex.get('apikey_encrypted', False),
'ip': settings.plex.get('ip', '127.0.0.1'),
'port': settings.plex.get('port', 32400),
'ssl': settings.plex.get('ssl', False),
'migration_attempted': True,
'migration_timestamp': datetime.now().isoformat() + '_backup'
}
# Mark that migration was attempted (prevents retry loops)
settings.plex.migration_attempted = True
write_config()
logging.info("Starting seamless migration from Plex API key to OAuth...")
# Add random delay to prevent thundering herd (0-30 seconds)
import random
delay = random.uniform(0, 30)
logging.debug(f"Migration delay: {delay:.1f}s to prevent server overload")
time.sleep(delay)
# Decrypt the API key
from bazarr.api.plex.security import TokenManager, get_or_create_encryption_key
encryption_key = get_or_create_encryption_key(settings.plex, 'encryption_key')
token_manager = TokenManager(encryption_key)
# Handle both encrypted and plain text API keys
try:
if settings.plex.get('apikey_encrypted', False):
decrypted_api_key = token_manager.decrypt(api_key)
else:
decrypted_api_key = api_key
except Exception as e:
logging.error(f"Failed to decrypt API key for migration: {e}")
return
# Use API key to fetch user data from Plex with retry logic
import requests
headers = {
'X-Plex-Token': decrypted_api_key,
'Accept': 'application/json'
}
# Get user account info with retries
max_retries = 3
retry_delay = 5
for attempt in range(max_retries):
try:
user_response = requests.get('https://plex.tv/api/v2/user',
headers=headers, timeout=10)
if user_response.status_code == 429: # Rate limited
logging.warning(f"Rate limited by Plex API, attempt {attempt + 1}/{max_retries}")
if attempt < max_retries - 1:
time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
continue
else:
logging.error("Migration failed due to rate limiting, will retry later")
return
user_response.raise_for_status()
user_data = user_response.json()
username = user_data.get('username', '')
email = user_data.get('email', '')
user_id = str(user_data.get('id', ''))
break
except requests.exceptions.Timeout:
logging.warning(f"Timeout getting user data, attempt {attempt + 1}/{max_retries}")
if attempt < max_retries - 1:
time.sleep(retry_delay)
continue
else:
logging.error("Migration failed due to timeouts, will retry later")
return
except Exception as e:
logging.error(f"Failed to fetch user data for migration: {e}")
return
# Get user's servers with retry logic
for attempt in range(max_retries):
try:
servers_response = requests.get('https://plex.tv/pms/resources',
headers=headers,
params={'includeHttps': '1', 'includeRelay': '1'},
timeout=10)
if servers_response.status_code == 429: # Rate limited
logging.warning(f"Rate limited getting servers, attempt {attempt + 1}/{max_retries}")
if attempt < max_retries - 1:
time.sleep(retry_delay * (attempt + 1))
continue
else:
logging.error("Migration failed due to rate limiting, will retry later")
return
servers_response.raise_for_status()
# Parse response - could be JSON or XML
content_type = servers_response.headers.get('content-type', '')
servers = []
if 'application/json' in content_type:
resources_data = servers_response.json()
for device in resources_data:
if isinstance(device, dict) and device.get('provides') == 'server' and device.get('owned'):
server = {
'name': device.get('name', ''),
'machineIdentifier': device.get('clientIdentifier', ''),
'connections': []
}
for conn in device.get('connections', []):
server['connections'].append({
'uri': conn.get('uri', ''),
'local': conn.get('local', False)
})
servers.append(server)
elif 'application/xml' in content_type or 'text/xml' in content_type:
# Parse XML response
import xml.etree.ElementTree as ET
root = ET.fromstring(servers_response.text)
for device in root.findall('Device'):
if device.get('provides') == 'server' and device.get('owned') == '1':
server = {
'name': device.get('name', ''),
'machineIdentifier': device.get('clientIdentifier', ''),
'connections': []
}
# Get connections directly from the XML
for conn in device.findall('Connection'):
server['connections'].append({
'uri': conn.get('uri', ''),
'local': conn.get('local') == '1'
})
servers.append(server)
else:
logging.error(f"Unexpected response format: {content_type}")
return
break
except requests.exceptions.Timeout:
logging.warning(f"Timeout getting servers, attempt {attempt + 1}/{max_retries}")
if attempt < max_retries - 1:
time.sleep(retry_delay)
continue
else:
logging.error("Migration failed due to timeouts, will retry later")
return
except Exception as e:
logging.error(f"Failed to fetch servers for migration: {e}")
return
# Find the server that matches current manual configuration
current_ip = settings.plex.get('ip', '127.0.0.1')
current_port = settings.plex.get('port', 32400)
current_ssl = settings.plex.get('ssl', False)
current_url = f"{'https' if current_ssl else 'http'}://{current_ip}:{current_port}"
selected_server = None
selected_connection = None
# Try to match current server configuration
for server in servers:
for connection in server['connections']:
if connection['uri'] == current_url:
selected_server = server
selected_connection = connection
break
if selected_server:
break
# If no exact match, try to find the first available local server
if not selected_server and servers:
for server in servers:
for connection in server['connections']:
if connection.get('local', False):
selected_server = server
selected_connection = connection
break
if selected_server:
break
# If still no match, use the first server
if not selected_server and servers:
selected_server = servers[0]
if selected_server['connections']:
selected_connection = selected_server['connections'][0]
if not selected_server or not selected_connection:
logging.warning("No suitable Plex server found for migration")
return
# Encrypt the API key as OAuth token (they're the same thing)
encrypted_token = token_manager.encrypt(decrypted_api_key)
# Validate OAuth configuration BEFORE making any changes
oauth_config = {
'auth_method': 'oauth',
'token': encrypted_token,
'username': username,
'email': email,
'user_id': user_id,
'server_machine_id': selected_server['machineIdentifier'],
'server_name': selected_server['name'],
'server_url': selected_connection['uri'],
'server_local': selected_connection.get('local', False)
}
# Test OAuth configuration before committing
logging.info("Testing OAuth configuration before applying changes...")
test_success = False
try:
# Temporarily apply OAuth settings in memory only
original_auth_method = settings.plex.auth_method
original_token = settings.plex.token
settings.plex.auth_method = oauth_config['auth_method']
settings.plex.token = oauth_config['token']
settings.plex.server_machine_id = oauth_config['server_machine_id']
settings.plex.server_name = oauth_config['server_name']
settings.plex.server_url = oauth_config['server_url']
settings.plex.server_local = oauth_config['server_local']
# Test connection
from bazarr.plex.operations import get_plex_server
test_server = get_plex_server()
test_server.account() # Test connection
test_success = True
# Restore original values temporarily
settings.plex.auth_method = original_auth_method
settings.plex.token = original_token
except Exception as e:
logging.error(f"OAuth pre-validation failed: {e}")
# Restore original values
settings.plex.auth_method = original_auth_method
settings.plex.token = original_token
return
if not test_success:
logging.error("OAuth configuration validation failed, aborting migration")
return
logging.info("OAuth configuration validated successfully, proceeding with migration")
# Now safely apply the OAuth configuration
settings.plex.auth_method = oauth_config['auth_method']
settings.plex.token = oauth_config['token']
settings.plex.username = oauth_config['username']
settings.plex.email = oauth_config['email']
settings.plex.user_id = oauth_config['user_id']
settings.plex.server_machine_id = oauth_config['server_machine_id']
settings.plex.server_name = oauth_config['server_name']
settings.plex.server_url = oauth_config['server_url']
settings.plex.server_local = oauth_config['server_local']
# Mark migration as successful and disable auto-migration
settings.plex.migration_successful = True
# Create human-readable timestamp: YYYYMMDD_HHMMSS_randomstring
random_suffix = secrets.token_hex(4) # 8 character random string
settings.plex.migration_timestamp = f"{datetime.now().isoformat()}_{random_suffix}"
settings.plex.disable_auto_migration = True
# Clean up legacy manual configuration fields (no longer needed with OAuth)
settings.plex.ip = ''
settings.plex.port = 32400 # Reset to default
settings.plex.ssl = False # Reset to default
# Save configuration with OAuth settings
write_config()
logging.info(f"Successfully migrated Plex configuration to OAuth for user '{username}'")
logging.info(f"Selected server: {selected_server['name']} ({selected_connection['uri']})")
logging.info("Legacy manual configuration fields cleared (ip, port, ssl)")
# Final validation test
try:
test_server = get_plex_server()
test_server.account() # Test connection
logging.info("Migration validated - OAuth connection successful")
# Only now permanently remove API key
settings.plex.apikey = ''
settings.plex.apikey_encrypted = False
write_config()
logging.info("Legacy API key permanently removed after successful OAuth migration")
except Exception as e:
logging.error(f"Final OAuth validation failed: {e}")
# Restore backup configuration
logging.info("Restoring backup configuration...")
settings.plex.auth_method = backup_config['auth_method']
settings.plex.apikey = backup_config['apikey']
settings.plex.apikey_encrypted = backup_config['apikey_encrypted']
settings.plex.ip = backup_config['ip']
settings.plex.port = backup_config['port']
settings.plex.ssl = backup_config['ssl']
# Clear OAuth settings and restore legacy manual config
settings.plex.token = ''
settings.plex.username = ''
settings.plex.email = ''
settings.plex.user_id = ''
settings.plex.server_machine_id = ''
settings.plex.server_name = ''
settings.plex.server_url = ''
settings.plex.server_local = False
settings.plex.migration_successful = False
settings.plex.disable_auto_migration = False # Allow retry
write_config()
# Test the rollback
try:
test_server = get_plex_server()
test_server.account() # Test connection with legacy settings
logging.info("Rollback successful - legacy API key connection restored")
logging.error("OAuth migration failed but legacy configuration is working. Please configure OAuth manually through the GUI.")
except Exception as rollback_error:
logging.error(f"Rollback validation also failed: {rollback_error}")
logging.error("Both OAuth and legacy API key configurations failed. Please check your Plex settings.")
except Exception as e:
logging.error(f"Unexpected error during Plex migration: {e}")
# Keep existing configuration intact
def cleanup_legacy_oauth_config():
"""
Clean up legacy manual configuration fields when using OAuth.
These fields (ip, port, ssl) are not used with OAuth since server_url contains everything.
"""
if settings.plex.get('auth_method') != 'oauth':
return
# Check if any legacy values exist
has_legacy_ip = bool(settings.plex.get('ip', '').strip())
has_legacy_ssl = settings.plex.get('ssl', False) == True
has_legacy_port = settings.plex.get('port', 32400) != 32400
# Only disable auto-migration if migration was actually successful
migration_successful = settings.plex.get('migration_successful', False)
auto_migration_enabled = not settings.plex.get('disable_auto_migration', False)
should_disable_auto_migration = migration_successful and auto_migration_enabled
if has_legacy_ip or has_legacy_ssl or has_legacy_port or should_disable_auto_migration:
logging.info("Cleaning up OAuth configuration")
# Clear legacy manual config fields (not needed with OAuth)
if has_legacy_ip or has_legacy_ssl or has_legacy_port:
settings.plex.ip = ''
settings.plex.port = 32400 # Reset to default
settings.plex.ssl = False # Reset to default
logging.info("Cleared legacy manual config fields (OAuth uses server_url)")
# Disable auto-migration only if it was previously successful
if should_disable_auto_migration:
settings.plex.disable_auto_migration = True
logging.info("Disabled auto-migration (previous migration was successful)")
write_config()
def initialize_plex():
@@ -987,6 +1409,9 @@ def initialize_plex():
# Run migration
migrate_plex_config()
# Clean up legacy fields for existing OAuth configurations
cleanup_legacy_oauth_config()
# Start cache cleanup if OAuth is enabled
if settings.general.use_plex and settings.plex.get('auth_method') == 'oauth':
try:

View File

@@ -6,9 +6,6 @@ from plexapi.server import PlexServer
logger = logging.getLogger(__name__)
# Constants
DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S'
def get_plex_server() -> PlexServer:
"""Connect to the Plex server and return the server instance."""
@@ -39,7 +36,7 @@ def get_plex_server() -> PlexServer:
if not server_url:
raise ValueError("Server URL not configured. Please select a Plex server.")
return PlexServer(server_url, decrypted_token)
plex_server = PlexServer(server_url, decrypted_token)
else:
# Manual/API key authentication - always use encryption now
@@ -66,7 +63,9 @@ def get_plex_server() -> PlexServer:
logger.error(f"Failed to decrypt API key: {type(e).__name__}")
raise ValueError("Invalid encrypted API key. Please reconfigure Plex authentication.")
return PlexServer(baseurl, decrypted_apikey)
plex_server = PlexServer(baseurl, decrypted_apikey)
return plex_server
except Exception as e:
logger.error(f"Failed to connect to Plex server: {e}")
@@ -94,8 +93,7 @@ def plex_set_movie_added_date_now(movie_metadata) -> None:
plex = get_plex_server()
library = plex.library.section(settings.plex.movie_library)
video = library.getGuid(guid=movie_metadata.imdbId)
current_date = datetime.now().strftime(DATETIME_FORMAT)
update_added_date(video, current_date)
update_added_date(video, datetime.now().isoformat())
except Exception as e:
logger.error(f"Error in plex_set_movie_added_date_now: {e}")
@@ -111,8 +109,7 @@ def plex_set_episode_added_date_now(episode_metadata) -> None:
library = plex.library.section(settings.plex.series_library)
show = library.getGuid(episode_metadata.imdbId)
episode = show.episode(season=episode_metadata.season, episode=episode_metadata.episode)
current_date = datetime.now().strftime(DATETIME_FORMAT)
update_added_date(episode, current_date)
update_added_date(episode, datetime.now().isoformat())
except Exception as e:
logger.error(f"Error in plex_set_episode_added_date_now: {e}")
@@ -131,3 +128,36 @@ def plex_update_library(is_movie_library: bool) -> None:
logger.info(f"Triggered update for library: {library_name}")
except Exception as e:
logger.error(f"Error in plex_update_library: {e}")
def plex_refresh_item(imdb_id: str, is_movie: bool, season: int = None, episode: int = None) -> None:
"""
Refresh a specific item in Plex instead of scanning the entire library.
This is much more efficient than a full library scan when subtitles are added.
:param imdb_id: IMDB ID of the content
:param is_movie: True for movie, False for TV episode
:param season: Season number for TV episodes
:param episode: Episode number for TV episodes
"""
try:
plex = get_plex_server()
library_name = settings.plex.movie_library if is_movie else settings.plex.series_library
library = plex.library.section(library_name)
if is_movie:
# Refresh specific movie
item = library.getGuid(f"imdb://{imdb_id}")
item.refresh()
logger.info(f"Refreshed movie: {item.title} (IMDB: {imdb_id})")
else:
# Refresh specific episode
show = library.getGuid(f"imdb://{imdb_id}")
episode_item = show.episode(season=season, episode=episode)
episode_item.refresh()
logger.info(f"Refreshed episode: {show.title} S{season:02d}E{episode:02d} (IMDB: {imdb_id})")
except Exception as e:
logger.warning(f"Failed to refresh specific item (IMDB: {imdb_id}), falling back to library update: {e}")
# Fallback to full library update if specific refresh fails
plex_update_library(is_movie)

View File

@@ -11,7 +11,7 @@ from app.database import TableShows, TableEpisodes, TableMovies, database, selec
from utilities.analytics import event_tracker
from radarr.notify import notify_radarr
from sonarr.notify import notify_sonarr
from plex.operations import plex_set_movie_added_date_now, plex_update_library, plex_set_episode_added_date_now
from plex.operations import plex_set_movie_added_date_now, plex_update_library, plex_set_episode_added_date_now, plex_refresh_item
from app.event_handler import event_stream
from .utils import _get_download_code3
@@ -145,7 +145,9 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
payload=episode_metadata.sonarrEpisodeId)
if settings.general.use_plex is True:
if settings.plex.update_series_library is True:
plex_update_library(is_movie_library=False)
# Use specific item refresh instead of full library scan
plex_refresh_item(episode_metadata.imdbId, is_movie=False,
season=episode_metadata.season, episode=episode_metadata.episode)
if settings.plex.set_episode_added is True:
plex_set_episode_added_date_now(episode_metadata)
@@ -158,7 +160,8 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
if settings.plex.set_movie_added is True:
plex_set_movie_added_date_now(movie_metadata)
if settings.plex.update_movie_library is True:
plex_update_library(is_movie_library=True)
# Use specific item refresh instead of full library scan
plex_refresh_item(movie_metadata.imdbId, is_movie=True)
event_tracker.track_subtitles(provider=downloaded_provider, action=action, language=downloaded_language)

View File

@@ -127,3 +127,63 @@ export const usePlexServerSelectionMutation = () => {
},
});
};
export const usePlexLibrariesQuery = <TData = Plex.Library[]>(
options?: Partial<
UseQueryOptions<Plex.Library[], Error, TData, (string | boolean)[]>
> & { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
return useQuery({
queryKey: [QueryKeys.Plex, "libraries"],
queryFn: () => api.plex.libraries(),
enabled,
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
refetchOnWindowFocus: false, // Don't refetch on window focus
...options,
});
};
export const usePlexWebhookCreateMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.plex.createWebhook(),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Plex, "webhooks"],
});
},
});
};
export const usePlexWebhookListQuery = <TData = Plex.WebhookList>(
options?: Partial<
UseQueryOptions<Plex.WebhookList, Error, TData, (string | boolean)[]>
> & { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
return useQuery({
queryKey: [QueryKeys.Plex, "webhooks"],
queryFn: () => api.plex.listWebhooks(),
enabled,
staleTime: 1000 * 60 * 2, // Cache for 2 minutes
refetchOnWindowFocus: false,
...options,
});
};
export const usePlexWebhookDeleteMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (webhookUrl: string) => api.plex.deleteWebhook(webhookUrl),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Plex, "webhooks"],
});
},
});
};

View File

@@ -77,6 +77,11 @@ export function useSettingsMutation() {
void client.invalidateQueries({
queryKey: [QueryKeys.Badges],
});
// Invalidate Plex libraries when settings change (e.g., server configuration)
void client.invalidateQueries({
queryKey: [QueryKeys.Plex, "libraries"],
});
},
});
}

View File

@@ -51,6 +51,37 @@ class NewPlexApi extends BaseApi {
return response.data;
}
async libraries() {
const response =
await this.get<DataWrapper<Plex.Library[]>>(`/oauth/libraries`);
return response.data;
}
async createWebhook() {
const response =
await this.post<DataWrapper<Plex.WebhookResult>>("/webhook/create");
return response.data;
}
async listWebhooks() {
const response =
await this.get<DataWrapper<Plex.WebhookList>>("/webhook/list");
return response.data;
}
async deleteWebhook(webhookUrl: string) {
const response = await this.post<DataWrapper<Plex.WebhookResult>>(
"/webhook/delete",
// eslint-disable-next-line camelcase
{ webhook_url: webhookUrl },
);
return response.data;
}
}
export default new NewPlexApi();

View File

@@ -5,3 +5,10 @@
.actionButton {
align-self: flex-start;
}
.authAlert {
height: 36px;
display: flex;
align-items: center;
padding: 8px 12px;
}

View File

@@ -93,7 +93,7 @@ const AuthSection = () => {
return (
<Paper withBorder radius="md" p="lg" className={styles.authSection}>
<Stack gap="md">
<Title order={4}>Plex OAuth (recommended)</Title>
<Title order={4}>Plex OAuth</Title>
<Stack gap="sm">
<Text size="lg" fw={600}>
Complete Authentication
@@ -126,12 +126,16 @@ const AuthSection = () => {
return (
<Paper withBorder radius="md" p="lg" className={styles.authSection}>
<Stack gap="md">
<Title order={4}>Plex OAuth (recommended)</Title>
<Title order={4}>Plex OAuth</Title>
<Stack gap="sm">
<Text size="sm">
Connect your Plex account to enable secure, automated integration
with Bazarr.
</Text>
<Text size="xs" c="dimmed">
Advanced users: Manual configuration is available via config.yaml
if OAuth is not suitable.
</Text>
{authError && (
<Alert color="red" variant="light">
{authError.message || "Authentication failed"}
@@ -156,8 +160,8 @@ const AuthSection = () => {
return (
<Paper withBorder radius="md" p="lg" className={styles.authSection}>
<Stack gap="md">
<Title order={4}>Plex OAuth (recommended)</Title>
<Alert color="brand" variant="light">
<Title order={4}>Plex OAuth</Title>
<Alert color="brand" variant="light" className={styles.authAlert}>
Connected as {authData?.username} ({authData?.email})
</Alert>
<Button

View File

@@ -0,0 +1,20 @@
.librarySelector {
margin-bottom: 1rem; /* 16px */
}
.alertMessage {
margin-top: 0.5rem; /* 8px */
}
.loadingField {
opacity: 0.6;
}
.selectField {
margin-top: 0.25rem; /* 4px */
}
.labelText {
font-weight: 500;
margin-bottom: 0.5rem; /* 8px */
}

View File

@@ -0,0 +1,114 @@
import { FunctionComponent } from "react";
import { Alert, Select, Stack, Text } from "@mantine/core";
import {
usePlexAuthValidationQuery,
usePlexLibrariesQuery,
} from "@/apis/hooks/plex";
import { BaseInput, useBaseInput } from "@/pages/Settings/utilities/hooks";
import styles from "@/pages/Settings/Plex/LibrarySelector.module.scss";
export type LibrarySelectorProps = BaseInput<string> & {
label: string;
libraryType: "movie" | "show";
placeholder?: string;
description?: string;
};
const LibrarySelector: FunctionComponent<LibrarySelectorProps> = (props) => {
const { libraryType, placeholder, description, label, ...baseProps } = props;
const { value, update, rest } = useBaseInput(baseProps);
// Check if user is authenticated with OAuth
const { data: authData } = usePlexAuthValidationQuery();
const isAuthenticated = Boolean(
authData?.valid && authData?.auth_method === "oauth",
);
// Fetch libraries if authenticated
const {
data: libraries = [],
isLoading,
error,
} = usePlexLibrariesQuery({
enabled: isAuthenticated,
});
// Filter libraries by type
const filtered = libraries.filter((library) => library.type === libraryType);
const selectData = filtered.map((library) => ({
value: library.title,
label: `${library.title} (${library.count} items)`,
}));
if (!isAuthenticated) {
return (
<Stack gap="xs" className={styles.librarySelector}>
<Text fw={500} className={styles.labelText}>
{label}
</Text>
<Alert color="brand" variant="light" className={styles.alertMessage}>
Enable Plex OAuth above to automatically discover your libraries.
</Alert>
</Stack>
);
}
if (isLoading) {
return (
<Stack gap="xs" className={styles.librarySelector}>
<Select
{...rest}
label={label}
placeholder="Loading libraries..."
data={[]}
disabled
className={styles.loadingField}
/>
</Stack>
);
}
if (error) {
return (
<Stack gap="xs" className={styles.librarySelector}>
<Alert color="red" variant="light" className={styles.alertMessage}>
Failed to load libraries:{" "}
{(error as Error)?.message || "Unknown error"}
</Alert>
</Stack>
);
}
if (selectData.length === 0) {
return (
<Stack gap="xs" className={styles.librarySelector}>
<Alert color="gray" variant="light" className={styles.alertMessage}>
No {libraryType} libraries found on your Plex server.
</Alert>
</Stack>
);
}
return (
<div className={styles.librarySelector}>
<Select
{...rest}
label={label}
placeholder={placeholder || `Select ${libraryType} library...`}
data={selectData}
description={description}
value={value || ""}
onChange={(newValue) => {
if (newValue !== null) {
update(newValue);
}
}}
allowDeselect={false}
className={styles.selectField}
/>
</div>
);
};
export default LibrarySelector;

View File

@@ -12,15 +12,22 @@
}
.serverSelectGroup {
align-items: flex-end;
gap: var(--mantine-spacing-md);
margin-bottom: 1rem; /* 16px */
}
.serverSelectField {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem; /* 12px */
}
.refreshButton {
min-width: 2.75rem;
height: 2.25rem;
}
.flexContainer {
display: flex;
align-items: center;
gap: 0.75rem; /* 12px */
}

View File

@@ -156,7 +156,7 @@ const ServerSection = () => {
// Single server - show simplified interface
<Stack gap="md">
<Group justify="space-between" align="center">
<Stack gap="xs" style={{ flex: 1 }}>
<Stack gap="xs" className={styles.flexContainer}>
<Group gap="xs">
<Text>
{servers[0].name} ({servers[0].platform} - v

View File

@@ -0,0 +1,26 @@
.webhookSelector {
margin-bottom: 0.75rem; /* 12px */
}
.alertMessage {
margin-bottom: 1rem; /* 16px */
}
.loadingField {
opacity: 0.6;
}
.selectField {
margin-top: 0.25rem; /* 4px */
}
.labelText {
font-weight: 500;
margin-bottom: 0.5rem; /* 8px */
}
.flexContainer {
display: flex;
align-items: center;
gap: 0.75rem; /* 12px */
}

View File

@@ -0,0 +1,216 @@
import { FunctionComponent, useState } from "react";
import { Alert, Button, Group, Select, Stack, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
usePlexAuthValidationQuery,
usePlexWebhookCreateMutation,
usePlexWebhookDeleteMutation,
usePlexWebhookListQuery,
} from "@/apis/hooks/plex";
import styles from "@/pages/Settings/Plex/WebhookSelector.module.scss";
export type WebhookSelectorProps = {
label: string;
description?: string;
};
const WebhookSelector: FunctionComponent<WebhookSelectorProps> = (props) => {
const { label, description } = props;
const [selectedWebhookUrl, setSelectedWebhookUrl] = useState<string>("");
// Check if user is authenticated with OAuth
const { data: authData } = usePlexAuthValidationQuery();
const isAuthenticated = Boolean(
authData?.valid && authData?.auth_method === "oauth",
);
// Fetch webhooks if authenticated
const {
data: webhooks,
isLoading,
error,
refetch,
} = usePlexWebhookListQuery({
enabled: isAuthenticated,
});
const createMutation = usePlexWebhookCreateMutation();
const deleteMutation = usePlexWebhookDeleteMutation();
// Find the Bazarr webhook
const bazarrWebhook = webhooks?.webhooks?.find((w) =>
w.url.includes("/api/webhooks/plex"),
);
// Create select data with Bazarr webhook first if it exists
const selectData =
webhooks?.webhooks
?.map((webhook) => ({
value: webhook.url,
label: webhook.url,
isBazarr: webhook.url.includes("/api/webhooks/plex"),
}))
.sort((a, b) => Number(b.isBazarr) - Number(a.isBazarr))
.map(({ value, label }) => ({ value: value, label: label })) || [];
// Determine the current value: prioritize user selection, fallback to bazarr webhook or first webhook
const currentValue =
selectedWebhookUrl ||
bazarrWebhook?.url ||
(selectData.length > 0 ? selectData[0].value : "");
const handleCreateWebhook = async () => {
try {
await createMutation.mutateAsync();
notifications.show({
title: "Success",
message: "Plex webhook created successfully",
color: "green",
});
await refetch();
} catch (error) {
notifications.show({
title: "Error",
message: "Failed to create webhook",
color: "red",
});
}
};
const handleDeleteWebhook = async (webhookUrl: string) => {
try {
await deleteMutation.mutateAsync(webhookUrl);
notifications.show({
title: "Success",
message: "Webhook deleted successfully",
color: "green",
});
// Clear selection if we deleted the currently selected webhook
if (webhookUrl === currentValue) {
setSelectedWebhookUrl("");
}
await refetch();
} catch (error) {
notifications.show({
title: "Error",
message: "Failed to delete webhook",
color: "red",
});
}
};
if (!isAuthenticated) {
return (
<Stack gap="xs" className={styles.webhookSelector}>
<Text fw={500} className={styles.labelText}>
{label}
</Text>
<Alert color="brand" variant="light" className={styles.alertMessage}>
Enable Plex OAuth above to automatically discover your webhooks.
</Alert>
</Stack>
);
}
if (isLoading) {
return (
<Stack gap="xs" className={styles.webhookSelector}>
<Select
label={label}
placeholder="Loading webhooks..."
data={[]}
disabled
className={styles.loadingField}
/>
</Stack>
);
}
if (error) {
return (
<Stack gap="xs" className={styles.webhookSelector}>
<Alert color="red" variant="light" className={styles.alertMessage}>
Failed to load webhooks:{" "}
{(error as Error)?.message || "Unknown error"}
</Alert>
</Stack>
);
}
if (selectData.length === 0) {
return (
<div className={styles.webhookSelector}>
<Stack gap="xs">
<Group justify="space-between" align="flex-end">
<div>
<Text fw={500} className={styles.labelText}>
{label}
</Text>
{description && (
<Text size="sm" c="dimmed">
{description}
</Text>
)}
</div>
<Button
onClick={handleCreateWebhook}
loading={createMutation.isPending}
size="sm"
>
ADD
</Button>
</Group>
<Alert color="gray" variant="light" className={styles.alertMessage}>
No webhooks found on your Plex server.
</Alert>
</Stack>
</div>
);
}
return (
<div className={styles.webhookSelector}>
<Stack gap="xs">
<Select
label={label}
placeholder="Select webhook..."
data={selectData}
description={
description ||
"Create or remove webhooks in Plex to trigger subtitle searches. In this list you can find your current webhooks."
}
value={currentValue}
onChange={(value) => setSelectedWebhookUrl(value || "")}
allowDeselect={false}
className={styles.selectField}
/>
<Group gap="xs">
{!bazarrWebhook && (
<Button
onClick={handleCreateWebhook}
loading={createMutation.isPending}
size="sm"
>
ADD
</Button>
)}
{currentValue && (
<Button
onClick={() => handleDeleteWebhook(currentValue)}
loading={deleteMutation.isPending}
size="sm"
variant="light"
color="brand"
>
REMOVE
</Button>
)}
</Group>
</Stack>
</div>
);
};
export default WebhookSelector;

View File

@@ -1,23 +1,16 @@
import { Box, Button, Collapse, Group, Paper, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Box, Paper } from "@mantine/core";
import {
Check,
CollapseBox,
Layout,
Message,
Number,
Section,
Text,
} from "@/pages/Settings/components";
import { plexEnabledKey } from "@/pages/Settings/keys";
import { PlexSettings } from "./PlexSettings";
import LibrarySelector from "./LibrarySelector";
import PlexSettings from "./PlexSettings";
import WebhookSelector from "./WebhookSelector";
const SettingsPlexView = () => {
const [manualConfigOpen, { toggle: manualConfigToggle }] =
useDisclosure(false);
return (
<Layout name="Interface">
<Section header="Use Plex Media Server">
@@ -26,105 +19,54 @@ const SettingsPlexView = () => {
<CollapseBox settingKey={plexEnabledKey}>
<Paper p="xl" radius="md">
<Stack gap="lg">
{/* OAuth Section - Prominent */}
<Box>
<PlexSettings />
</Box>
{/* Manual Configuration - Collapsible */}
<Box>
<Button
variant="subtle"
color="gray"
size="md"
leftSection={
manualConfigOpen ? (
<FontAwesomeIcon icon={faChevronUp} size="sm" />
) : (
<FontAwesomeIcon icon={faChevronDown} size="sm" />
)
}
onClick={manualConfigToggle}
>
Manual Configuration (Legacy)
</Button>
<Collapse in={manualConfigOpen}>
<Paper p="lg" mt="sm" radius="md" withBorder>
<Stack gap="md">
<Message>
This legacy manual configuration is not needed when using
Plex OAuth above. Use this only if OAuth is not available
or preferred.
</Message>
<Group grow>
<Text
label="Server Address"
settingKey="settings-plex-ip"
/>
<Number
label="Port"
settingKey="settings-plex-port"
defaultValue={32400}
/>
</Group>
<Text
label="API Token"
settingKey="settings-plex-apikey"
placeholder="Enter your Plex API token"
/>
<Check
label="Use SSL/HTTPS connection"
settingKey="settings-plex-ssl"
/>
<Message>
To get your API token, visit: https://app.plex.tv/web/app
Settings Account Privacy Show API Token
</Message>
</Stack>
</Paper>
</Collapse>
</Box>
</Stack>
<Box>
<PlexSettings />
</Box>
</Paper>
{/* Plex Library Configuration */}
<Section header="Movie Library">
<Text
<LibrarySelector
label="Library Name"
settingKey="settings-plex-movie_library"
libraryType="movie"
placeholder="Movies"
description="Select your movie library from Plex"
/>
<Check
label="Mark movies as recently added after downloading subtitles"
settingKey="settings-plex-set_movie_added"
/>
<Check
label="Update movie library after downloading subtitles"
label="Refresh movie metadata after downloading subtitles (recommended)"
settingKey="settings-plex-update_movie_library"
/>
</Section>
<Section header="Series Library">
<Text
<LibrarySelector
label="Library Name"
settingKey="settings-plex-series_library"
libraryType="show"
placeholder="TV Shows"
description="Select your TV show library from Plex"
/>
<Check
label="Mark episodes as recently added after downloading subtitles"
settingKey="settings-plex-set_episode_added"
/>
<Check
label="Update series library after downloading subtitles"
label="Refresh series metadata after downloading subtitles (recommended)"
settingKey="settings-plex-update_series_library"
/>
</Section>
<Section header="Automation">
<WebhookSelector
label="Webhooks"
description="Create a Bazarr webhook in Plex to automatically search for subtitles when content starts playing. Manage and remove existing webhooks for convenience."
/>
</Section>
</CollapseBox>
</Layout>
);

View File

@@ -306,6 +306,35 @@ declare namespace Plex {
device: string;
bestConnection?: ServerConnection | null;
}
interface Library {
key: string;
title: string;
type: string;
count: number;
agent: string;
scanner: string;
language: string;
uuid: string;
updatedAt: number;
createdAt: number;
}
interface WebhookResult {
success: boolean;
message: string;
webhook_url?: string;
total_webhooks?: number;
}
interface WebhookInfo {
url: string;
}
interface WebhookList {
webhooks: WebhookInfo[];
count: number;
}
}
interface SearchResultType {