diff --git a/.gitignore b/.gitignore index f32d5ad..bb506e1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ constants.py tests.py db_migrate.py notifier.py +test_hifi.py youtube* playlist_creator.py artist_genre_tag.py @@ -21,4 +22,4 @@ endpoints/radio.py utils/radio_util.py redis_playlist.py endpoints/radio2 -endpoints/radio2/** \ No newline at end of file +endpoints/radio2/** diff --git a/base.py b/base.py index 9d4bc2d..249b248 100644 --- a/base.py +++ b/base.py @@ -98,6 +98,9 @@ routes: dict = { app, util, constants, loop ), "meme": importlib.import_module("endpoints.meme").Meme(app, util, constants), + "trip": importlib.import_module("endpoints.rip").RIP( + app, util, constants + ), } # Misc endpoint depends on radio endpoint instance diff --git a/endpoints/meme.py b/endpoints/meme.py index f048ac8..009334f 100644 --- a/endpoints/meme.py +++ b/endpoints/meme.py @@ -1,4 +1,3 @@ -import logging from fastapi import FastAPI, Request, Response, Depends from fastapi_throttle import RateLimiter from fastapi.responses import JSONResponse @@ -22,12 +21,15 @@ class Meme(FastAPI): } for endpoint, handler in self.endpoints.items(): + dependencies = None + if endpoint == "memes/list_memes": + dependencies = [Depends(RateLimiter(times=10, seconds=2))] # Do not rate limit image retrievals (cached) app.add_api_route( f"/{endpoint}", handler, methods=["GET"], include_in_schema=True, - dependencies=[Depends(RateLimiter(times=10, seconds=1))], + dependencies=dependencies, ) async def get_meme_by_id(self, id: int, request: Request) -> Response: diff --git a/endpoints/radio.py b/endpoints/radio.py index 1e65d23..1e7b519 100644 --- a/endpoints/radio.py +++ b/endpoints/radio.py @@ -332,10 +332,7 @@ class Radio(FastAPI): time_started: int = int(time.time()) time_ends: int = int(time_started + duration) - 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.active_playlist[data.station].append(next) # Push to end of playlist self.radio_util.now_playing[data.station] = next next["start"] = time_started diff --git a/endpoints/rip.py b/endpoints/rip.py new file mode 100644 index 0000000..949136a --- /dev/null +++ b/endpoints/rip.py @@ -0,0 +1,71 @@ +import logging +from fastapi import FastAPI, Request, Response, Depends +from fastapi_throttle import RateLimiter +from fastapi.responses import JSONResponse +from utils.hifi_wrapper import HifiUtil + +logging.getLogger().setLevel(logging.INFO) + +class RIP(FastAPI): + """ + Ripping Endpoints + """ + + def __init__(self, app: FastAPI, my_util, constants) -> None: + self.app: FastAPI = app + self.util = my_util + self.trip_util = HifiUtil() + self.constants = constants + self.endpoints: dict = { + "trip/get_artists_by_name": self.artists_by_name_handler, + "trip/get_albums_by_artist_id/{artist_id:path}": self.albums_by_artist_id_handler, + "trip/get_tracks_by_artist_song": self.tracks_by_artist_song_handler, + "trip/get_tracks_by_album_id/{album_id:path}": self.tracks_by_album_id_handler, + "trip/get_track_by_id/{track_id:path}": self.track_by_id_handler, + } + + for endpoint, handler in self.endpoints.items(): + dependencies = [Depends(RateLimiter(times=8, seconds=2))] # Do not rate limit image retrievals (cached) + app.add_api_route( + f"/{endpoint}", + handler, + methods=["GET"], + include_in_schema=True, + dependencies=dependencies, + ) + + async def artists_by_name_handler(self, artist: str, request: Request) -> Response: + """Get artists by name""" + artists = await self.trip_util.get_artists_by_name(artist) + if not artists: + return Response(status_code=404, content="Not found") + return JSONResponse(content=artists) + + async def albums_by_artist_id_handler(self, artist_id: int, request: Request) -> Response: + """Get albums by artist ID""" + albums = await self.trip_util.get_albums_by_artist_id(artist_id) + if not albums: + return Response(status_code=404, content="Not found") + return JSONResponse(content=albums) + + async def tracks_by_album_id_handler(self, album_id: int, request: Request) -> Response: + """Get tracks by album id""" + tracks = await self.trip_util.get_tracks_by_album_id(album_id) + if not tracks: + return Response(status_code=404, content="Not Found") + return JSONResponse(content=tracks) + + async def tracks_by_artist_song_handler(self, artist: str, song: str, request: Request) -> Response: + """Get tracks by artist and song name""" + logging.critical("Searching for tracks by artist: %s, song: %s", artist, song) + tracks = await self.trip_util.get_tracks_by_artist_song(artist, song) + if not tracks: + return Response(status_code=404, content="Not found") + return JSONResponse(content=tracks) + + async def track_by_id_handler(self, track_id: int, request: Request) -> Response: + """Get track by ID""" + track = await self.trip_util.get_stream_url_by_track_id(track_id) + if not track: + return Response(status_code=404, content="Not found") + return JSONResponse(content={"stream_url": track}) diff --git a/lyric_search/utils.py b/lyric_search/utils.py index feb3dab..4329547 100644 --- a/lyric_search/utils.py +++ b/lyric_search/utils.py @@ -111,8 +111,19 @@ class DataUtils: """ def __init__(self) -> None: - self.lrc_regex = regex.compile( - r"\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}" + self.lrc_regex = regex.compile( # capture mm:ss and optional .xxx, then the lyric text + r""" + \[ # literal “[” + ( # 1st (and only) capture group: + [0-9]{2} # two-digit minutes + :[0-9]{2} # colon + two-digit seconds + (?:\.[0-9]{1,3})? # optional decimal part, e.g. .123 + ) + \] # literal “]” + \s* # optional whitespace + (.*) # capture the rest of the line as words + """, + regex.VERBOSE, ) self.scrub_regex_1: Pattern = regex.compile(r"(\[.*?\])(\s){0,}(\:){0,1}") self.scrub_regex_2: Pattern = regex.compile( @@ -161,7 +172,7 @@ class DataUtils: ) _timetag = reg_helper[0] if not reg_helper[1].strip(): - _words = "♪" + continue else: _words = reg_helper[1].strip() lrc_out.append( diff --git a/utils/hifi_wrapper.py b/utils/hifi_wrapper.py new file mode 100644 index 0000000..14bcde8 --- /dev/null +++ b/utils/hifi_wrapper.py @@ -0,0 +1,247 @@ +from aiohttp import ClientSession, ClientTimeout +from typing import Optional +from urllib.parse import urlencode, quote +import logging + +class HifiUtil: + """ + HiFi API Utility Class + """ + def __init__(self) -> None: + """Initialize HiFi API utility with base URLs.""" + self.hifi_api_url: str = 'http://127.0.0.1:8000' + self.hifi_search_url: str = f"{self.hifi_api_url}/search" + + def dedupe_by_key(self, + key: str, + entries: list[dict]) -> list[dict]: + deduped = {} + for entry in entries: + norm = entry[key].strip().lower() + if norm not in deduped: + deduped[norm] = entry + return list(deduped.values()) + + def format_duration(self, seconds): + if not seconds: + return None + m, s = divmod(seconds, 60) + return f"{m}:{s:02}" + + async def search(self, + artist: str, + song: str = "", + album: str = "", + video_name: str = "", + playlist_name: str = "") -> Optional[list[dict]]: + """Search HiFi API + Args: + artist (str, required) + song (str, optional) + album (str, optional) + video_name (str, optional) + playlist_name (str, optional) + Returns: + Optional[dict]: Returns the first result from the HiFi API search or None if no results found. + """ + async with ClientSession(timeout=ClientTimeout(total=30)) as session: + params: dict = { + "a": artist, + "s": song, + "al": album, + "v": video_name, + "p": playlist_name, + } + query: str = urlencode(params, quote_via=quote) + built_url: str = f"{self.hifi_search_url}?{query}" + async with session.get(built_url) as response: + json_response: dict = await response.json() + if isinstance(json_response, list): + json_response = json_response[0] + key = next(iter(json_response), None) + if not key: + logging.error("No matching key found in JSON response.") + return None + logging.info("Key: %s", key) + if key not in ["limit"]: + json_response = json_response[key] + items: list[dict] = json_response.get("items", []) + if not items: + logging.info("No results found.") + return None + return items + + async def _retrieve(self, + req_type: str, + id: int, + quality: str = "LOSSLESS") -> Optional[list|dict]: + """Retrieve a specific item by type and ID from the HiFi API. + Args: + type (str): The type of item (e.g., 'song', 'album'). + id (int): The ID of the item. + quality (str): The quality of the item, default is "LOSSLESS". Other options: HIGH, LOW + Returns: + Optional[dict]: The item details or None if not found. + """ + async with ClientSession(timeout=ClientTimeout(total=10)) as session: + params: dict = { + 'id': id, + 'quality': quality, + } + if req_type not in ["track", "artist", "album", "playlist", "video"]: + logging.error("Invalid type: %s", type) + return None + if req_type in ["artist"]: + params.pop('id') + params['f'] = id # For non-track types, use 'f' instead of 'id' for full API output + query: str = urlencode(params, quote_via=quote) + built_url: str = f"{self.hifi_api_url}/{req_type}/?{query}" + logging.info("Built URL: %s", built_url) + async with session.get(built_url) as response: + if response.status != 200: + logging.warning("Item not found: %s %s", req_type, id) + return None + response_json: list|dict = await response.json() + match req_type: + case "artist": + response_json = response_json[0] + if not isinstance(response_json, dict): + logging.error("Expected a dict but got: %s", type(response_json)) + return None + response_json_rows = response_json.get('rows') + if not isinstance(response_json_rows, list): + logging.error("Expected a list but got: %s", type(response_json_rows)) + return None + response_json = response_json_rows[0].get('modules')[0].get('pagedList') + case "album": + return response_json[1].get('items', []) + case "track": + return response_json + if not isinstance(response_json, dict): + logging.error("Expected a list but got: %s", type(response_json)) + return None + return response_json.get('items') + + async def get_artists_by_name(self, + artist_name: str) -> Optional[list]: + """Get artist(s) by name from HiFi API. + Args: + artist_name (str): The name of the artist. + Returns: + Optional[dict]: The artist details or None if not found. + """ + artists_out: list[dict] = [] + artists = await self.search(artist=artist_name) + if not artists: + logging.warning("No artist found for name: %s", artist_name) + return None + artists_out = [ + { + 'artist': res['name'], + 'id': res['id'], + } for res in artists if 'name' in res and 'id' in res + ] + artists_out = self.dedupe_by_key('artist', artists_out) # Remove duplicates + return artists_out + + async def get_albums_by_artist_id(self, + artist_id: int) -> Optional[list|dict]: + """Get albums by artist ID from HiFi API. + Args: + artist_id (int): The ID of the artist. + Returns: + Optional[list[dict]]: List of albums or None if not found. + """ + albums_out: list[dict] = [] + albums = await self._retrieve("artist", artist_id) + if not albums: + logging.warning("No albums found for artist ID: %s", artist_id) + return None + albums_out = [ + { + 'artist': ", ".join(artist['name'] for artist in album['artists']), + 'album': album['title'], + 'id': album['id'], + 'release_date': album.get('releaseDate', 'Unknown') + } for album in albums if 'title' in album and 'id' in album and 'artists' in album + ] + + logging.info("Retrieved albums: %s", albums_out) + return albums_out + + async def get_tracks_by_album_id(self, + album_id: int) -> Optional[list|dict]: + """Get tracks by album ID from HiFi API. + Args: + album_id (int): The ID of the album. + Returns: + Optional[list[dict]]: List of tracks or None if not found. + """ + track_list = await self._retrieve("album", album_id) + if not track_list: + logging.warning("No tracks found for album ID: %s", album_id) + return None + tracks_out: list[dict] = [ + { + 'id': track.get('item').get('id'), + 'artist': track.get('item').get('artist').get('name'), + 'title': track.get('item').get('title'), + 'duration': self.format_duration(track.get('item').get('duration', 0)), + 'version': track.get('item').get('version'), + 'audioQuality': track.get('item').get('audioQuality'), + } for track in track_list + ] + return tracks_out + + + async def get_tracks_by_artist_song(self, + artist: str, + song: str) -> Optional[list]: + """Get track by artist and song name from HiFi API. + Args: + artist (str): The name of the artist. + song (str): The name of the song. + Returns: + Optional[dict]: The track details or None if not found. + """ + tracks_out: list[dict] = [] + tracks = await self.search(artist=artist, song=song) + if not tracks: + logging.warning("No track found for artist: %s, song: %s", artist, song) + return None + tracks_out = [ + { + 'artist': ", ".join(artist['name'] for artist in track['artists']), + 'song': track['title'], + 'id': track['id'], + 'album': track.get('album', {}).get('title', 'Unknown'), + 'duration': track.get('duration', 0) + } for track in tracks if 'title' in track and 'id' in track and 'artists' in track + ] + if not tracks_out: + logging.warning("No valid tracks found after processing.") + return None + logging.info("Retrieved tracks: %s", tracks_out) + return tracks_out + + async def get_stream_url_by_track_id(self, + track_id: int, + quality: str = "LOSSLESS") -> Optional[str]: + """Get stream URL by track ID from HiFi API. + Args: + track_id (int): The ID of the track. + quality (str): The quality of the stream, default is "LOSSLESS". Other options: HIGH, LOW + Returns: + Optional[str]: The stream URL or None if not found. + """ + track = await self._retrieve("track", track_id, quality) + if not isinstance(track, list) : + logging.warning("No track found for ID: %s", track_id) + return None + stream_url = track[2].get('OriginalTrackUrl') + if not stream_url: + logging.warning("No stream URL found for track ID: %s", track_id) + return None + logging.info("Retrieved stream URL: %s", stream_url) + return stream_url + \ No newline at end of file diff --git a/utils/radio_util.py b/utils/radio_util.py index eb2d030..df45e15 100644 --- a/utils/radio_util.py +++ b/utils/radio_util.py @@ -3,6 +3,7 @@ import traceback import time import datetime import os +import random from uuid import uuid4 as uuid from typing import Union, Optional, Iterable from aiohttp import ClientSession, ClientTimeout @@ -59,11 +60,13 @@ class RadioUtil: # # "hip hop", # "metalcore", # "deathcore", - # # "edm", + # "edm", # "electronic", - # "hard rock", - # "rock", - # # "ska", + # "post-hardcore", + # "post hardcore", + # # "hard rock", + # # "rock", + # # # "ska", # # "post punk", # # "post-punk", # # "pop punk", @@ -96,9 +99,11 @@ class RadioUtil: self.webhooks: dict = { "gpt": { "hook": self.constants.GPT_WEBHOOK, + "lastRun": None, }, "sfm": { "hook": self.constants.SFM_WEBHOOK, + "lastRun": None, }, } @@ -358,8 +363,8 @@ class RadioUtil: query: str = ( "SELECT genre FROM artist_genre WHERE artist LIKE ? COLLATE NOCASE" ) - params: tuple[str] = (f"%%{artist}%%",) - with sqlite3.connect(self.artist_genre_db_path, timeout=2) as _db: + params: tuple[str] = (artist,) + with sqlite3.connect(self.playback_db_path, timeout=2) as _db: _db.row_factory = sqlite3.Row _cursor = _db.execute(query, params) res = _cursor.fetchone() @@ -386,6 +391,8 @@ class RadioUtil: _playlist = await self.redis_client.json().get(playlist_redis_key) if playlist not in self.active_playlist.keys(): self.active_playlist[playlist] = [] + if not playlist == "rock": + random.shuffle(_playlist) # Temp/for Cocteau Twins self.active_playlist[playlist] = [ { "uuid": str(uuid().hex), @@ -542,7 +549,7 @@ class RadioUtil: text: Optional[str] = await request.text() return isinstance(text, str) and text.startswith("OK") except Exception as e: - logging.debug("Skip failed: %s", str(e)) + logging.critical("Skip failed: %s", str(e)) return False # failsafe @@ -572,7 +579,10 @@ class RadioUtil: None """ try: - return None # disabled temporarily (needs rate limiting) + """TEMP - ONLY MAIN""" + if not station == "main": + return + return # Temp disable global # First, send track info """ TODO: @@ -630,42 +640,18 @@ class RadioUtil: ], } - sfm_hook: str = self.webhooks["sfm"].get("hook") - async with ClientSession() as session: - async with await session.post( - sfm_hook, - json=hook_data, - timeout=ClientTimeout(connect=5, sock_read=5), - headers={ - "content-type": "application/json; charset=utf-8", - }, - ) as request: - request.raise_for_status() + now: float = time.time() + _sfm: dict = self.webhooks["sfm"] + if _sfm: + sfm_hook: str = _sfm.get("hook", "") + sfm_hook_lastRun: Optional[float] = _sfm.get("lastRun", 0.0) - # Next, AI feedback (for main stream only) - - if station == "main": - ai_response: Optional[str] = await self.get_ai_song_info( - track["artist"], track["song"] - ) - if not ai_response: - return - - hook_data = { - "username": "GPT", - "embeds": [ - { - "title": "AI Feedback", - "color": 0x35D0FF, - "description": ai_response.strip(), - } - ], - } - - ai_hook: str = self.webhooks["gpt"].get("hook") + if sfm_hook_lastRun and ((now - sfm_hook_lastRun) < 5): + logging.info("SFM Webhook: Throttled!") + return async with ClientSession() as session: async with await session.post( - ai_hook, + sfm_hook, json=hook_data, timeout=ClientTimeout(connect=5, sock_read=5), headers={ @@ -673,6 +659,41 @@ class RadioUtil: }, ) as request: request.raise_for_status() + + # Next, AI feedback (for main stream only) + """ + TEMP. DISABLED + """ + + # if station == "main": + # ai_response: Optional[str] = await self.get_ai_song_info( + # track["artist"], track["song"] + # ) + # if not ai_response: + # return + + # hook_data = { + # "username": "GPT", + # "embeds": [ + # { + # "title": "AI Feedback", + # "color": 0x35D0FF, + # "description": ai_response.strip(), + # } + # ], + # } + + # ai_hook: str = self.webhooks["gpt"].get("hook") + # async with ClientSession() as session: + # async with await session.post( + # ai_hook, + # json=hook_data, + # timeout=ClientTimeout(connect=5, sock_read=5), + # headers={ + # "content-type": "application/json; charset=utf-8", + # }, + # ) as request: + # request.raise_for_status() except Exception as e: logging.info("Webhook error occurred: %s", str(e)) traceback.print_exc()