2025-02-09 20:34:11 -05:00
|
|
|
import logging
|
|
|
|
import traceback
|
|
|
|
import time
|
2025-02-10 20:29:57 -05:00
|
|
|
import random
|
2025-09-26 11:36:13 -04:00
|
|
|
import json
|
|
|
|
import asyncio
|
|
|
|
from typing import Dict, Set
|
2025-04-17 07:28:05 -04:00
|
|
|
from .constructors import (
|
|
|
|
ValidRadioNextRequest,
|
|
|
|
ValidRadioReshuffleRequest,
|
|
|
|
ValidRadioQueueShiftRequest,
|
|
|
|
ValidRadioQueueRemovalRequest,
|
|
|
|
ValidRadioSongRequest,
|
|
|
|
ValidRadioTypeaheadRequest,
|
2025-04-26 12:01:45 -04:00
|
|
|
ValidRadioQueueRequest,
|
2025-08-21 15:35:10 -04:00
|
|
|
Station
|
2025-04-17 07:28:05 -04:00
|
|
|
)
|
2025-04-22 09:18:15 -04:00
|
|
|
from utils import radio_util
|
2025-09-26 11:36:13 -04:00
|
|
|
from utils.sr_wrapper import SRUtil
|
|
|
|
from lyric_search.sources.lrclib import LRCLib
|
2025-02-15 21:09:33 -05:00
|
|
|
from typing import Optional
|
2025-07-01 11:38:38 -04:00
|
|
|
from fastapi import (
|
|
|
|
FastAPI,
|
|
|
|
BackgroundTasks,
|
|
|
|
Request,
|
|
|
|
Response,
|
|
|
|
HTTPException,
|
2025-09-26 11:36:13 -04:00
|
|
|
Depends,
|
|
|
|
WebSocket,
|
|
|
|
WebSocketDisconnect)
|
2025-07-01 11:38:38 -04:00
|
|
|
from fastapi_throttle import RateLimiter
|
2025-09-22 11:08:48 -04:00
|
|
|
from fastapi.responses import RedirectResponse, JSONResponse, FileResponse
|
2025-09-24 16:30:54 -04:00
|
|
|
from auth.deps import get_current_user
|
2025-09-27 09:17:24 -04:00
|
|
|
from collections import defaultdict
|
2025-02-11 20:49:14 -05:00
|
|
|
|
2025-02-09 20:34:11 -05:00
|
|
|
class Radio(FastAPI):
|
|
|
|
"""Radio Endpoints"""
|
2025-04-17 07:28:05 -04:00
|
|
|
|
2025-04-26 19:47:12 -04:00
|
|
|
def __init__(self, app: FastAPI, my_util, constants, loop) -> None:
|
2025-09-27 09:17:24 -04:00
|
|
|
# Initialize broadcast locks to prevent duplicate events (will be set in on_start)
|
|
|
|
self.broadcast_locks = {}
|
2025-02-15 21:09:33 -05:00
|
|
|
self.app: FastAPI = app
|
2025-02-09 20:34:11 -05:00
|
|
|
self.util = my_util
|
|
|
|
self.constants = constants
|
2025-04-26 19:47:12 -04:00
|
|
|
self.loop = loop
|
|
|
|
self.radio_util = radio_util.RadioUtil(self.constants, self.loop)
|
2025-09-26 11:36:13 -04:00
|
|
|
self.sr_util = SRUtil()
|
|
|
|
self.lrclib = LRCLib()
|
|
|
|
self.lrc_cache: Dict[str, Optional[str]] = {}
|
2025-09-27 09:17:24 -04:00
|
|
|
self.lrc_cache_locks = {}
|
2025-07-17 06:55:16 -04:00
|
|
|
self.playlists_loaded: bool = False
|
2025-09-26 11:36:13 -04:00
|
|
|
# WebSocket connection management
|
|
|
|
self.active_connections: Dict[str, Set[WebSocket]] = {}
|
2025-09-27 09:17:24 -04:00
|
|
|
# Initialize broadcast locks to prevent duplicate events
|
|
|
|
self.broadcast_locks = defaultdict(asyncio.Lock)
|
2025-02-11 20:01:07 -05:00
|
|
|
self.endpoints: dict = {
|
2025-02-09 20:34:11 -05:00
|
|
|
"radio/np": self.radio_now_playing,
|
|
|
|
"radio/request": self.radio_request,
|
2025-02-16 13:54:28 -05:00
|
|
|
"radio/typeahead": self.radio_typeahead,
|
2025-02-09 20:34:11 -05:00
|
|
|
"radio/get_queue": self.radio_get_queue,
|
|
|
|
"radio/skip": self.radio_skip,
|
2025-02-10 20:29:57 -05:00
|
|
|
"radio/queue_shift": self.radio_queue_shift,
|
|
|
|
"radio/reshuffle": self.radio_reshuffle,
|
|
|
|
"radio/queue_remove": self.radio_queue_remove,
|
2025-04-17 07:28:05 -04:00
|
|
|
"radio/ls._next_": self.radio_get_next,
|
2025-08-21 15:35:10 -04:00
|
|
|
"radio/album_art": self.album_art_handler,
|
2025-02-09 20:34:11 -05:00
|
|
|
}
|
2025-04-17 07:28:05 -04:00
|
|
|
|
2025-02-09 20:34:11 -05:00
|
|
|
for endpoint, handler in self.endpoints.items():
|
2025-08-21 15:35:10 -04:00
|
|
|
methods: list[str] = ["POST"]
|
|
|
|
if endpoint == "radio/album_art":
|
|
|
|
methods = ["GET"]
|
2025-04-17 07:28:05 -04:00
|
|
|
app.add_api_route(
|
2025-09-24 16:30:54 -04:00
|
|
|
f"/{endpoint}", handler, methods=methods, include_in_schema=True,
|
2025-07-15 11:39:12 -04:00
|
|
|
dependencies=[Depends(
|
2025-09-24 16:30:54 -04:00
|
|
|
RateLimiter(times=25, seconds=2))] if not endpoint == "radio/np" else None,
|
2025-04-17 07:28:05 -04:00
|
|
|
)
|
|
|
|
|
2025-09-26 11:36:13 -04:00
|
|
|
# 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)
|
|
|
|
|
2025-04-22 16:24:00 -04:00
|
|
|
app.add_event_handler("startup", self.on_start)
|
|
|
|
|
|
|
|
async def on_start(self) -> None:
|
2025-09-27 09:17:24 -04:00
|
|
|
# Initialize locks in the event loop
|
|
|
|
self.lrc_cache_locks = defaultdict(asyncio.Lock)
|
|
|
|
self.broadcast_locks = defaultdict(asyncio.Lock)
|
2025-07-17 06:55:16 -04:00
|
|
|
stations = ", ".join(self.radio_util.db_queries.keys())
|
|
|
|
logging.info("radio: Initializing stations:\n%s", stations)
|
2025-07-20 15:50:25 -04:00
|
|
|
await self.radio_util.load_playlists()
|
2025-04-17 07:28:05 -04:00
|
|
|
|
|
|
|
async def radio_skip(
|
2025-09-24 16:30:54 -04:00
|
|
|
self, data: ValidRadioNextRequest, request: Request, user=Depends(get_current_user)
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> JSONResponse:
|
2025-02-09 20:34:11 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Skip to the next track in the queue, or to the UUID specified in `skipTo` if provided.
|
|
|
|
|
|
|
|
Parameters:
|
2025-09-24 16:30:54 -04:00
|
|
|
- **data** (ValidRadioNextRequest): Contains optional UUID to skip to, and station name.
|
2025-09-23 13:17:34 -04:00
|
|
|
- **request** (Request): The HTTP request object.
|
2025-09-24 16:30:54 -04:00
|
|
|
- **user**: Current authenticated user.
|
2025-09-23 13:17:34 -04:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Indicates success or failure of the skip operation.
|
2025-02-09 20:34:11 -05:00
|
|
|
"""
|
2025-09-24 16:30:54 -04:00
|
|
|
if "dj" not in user.get("roles", []):
|
|
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
2025-02-09 20:34:11 -05:00
|
|
|
try:
|
2025-02-10 20:29:57 -05:00
|
|
|
if data.skipTo:
|
2025-07-20 08:03:31 -04:00
|
|
|
queue_item = self.radio_util.get_queue_item_by_uuid(data.skipTo, data.station)
|
2025-02-14 16:07:24 -05:00
|
|
|
if not queue_item:
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "No such queue item.",
|
|
|
|
},
|
|
|
|
)
|
2025-07-17 06:55:16 -04:00
|
|
|
self.radio_util.active_playlist[data.station] = self.radio_util.active_playlist[data.station][
|
2025-04-17 07:28:05 -04:00
|
|
|
queue_item[0] :
|
|
|
|
]
|
2025-07-19 21:57:21 -04:00
|
|
|
skip_result: bool = await self.radio_util._ls_skip(data.station)
|
2025-02-15 21:09:33 -05:00
|
|
|
status_code = 200 if skip_result else 500
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=status_code,
|
|
|
|
content={
|
|
|
|
"success": skip_result,
|
|
|
|
},
|
|
|
|
)
|
2025-02-09 20:34:11 -05:00
|
|
|
except Exception as e:
|
2025-04-26 17:17:42 -04:00
|
|
|
logging.debug("radio_skip Exception: %s", str(e))
|
2025-02-09 20:34:11 -05:00
|
|
|
traceback.print_exc()
|
2025-05-21 07:28:42 -04:00
|
|
|
if not isinstance(e, HTTPException):
|
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "General failure.",
|
|
|
|
},
|
2025-06-09 07:15:57 -04:00
|
|
|
)
|
|
|
|
raise e # Re-raise HTTPException
|
2025-04-17 07:28:05 -04:00
|
|
|
|
|
|
|
async def radio_reshuffle(
|
2025-09-24 16:30:54 -04:00
|
|
|
self, data: ValidRadioReshuffleRequest, request: Request, user=Depends(get_current_user)
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> JSONResponse:
|
2025-02-10 20:29:57 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Reshuffle the play queue.
|
|
|
|
|
|
|
|
Parameters:
|
2025-09-24 16:30:54 -04:00
|
|
|
- **data** (ValidRadioReshuffleRequest): Contains the station name.
|
2025-09-23 13:17:34 -04:00
|
|
|
- **request** (Request): The HTTP request object.
|
2025-09-24 16:30:54 -04:00
|
|
|
- **user**: Current authenticated user.
|
2025-09-23 13:17:34 -04:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Indicates success of the reshuffle operation.
|
2025-02-10 20:29:57 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
|
2025-09-24 16:30:54 -04:00
|
|
|
if "dj" not in user.get("roles", []):
|
|
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
2025-04-17 07:28:05 -04:00
|
|
|
|
2025-07-17 06:55:16 -04:00
|
|
|
random.shuffle(self.radio_util.active_playlist[data.station])
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(content={"ok": True})
|
|
|
|
|
|
|
|
async def radio_get_queue(
|
2025-04-26 17:17:42 -04:00
|
|
|
self,
|
|
|
|
request: Request,
|
2025-05-27 16:48:28 -04:00
|
|
|
data: Optional[ValidRadioQueueRequest] = None,
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> JSONResponse:
|
2025-02-09 20:34:11 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Get the current play queue (paged, 20 results per page).
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
- **request** (Request): The HTTP request object.
|
|
|
|
- **data** (Optional[ValidRadioQueueRequest]): Contains the station name and optional search query.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Contains the paged queue data.
|
2025-02-09 20:34:11 -05:00
|
|
|
"""
|
2025-08-21 15:35:10 -04:00
|
|
|
if not (data and data.station):
|
|
|
|
return JSONResponse(status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "Invalid request.",
|
|
|
|
})
|
2025-05-27 16:48:28 -04:00
|
|
|
search: Optional[str] = None
|
2025-06-09 07:15:57 -04:00
|
|
|
draw: int = 0
|
2025-05-27 16:48:28 -04:00
|
|
|
if isinstance(data, ValidRadioQueueRequest):
|
|
|
|
search = data.search
|
2025-08-21 15:35:10 -04:00
|
|
|
draw = data.draw or 0
|
|
|
|
start: int = int(data.start or 0)
|
2025-06-09 07:15:57 -04:00
|
|
|
end: int = start + 20
|
2025-05-27 16:48:28 -04:00
|
|
|
else:
|
|
|
|
start: int = 0
|
|
|
|
end: int = 20
|
2025-07-17 06:55:16 -04:00
|
|
|
orig_queue: list[dict] = self.radio_util.active_playlist[data.station]
|
2025-04-26 17:17:42 -04:00
|
|
|
if not search:
|
2025-08-21 15:35:10 -04:00
|
|
|
queue_full: Optional[list] = orig_queue
|
2025-04-26 17:17:42 -04:00
|
|
|
else:
|
2025-08-21 15:35:10 -04:00
|
|
|
queue_full = self.radio_util.datatables_search(search, data.station)
|
|
|
|
if not queue_full:
|
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "No queue found.",
|
|
|
|
}
|
|
|
|
)
|
2025-04-26 12:01:45 -04:00
|
|
|
queue: list = queue_full[start:end]
|
2025-02-11 20:49:14 -05:00
|
|
|
queue_out: list[dict] = []
|
2025-03-13 11:15:52 -04:00
|
|
|
for x, item in enumerate(queue):
|
2025-04-17 07:28:05 -04:00
|
|
|
queue_out.append(
|
|
|
|
{
|
2025-04-27 08:27:08 -04:00
|
|
|
"pos": orig_queue.index(item),
|
2025-04-17 07:28:05 -04:00
|
|
|
"id": item.get("id"),
|
|
|
|
"uuid": item.get("uuid"),
|
|
|
|
"artist": item.get("artist"),
|
|
|
|
"song": item.get("song"),
|
|
|
|
"album": item.get("album", "N/A"),
|
|
|
|
"genre": item.get("genre", "N/A"),
|
|
|
|
"artistsong": item.get("artistsong"),
|
|
|
|
"duration": item.get("duration"),
|
|
|
|
}
|
|
|
|
)
|
2025-05-01 06:32:28 -04:00
|
|
|
full_playlist_len: int = len(orig_queue)
|
|
|
|
filtered_len: int = len(queue_full)
|
2025-04-26 12:01:45 -04:00
|
|
|
out_json = {
|
2025-05-27 16:48:28 -04:00
|
|
|
"draw": draw,
|
2025-05-01 06:32:28 -04:00
|
|
|
"recordsTotal": full_playlist_len,
|
|
|
|
"recordsFiltered": filtered_len,
|
2025-04-26 12:01:45 -04:00
|
|
|
"items": queue_out,
|
|
|
|
}
|
|
|
|
return JSONResponse(content=out_json)
|
2025-04-17 07:28:05 -04:00
|
|
|
|
|
|
|
async def radio_queue_shift(
|
2025-09-24 16:30:54 -04:00
|
|
|
self, data: ValidRadioQueueShiftRequest, request: Request, user=Depends(get_current_user)
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> JSONResponse:
|
2025-02-15 21:09:33 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Shift the position of a UUID within the queue.
|
|
|
|
|
|
|
|
Parameters:
|
2025-09-24 16:30:54 -04:00
|
|
|
- **data** (ValidRadioQueueShiftRequest): Contains the UUID to shift, and station name.
|
2025-09-23 13:17:34 -04:00
|
|
|
- **request** (Request): The HTTP request object.
|
2025-09-24 16:30:54 -04:00
|
|
|
- **user**: Current authenticated user.
|
2025-09-23 13:17:34 -04:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Indicates success of the shift operation.
|
2025-02-15 21:09:33 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
|
2025-09-24 16:30:54 -04:00
|
|
|
if "dj" not in user.get("roles", []):
|
|
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
2025-04-17 07:28:05 -04:00
|
|
|
|
2025-07-17 06:55:16 -04:00
|
|
|
queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid, data.station)
|
2025-02-14 16:07:24 -05:00
|
|
|
if not queue_item:
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "Queue item not found.",
|
|
|
|
},
|
|
|
|
)
|
2025-02-14 16:07:24 -05:00
|
|
|
(x, item) = queue_item
|
2025-07-17 06:55:16 -04:00
|
|
|
self.radio_util.active_playlist[data.station].pop(x)
|
|
|
|
self.radio_util.active_playlist[data.station].insert(0, item)
|
2025-02-10 20:29:57 -05:00
|
|
|
if not data.next:
|
2025-07-19 21:57:21 -04:00
|
|
|
await self.radio_util._ls_skip(data.station)
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
content={
|
|
|
|
"ok": True,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
async def radio_queue_remove(
|
2025-09-24 16:30:54 -04:00
|
|
|
self, data: ValidRadioQueueRemovalRequest, request: Request, user=Depends(get_current_user)
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> JSONResponse:
|
2025-02-15 21:09:33 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Remove an item from the current play queue.
|
|
|
|
|
|
|
|
Parameters:
|
2025-09-24 16:30:54 -04:00
|
|
|
- **data** (ValidRadioQueueRemovalRequest): Contains the UUID of the item to remove, and station name.
|
2025-09-23 13:17:34 -04:00
|
|
|
- **request** (Request): The HTTP request object.
|
2025-09-24 16:30:54 -04:00
|
|
|
- **user**: Current authenticated user.
|
2025-09-23 13:17:34 -04:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Indicates success of the removal operation.
|
2025-02-15 21:09:33 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
|
2025-09-24 16:30:54 -04:00
|
|
|
if "dj" not in user.get("roles", []):
|
|
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
2025-04-17 07:28:05 -04:00
|
|
|
|
2025-07-17 06:55:16 -04:00
|
|
|
queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid, data.station)
|
2025-02-14 16:07:24 -05:00
|
|
|
if not queue_item:
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "Queue item not found.",
|
|
|
|
},
|
|
|
|
)
|
2025-07-17 06:55:16 -04:00
|
|
|
self.radio_util.active_playlist[data.station].pop(queue_item[0])
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
content={
|
|
|
|
"ok": True,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
async def album_art_handler(
|
2025-07-17 06:55:16 -04:00
|
|
|
self, request: Request, track_id: Optional[int] = None,
|
2025-08-21 15:35:10 -04:00
|
|
|
station: Station = "main"
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> Response:
|
2025-02-11 20:01:07 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Get album art for the current or specified track.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
- **request** (Request): The HTTP request object.
|
|
|
|
- **track_id** (Optional[int]): ID of the track to retrieve album art for. Defaults to the current track.
|
|
|
|
- **station** (Station): Name of the station. Defaults to "main".
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **Response**: Contains the album art image or a default image.
|
2025-02-11 20:01:07 -05:00
|
|
|
"""
|
2025-02-09 20:34:11 -05:00
|
|
|
try:
|
2025-02-12 07:53:22 -05:00
|
|
|
if not track_id:
|
2025-07-17 06:55:16 -04:00
|
|
|
track_id = self.radio_util.now_playing[station].get("id")
|
2025-08-21 15:35:10 -04:00
|
|
|
if not track_id:
|
|
|
|
# Still no track ID
|
|
|
|
return JSONResponse(status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "Invalid request",
|
|
|
|
})
|
2025-02-11 15:23:33 -05:00
|
|
|
logging.debug("Seeking album art with trackId: %s", track_id)
|
2025-04-26 19:47:12 -04:00
|
|
|
album_art: Optional[bytes] = self.radio_util.get_album_art(
|
2025-04-17 07:28:05 -04:00
|
|
|
track_id=track_id
|
|
|
|
)
|
2025-09-22 11:08:48 -04:00
|
|
|
if not album_art:
|
|
|
|
return FileResponse(
|
|
|
|
path="/var/www/codey.lol/new/public/images/radio_art_default.jpg",
|
2025-04-17 07:28:05 -04:00
|
|
|
)
|
|
|
|
return Response(content=album_art, media_type="image/png")
|
2025-02-09 20:34:11 -05:00
|
|
|
except Exception as e:
|
2025-04-26 17:17:42 -04:00
|
|
|
logging.debug("album_art_handler Exception: %s", str(e))
|
2025-02-09 20:34:11 -05:00
|
|
|
traceback.print_exc()
|
2025-04-17 07:28:05 -04:00
|
|
|
return RedirectResponse(
|
|
|
|
url="https://codey.lol/images/radio_art_default.jpg", status_code=302
|
|
|
|
)
|
|
|
|
|
2025-07-17 06:55:16 -04:00
|
|
|
async def radio_now_playing(self, request: Request,
|
2025-08-21 15:35:10 -04:00
|
|
|
station: Station = "main") -> JSONResponse:
|
2025-02-15 21:09:33 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Get information about the currently playing track.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
- **request** (Request): The HTTP request object.
|
|
|
|
- **station** (Station): Name of the station. Defaults to "main".
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Contains the track information.
|
2025-02-15 21:09:33 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
|
2025-07-17 06:55:16 -04:00
|
|
|
ret_obj: dict = {**self.radio_util.now_playing[station]}
|
|
|
|
ret_obj["station"] = station
|
2025-02-09 20:34:11 -05:00
|
|
|
try:
|
2025-10-07 12:07:45 -04:00
|
|
|
ret_obj["elapsed"] = int(time.time()) - ret_obj["start"] if ret_obj["start"] else 0
|
2025-02-09 20:34:11 -05:00
|
|
|
except KeyError:
|
|
|
|
traceback.print_exc()
|
2025-04-17 07:28:05 -04:00
|
|
|
ret_obj["elapsed"] = 0
|
|
|
|
ret_obj.pop("file_path")
|
2025-02-15 21:09:33 -05:00
|
|
|
return JSONResponse(content=ret_obj)
|
2025-04-17 07:28:05 -04:00
|
|
|
|
|
|
|
async def radio_get_next(
|
|
|
|
self,
|
|
|
|
data: ValidRadioNextRequest,
|
|
|
|
request: Request,
|
|
|
|
background_tasks: BackgroundTasks,
|
2025-09-24 16:30:54 -04:00
|
|
|
user=Depends(get_current_user),
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> JSONResponse:
|
2025-02-09 20:34:11 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Get the next track in the queue. The track will be removed from the queue in the process.
|
|
|
|
|
|
|
|
Parameters:
|
2025-09-24 16:30:54 -04:00
|
|
|
- **data** (ValidRadioNextRequest): Contains optional UUID to skip to, and station name.
|
2025-09-23 13:17:34 -04:00
|
|
|
- **request** (Request): The HTTP request object.
|
|
|
|
- **background_tasks** (BackgroundTasks): Background tasks for webhook execution.
|
2025-09-24 16:30:54 -04:00
|
|
|
- **user**: Current authenticated user.
|
2025-09-23 13:17:34 -04:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Contains the next track information.
|
2025-02-09 20:34:11 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
|
2025-09-24 16:30:54 -04:00
|
|
|
if "dj" not in user.get("roles", []):
|
|
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
|
2025-07-17 06:55:16 -04:00
|
|
|
logging.info("Radio get next")
|
2025-07-20 15:50:25 -04:00
|
|
|
if data.station not in self.radio_util.active_playlist.keys():
|
|
|
|
raise HTTPException(status_code=500, detail="No such station/not ready")
|
2025-04-17 07:28:05 -04:00
|
|
|
if (
|
2025-07-17 06:55:16 -04:00
|
|
|
not isinstance(self.radio_util.active_playlist[data.station], list)
|
|
|
|
or not self.radio_util.active_playlist[data.station]
|
2025-04-17 07:28:05 -04:00
|
|
|
):
|
2025-07-17 06:55:16 -04:00
|
|
|
if self.radio_util.playlists_loaded:
|
|
|
|
self.radio_util.playlists_loaded = False
|
2025-04-22 16:24:00 -04:00
|
|
|
await self.on_start()
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "General failure occurred, prompting playlist reload.",
|
|
|
|
},
|
|
|
|
)
|
2025-07-17 06:55:16 -04:00
|
|
|
next = self.radio_util.active_playlist[data.station].pop(0)
|
2025-02-11 15:21:01 -05:00
|
|
|
if not isinstance(next, dict):
|
2025-02-11 15:23:33 -05:00
|
|
|
logging.critical("next is of type: %s, reloading playlist...", type(next))
|
2025-04-22 16:24:00 -04:00
|
|
|
await self.on_start()
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "General failure occurred, prompting playlist reload.",
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
|
|
|
duration: int = next["duration"]
|
2025-02-11 20:01:07 -05:00
|
|
|
time_started: int = int(time.time())
|
2025-04-17 07:28:05 -04:00
|
|
|
time_ends: int = int(time_started + duration)
|
|
|
|
|
2025-08-07 11:47:57 -04:00
|
|
|
self.radio_util.active_playlist[data.station].append(next) # Push to end of playlist
|
2025-04-17 07:28:05 -04:00
|
|
|
|
2025-07-17 06:55:16 -04:00
|
|
|
self.radio_util.now_playing[data.station] = next
|
2025-04-17 07:28:05 -04:00
|
|
|
next["start"] = time_started
|
|
|
|
next["end"] = time_ends
|
2025-09-26 13:45:39 -04:00
|
|
|
|
2025-09-27 09:28:47 -04:00
|
|
|
# Use BackgroundTasks for LRC fetch/cache
|
2025-09-30 10:01:57 -04:00
|
|
|
asyncio.create_task(self._do_lrc_fetch(data.station, next.copy()))
|
2025-09-27 09:17:24 -04:00
|
|
|
|
|
|
|
try:
|
|
|
|
background_tasks.add_task(self.radio_util.webhook_song_change, next, data.station)
|
|
|
|
except Exception as e:
|
|
|
|
logging.info("radio_get_next Exception: %s", str(e))
|
|
|
|
traceback.print_exc()
|
2025-09-26 13:45:39 -04:00
|
|
|
|
2025-02-11 15:21:01 -05:00
|
|
|
try:
|
2025-09-27 09:17:24 -04:00
|
|
|
await self.broadcast_track_change(data.station, next.copy())
|
2025-02-11 15:21:01 -05:00
|
|
|
except Exception as e:
|
2025-04-22 09:18:15 -04:00
|
|
|
logging.info("radio_get_next Exception: %s", str(e))
|
2025-02-11 15:21:01 -05:00
|
|
|
traceback.print_exc()
|
2025-09-27 09:17:24 -04:00
|
|
|
|
2025-02-11 15:21:01 -05:00
|
|
|
try:
|
2025-04-26 19:47:12 -04:00
|
|
|
album_art = self.radio_util.get_album_art(track_id=next["id"])
|
2025-04-22 09:18:15 -04:00
|
|
|
if not album_art:
|
2025-04-26 19:47:12 -04:00
|
|
|
self.radio_util.cache_album_art(next["id"], next["file_path"])
|
2025-04-22 09:18:15 -04:00
|
|
|
except Exception as e:
|
|
|
|
logging.info("radio_get_next Exception: %s", str(e))
|
2025-02-11 15:21:01 -05:00
|
|
|
traceback.print_exc()
|
2025-09-27 09:17:24 -04:00
|
|
|
|
2025-02-15 21:09:33 -05:00
|
|
|
return JSONResponse(content=next)
|
2025-02-09 20:34:11 -05:00
|
|
|
|
2025-04-17 07:28:05 -04:00
|
|
|
async def radio_request(
|
2025-09-24 16:30:54 -04:00
|
|
|
self, data: ValidRadioSongRequest, request: Request, user=Depends(get_current_user)
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> JSONResponse:
|
2025-02-15 21:09:33 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Handle song requests.
|
|
|
|
|
|
|
|
Parameters:
|
2025-09-24 16:30:54 -04:00
|
|
|
- **data** (ValidRadioSongRequest): Contains artist, song, and station name.
|
2025-09-23 13:17:34 -04:00
|
|
|
- **request** (Request): The HTTP request object.
|
2025-09-24 16:30:54 -04:00
|
|
|
- **user**: Current authenticated user.
|
2025-09-23 13:17:34 -04:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Indicates success or failure of the request.
|
2025-02-15 21:09:33 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
|
2025-09-24 16:30:54 -04:00
|
|
|
if "dj" not in user.get("roles", []):
|
|
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
|
2025-02-14 16:07:24 -05:00
|
|
|
artistsong: Optional[str] = data.artistsong
|
|
|
|
artist: Optional[str] = data.artist
|
|
|
|
song: Optional[str] = data.song
|
2025-02-09 20:34:11 -05:00
|
|
|
if artistsong and (artist or song):
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "Invalid request",
|
|
|
|
},
|
|
|
|
)
|
2025-02-09 20:34:11 -05:00
|
|
|
if not artistsong and (not artist or not song):
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "Invalid request",
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
2025-07-17 06:55:16 -04:00
|
|
|
search: bool = self.radio_util.search_db(
|
2025-07-19 21:57:21 -04:00
|
|
|
artistsong=artistsong, artist=artist, song=song, station=data.station
|
2025-04-17 07:28:05 -04:00
|
|
|
)
|
2025-02-09 20:34:11 -05:00
|
|
|
if data.alsoSkip:
|
2025-07-19 21:57:21 -04:00
|
|
|
await self.radio_util._ls_skip(data.station)
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(content={"result": search})
|
|
|
|
|
2025-04-26 21:27:55 -04:00
|
|
|
def radio_typeahead(
|
2025-09-26 11:36:13 -04:00
|
|
|
self, data: ValidRadioTypeaheadRequest, request: Request
|
2025-04-17 07:28:05 -04:00
|
|
|
) -> JSONResponse:
|
2025-02-16 13:54:28 -05:00
|
|
|
"""
|
2025-09-23 13:17:34 -04:00
|
|
|
Handle typeahead queries for the radio.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
- **data** (ValidRadioTypeaheadRequest): Contains the typeahead query.
|
|
|
|
- **request** (Request): The HTTP request object.
|
2025-09-26 11:36:13 -04:00
|
|
|
# - **user**: Current authenticated user.
|
2025-09-23 13:17:34 -04:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
- **JSONResponse**: Contains the typeahead results.
|
2025-02-16 13:54:28 -05:00
|
|
|
"""
|
2025-09-26 11:36:13 -04:00
|
|
|
# if "dj" not in user.get("roles", []):
|
|
|
|
# raise HTTPException(status_code=403, detail="Insufficient permissions")
|
2025-09-24 16:30:54 -04:00
|
|
|
|
2025-02-16 13:54:28 -05:00
|
|
|
if not isinstance(data.query, str):
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(
|
|
|
|
status_code=500,
|
|
|
|
content={
|
|
|
|
"err": True,
|
|
|
|
"errorText": "Invalid request.",
|
|
|
|
},
|
|
|
|
)
|
2025-04-26 19:47:12 -04:00
|
|
|
typeahead: Optional[list[str]] = self.radio_util.trackdb_typeahead(data.query)
|
2025-02-16 13:54:28 -05:00
|
|
|
if not typeahead:
|
|
|
|
return JSONResponse(content=[])
|
2025-04-17 07:28:05 -04:00
|
|
|
return JSONResponse(content=typeahead)
|
2025-09-26 11:36:13 -04:00
|
|
|
|
|
|
|
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)
|
2025-09-26 12:30:23 -04:00
|
|
|
current_track.pop("file_path", None) # Ensure file_path is stripped
|
2025-09-26 11:36:13 -04:00
|
|
|
await websocket.send_text(json.dumps(current_track))
|
|
|
|
|
2025-09-27 09:17:24 -04:00
|
|
|
# Send cached LRC if available; do not attempt to fetch again for this client
|
|
|
|
async with self.lrc_cache_locks[station]:
|
|
|
|
cached_lrc = self.lrc_cache.get(station)
|
|
|
|
if cached_lrc:
|
|
|
|
lrc_data = {
|
|
|
|
"type": "lrc",
|
|
|
|
"data": cached_lrc,
|
|
|
|
"source": "Cache"
|
|
|
|
}
|
|
|
|
await websocket.send_text(json.dumps(lrc_data))
|
2025-09-26 11:36:13 -04:00
|
|
|
|
|
|
|
# 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
|
|
|
|
"""
|
2025-09-27 09:17:24 -04:00
|
|
|
async with self.broadcast_locks[station]:
|
|
|
|
if station not in self.active_connections:
|
|
|
|
return
|
|
|
|
|
|
|
|
# Take a snapshot of current clients for this update
|
|
|
|
current_clients = set(self.active_connections[station])
|
|
|
|
if not current_clients:
|
|
|
|
return
|
|
|
|
|
|
|
|
# Remove sensitive file path info before broadcasting
|
|
|
|
track_data_clean = track_data.copy()
|
|
|
|
track_data_clean.pop("file_path", None)
|
|
|
|
|
|
|
|
# Create and send track change message first
|
|
|
|
broadcast_data = {
|
|
|
|
"type": "track_change",
|
|
|
|
"data": track_data_clean
|
|
|
|
}
|
|
|
|
|
|
|
|
# Send track change to all clients
|
|
|
|
disconnected_clients = set()
|
|
|
|
for websocket in current_clients:
|
|
|
|
try:
|
|
|
|
await websocket.send_text(json.dumps(broadcast_data))
|
|
|
|
except Exception as e:
|
|
|
|
logging.warning(f"[Track] Failed to send track change: {e}")
|
|
|
|
disconnected_clients.add(websocket)
|
|
|
|
|
|
|
|
# Remove disconnected clients from our snapshot and active connections
|
|
|
|
current_clients -= disconnected_clients
|
|
|
|
for websocket in disconnected_clients:
|
|
|
|
self.active_connections[station].discard(websocket)
|
|
|
|
|
|
|
|
if not current_clients:
|
|
|
|
logging.warning("[Track] No clients remaining after track broadcast")
|
|
|
|
return
|
|
|
|
|
|
|
|
async def broadcast_lrc(self, station: str, lrc: str, source: str):
|
|
|
|
"""Broadcast LRC data to all connected clients for a station."""
|
2025-09-26 11:36:13 -04:00
|
|
|
if station not in self.active_connections:
|
|
|
|
return
|
2025-09-27 09:17:24 -04:00
|
|
|
current_clients = set(self.active_connections[station])
|
|
|
|
if not current_clients:
|
|
|
|
return
|
|
|
|
lrc_data = {
|
|
|
|
"type": "lrc",
|
|
|
|
"data": lrc,
|
|
|
|
"source": source
|
2025-09-26 11:36:13 -04:00
|
|
|
}
|
|
|
|
disconnected_clients = set()
|
2025-09-27 09:17:24 -04:00
|
|
|
for websocket in current_clients:
|
2025-09-26 11:36:13 -04:00
|
|
|
try:
|
2025-09-27 09:17:24 -04:00
|
|
|
await websocket.send_text(json.dumps(lrc_data))
|
2025-09-26 11:36:13 -04:00
|
|
|
except Exception as e:
|
2025-09-27 09:17:24 -04:00
|
|
|
logging.warning(f"[LRC Broadcast] Failed to send to client: {e}")
|
2025-09-26 11:36:13 -04:00
|
|
|
disconnected_clients.add(websocket)
|
|
|
|
for websocket in disconnected_clients:
|
|
|
|
self.active_connections[station].discard(websocket)
|
|
|
|
|
|
|
|
async def _send_lrc_to_client(self, websocket: WebSocket, station: str, track_data: dict):
|
2025-09-27 09:17:24 -04:00
|
|
|
"""Send cached LRC data to a specific client asynchronously. Only sends if LRC exists in cache."""
|
|
|
|
logging.info(f"[LRC Send] Checking cached LRC for station {station}")
|
|
|
|
logging.info(f"[LRC Send] Current track: {track_data.get('artist', 'Unknown')} - {track_data.get('song', 'Unknown')}")
|
2025-09-26 11:36:13 -04:00
|
|
|
try:
|
2025-09-27 09:17:24 -04:00
|
|
|
# Only send if LRC is in cache
|
2025-09-26 12:36:20 -04:00
|
|
|
cached_lrc = self.lrc_cache.get(station)
|
2025-09-27 09:17:24 -04:00
|
|
|
logging.info(f"[LRC Send] Cache status for station {station}: {'Found' if cached_lrc else 'Not found'}")
|
2025-09-26 12:36:20 -04:00
|
|
|
if cached_lrc:
|
2025-09-27 09:17:24 -04:00
|
|
|
logging.info("[LRC Send] Sending cached LRC to client")
|
2025-09-26 12:36:20 -04:00
|
|
|
lrc_data: dict = {
|
|
|
|
"type": "lrc",
|
|
|
|
"data": cached_lrc,
|
|
|
|
"source": "Cache"
|
|
|
|
}
|
|
|
|
await websocket.send_text(json.dumps(lrc_data))
|
2025-09-27 09:17:24 -04:00
|
|
|
logging.info("[LRC Send] Successfully sent cached LRC to client")
|
|
|
|
else:
|
|
|
|
logging.info(f"[LRC Send] No cached LRC available for station {station}")
|
2025-09-26 11:36:13 -04:00
|
|
|
except Exception as e:
|
2025-09-27 09:17:24 -04:00
|
|
|
logging.error(f"[LRC Send] Failed to send cached LRC to client: {e}")
|
|
|
|
logging.error(f"[LRC Send] Error details: {traceback.format_exc()}")
|
2025-09-26 12:36:20 -04:00
|
|
|
|
2025-09-27 09:17:24 -04:00
|
|
|
async def send_lrc_to_client(self, websocket: WebSocket, station: str, track_data: dict):
|
|
|
|
"""Send cached LRC data to a specific client asynchronously. Only sends if valid LRC exists in cache."""
|
2025-09-26 11:36:13 -04:00
|
|
|
try:
|
2025-09-27 09:17:24 -04:00
|
|
|
track_info = f"{track_data.get('artist', 'Unknown')} - {track_data.get('song', 'Unknown')}"
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Starting LRC send for {track_info}")
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Cache keys before lock: {list(self.lrc_cache.keys())}")
|
|
|
|
|
|
|
|
# Get cached LRC with lock to ensure consistency
|
|
|
|
async with self.lrc_cache_locks[station]:
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Got cache lock")
|
|
|
|
cached_lrc = self.lrc_cache.get(station)
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Cache keys during lock: {list(self.lrc_cache.keys())}")
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Cache entry length: {len(cached_lrc) if cached_lrc else 0}")
|
|
|
|
|
|
|
|
# Only send if we have actual lyrics
|
|
|
|
if cached_lrc:
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Preparing to send {len(cached_lrc)} bytes of LRC")
|
|
|
|
lrc_data: dict = {
|
|
|
|
"type": "lrc",
|
|
|
|
"data": cached_lrc,
|
|
|
|
"source": "Cache"
|
|
|
|
}
|
|
|
|
await websocket.send_text(json.dumps(lrc_data))
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Successfully sent LRC")
|
|
|
|
else:
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] No LRC in cache")
|
|
|
|
# If we have no cache entry, let's check if a fetch is needed
|
|
|
|
async with self.lrc_cache_locks[station]:
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Checking if fetch needed")
|
|
|
|
# Only attempt fetch if we're the first to notice missing lyrics
|
|
|
|
if station not in self.lrc_cache:
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Initiating LRC fetch")
|
|
|
|
lrc, source = await self._fetch_and_cache_lrc(station, track_data)
|
|
|
|
if lrc:
|
|
|
|
self.lrc_cache[station] = lrc
|
|
|
|
lrc_data: dict = {
|
|
|
|
"type": "lrc",
|
|
|
|
"data": lrc,
|
|
|
|
"source": source
|
|
|
|
}
|
|
|
|
await websocket.send_text(json.dumps(lrc_data))
|
|
|
|
logging.info(f"[LRC Send {id(websocket)}] Sent newly fetched LRC")
|
|
|
|
except Exception as e:
|
|
|
|
logging.error(f"[LRC Send {id(websocket)}] Failed: {e}")
|
|
|
|
logging.error(f"[LRC Send {id(websocket)}] Error details: {traceback.format_exc()}")
|
2025-09-26 13:45:39 -04:00
|
|
|
|
2025-09-27 09:17:24 -04:00
|
|
|
async def _fetch_and_cache_lrc(self, station: str, track_data: dict) -> tuple[Optional[str], str]:
|
|
|
|
"""Fetch and cache LRC data for a station's current track."""
|
|
|
|
try:
|
2025-09-26 11:49:25 -04:00
|
|
|
artist: Optional[str] = track_data.get("artist")
|
2025-09-27 09:17:24 -04:00
|
|
|
title: Optional[str] = track_data.get("song")
|
2025-09-26 11:49:25 -04:00
|
|
|
duration: Optional[int] = track_data.get("duration")
|
2025-09-27 09:17:24 -04:00
|
|
|
|
|
|
|
if not (artist and title):
|
|
|
|
logging.info("[LRC] Missing artist or title, skipping fetch")
|
|
|
|
return None, "None"
|
|
|
|
|
|
|
|
logging.info(f"[LRC] Starting fetch for {station}: {artist} - {title}")
|
|
|
|
|
|
|
|
# Try SR first with timeout
|
|
|
|
try:
|
|
|
|
async with asyncio.timeout(5.0): # 5 second timeout
|
|
|
|
lrc = await self.sr_util.get_lrc_by_artist_song(
|
|
|
|
artist, title, duration=duration
|
|
|
|
)
|
|
|
|
if lrc:
|
|
|
|
logging.info("[LRC] Found from SR")
|
|
|
|
return lrc, "SR"
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
logging.warning("[LRC] SR fetch timed out")
|
|
|
|
except Exception as e:
|
|
|
|
logging.error(f"[LRC] SR fetch error: {e}")
|
|
|
|
|
|
|
|
logging.info("[LRC] SR fetch completed without results")
|
|
|
|
|
|
|
|
# Try LRCLib as fallback with timeout
|
|
|
|
try:
|
|
|
|
async with asyncio.timeout(5.0): # 5 second timeout
|
|
|
|
logging.info("[LRC] Trying LRCLib fallback")
|
2025-09-26 11:36:13 -04:00
|
|
|
lrclib_result = await self.lrclib.search(artist, title, plain=False)
|
|
|
|
if lrclib_result and lrclib_result.lyrics and isinstance(lrclib_result.lyrics, str):
|
2025-09-27 09:17:24 -04:00
|
|
|
logging.info("[LRC] Found from LRCLib")
|
|
|
|
return lrclib_result.lyrics, "LRCLib"
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
logging.warning("[LRC] LRCLib fetch timed out")
|
|
|
|
except Exception as e:
|
|
|
|
logging.error(f"[LRC] LRCLib fetch error: {e}")
|
|
|
|
|
|
|
|
logging.info("[LRC] No lyrics found from any source")
|
|
|
|
return None, "None"
|
2025-09-26 11:36:13 -04:00
|
|
|
except Exception as e:
|
2025-09-27 09:17:24 -04:00
|
|
|
logging.error(f"[LRC] Error fetching lyrics: {e}")
|
|
|
|
return None, "None"
|
2025-09-27 09:28:47 -04:00
|
|
|
|
|
|
|
async def _do_lrc_fetch(self, station: str, track_json: dict):
|
|
|
|
"""Fetch and cache LRC for a station's track."""
|
|
|
|
try:
|
|
|
|
async with self.lrc_cache_locks[station]:
|
|
|
|
self.lrc_cache.pop(station, None)
|
|
|
|
lrc, source = await self._fetch_and_cache_lrc(station, track_json)
|
|
|
|
if lrc:
|
|
|
|
self.lrc_cache[station] = lrc
|
|
|
|
else:
|
|
|
|
self.lrc_cache[station] = None
|
|
|
|
if lrc:
|
|
|
|
await self.broadcast_lrc(station, lrc, source)
|
|
|
|
except Exception as e:
|
|
|
|
logging.error(f"[LRC] Error during LRC fetch/cache: {e}")
|