mirror of
https://github.com/morpheus65535/bazarr.git
synced 2025-12-23 23:59:04 -05:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,3 +5,10 @@
|
||||
.actionButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.authAlert {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
20
frontend/src/pages/Settings/Plex/LibrarySelector.module.scss
Normal file
20
frontend/src/pages/Settings/Plex/LibrarySelector.module.scss
Normal 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 */
|
||||
}
|
||||
114
frontend/src/pages/Settings/Plex/LibrarySelector.tsx
Normal file
114
frontend/src/pages/Settings/Plex/LibrarySelector.tsx
Normal 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;
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
26
frontend/src/pages/Settings/Plex/WebhookSelector.module.scss
Normal file
26
frontend/src/pages/Settings/Plex/WebhookSelector.module.scss
Normal 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 */
|
||||
}
|
||||
216
frontend/src/pages/Settings/Plex/WebhookSelector.tsx
Normal file
216
frontend/src/pages/Settings/Plex/WebhookSelector.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
29
frontend/src/types/api.d.ts
vendored
29
frontend/src/types/api.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user