Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,329 @@
|
||||
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 {}
|
||||
Reference in new issue
Block a user