diff --git a/README.md b/README.md index 40a0dc6..bb502e0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,61 @@ # Pythagoras Local-run server which acts as a proxy between the moderation server, presentation client and subtitle scripts. This is a single-purpose ugly implementation with its sole purpose being the Richard Stallman lecture at TUL. + +## Installation + +Clone the repository: + +`git clone git@gordon.zumepro.cz:zumepro/pythagoras.git` + +Install the dependencies: + +`cd pythagoras` + +`python -m venv venv` + +`source venv/bin/activate` + +`pip install -r requirements.txt` + +## Running the app + +Simply run the main Python file to start the server: + + `python main.py` + +## Usage + +To push new subtitles onto the server, use the `/subtitles` endpoint, for example like this: + +` +curl -X POST http://localhost:8000/subtitles -H "Content-Type: text/plain" -d 'I love pushing subtitles to servers!' +` + +To control the server using commands, use the `/control` endpoint, for example like this: + +- Poll the peehaitchpea server for the latest message to display: + +` +curl -X POST http://localhost:8000/control -H "Content-Type: application/json" -d '{"command": "getselectedmessage"}' +` + +- Change the state of auto-polling: + +` +curl -X POST http://localhost:8000/control -H "Content-Type: application/json" -d '{"command": "setautopolling", "state": false}' +` + +` +curl -X POST http://localhost:8000/control -H "Content-Type: application/json" -d '{"command": "setautopolling", "state": true}' +` + +- Set the polling rate to a value (in seconds): + +` +curl -X POST http://localhost:8000/control -H "Content-Type: application/json" -d '{"command": "autopollingrate", "rate": 10}' +` + +## TODO + +- Create a command for playing a video (the TED talk RMS likes to play at the beginning of his lecture) diff --git a/main.py b/main.py new file mode 100644 index 0000000..5c330f9 --- /dev/null +++ b/main.py @@ -0,0 +1,163 @@ +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, BackgroundTasks +from fastapi.responses import JSONResponse +import logging +import uvicorn +from typing import Dict, List, Any +import json +import httpx +import asyncio + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger("pythagoras") + +# Configure FastAPI app and initial values for variables +app = FastAPI(title="Pythagoras", description="A proxy service handling HTTP and WebSocket connections") +app.state.auto_polling = False +app.state.polling_rate = 5 + +# Store for connected websocket clients +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + logger.info(f"WebSocket client connected. Total connections: {len(self.active_connections)}") + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + logger.info(f"WebSocket client disconnected. Total connections: {len(self.active_connections)}") + + async def broadcast(self, message: str): + for connection in self.active_connections: + await connection.send_text(message) + +manager = ConnectionManager() + +# Endpoints +@app.post("/control") +async def control_endpoint(request: Request): + """Endpoint for control data.""" + try: + data = await request.json() + logger.info(f"Received control data: {data}") + + if data['command'] == "getselectedmessage": + message = await fetch_selected_message() + await process_selected_message(message) + logger.info(f"Received new selected message initiated from control: {message}") + + elif data['command'] == "setautopolling" and 'state' in data: + new_state = data['state'] + app.state.auto_polling = new_state + logger.info(f"Polling command issued, changing auto-polling to {new_state}") + + elif data['command'] == "autopollingrate" and 'rate' in data: + new_rate = data['rate'] + app.state.polling_rate = new_rate + logger.info(f"Auto-polling rate change requested: {new_rate} seconds") + + + return JSONResponse( + status_code=200, + content={"status": "success", "message": "Control data received"} + ) + except Exception as e: + logger.error(f"Error processing control data: {str(e)}") + return JSONResponse( + status_code=400, + content={"status": "error", "message": f"Failed to process request."} + ) + +@app.post("/subtitles") +async def subtitles_endpoint(request: Request): + """Endpoint for subtitle data.""" + try: + text_content = await request.body() + subtitle_text = text_content.decode("utf-8") + logger.info(f"Received subtitle text: {subtitle_text}") + + if manager.active_connections: + await manager.broadcast(json.dumps({"type": "subtitle", "text": subtitle_text})) + + return JSONResponse( + status_code=200, + content={"status": "success", "message": "Subtitle text received"} + ) + except Exception as e: + logger.error(f"Error processing subtitle data: {str(e)}") + return JSONResponse( + status_code=400, + content={"status": "error", "message": f"Failed to process request."} + ) + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint for real-time communication.""" + await manager.connect(websocket) + try: + while True: + data = await websocket.receive_text() + logger.info(f"Received message from WebSocket: {data}") + except WebSocketDisconnect: + manager.disconnect(websocket) + except Exception as e: + logger.error(f"WebSocket error: {str(e)}") + manager.disconnect(websocket) + + + +# Functions +async def fetch_selected_message(): + """ + Fetches a selected message from the specified endpoint. + Returns the message as a string or None if no message is available. + """ + try: + async with httpx.AsyncClient() as client: + response = await client.get("http://localhost:9000/api.php?cmd=getselectedmessage") + if response.status_code == 200: + message = response.text.strip() + if message: + logger.info(f"Received selected message: {message}") + return message + else: + return None + else: + logger.warning(f"Failed to fetch message. Status code: {response.status_code}") + return None + except Exception as e: + logger.error(f"Error fetching selected message: {str(e)}") + return None + +async def process_selected_message(message: str): + """ + Processes the selected message. + """ + logger.info(f"Processing message: {message}") + if manager.active_connections: + await manager.broadcast(json.dumps({"type": "selectedmessage", "message": message})) + +async def periodic_message_check(): + """Periodically checks for new messages.""" + while True: + if app.state.auto_polling is True: + logger.info("Automatically polling message...") + message = await fetch_selected_message() + await process_selected_message(message) + await asyncio.sleep(app.state.polling_rate) + +# Startup tasks setup +@app.on_event("startup") +async def startup_event(): + """Start background tasks when the application starts.""" + asyncio.create_task(periodic_message_check()) + +# Main function and app entry point +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..89c5977 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.95.0 +uvicorn>=0.22.0 +pydantic>=2.0.0 +websockets>=10.4 +httpx>=0.24.0 +python-dotenv>=1.0.0