Files
Unraid-AppOps-Gateway-Public/backend/app/connectors/plex.py
T

330 lines
15 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _PlexConfig:
url: str
token: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _safe_path(value: Any) -> str:
text = str(value or '').replace('\\', '/').rstrip('/')
parts = [part for part in text.split('/') if part]
return f'.../{parts[-1]}' if parts else ''
def _mask_ip(value: Any) -> str:
text = str(value or '')
if not text:
return ''
parts = text.split('.')
if len(parts) == 4:
return f'{parts[0]}.{parts[1]}.{parts[2]}.x'
return '[masked]'
def _safe_url() -> str:
value = settings.apis.get('PLEX_URL')
return str(value).rstrip('/') if value else ''
class PlexConnector(BaseConnector):
name = 'Plex'
def is_configured(self) -> bool:
return bool(settings.apis.get('PLEX_URL') and settings.apis.get('PLEX_TOKEN'))
def _config(self) -> _PlexConfig | None:
if not self.is_configured():
return None
return _PlexConfig(
url=str(settings.apis['PLEX_URL']).rstrip('/'),
token=str(settings.apis['PLEX_TOKEN']),
verify_tls=_as_bool(settings.apis.get('PLEX_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('PLEX_TIMEOUT_SECONDS'), 10.0),
)
def _request(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
request_params = dict(params or {})
request_params['X-Plex-Token'] = config.token
url = f'{config.url}{path if path.startswith("/") else f"/{path}"}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.get(url, headers={'Accept': 'application/json'}, params=request_params)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Plex API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
return response.json()
except ValueError:
return self._xml_to_container(response.text)
def _xml_to_container(self, text: str) -> dict[str, Any]:
try:
root = ElementTree.fromstring(text)
except ElementTree.ParseError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
container: dict[str, Any] = {'MediaContainer': dict(root.attrib)}
children = []
for child in root:
item = dict(child.attrib)
item['tag'] = child.tag
children.append(item)
if children:
container['MediaContainer']['Directory' if children[0].get('tag') == 'Directory' else 'Metadata'] = children
return container
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
container = self._media_container(self._request('/'))
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {
'status': 'connected',
'friendlyName': str(container.get('friendlyName') or ''),
'version': str(container.get('version') or 'unknown'),
'machineIdentifier': str(container.get('machineIdentifier') or ''),
}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'url': '',
'version': 'unknown',
'machineIdentifier': '',
'friendlyName': '',
'required_env_vars': ['PLEX_URL', 'PLEX_TOKEN'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Plex connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'version': str(result.get('version', 'unknown')),
'machineIdentifier': str(result.get('machineIdentifier', '')),
'friendlyName': str(result.get('friendlyName', '')),
}
def server_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
try:
container = self._media_container(self._request('/'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc))}
return {
'success': True,
'status': 'ok',
'friendlyName': str(container.get('friendlyName') or ''),
'version': str(container.get('version') or 'unknown'),
'platform': str(container.get('platform') or ''),
'updatedAt': container.get('updatedAt'),
'myPlex': container.get('myPlex'),
'machineIdentifier': str(container.get('machineIdentifier') or ''),
'libraryCount': _as_int(container.get('size')),
}
def library_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/library/sections'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Directory') if isinstance(container.get('Directory'), list) else []
items = [self._map_library(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'libraries_ok', 'total': len(items), 'items': items, 'libraries': items}
def active_sessions(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/status/sessions'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Metadata') if isinstance(container.get('Metadata'), list) else []
items = [self._map_session(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'sessions_ok', 'total': len(items), 'items': items, 'sessions': items}
def recent_media(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/library/recentlyAdded', {'X-Plex-Container-Size': 25}))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Metadata') if isinstance(container.get('Metadata'), list) else []
items = [self._map_recent(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'recent_media_ok', 'total': len(items), 'items': items}
def stream_summary(self) -> dict[str, Any]:
sessions = self.active_sessions()
items = sessions.get('items', []) if isinstance(sessions, dict) else []
return {
'success': bool(sessions.get('success')),
'status': sessions.get('status', 'unreachable'),
'activeStreams': len(items),
'total': len(items),
'directPlay': sum(1 for item in items if item.get('transcodeDecision') == 'directplay'),
'directStream': sum(1 for item in items if item.get('transcodeDecision') == 'directstream'),
'transcoding': sum(1 for item in items if item.get('transcodeDecision') == 'transcode'),
'bandwidth': sum(_as_int(item.get('bandwidth')) for item in items),
'users': len({item.get('user') for item in items if item.get('user')}),
}
def scan_library(self, library_id: str) -> dict[str, Any]:
clean_id = library_id.strip()
if not clean_id:
return {'success': False, 'status': 'error', 'message': 'library_id_required'}
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {'libraryId': clean_id}}
try:
self._request(f'/library/sections/{clean_id}/refresh')
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'data': {'libraryId': clean_id}}
return {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': {'libraryId': clean_id}}
def scan_all_libraries(self) -> dict[str, Any]:
libraries = self.library_status()
if not libraries.get('success'):
return {'success': False, 'status': libraries.get('status', 'error'), 'message': libraries.get('message', 'library_status_failed')}
accepted: list[str] = []
for item in libraries.get('items', []):
library_id = str(item.get('id') or '')
if library_id and self.scan_library(library_id).get('success'):
accepted.append(library_id)
return {'success': True, 'status': 'ok', 'message': 'scan_all_started', 'data': {'libraryIds': accepted}}
def _map_library(self, raw: dict[str, Any]) -> dict[str, Any]:
library_id = str(raw.get('key') or raw.get('id') or '')
count = 0
if library_id:
try:
count = _as_int(self._media_container(self._request(f'/library/sections/{library_id}/all')).get('size'))
except RuntimeError:
count = 0
locations_raw = raw.get('Location') if isinstance(raw.get('Location'), list) else []
return {
'id': library_id,
'key': library_id,
'title': str(raw.get('title') or ''),
'type': str(raw.get('type') or ''),
'agent': str(raw.get('agent') or ''),
'scanner': str(raw.get('scanner') or ''),
'count': count,
'itemCount': count,
'refreshStatus': raw.get('refreshing'),
'locations': [_safe_path(item.get('path')) for item in locations_raw if isinstance(item, dict)],
}
@staticmethod
def _map_session(raw: dict[str, Any]) -> dict[str, Any]:
user = raw.get('User') if isinstance(raw.get('User'), dict) else {}
player = raw.get('Player') if isinstance(raw.get('Player'), dict) else {}
session = raw.get('Session') if isinstance(raw.get('Session'), dict) else {}
transcode = raw.get('TranscodeSession') if isinstance(raw.get('TranscodeSession'), dict) else {}
media = raw.get('Media')[0] if isinstance(raw.get('Media'), list) and raw.get('Media') else {}
decision = str(transcode.get('videoDecision') or media.get('videoDecision') or (media.get('Part', [{}])[0].get('decision') if isinstance(media.get('Part'), list) and media.get('Part') else '') or 'directplay').lower()
duration = _as_int(raw.get('duration'))
offset = _as_int(raw.get('viewOffset'))
return {
'sessionKey': str(raw.get('sessionKey') or ''),
'id': str(raw.get('sessionKey') or ''),
'user': str(user.get('title') or 'unknown'),
'title': str(raw.get('grandparentTitle') or raw.get('title') or 'unknown'),
'libraryType': str(raw.get('type') or ''),
'type': str(raw.get('type') or ''),
'player': str(player.get('title') or ''),
'state': str(player.get('state') or ''),
'product': str(player.get('product') or ''),
'platform': str(player.get('platform') or ''),
'ip': _mask_ip(player.get('address')),
'ipAddress': _mask_ip(player.get('address')),
'transcodeDecision': decision,
'videoDecision': str(transcode.get('videoDecision') or media.get('videoDecision') or decision),
'audioDecision': str(transcode.get('audioDecision') or media.get('audioDecision') or ''),
'bandwidth': _as_int(session.get('bandwidth')),
'progress': round((offset / duration) * 100, 1) if duration else 0,
}
@staticmethod
def _map_recent(raw: dict[str, Any]) -> dict[str, Any]:
title = str(raw.get('title') or 'unknown')
if raw.get('grandparentTitle'):
title = f"{raw.get('grandparentTitle')} - {title}"
return {
'id': str(raw.get('ratingKey') or raw.get('key') or raw.get('title') or ''),
'title': title,
'type': str(raw.get('type') or ''),
'year': raw.get('year'),
'library': str(raw.get('librarySectionTitle') or ''),
'addedAt': raw.get('addedAt'),
'thumb': raw.get('thumb'),
'summary': raw.get('summary'),
}
@staticmethod
def _media_container(payload: dict[str, Any]) -> dict[str, Any]:
container = payload.get('MediaContainer') if isinstance(payload, dict) else {}
return container if isinstance(container, dict) else {}