Implement WebSocket support for real-time radio updates and enhance LRC fetching logic

This commit is contained in:
2025-09-26 11:36:13 -04:00
parent 9b32a8f984
commit 65e0d3ae7a
3 changed files with 270 additions and 12 deletions

View File

@@ -2,6 +2,9 @@ import logging
import traceback
import time
import random
import json
import asyncio
from typing import Dict, Set
from .constructors import (
ValidRadioNextRequest,
ValidRadioReshuffleRequest,
@@ -13,6 +16,8 @@ from .constructors import (
Station
)
from utils import radio_util
from utils.sr_wrapper import SRUtil
from lyric_search.sources.lrclib import LRCLib
from typing import Optional
from fastapi import (
FastAPI,
@@ -20,7 +25,9 @@ from fastapi import (
Request,
Response,
HTTPException,
Depends)
Depends,
WebSocket,
WebSocketDisconnect)
from fastapi_throttle import RateLimiter
from fastapi.responses import RedirectResponse, JSONResponse, FileResponse
from auth.deps import get_current_user
@@ -34,7 +41,12 @@ class Radio(FastAPI):
self.constants = constants
self.loop = loop
self.radio_util = radio_util.RadioUtil(self.constants, self.loop)
self.sr_util = SRUtil()
self.lrclib = LRCLib()
self.lrc_cache: Dict[str, Optional[str]] = {}
self.playlists_loaded: bool = False
# WebSocket connection management
self.active_connections: Dict[str, Set[WebSocket]] = {}
self.endpoints: dict = {
"radio/np": self.radio_now_playing,
"radio/request": self.radio_request,
@@ -58,6 +70,13 @@ class Radio(FastAPI):
RateLimiter(times=25, seconds=2))] if not endpoint == "radio/np" else None,
)
# Add WebSocket route
async def websocket_route_handler(websocket: WebSocket):
station = websocket.path_params.get("station", "main")
await self.websocket_endpoint_handler(websocket, station)
app.add_websocket_route("/radio/ws/{station}", websocket_route_handler)
app.add_event_handler("startup", self.on_start)
async def on_start(self) -> None:
@@ -406,6 +425,8 @@ class Radio(FastAPI):
next["end"] = time_ends
try:
background_tasks.add_task(self.radio_util.webhook_song_change, next, data.station)
# Broadcast track change to WebSocket clients
background_tasks.add_task(self.broadcast_track_change, data.station, next.copy())
except Exception as e:
logging.info("radio_get_next Exception: %s", str(e))
traceback.print_exc()
@@ -464,7 +485,7 @@ class Radio(FastAPI):
return JSONResponse(content={"result": search})
def radio_typeahead(
self, data: ValidRadioTypeaheadRequest, request: Request, user=Depends(get_current_user)
self, data: ValidRadioTypeaheadRequest, request: Request
) -> JSONResponse:
"""
Handle typeahead queries for the radio.
@@ -472,13 +493,13 @@ class Radio(FastAPI):
Parameters:
- **data** (ValidRadioTypeaheadRequest): Contains the typeahead query.
- **request** (Request): The HTTP request object.
- **user**: Current authenticated user.
# - **user**: Current authenticated user.
Returns:
- **JSONResponse**: Contains the typeahead results.
"""
if "dj" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions")
# if "dj" not in user.get("roles", []):
# raise HTTPException(status_code=403, detail="Insufficient permissions")
if not isinstance(data.query, str):
return JSONResponse(
@@ -492,3 +513,186 @@ class Radio(FastAPI):
if not typeahead:
return JSONResponse(content=[])
return JSONResponse(content=typeahead)
async def websocket_endpoint_handler(self, websocket: WebSocket, station: str):
"""
WebSocket endpoint for real-time radio updates.
Clients can connect to /radio/ws/{station} to receive:
- Current track info on connect
- Real-time updates when tracks change
Parameters:
- **websocket** (WebSocket): The WebSocket connection
- **station** (str): The radio station name
"""
await websocket.accept()
# Initialize connections dict for this station if not exists
if station not in self.active_connections:
self.active_connections[station] = set()
# Add this connection to the station's connection set
self.active_connections[station].add(websocket)
try:
# Send current track info immediately on connect
current_track = await self._get_now_playing_data(station)
await websocket.send_text(json.dumps(current_track))
# Send LRC asynchronously
asyncio.create_task(self._send_lrc_to_client(websocket, station, current_track))
# Keep connection alive and handle incoming messages
while True:
try:
# Wait for messages (optional - could be used for client commands)
data = await websocket.receive_text()
# For now, just echo back a confirmation
await websocket.send_text(json.dumps({"type": "ack", "data": data}))
except WebSocketDisconnect:
break
except WebSocketDisconnect:
pass
finally:
# Remove connection when client disconnects
if station in self.active_connections:
self.active_connections[station].discard(websocket)
# Clean up empty station sets
if not self.active_connections[station]:
del self.active_connections[station]
async def _get_now_playing_data(self, station: str) -> dict:
"""
Get now playing data for a specific station.
Parameters:
- **station** (str): Station name
Returns:
- **dict**: Current track information
"""
ret_obj: dict = {**self.radio_util.now_playing.get(station, {})}
ret_obj["station"] = station
try:
if "start" in ret_obj:
ret_obj["elapsed"] = int(time.time()) - ret_obj["start"]
else:
ret_obj["elapsed"] = 0
except KeyError:
ret_obj["elapsed"] = 0
# Remove sensitive file path info
ret_obj.pop("file_path", None)
return ret_obj
async def broadcast_track_change(self, station: str, track_data: dict):
"""
Broadcast track change to all connected WebSocket clients for a station.
Parameters:
- **station** (str): Station name
- **track_data** (dict): New track information
"""
if station not in self.active_connections:
return
# Create broadcast message
broadcast_data = {
"type": "track_change",
"data": track_data
}
# Send to all connected clients for this station
disconnected_clients = set()
for websocket in self.active_connections[station]:
try:
await websocket.send_text(json.dumps(broadcast_data))
except Exception as e:
logging.warning(f"Failed to send WebSocket message: {e}")
disconnected_clients.add(websocket)
# Remove failed connections
for websocket in disconnected_clients:
self.active_connections[station].discard(websocket)
# Broadcast LRC asynchronously
asyncio.create_task(self._broadcast_lrc(station, track_data))
async def _send_lrc_to_client(self, websocket: WebSocket, station: str, track_data: dict):
"""Send LRC data to a specific client asynchronously."""
logging.info(f"Sending LRC to client for station {station}")
logging.info(f"Track data: {track_data}")
try:
artist = track_data.get("artist")
title = track_data.get("song") # Changed from "title" to "song"
duration = track_data.get("duration")
if artist and title:
logging.info(f"Fetching LRC for {artist} - {title} (duration: {duration})")
lrc = await self.sr_util.get_lrc_by_artist_song(
artist, title, duration=duration
)
if not lrc:
logging.info(f"No LRC from SR, trying LRCLib for {artist} - {title}")
lrclib_result = await self.lrclib.search(artist, title, plain=False)
if lrclib_result and lrclib_result.lyrics and isinstance(lrclib_result.lyrics, str):
lrc = lrclib_result.lyrics
logging.info("LRC found via LRCLib fallback")
self.lrc_cache[station] = lrc
logging.info(f"LRC fetched: {lrc is not None}")
if lrc:
lrc_data = {
"type": "lrc",
"data": lrc
}
await websocket.send_text(json.dumps(lrc_data))
logging.info("LRC sent to client")
except Exception as e:
logging.error(f"Failed to send LRC to client: {e}")
async def _broadcast_lrc(self, station: str, track_data: dict):
"""Broadcast LRC data to all connected clients for a station asynchronously."""
if station not in self.active_connections:
return
try:
artist = track_data.get("artist")
title = track_data.get("song") # Changed from "title" to "song"
duration = track_data.get("duration")
if artist and title:
logging.info(f"Broadcasting LRC fetch for {artist} - {title} (duration: {duration})")
lrc = await self.sr_util.get_lrc_by_artist_song(
artist, title, duration=duration
)
if not lrc:
logging.info(f"No LRC from SR, trying LRCLib for {artist} - {title}")
lrclib_result = await self.lrclib.search(artist, title, plain=False)
if lrclib_result and lrclib_result.lyrics and isinstance(lrclib_result.lyrics, str):
lrc = lrclib_result.lyrics
logging.info("LRC found via LRCLib fallback")
self.lrc_cache[station] = lrc
logging.info(f"LRC fetched for broadcast: {lrc is not None}")
if lrc:
lrc_data = {
"type": "lrc",
"data": lrc
}
# Send to all connected clients
disconnected_clients = set()
for websocket in self.active_connections[station]:
try:
await websocket.send_text(json.dumps(lrc_data))
except Exception as e:
logging.warning(f"Failed to send LRC to client: {e}")
disconnected_clients.add(websocket)
# Remove failed connections
for websocket in disconnected_clients:
self.active_connections[station].discard(websocket)
logging.info("LRC broadcasted to clients")
except Exception as e:
logging.error(f"Failed to broadcast LRC: {e}")

View File

@@ -4,6 +4,7 @@ import time
import datetime
import os
import random
import asyncio
from uuid import uuid4 as uuid
from typing import Union, Optional, Iterable
from aiohttp import ClientSession, ClientTimeout
@@ -478,10 +479,11 @@ class RadioUtil:
playlist, len(self.active_playlist[playlist]),
)
"""Loading Complete"""
logging.info(f"Skipping: {playlist}")
await self._ls_skip(playlist) # Request skip from LS to bring streams current
"""Loading Complete"""
# Request skip from LS to bring streams current
for playlist in self.playlists:
logging.info("Skipping: %s", playlist)
await self._ls_skip(playlist)
self.playlists_loaded = True
except Exception as e:
logging.info("Playlist load failed: %s", str(e))

View File

@@ -22,12 +22,12 @@ class MetadataFetchError(Exception):
# Suppress all logging output from this module and its children
for name in [__name__, "utils.sr_wrapper"]:
logger = logging.getLogger(name)
logger.setLevel(logging.CRITICAL)
logger.setLevel(logging.INFO) # Temporarily set to INFO for debugging LRC
logger.propagate = False
for handler in logger.handlers:
handler.setLevel(logging.CRITICAL)
handler.setLevel(logging.INFO)
# Also set the root logger to CRITICAL as a last resort (may affect global logging)
logging.getLogger().setLevel(logging.CRITICAL)
# logging.getLogger().setLevel(logging.CRITICAL)
load_dotenv()
@@ -746,3 +746,55 @@ class SRUtil:
except Exception as e:
logging.critical("Error: %s", str(e))
return False
async def get_lrc_by_track_id(self, track_id: int) -> Optional[str]:
"""Get LRC lyrics by track ID."""
logging.info(f"SR: Fetching metadata for track ID {track_id}")
metadata = await self.get_metadata_by_track_id(track_id)
lrc = metadata.get('lyrics') if metadata else None
logging.info(f"SR: LRC {'found' if lrc else 'not found'}")
return lrc
async def get_lrc_by_artist_song(
self, artist: str, song: str, album: Optional[str] = None, duration: Optional[int] = None
) -> Optional[str]:
"""Get LRC lyrics by artist and song, optionally filtering by album and duration."""
logging.info(f"SR: Searching tracks for {artist} - {song}")
tracks = await self.get_tracks_by_artist_song(artist, song)
logging.info(f"SR: Found {len(tracks) if tracks else 0} tracks")
if not tracks:
return None
# Filter by album if provided
if album:
tracks = [
t for t in tracks
if t.get('album', {}).get('title', '').lower() == album.lower()
]
if not tracks:
return None
# If duration provided, select the track with closest duration match
if duration is not None:
tracks_with_diff = [
(t, abs(t.get('duration', 0) - duration)) for t in tracks
]
tracks_with_diff.sort(key=lambda x: x[1])
best_track, min_diff = tracks_with_diff[0]
logging.info(f"SR: Best match duration diff: {min_diff}s")
# If the closest match is more than 5 seconds off, consider no match
if min_diff > 5:
logging.info("SR: Duration diff too large, no match")
return None
else:
best_track = tracks[0]
track_id = best_track.get('id')
logging.info(f"SR: Using track ID {track_id}")
if not track_id:
return None
return await self.get_lrc_by_track_id(track_id)