330 lines
15 KiB
Python
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 {}
|