From 85182b7d8cb75f25ad6aefece225f3e2da1daad3 Mon Sep 17 00:00:00 2001 From: codey Date: Thu, 17 Jul 2025 06:55:16 -0400 Subject: [PATCH] WIP: additional radio stations --- endpoints/constructors.py | 9 ++ endpoints/lyric_search.py | 9 +- endpoints/radio.py | 57 +++++----- gpt/__init__.py | 1 + utils/radio_util.py | 225 +++++++++++++++++++------------------- 5 files changed, 162 insertions(+), 139 deletions(-) diff --git a/endpoints/constructors.py b/endpoints/constructors.py index fc515cf..637b0ca 100644 --- a/endpoints/constructors.py +++ b/endpoints/constructors.py @@ -235,15 +235,18 @@ class ValidRadioNextRequest(BaseModel): """ - **key**: API Key - **skipTo**: UUID to skip to [optional] + - **station**: Station (default: "main") """ key: str skipTo: Optional[str] = None + station: str = "main" class ValidRadioReshuffleRequest(ValidRadioNextRequest): """ - **key**: API Key + - **station**: Station (default: "main") """ @@ -252,11 +255,13 @@ class ValidRadioQueueRequest(BaseModel): - **draw**: DataTables draw count, default 1 - **start**: paging start position, default 0 - **search**: Optional search query + - **station**: Station (default: "main") """ draw: Optional[int] = 1 start: Optional[int] = 0 search: Optional[str] = None + station: str = "main" class ValidRadioQueueShiftRequest(BaseModel): @@ -264,18 +269,22 @@ class ValidRadioQueueShiftRequest(BaseModel): - **key**: API Key - **uuid**: UUID to shift - **next**: Play next if true, immediately if false, default False + - **station**: Station (default: "main") """ key: str uuid: str next: Optional[bool] = False + station: str = "main" class ValidRadioQueueRemovalRequest(BaseModel): """ - **key**: API Key - **uuid**: UUID to remove + - **station**: Station (default: "main") """ key: str uuid: str + station: str = "main" diff --git a/endpoints/lyric_search.py b/endpoints/lyric_search.py index 965443f..89fc9cd 100644 --- a/endpoints/lyric_search.py +++ b/endpoints/lyric_search.py @@ -71,14 +71,21 @@ class LyricSearch(FastAPI): ) for endpoint, handler in self.endpoints.items(): + rate_limit: tuple[int, int] = (2, 3) # Default; (Times, Seconds) _schema_include = endpoint in ["lyric/search"] + + if endpoint == "typeahead/lyrics": # More permissive rate limiting for typeahead + rate_limit = (20, 2) + (times, seconds) = rate_limit + app.add_api_route( f"/{endpoint}", handler, methods=["POST"], include_in_schema=_schema_include, - dependencies=[Depends(RateLimiter(times=2, seconds=3))], + dependencies=[Depends(RateLimiter(times=times, seconds=seconds))] if not endpoint == "typeahead/lyrics" else None ) + async def typeahead_handler(self, data: ValidTypeAheadRequest) -> JSONResponse: """ diff --git a/endpoints/radio.py b/endpoints/radio.py index e3d5ece..a303c76 100644 --- a/endpoints/radio.py +++ b/endpoints/radio.py @@ -33,7 +33,7 @@ class Radio(FastAPI): self.constants = constants self.loop = loop self.radio_util = radio_util.RadioUtil(self.constants, self.loop) - self.playlist_loaded: bool = False + self.playlists_loaded: bool = False self.endpoints: dict = { "radio/np": self.radio_now_playing, @@ -65,8 +65,9 @@ class Radio(FastAPI): app.add_event_handler("startup", self.on_start) async def on_start(self) -> None: - logging.info("radio: Initializing") - self.loop.run_in_executor(None, self.radio_util.load_playlist) + stations = ", ".join(self.radio_util.db_queries.keys()) + logging.info("radio: Initializing stations:\n%s", stations) + self.loop.run_in_executor(None, self.radio_util.load_playlists) async def radio_skip( self, data: ValidRadioNextRequest, request: Request @@ -89,7 +90,7 @@ class Radio(FastAPI): "errorText": "No such queue item.", }, ) - self.radio_util.active_playlist = self.radio_util.active_playlist[ + self.radio_util.active_playlist[data.station] = self.radio_util.active_playlist[data.station][ queue_item[0] : ] # if not self.radio_util.active_playlist: @@ -125,7 +126,7 @@ class Radio(FastAPI): if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): raise HTTPException(status_code=403, detail="Unauthorized") - random.shuffle(self.radio_util.active_playlist) + random.shuffle(self.radio_util.active_playlist[data.station]) return JSONResponse(content={"ok": True}) async def radio_get_queue( @@ -146,7 +147,7 @@ class Radio(FastAPI): else: start: int = 0 end: int = 20 - orig_queue: list[dict] = self.radio_util.active_playlist + orig_queue: list[dict] = self.radio_util.active_playlist[data.station] if not search: queue_full: list = orig_queue else: @@ -190,7 +191,7 @@ class Radio(FastAPI): if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): raise HTTPException(status_code=403, detail="Unauthorized") - queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid) + queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid, data.station) if not queue_item: return JSONResponse( status_code=500, @@ -200,8 +201,8 @@ class Radio(FastAPI): }, ) (x, item) = queue_item - self.radio_util.active_playlist.pop(x) - self.radio_util.active_playlist.insert(0, item) + self.radio_util.active_playlist[data.station].pop(x) + self.radio_util.active_playlist[data.station].insert(0, item) if not data.next: await self.radio_util._ls_skip() return JSONResponse( @@ -221,7 +222,7 @@ class Radio(FastAPI): if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): raise HTTPException(status_code=403, detail="Unauthorized") - queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid) + queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid, data.station) if not queue_item: return JSONResponse( status_code=500, @@ -230,7 +231,7 @@ class Radio(FastAPI): "errorText": "Queue item not found.", }, ) - self.radio_util.active_playlist.pop(queue_item[0]) + self.radio_util.active_playlist[data.station].pop(queue_item[0]) return JSONResponse( content={ "ok": True, @@ -238,7 +239,8 @@ class Radio(FastAPI): ) async def album_art_handler( - self, request: Request, track_id: Optional[int] = None + self, request: Request, track_id: Optional[int] = None, + station: Optional[str] = "main" ) -> Response: """ Get album art, optional parameter track_id may be specified. @@ -247,7 +249,7 @@ class Radio(FastAPI): """ try: if not track_id: - track_id = self.radio_util.now_playing.get("id") + track_id = self.radio_util.now_playing[station].get("id") logging.debug("Seeking album art with trackId: %s", track_id) album_art: Optional[bytes] = self.radio_util.get_album_art( track_id=track_id @@ -265,11 +267,13 @@ class Radio(FastAPI): url="https://codey.lol/images/radio_art_default.jpg", status_code=302 ) - async def radio_now_playing(self, request: Request) -> JSONResponse: + async def radio_now_playing(self, request: Request, + station: Optional[str] = "main") -> JSONResponse: """ Get currently playing track info """ - ret_obj: dict = {**self.radio_util.now_playing} + ret_obj: dict = {**self.radio_util.now_playing[station]} + ret_obj["station"] = station try: ret_obj["elapsed"] = int(time.time()) - ret_obj["start"] except KeyError: @@ -289,15 +293,17 @@ class Radio(FastAPI): (Track will be removed from the queue in the process.) - **key**: API key - **skipTo**: Optional UUID to skip to + - **station**: Station (default: "main") """ + logging.info("Radio get next") if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): raise HTTPException(status_code=403, detail="Unauthorized") if ( - not isinstance(self.radio_util.active_playlist, list) - or not self.radio_util.active_playlist + not isinstance(self.radio_util.active_playlist[data.station], list) + or not self.radio_util.active_playlist[data.station] ): - if self.radio_util.playlist_loaded: - self.radio_util.playlist_loaded = False + if self.radio_util.playlists_loaded: + self.radio_util.playlists_loaded = False await self.on_start() return JSONResponse( status_code=500, @@ -306,7 +312,7 @@ class Radio(FastAPI): "errorText": "General failure occurred, prompting playlist reload.", }, ) - next = self.radio_util.active_playlist.pop(0) + next = self.radio_util.active_playlist[data.station].pop(0) if not isinstance(next, dict): logging.critical("next is of type: %s, reloading playlist...", type(next)) await self.on_start() @@ -322,16 +328,17 @@ class Radio(FastAPI): time_started: int = int(time.time()) time_ends: int = int(time_started + duration) - if len(self.radio_util.active_playlist) > 1: - self.radio_util.active_playlist.append(next) # Push to end of playlist + if len(self.radio_util.active_playlist[data.station]) > 1: + self.radio_util.active_playlist[data.station].append(next) # Push to end of playlist else: self.loop.run_in_executor(None, self.radio_util.load_playlist) - self.radio_util.now_playing = next + self.radio_util.now_playing[data.station] = next next["start"] = time_started next["end"] = time_ends try: - background_tasks.add_task(self.radio_util.webhook_song_change, next) + if data.station == "main": + background_tasks.add_task(self.radio_util.webhook_song_change, next) except Exception as e: logging.info("radio_get_next Exception: %s", str(e)) traceback.print_exc() @@ -377,7 +384,7 @@ class Radio(FastAPI): }, ) - search: bool = self.radio_util.search_playlist( + search: bool = self.radio_util.search_db( artistsong=artistsong, artist=artist, song=song ) if data.alsoSkip: diff --git a/gpt/__init__.py b/gpt/__init__.py index 8f21098..6529249 100644 --- a/gpt/__init__.py +++ b/gpt/__init__.py @@ -39,6 +39,7 @@ class GPT: ], model="gpt-4o-mini", temperature=1.00, + max_completion_tokens=512, ) response: Optional[str] = chat_completion.choices[0].message.content return response diff --git a/utils/radio_util.py b/utils/radio_util.py index 2a3ac73..82ee4e5 100644 --- a/utils/radio_util.py +++ b/utils/radio_util.py @@ -18,15 +18,6 @@ from endpoints.constructors import RadioException double_space: Pattern = regex.compile(r"\s{2,}") non_alnum: Pattern = regex.compile(r"[^a-zA-Z0-9]") -""" -TODO: - - get_genre should only be called once for load_playlist, rework get_genre to (optionally) accept a list of artists, - and return (optionally) a list instead of an str - - Ask GPT when we encounter an untagged (no genre defined) artist, automation is needed for this tedious task - - etc.. -""" - - class RadioUtil: """ Radio Utils @@ -40,7 +31,7 @@ class RadioUtil: self.sqlite_exts: list[str] = [ "/home/kyle/api/solibs/spellfix1.cpython-311-x86_64-linux-gnu.so" ] - self.active_playlist_path: str = os.path.join( + self.playback_db_path: str = os.path.join( "/usr/local/share", "sqlite_dbs", "track_file_map.db" ) self.artist_genre_db_path: str = os.path.join( @@ -49,6 +40,14 @@ class RadioUtil: self.album_art_db_path: str = os.path.join( "/usr/local/share", "sqlite_dbs", "track_album_art.db" ) + self.db_queries = { + 'main': self.constants.RADIO_DB_QUERY, + 'rap': self.constants.RADIO_DB_QUERY_RAP, + 'pop': self.constants.RADIO_DB_QUERY_POP, + 'classical': self.constants.RADIO_DB_QUERY_CLASSICAL, + 'rock': self.constants.RADIO_DB_QUERY_ROCK, + 'electronic': self.constants.RADIO_DB_QUERY_ELECTRONIC, + } self.playback_genres: list[str] = [ # "metal", # # "hip hop", @@ -64,19 +63,21 @@ class RadioUtil: # # "pop punk", # # "pop-punk", ] - self.active_playlist: list[dict] = [] - self.playlist_loaded: bool = False - self.now_playing: dict = { - "artist": "N/A", - "song": "N/A", - "album": "N/A", - "genre": "N/A", - "artistsong": "N/A - N/A", - "duration": 0, - "start": 0, - "end": 0, - "file_path": None, - "id": None, + self.active_playlist: dict[str, list[dict]] = {} + self.playlists_loaded: bool = False + self.now_playing: dict[str, dict] = { + k: { + "artist": "N/A", + "song": "N/A", + "album": "N/A", + "genre": "N/A", + "artistsong": "N/A - N/A", + "duration": 0, + "start": 0, + "end": 0, + "file_path": None, + "id": None, + } for k in self.db_queries.keys() } self.webhooks: dict = { "gpt": { @@ -107,7 +108,7 @@ class RadioUtil: """ if not query: return None - with sqlite3.connect(self.active_playlist_path, timeout=1) as _db: + with sqlite3.connect(self.playback_db_path, timeout=1) as _db: _db.row_factory = sqlite3.Row db_query: str = """SELECT DISTINCT(LOWER(TRIM(artist) || " - " || TRIM(song))),\ (TRIM(artist) || " - " || TRIM(song)) as artistsong FROM tracks WHERE\ @@ -118,7 +119,9 @@ class RadioUtil: out_result = [str(r["artistsong"]) for r in result] return out_result - def datatables_search(self, filter: str) -> Optional[list[dict]]: + def datatables_search(self, + filter: str, + station: str = "main") -> Optional[list[dict]]: """DataTables Search Args: @@ -129,7 +132,7 @@ class RadioUtil: """ filter = filter.strip().lower() matched: list[dict] = [] - for item in self.active_playlist: + for item in self.active_playlist[station]: artist: str = item.get("artist", None) song: str = item.get("song", None) artistsong: str = item.get("artistsong", None) @@ -147,11 +150,12 @@ class RadioUtil: matched.append(item) return matched - def search_playlist( + def search_db( self, artistsong: Optional[str] = None, artist: Optional[str] = None, song: Optional[str] = None, + station: str = "main" ) -> bool: """ Search for track, add it up next in play queue if found @@ -183,7 +187,7 @@ class RadioUtil: search_song.lower(), artistsong.lower(), ) - with sqlite3.connect(self.active_playlist_path, timeout=2) as db_conn: + with sqlite3.connect(self.playback_db_path, timeout=2) as db_conn: db_conn.enable_load_extension(True) for ext in self.sqlite_exts: db_conn.load_extension(ext) @@ -204,10 +208,10 @@ class RadioUtil: "file_path": result["file_path"], "duration": result["duration"], } - self.active_playlist.insert(0, push_obj) + self.active_playlist[station].insert(0, push_obj) return True except Exception as e: - logging.critical("search_playlist:: Search error occurred: %s", str(e)) + logging.critical("search_db:: Search error occurred: %s", str(e)) traceback.print_exc() return False @@ -321,7 +325,7 @@ class RadioUtil: if not res: artist_genre[artist] = "N/A" continue - artist_genre[artist] = res["genre"].title() + artist_genre[artist] = res["genre"] time_end: float = time.time() logging.info(f"Time taken: {time_end - time_start}") return artist_genre @@ -356,94 +360,87 @@ class RadioUtil: traceback.print_exc() return "Not Found" - def load_playlist(self) -> None: - """Load Playlist""" + def load_playlists(self) -> None: + """Load Playlists""" try: - logging.info("Loading playlist...") - self.active_playlist.clear() - - db_query: str = self.constants.RADIO_DB_QUERY + logging.info("Loading playlists...") + if isinstance(self.active_playlist, dict): + self.active_playlist.clear() with sqlite3.connect( - f"file:{self.active_playlist_path}?mode=ro", uri=True, timeout=15 + f"file:{self.playback_db_path}?mode=ro", uri=True, timeout=15 ) as db_conn: db_conn.row_factory = sqlite3.Row - db_cursor = db_conn.execute(db_query) - results: list[sqlite3.Row] = db_cursor.fetchall() - self.active_playlist = [ - { - "uuid": str(uuid().hex), - "id": r["id"], - "artist": double_space.sub(" ", r["artist"]).strip(), - "song": double_space.sub(" ", r["song"]).strip(), - "album": double_space.sub(" ", r["album"]).strip(), - "genre": r["genre"].title() if r["genre"] else "Not Found", - "artistsong": double_space.sub( - " ", r["artistdashsong"] - ).strip(), - "file_path": r["file_path"], - "duration": r["duration"], - } - for r in results - if r not in self.active_playlist - ] - logging.info( - "Populated active playlists with %s items", - len(self.active_playlist), - ) - - random.shuffle(self.active_playlist) - - """Dedupe""" - logging.info("Removing duplicate tracks...") - dedupe_processed = [] - for item in self.active_playlist: - artistsongabc: str = non_alnum.sub("", item.get("artistsong", None)) - if not artistsongabc: - logging.info("Missing artistsong: %s", item) + for station in self.db_queries: + db_query = self.db_queries.get(station) + if not db_query: + logging.critical("No query found for %s", station) continue - if artistsongabc in dedupe_processed: - self.active_playlist.remove(item) - dedupe_processed.append(artistsongabc) - - logging.info( - "Duplicates removed.New playlist size: %s", - len(self.active_playlist), - ) - - logging.info( - "Playlist: %s", - [str(a.get("artistsong", "")) for a in self.active_playlist], - ) - - if self.playback_genres: - new_playlist: list[dict] = [] - logging.info("Limiting playback genres") - # for item in self.active_playlist: - # matched_genre: bool = False - # item_genres: str = item.get("genre", "").strip().lower() - # for genre in self.playback_genres: - # genre = genre.strip().lower() - # if genre in item_genres: - # if item in new_playlist: - # continue - # new_playlist.append(item) - # matched_genre = True - # continue - # if matched_genre: - # continue - for item in self.active_playlist: - item_genres = item.get("genre", "").strip().lower() - # Check if any genre matches and item isn't already in new_playlist - if any(genre.strip().lower() in item_genres for genre in self.playback_genres): - if item not in new_playlist: - new_playlist.append(item) - self.active_playlist = new_playlist + if station not in self.active_playlist: + self.active_playlist[station] = [] + db_cursor = db_conn.execute(db_query) + results: list[sqlite3.Row] = db_cursor.fetchall() + self.active_playlist[station] = [ + { + "uuid": str(uuid().hex), + "id": r["id"], + "artist": double_space.sub(" ", r["artist"]).strip(), + "song": double_space.sub(" ", r["song"]).strip(), + "album": double_space.sub(" ", r["album"]).strip(), + "genre": r["genre"] if r["genre"] else "Not Found", + "artistsong": double_space.sub( + " ", r["artistdashsong"] + ).strip(), + "file_path": r["file_path"], + "duration": r["duration"], + } + for r in results + if r not in self.active_playlist[station] + ] logging.info( - "%s items remain for playback after filtering", - len(self.active_playlist), + "Populated playlist: %s with %s items", + station, len(self.active_playlist[station]), ) - self.playlist_loaded = True + + random.shuffle(self.active_playlist[station]) + + """Dedupe""" + logging.info("Removing duplicate tracks...") + dedupe_processed = [] + for item in self.active_playlist[station]: + artistsongabc: str = non_alnum.sub("", item.get("artistsong", None)) + if not artistsongabc: + logging.info("Missing artistsong: %s", item) + continue + if artistsongabc in dedupe_processed: + self.active_playlist[station].remove(item) + dedupe_processed.append(artistsongabc) + + logging.info( + "Duplicates for playlist: %s removed. New playlist size: %s", + station, len(self.active_playlist[station]), + ) + + # logging.info( + # "Playlist: %s", + # [str(a.get("artistsong", "")) for a in self.active_playlist[station]], + # ) + + if station == 'main' and self.playback_genres: + new_playlist: list[dict] = [] + logging.info("Limiting playback genres") + for item in self.active_playlist[station]: + item_genres = item.get("genre", "").strip().lower() + # Check if any genre matches and item isn't already in new_playlist + if any(genre.strip().lower() in item_genres for genre in self.playback_genres): + if item not in new_playlist: + new_playlist.append(item) + self.active_playlist[station] = new_playlist + logging.info( + "%s items for playlist: %s remain for playback after filtering", + station, len(self.active_playlist[station]), + ) + self.playlists_loaded = True self.loop.run_until_complete(self._ls_skip()) except Exception as e: logging.info("Playlist load failed: %s", str(e)) @@ -509,7 +506,9 @@ class RadioUtil: traceback.print_exc() return None - def get_queue_item_by_uuid(self, _uuid: str) -> Optional[tuple[int, dict]]: + def get_queue_item_by_uuid(self, + _uuid: str, + station: str = "main") -> Optional[tuple[int, dict]]: """ Get queue item by UUID Args: @@ -517,7 +516,7 @@ class RadioUtil: Returns: Optional[tuple[int, dict]] """ - for x, item in enumerate(self.active_playlist): + for x, item in enumerate(self.active_playlist[station]): if item.get("uuid") == _uuid: return (x, item) return None