#!/usr/bin/env python3 """ FastAPI gateway that exposes simple REST endpoints and internally talks to the running SpotSpot Socket.IO server. Exposed endpoints (Phase-1 MVP): GET /search?query=...&type=track → returns SpotSpot's "search_results" payload (JSON) POST /download {"url": "https://open.spotify.com/track/...", "name": "", "artist": ""} → queues the download and immediately returns {"queued": true} GET /status → returns SpotSpot's current download history/status. The gateway assumes SpotSpot is reachable at ws://localhost:6544 . Adjust the SPOTSPOT_HOST/PORT constants if you mapped it elsewhere. """ from __future__ import annotations import asyncio import logging from typing import Any, Dict, Optional import socketio # python-socketio (AsyncClient) from fastapi import FastAPI, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel, HttpUrl SPOTSPOT_HOST = "localhost" SPOTSPOT_PORT = 6544 SOCKET_TIMEOUT = 15 # seconds to await SpotSpot reply logger = logging.getLogger("spotspot.gateway") logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") from fastapi.staticfiles import StaticFiles app = FastAPI(title="SpotSpot Gateway", version="0.1") app.mount("/", StaticFiles(directory=Path(__file__).parent/"static", html=True), name="static") # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _with_client(fn): """Utility decorator to create/connect AsyncClient and disconnect after.""" async def wrapper(*args, **kwargs): sio = socketio.AsyncClient(reconnection=False) await sio.connect(f"ws://{SPOTSPOT_HOST}:{SPOTSPOT_PORT}") try: return await fn(sio, *args, **kwargs) finally: await sio.disconnect() return wrapper @_with_client async def do_search(sio: socketio.AsyncClient, query: str, search_type: str) -> Dict[str, Any]: result: Dict[str, Any] = {} evt = asyncio.Event() @sio.on("search_results") async def on_results(data): nonlocal result result = data evt.set() @sio.on("toast") async def on_toast(data): nonlocal result result = {"error": data} evt.set() await sio.emit("search", {"query": query, "type": search_type}) try: await asyncio.wait_for(evt.wait(), timeout=SOCKET_TIMEOUT) except asyncio.TimeoutError as exc: logger.error("Timed out waiting for SpotSpot search response") raise HTTPException(status_code=504, detail="SpotSpot did not respond in time") from exc return result @_with_client async def get_status(sio: socketio.AsyncClient) -> Dict[str, Any]: result: Dict[str, Any] = {} evt = asyncio.Event() @sio.on("update_status") async def on_status(data): nonlocal result result = data evt.set() await sio.emit("get_status") try: await asyncio.wait_for(evt.wait(), timeout=SOCKET_TIMEOUT) except asyncio.TimeoutError as exc: raise HTTPException(status_code=504, detail="SpotSpot status timeout") from exc return result @_with_client async def queue_download( sio: socketio.AsyncClient, url: str, name: str = "", artist: str = "", dl_type: str = "track", ): item = {"url": str(url), "name": name, "artist": artist, "type": dl_type} await sio.emit("download", item) # Fire-and-forget – we won't wait for completion here # --------------------------------------------------------------------------- # API schemas # --------------------------------------------------------------------------- class DownloadRequest(BaseModel): url: HttpUrl name: Optional[str] = "" artist: Optional[str] = "" type: Optional[str] = "track" # --------------------------------------------------------------------------- # FastAPI routes # --------------------------------------------------------------------------- @app.get("/search") async def api_search(query: str, type: str = "track"): """Proxy search to SpotSpot and return the raw search_results payload.""" data = await do_search(query, type) return JSONResponse(content=data) @app.post("/download") async def api_download(req: DownloadRequest): await queue_download(req.url, req.name, req.artist, req.type) return {"queued": True} @app.get("/status") async def api_status(): data = await get_status() return JSONResponse(content=data) # --------------------------------------------------------------------------- # Entrypoint for `python tools/spotspot_gateway.py` # --------------------------------------------------------------------------- if __name__ == "__main__": import uvicorn uvicorn.run( "tools.spotspot_gateway:app", host="0.0.0.0", port=int(__import__("os").environ.get("GATEWAY_PORT", "6545")), reload=False, log_level="info", )