misc / tRIP - beginnings/work in progress

This commit is contained in:
2025-08-07 11:47:57 -04:00
parent 8603b11438
commit 9e9748076b
8 changed files with 404 additions and 51 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ constants.py
tests.py tests.py
db_migrate.py db_migrate.py
notifier.py notifier.py
test_hifi.py
youtube* youtube*
playlist_creator.py playlist_creator.py
artist_genre_tag.py artist_genre_tag.py

View File

@@ -98,6 +98,9 @@ routes: dict = {
app, util, constants, loop app, util, constants, loop
), ),
"meme": importlib.import_module("endpoints.meme").Meme(app, util, constants), "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 # Misc endpoint depends on radio endpoint instance

View File

@@ -1,4 +1,3 @@
import logging
from fastapi import FastAPI, Request, Response, Depends from fastapi import FastAPI, Request, Response, Depends
from fastapi_throttle import RateLimiter from fastapi_throttle import RateLimiter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -22,12 +21,15 @@ class Meme(FastAPI):
} }
for endpoint, handler in self.endpoints.items(): 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( app.add_api_route(
f"/{endpoint}", f"/{endpoint}",
handler, handler,
methods=["GET"], methods=["GET"],
include_in_schema=True, 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: async def get_meme_by_id(self, id: int, request: Request) -> Response:

View File

@@ -332,10 +332,7 @@ class Radio(FastAPI):
time_started: int = int(time.time()) time_started: int = int(time.time())
time_ends: int = int(time_started + duration) 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
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[data.station] = next self.radio_util.now_playing[data.station] = next
next["start"] = time_started next["start"] = time_started

71
endpoints/rip.py Normal file
View File

@@ -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})

View File

@@ -111,8 +111,19 @@ class DataUtils:
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.lrc_regex = regex.compile( self.lrc_regex = regex.compile( # capture mm:ss and optional .xxx, then the lyric text
r"\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}" 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_1: Pattern = regex.compile(r"(\[.*?\])(\s){0,}(\:){0,1}")
self.scrub_regex_2: Pattern = regex.compile( self.scrub_regex_2: Pattern = regex.compile(
@@ -161,7 +172,7 @@ class DataUtils:
) )
_timetag = reg_helper[0] _timetag = reg_helper[0]
if not reg_helper[1].strip(): if not reg_helper[1].strip():
_words = "" continue
else: else:
_words = reg_helper[1].strip() _words = reg_helper[1].strip()
lrc_out.append( lrc_out.append(

247
utils/hifi_wrapper.py Normal file
View File

@@ -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

View File

@@ -3,6 +3,7 @@ import traceback
import time import time
import datetime import datetime
import os import os
import random
from uuid import uuid4 as uuid from uuid import uuid4 as uuid
from typing import Union, Optional, Iterable from typing import Union, Optional, Iterable
from aiohttp import ClientSession, ClientTimeout from aiohttp import ClientSession, ClientTimeout
@@ -59,11 +60,13 @@ class RadioUtil:
# # "hip hop", # # "hip hop",
# "metalcore", # "metalcore",
# "deathcore", # "deathcore",
# # "edm", # "edm",
# "electronic", # "electronic",
# "hard rock", # "post-hardcore",
# "rock", # "post hardcore",
# # "ska", # # "hard rock",
# # "rock",
# # # "ska",
# # "post punk", # # "post punk",
# # "post-punk", # # "post-punk",
# # "pop punk", # # "pop punk",
@@ -96,9 +99,11 @@ class RadioUtil:
self.webhooks: dict = { self.webhooks: dict = {
"gpt": { "gpt": {
"hook": self.constants.GPT_WEBHOOK, "hook": self.constants.GPT_WEBHOOK,
"lastRun": None,
}, },
"sfm": { "sfm": {
"hook": self.constants.SFM_WEBHOOK, "hook": self.constants.SFM_WEBHOOK,
"lastRun": None,
}, },
} }
@@ -358,8 +363,8 @@ class RadioUtil:
query: str = ( query: str = (
"SELECT genre FROM artist_genre WHERE artist LIKE ? COLLATE NOCASE" "SELECT genre FROM artist_genre WHERE artist LIKE ? COLLATE NOCASE"
) )
params: tuple[str] = (f"%%{artist}%%",) params: tuple[str] = (artist,)
with sqlite3.connect(self.artist_genre_db_path, timeout=2) as _db: with sqlite3.connect(self.playback_db_path, timeout=2) as _db:
_db.row_factory = sqlite3.Row _db.row_factory = sqlite3.Row
_cursor = _db.execute(query, params) _cursor = _db.execute(query, params)
res = _cursor.fetchone() res = _cursor.fetchone()
@@ -386,6 +391,8 @@ class RadioUtil:
_playlist = await self.redis_client.json().get(playlist_redis_key) _playlist = await self.redis_client.json().get(playlist_redis_key)
if playlist not in self.active_playlist.keys(): if playlist not in self.active_playlist.keys():
self.active_playlist[playlist] = [] self.active_playlist[playlist] = []
if not playlist == "rock":
random.shuffle(_playlist) # Temp/for Cocteau Twins
self.active_playlist[playlist] = [ self.active_playlist[playlist] = [
{ {
"uuid": str(uuid().hex), "uuid": str(uuid().hex),
@@ -542,7 +549,7 @@ class RadioUtil:
text: Optional[str] = await request.text() text: Optional[str] = await request.text()
return isinstance(text, str) and text.startswith("OK") return isinstance(text, str) and text.startswith("OK")
except Exception as e: except Exception as e:
logging.debug("Skip failed: %s", str(e)) logging.critical("Skip failed: %s", str(e))
return False # failsafe return False # failsafe
@@ -572,7 +579,10 @@ class RadioUtil:
None None
""" """
try: try:
return None # disabled temporarily (needs rate limiting) """TEMP - ONLY MAIN"""
if not station == "main":
return
return # Temp disable global
# First, send track info # First, send track info
""" """
TODO: TODO:
@@ -630,42 +640,18 @@ class RadioUtil:
], ],
} }
sfm_hook: str = self.webhooks["sfm"].get("hook") now: float = time.time()
async with ClientSession() as session: _sfm: dict = self.webhooks["sfm"]
async with await session.post( if _sfm:
sfm_hook, sfm_hook: str = _sfm.get("hook", "")
json=hook_data, sfm_hook_lastRun: Optional[float] = _sfm.get("lastRun", 0.0)
timeout=ClientTimeout(connect=5, sock_read=5),
headers={
"content-type": "application/json; charset=utf-8",
},
) as request:
request.raise_for_status()
# Next, AI feedback (for main stream only) if sfm_hook_lastRun and ((now - sfm_hook_lastRun) < 5):
logging.info("SFM Webhook: Throttled!")
if station == "main":
ai_response: Optional[str] = await self.get_ai_song_info(
track["artist"], track["song"]
)
if not ai_response:
return 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 ClientSession() as session:
async with await session.post( async with await session.post(
ai_hook, sfm_hook,
json=hook_data, json=hook_data,
timeout=ClientTimeout(connect=5, sock_read=5), timeout=ClientTimeout(connect=5, sock_read=5),
headers={ headers={
@@ -673,6 +659,41 @@ class RadioUtil:
}, },
) as request: ) as request:
request.raise_for_status() 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: except Exception as e:
logging.info("Webhook error occurred: %s", str(e)) logging.info("Webhook error occurred: %s", str(e))
traceback.print_exc() traceback.print_exc()