""" 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