tubewatch/playlist-monitor/app/services/metube_client.py

282 lines
11 KiB
Python

"""
MeTube client for REST API and WebSocket communication
"""
import asyncio
import logging
from typing import Dict, Any, Optional, Callable
import aiohttp
import socketio
from datetime import datetime
logger = logging.getLogger(__name__)
class MeTubeClient:
"""Client for communicating with MeTube service"""
def __init__(self, base_url: str, timeout: int = 30):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session: aiohttp.ClientSession | None = None
self.socket_client: socketio.AsyncClient | None = None
self._event_callbacks: Dict[str, Callable] = {}
self._connected = False
self._websocket_connected = False
async def connect(self) -> None:
"""Connect to MeTube service"""
try:
# Create HTTP session
self.session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=self.timeout),
headers={"User-Agent": "PlaylistMonitor/0.1.0"}
)
# Test HTTP connection
await self.health_check()
# Connect to WebSocket
await self._connect_websocket()
self._connected = True
logger.info(f"Successfully connected to MeTube at {self.base_url}")
except Exception as e:
logger.error(f"Failed to connect to MeTube: {e}")
raise
async def disconnect(self) -> None:
"""Disconnect from MeTube service"""
try:
# Disconnect WebSocket
if self.socket_client and self._websocket_connected:
await self.socket_client.disconnect()
self._websocket_connected = False
# Close HTTP session
if self.session:
await self.session.close()
self.session = None
self._connected = False
logger.info("Disconnected from MeTube service")
except Exception as e:
logger.error(f"Error disconnecting from MeTube: {e}")
async def health_check(self) -> bool:
"""Check if MeTube service is healthy"""
try:
if not self.session:
return False
async with self.session.get(f"{self.base_url}/info") as response:
if response.status == 200:
data = await response.json()
logger.debug(f"MeTube health check: {data}")
return True
else:
logger.warning(f"MeTube health check failed with status {response.status}")
return False
except Exception as e:
logger.error(f"MeTube health check failed: {e}")
return False
async def add_download(
self,
url: str,
quality: str = "best",
format: str = "mp4",
folder: Optional[str] = None,
custom_name_prefix: Optional[str] = None,
auto_start: bool = True,
playlist_item_limit: Optional[int] = None
) -> Dict[str, Any]:
"""Add a download to MeTube"""
try:
if not self.session:
raise RuntimeError("Not connected to MeTube")
payload = {
"url": url,
"quality": quality,
"format": format,
"auto_start": auto_start,
}
if folder:
payload["folder"] = folder
if custom_name_prefix:
payload["custom_name_prefix"] = custom_name_prefix
if playlist_item_limit:
payload["playlist_item_limit"] = playlist_item_limit
logger.debug(f"Adding download to MeTube: {payload}")
async with self.session.post(
f"{self.base_url}/add",
json=payload
) as response:
if response.status == 200:
result = await response.json()
logger.info(f"Successfully added download: {result}")
return result
else:
error_text = await response.text()
raise RuntimeError(f"Failed to add download: {response.status} - {error_text}")
except Exception as e:
logger.error(f"Error adding download to MeTube: {e}")
raise
async def delete_download(self, download_id: str) -> Dict[str, Any]:
"""Delete/cancel a download"""
try:
if not self.session:
raise RuntimeError("Not connected to MeTube")
payload = {"ids": [download_id]}
async with self.session.post(
f"{self.base_url}/delete",
json=payload
) as response:
if response.status == 200:
result = await response.json()
logger.info(f"Successfully deleted download: {download_id}")
return result
else:
error_text = await response.text()
raise RuntimeError(f"Failed to delete download: {response.status} - {error_text}")
except Exception as e:
logger.error(f"Error deleting download from MeTube: {e}")
raise
async def get_history(self) -> Dict[str, Any]:
"""Get download history from MeTube"""
try:
if not self.session:
raise RuntimeError("Not connected to MeTube")
async with self.session.get(f"{self.base_url}/history") as response:
if response.status == 200:
result = await response.json()
return result
else:
error_text = await response.text()
raise RuntimeError(f"Failed to get history: {response.status} - {error_text}")
except Exception as e:
logger.error(f"Error getting history from MeTube: {e}")
raise
async def get_download_info(self, download_id: str) -> Optional[Dict[str, Any]]:
"""Get information about a specific download"""
try:
history = await self.get_history()
# Search in completed downloads
for download in history.get("completed", []):
if download.get("id") == download_id:
return download
# Search in pending downloads
for download in history.get("pending", []):
if download.get("id") == download_id:
return download
return None
except Exception as e:
logger.error(f"Error getting download info: {e}")
return None
def register_event_callback(self, event: str, callback: Callable) -> None:
"""Register a callback for WebSocket events"""
self._event_callbacks[event] = callback
logger.debug(f"Registered callback for event: {event}")
async def _connect_websocket(self) -> None:
"""Connect to MeTube WebSocket"""
try:
self.socket_client = socketio.AsyncClient()
# Register event handlers
@self.socket_client.on("connect")
async def on_connect():
logger.info("Connected to MeTube WebSocket")
self._websocket_connected = True
@self.socket_client.on("disconnect")
async def on_disconnect():
logger.info("Disconnected from MeTube WebSocket")
self._websocket_connected = False
@self.socket_client.on("added")
async def on_added(data):
logger.debug(f"WebSocket event - added: {data}")
await self._handle_event("added", data)
@self.socket_client.on("updated")
async def on_updated(data):
logger.debug(f"WebSocket event - updated: {data}")
await self._handle_event("updated", data)
@self.socket_client.on("completed")
async def on_completed(data):
logger.debug(f"WebSocket event - completed: {data}")
await self._handle_event("completed", data)
@self.socket_client.on("canceled")
async def on_canceled(data):
logger.debug(f"WebSocket event - canceled: {data}")
await self._handle_event("canceled", data)
@self.socket_client.on("cleared")
async def on_cleared(data):
logger.debug(f"WebSocket event - cleared: {data}")
await self._handle_event("cleared", data)
# Connect to WebSocket
ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://")
await self.socket_client.connect(ws_url)
# Start background task to keep connection alive
asyncio.create_task(self._keep_websocket_alive())
except Exception as e:
logger.error(f"Failed to connect to MeTube WebSocket: {e}")
# Don't fail the entire connection if WebSocket fails
self._websocket_connected = False
async def _handle_event(self, event: str, data: Dict[str, Any]) -> None:
"""Handle WebSocket events"""
if event in self._event_callbacks:
try:
await self._event_callbacks[event](data)
except Exception as e:
logger.error(f"Error handling WebSocket event {event}: {e}")
async def _keep_websocket_alive(self) -> None:
"""Keep WebSocket connection alive"""
while self._websocket_connected and self.socket_client:
try:
await asyncio.sleep(30) # Ping every 30 seconds
if self.socket_client:
await self.socket_client.emit("ping")
except Exception as e:
logger.error(f"Error keeping WebSocket alive: {e}")
break
@property
def is_connected(self) -> bool:
"""Check if client is connected"""
return self._connected
@property
def is_websocket_connected(self) -> bool:
"""Check if WebSocket is connected"""
return self._websocket_connected