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 {}