Files
LEDMatrix/src/ytm_client.py

194 lines
8.4 KiB
Python

import socketio
import logging
import json
import os
import time
import threading
# Ensure application-level logging is configured (as it is)
# logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Reduce verbosity of socketio and engineio libraries
logging.getLogger('socketio.client').setLevel(logging.WARNING)
logging.getLogger('socketio.server').setLevel(logging.WARNING)
logging.getLogger('engineio.client').setLevel(logging.WARNING)
logging.getLogger('engineio.server').setLevel(logging.WARNING)
# Define paths relative to this file's location
CONFIG_DIR = os.path.join(os.path.dirname(__file__), '..', 'config')
CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.json')
# Resolve to an absolute path
CONFIG_PATH = os.path.abspath(CONFIG_PATH)
# Path for the separate YTM authentication token file
YTM_AUTH_CONFIG_PATH = os.path.join(CONFIG_DIR, 'ytm_auth.json')
YTM_AUTH_CONFIG_PATH = os.path.abspath(YTM_AUTH_CONFIG_PATH)
class YTMClient:
def __init__(self, update_callback=None):
self.base_url = None
self.ytm_token = None
self.load_config() # Loads URL and token
self.sio = socketio.Client(logger=False, engineio_logger=False)
self.last_known_track_data = None
self.is_connected = False
self._data_lock = threading.Lock()
self._connection_event = threading.Event()
self.external_update_callback = update_callback
@self.sio.event(namespace='/api/v1/realtime')
def connect():
logging.info(f"Successfully connected to YTM Companion Socket.IO server at {self.base_url} on namespace /api/v1/realtime")
self.is_connected = True
self._connection_event.set()
@self.sio.event(namespace='/api/v1/realtime')
def connect_error(data):
logging.error(f"YTM Companion Socket.IO connection failed for namespace /api/v1/realtime: {data}")
self.is_connected = False
self._connection_event.set()
@self.sio.event(namespace='/api/v1/realtime')
def disconnect():
logging.info(f"Disconnected from YTM Companion Socket.IO server at {self.base_url} on namespace /api/v1/realtime")
self.is_connected = False
@self.sio.on('state-update', namespace='/api/v1/realtime')
def on_state_update(data):
logging.debug(f"Received state update from YTM Companion on /api/v1/realtime: {data}")
new_data_received = False
with self._data_lock:
if self.last_known_track_data != data:
self.last_known_track_data = data
new_data_received = True
if new_data_received and self.external_update_callback:
try:
self.external_update_callback(data)
except Exception as cb_ex:
logging.error(f"Error executing YTMClient external_update_callback: {cb_ex}")
def load_config(self):
default_url = "http://localhost:9863"
self.base_url = default_url # Start with default
# Load base_url from main config.json
if not os.path.exists(CONFIG_PATH):
logging.warning(f"Main config file not found at {CONFIG_PATH}. Using default YTM URL: {self.base_url}")
else:
try:
with open(CONFIG_PATH, 'r') as f:
loaded_config = json.load(f)
music_config = loaded_config.get("music", {})
self.base_url = music_config.get("YTM_COMPANION_URL", default_url)
if not self.base_url:
logging.warning("YTM_COMPANION_URL missing or empty in config.json music section, using default.")
self.base_url = default_url
except json.JSONDecodeError:
logging.error(f"Error decoding JSON from main config {CONFIG_PATH}. Using default YTM URL.")
except Exception as e:
logging.error(f"Error loading YTM_COMPANION_URL from main config {CONFIG_PATH}: {e}. Using default YTM URL.")
logging.info(f"YTM Companion URL set to: {self.base_url}")
if self.base_url and self.base_url.startswith("ws://"):
self.base_url = "http://" + self.base_url[5:]
elif self.base_url and self.base_url.startswith("wss://"):
self.base_url = "https://" + self.base_url[6:]
# Load ytm_token from ytm_auth.json
self.ytm_token = None # Reset token before trying to load
if os.path.exists(YTM_AUTH_CONFIG_PATH):
try:
with open(YTM_AUTH_CONFIG_PATH, 'r') as f:
auth_data = json.load(f)
self.ytm_token = auth_data.get("YTM_COMPANION_TOKEN")
if self.ytm_token:
logging.info(f"YTM Companion token loaded from {YTM_AUTH_CONFIG_PATH}.")
else:
logging.warning(f"YTM_COMPANION_TOKEN not found in {YTM_AUTH_CONFIG_PATH}. YTM features will be disabled until token is present.")
except json.JSONDecodeError:
logging.error(f"Error decoding JSON from YTM auth file {YTM_AUTH_CONFIG_PATH}. YTM features will be disabled.")
except Exception as e:
logging.error(f"Error loading YTM auth config {YTM_AUTH_CONFIG_PATH}: {e}. YTM features will be disabled.")
else:
logging.warning(f"YTM auth file not found at {YTM_AUTH_CONFIG_PATH}. Run the authentication script to generate it. YTM features will be disabled.")
def connect_client(self, timeout=10):
if not self.ytm_token:
logging.warning("No YTM token loaded. Cannot connect to Socket.IO. Run authentication script.")
self.is_connected = False
return False
if self.is_connected:
logging.debug("YTM client already connected.")
return True
logging.info(f"Attempting to connect to YTM Socket.IO server: {self.base_url} on namespace /api/v1/realtime")
auth_payload = {"token": self.ytm_token}
try:
self._connection_event.clear()
self.sio.connect(
self.base_url,
transports=['websocket'],
wait_timeout=timeout,
namespaces=['/api/v1/realtime'],
auth=auth_payload
)
event_wait_timeout = timeout + 5
if not self._connection_event.wait(timeout=event_wait_timeout):
logging.warning(f"YTM Socket.IO connection event not received within {event_wait_timeout}s (connect timeout was {timeout}s).")
self.is_connected = False
return False
logging.info(f"YTM Socket.IO connection successful: {self.is_connected}")
return self.is_connected
except socketio.exceptions.ConnectionError as e:
logging.error(f"YTM Socket.IO connection error: {e}")
self.is_connected = False
return False
except Exception as e:
logging.error(f"Unexpected error during YTM Socket.IO connection: {e}")
self.is_connected = False
return False
def is_available(self):
if not self.ytm_token:
return False
return self.is_connected
def get_current_track(self):
if not self.is_connected:
return None
with self._data_lock:
if self.last_known_track_data:
return self.last_known_track_data
else:
return None
def disconnect_client(self):
if self.is_connected:
self.sio.disconnect()
logging.info("YTM Socket.IO client disconnected.")
self.is_connected = False
else:
logging.debug("YTM Socket.IO client already disconnected or not connected.")
# Example Usage (for testing - needs to be adapted for Socket.IO async nature)
# if __name__ == '__main__':
# client = YTMClient()
# if client.connect_client():
# print("YTM Server is available (Socket.IO).")
# try:
# for _ in range(10): # Poll for a few seconds
# track = client.get_current_track()
# if track:
# print(json.dumps(track, indent=2))
# else:
# print("No track currently playing or error fetching (Socket.IO).")
# time.sleep(2)
# finally:
# client.disconnect_client()
# else:
# print(f"YTM Server not available at {client.base_url} (Socket.IO). Is YTMD running with companion server enabled and token generated?")