radio_util: open tracks SQLite DB in readonly mode; black: reformat files

This commit is contained in:
codey 2025-04-17 07:28:05 -04:00
parent 96add377df
commit 6c88c23a4d
25 changed files with 1913 additions and 1340 deletions

1
.gitignore vendored
View File

@ -12,7 +12,6 @@ youtube*
playlist_creator.py
artist_genre_tag.py
uv.lock
py.typed
pyproject.toml
mypy.ini
.python-version

79
base.py
View File

@ -1,5 +1,6 @@
import importlib
import sys
sys.path.insert(0, ".")
import logging
import asyncio
@ -12,58 +13,57 @@ logger = logging.getLogger()
logger.setLevel(logging.INFO)
loop = asyncio.get_event_loop()
app = FastAPI(title="codey.lol API",
version="1.0",
contact={
'name': 'codey'
},
redirect_slashes=False,
loop=loop)
app = FastAPI(
title="codey.lol API",
version="1.0",
contact={"name": "codey"},
redirect_slashes=False,
loop=loop,
)
constants = importlib.import_module("constants").Constants()
util = importlib.import_module("util").Utilities(app, constants)
origins = [
"https://codey.lol",
"https://api.codey.lol"
]
origins = ["https://codey.lol", "https://api.codey.lol"]
app.add_middleware(CORSMiddleware, # type: ignore
allow_origins=origins,
allow_credentials=True,
allow_methods=["POST", "GET", "HEAD"],
allow_headers=["*"]) # type: ignore
app.add_middleware(
CORSMiddleware, # type: ignore
allow_origins=origins,
allow_credentials=True,
allow_methods=["POST", "GET", "HEAD"],
allow_headers=["*"],
) # type: ignore
"""
Blacklisted routes
"""
@app.get("/", include_in_schema=False)
def disallow_get():
return util.get_blocked_response()
@app.head("/", include_in_schema=False)
def base_head():
return
@app.get("/{path}", include_in_schema=False)
def disallow_get_any(request: Request, var: Any = None):
path = request.path_params['path']
if not (
isinstance(path, str)
and
path.split("/", maxsplit=1) == "widget"
):
path = request.path_params["path"]
if not (isinstance(path, str) and path.split("/", maxsplit=1) == "widget"):
return util.get_blocked_response()
else:
logging.info("OK, %s",
path)
logging.info("OK, %s", path)
@app.post("/", include_in_schema=False)
def disallow_base_post():
return util.get_blocked_response()
"""
End Blacklisted Routes
"""
@ -73,20 +73,28 @@ Actionable Routes
"""
routes: dict = {
'randmsg': importlib.import_module("endpoints.rand_msg").RandMsg(app, util, constants),
'transcriptions': importlib.import_module("endpoints.transcriptions").Transcriptions(app, util, constants),
'lyrics': importlib.import_module("endpoints.lyric_search").LyricSearch(app, util, constants),
'lastfm': importlib.import_module("endpoints.lastfm").LastFM(app, util, constants),
'yt': importlib.import_module("endpoints.yt").YT(app, util, constants),
'karma': importlib.import_module("endpoints.karma").Karma(app, util, constants),
'radio': importlib.import_module("endpoints.radio").Radio(app, util, constants),
'mgr': importlib.import_module("endpoints.mgr.mgr_test").Mgr(app, util, constants),
"randmsg": importlib.import_module("endpoints.rand_msg").RandMsg(
app, util, constants
),
"transcriptions": importlib.import_module(
"endpoints.transcriptions"
).Transcriptions(app, util, constants),
"lyrics": importlib.import_module("endpoints.lyric_search").LyricSearch(
app, util, constants
),
"lastfm": importlib.import_module("endpoints.lastfm").LastFM(app, util, constants),
"yt": importlib.import_module("endpoints.yt").YT(app, util, constants),
"karma": importlib.import_module("endpoints.karma").Karma(app, util, constants),
"radio": importlib.import_module("endpoints.radio").Radio(app, util, constants),
"mgr": importlib.import_module("endpoints.mgr.mgr_test").Mgr(app, util, constants),
}
# Misc endpoint depends on radio endpoint instance
radio_endpoint = routes.get('radio')
radio_endpoint = routes.get("radio")
if radio_endpoint:
routes['misc'] = importlib.import_module("endpoints.misc").Misc(app, util, constants, radio_endpoint)
routes["misc"] = importlib.import_module("endpoints.misc").Misc(
app, util, constants, radio_endpoint
)
"""
End Actionable Routes
@ -98,5 +106,4 @@ Startup
"""
redis = redis_cache.RedisCache()
loop.create_task(
redis.create_index())
loop.create_task(redis.create_index())

View File

@ -5,6 +5,7 @@ from pydantic import BaseModel
Karma
"""
class ValidKarmaUpdateRequest(BaseModel):
"""
Requires authentication
@ -25,35 +26,42 @@ class ValidKarmaRetrievalRequest(BaseModel):
keyword: str
class ValidTopKarmaRequest(BaseModel):
"""
- **n**: Number of top results to return (default: 10)
"""
n: Optional[int] = 10
"""
LastFM
"""
class LastFMException(Exception):
pass
class ValidArtistSearchRequest(BaseModel):
"""
- **a**: artist name
"""
a: str
model_config = {
"json_schema_extra": {
"json_schema_extra": {
"examples": [
{
"a": "eminem",
}]
}
]
}
}
class ValidAlbumDetailRequest(BaseModel):
"""
- **a**: artist name
@ -64,15 +72,17 @@ class ValidAlbumDetailRequest(BaseModel):
release: str
model_config = {
"json_schema_extra": {
"json_schema_extra": {
"examples": [
{
"a": "eminem",
"release": "houdini",
}]
}
]
}
}
class ValidTrackInfoRequest(BaseModel):
"""
- **a**: artist name
@ -81,21 +91,24 @@ class ValidTrackInfoRequest(BaseModel):
a: str
t: str
model_config = {
"json_schema_extra": {
"json_schema_extra": {
"examples": [
{
"a": "eminem",
"t": "rap god",
}]
}
]
}
}
}
"""
Rand Msg
"""
class RandMsgRequest(BaseModel):
"""
- **short**: Short randmsg?
@ -103,10 +116,12 @@ class RandMsgRequest(BaseModel):
short: Optional[bool] = False
"""
YT
"""
class ValidYTSearchRequest(BaseModel):
"""
- **t**: title to search
@ -114,10 +129,12 @@ class ValidYTSearchRequest(BaseModel):
t: str = "rick astley - never gonna give you up"
"""
XC
"""
class ValidXCRequest(BaseModel):
"""
- **key**: valid XC API key
@ -129,12 +146,14 @@ class ValidXCRequest(BaseModel):
key: str
bid: int
cmd: str
data: Optional[dict]
data: Optional[dict]
"""
Transcriptions
"""
class ValidShowEpisodeListRequest(BaseModel):
"""
- **s**: show id
@ -142,6 +161,7 @@ class ValidShowEpisodeListRequest(BaseModel):
s: int
class ValidShowEpisodeLineRequest(BaseModel):
"""
- **s**: show id
@ -151,10 +171,12 @@ class ValidShowEpisodeLineRequest(BaseModel):
s: int
e: int
"""
Lyric Search
"""
class ValidLyricRequest(BaseModel):
"""
- **a**: artist
@ -167,7 +189,7 @@ class ValidLyricRequest(BaseModel):
- **excluded_sources**: sources to exclude (new only)
"""
a: Optional[str] = None
a: Optional[str] = None
s: Optional[str] = None
t: Optional[str] = None
sub: Optional[str] = None
@ -178,32 +200,37 @@ class ValidLyricRequest(BaseModel):
model_config = {
"json_schema_extra": {
"examples": [
{
"a": "eminem",
"s": "rap god",
"src": "WEB",
"extra": True,
"lrc": False,
"excluded_sources": [],
}]
}
"examples": [
{
"a": "eminem",
"s": "rap god",
"src": "WEB",
"extra": True,
"lrc": False,
"excluded_sources": [],
}
]
}
}
class ValidTypeAheadRequest(BaseModel):
"""
- **query**: query string
"""
query: str
"""
Radio
"""
class RadioException(Exception):
pass
class ValidRadioSongRequest(BaseModel):
"""
- **key**: API Key
@ -212,55 +239,65 @@ class ValidRadioSongRequest(BaseModel):
- **artistsong**: may be used IN PLACE OF artist/song to perform a combined/string search in the format "artist - song"
- **alsoSkip**: Whether to skip immediately to this track [not implemented]
"""
key: str
artist: Optional[str] = None
song: Optional[str] = None
artistsong: Optional[str] = None
alsoSkip: Optional[bool] = False
class ValidRadioTypeaheadRequest(BaseModel):
"""
- **query**: Typeahead query
"""
query: str
class ValidRadioQueueGetRequest(BaseModel):
"""
- **key**: API key (optional, needed if specifying a non-default limit)
- **limit**: optional, default: 15k
"""
key: Optional[str] = None
limit: Optional[int] = 15_000
class ValidRadioNextRequest(BaseModel):
"""
- **key**: API Key
- **skipTo**: UUID to skip to [optional]
"""
key: str
skipTo: Optional[str] = None
class ValidRadioReshuffleRequest(ValidRadioNextRequest):
"""
- **key**: API Key
"""
class ValidRadioQueueShiftRequest(BaseModel):
"""
- **key**: API Key
- **uuid**: UUID to shift
- **uuid**: UUID to shift
- **next**: Play next if true, immediately if false, default False
"""
key: str
uuid: str
next: Optional[bool] = False
class ValidRadioQueueRemovalRequest(BaseModel):
"""
- **key**: API Key
- **uuid**: UUID to remove
- **uuid**: UUID to remove
"""
key: str
uuid: str
uuid: str

View File

@ -7,16 +7,22 @@ import aiosqlite as sqlite3
from typing import LiteralString, Optional, Union
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from .constructors import (ValidTopKarmaRequest, ValidKarmaRetrievalRequest,
ValidKarmaUpdateRequest)
from .constructors import (
ValidTopKarmaRequest,
ValidKarmaRetrievalRequest,
ValidKarmaUpdateRequest,
)
class KarmaDB:
"""Karma DB Util"""
def __init__(self) -> None:
self.db_path: LiteralString = os.path.join("/", "usr", "local", "share",
"sqlite_dbs", "karma.db")
async def get_karma(self, keyword: str) -> Union[int, dict]:
def __init__(self) -> None:
self.db_path: LiteralString = os.path.join(
"/", "usr", "local", "share", "sqlite_dbs", "karma.db"
)
async def get_karma(self, keyword: str) -> Union[int, dict]:
"""Get Karma Value for Keyword
Args:
keyword (str): The keyword to search
@ -24,16 +30,18 @@ class KarmaDB:
Union[int, dict]
"""
async with sqlite3.connect(self.db_path, timeout=2) as db_conn:
async with await db_conn.execute("SELECT score FROM karma WHERE keyword LIKE ? LIMIT 1", (keyword,)) as db_cursor:
async with await db_conn.execute(
"SELECT score FROM karma WHERE keyword LIKE ? LIMIT 1", (keyword,)
) as db_cursor:
try:
(score,) = await db_cursor.fetchone()
return score
except TypeError:
return {
'err': True,
'errorText': f'No records for {keyword}',
"err": True,
"errorText": f"No records for {keyword}",
}
async def get_top(self, n: Optional[int] = 10) -> Optional[list[tuple]]:
"""
Get Top n=10 Karma Entries
@ -44,14 +52,17 @@ class KarmaDB:
"""
try:
async with sqlite3.connect(self.db_path, timeout=2) as db_conn:
async with await db_conn.execute("SELECT keyword, score FROM karma ORDER BY score DESC LIMIT ?", (n,)) as db_cursor:
async with await db_conn.execute(
"SELECT keyword, score FROM karma ORDER BY score DESC LIMIT ?", (n,)
) as db_cursor:
return await db_cursor.fetchall()
except:
traceback.print_exc()
return None
async def update_karma(self, granter: str, keyword: str,
flag: int) -> Optional[bool]:
async def update_karma(
self, granter: str, keyword: str, flag: int
) -> Optional[bool]:
"""
Update Karma for Keyword
Args:
@ -61,42 +72,71 @@ class KarmaDB:
Returns:
Optional[bool]
"""
if not flag in [0, 1]:
return None
modifier: str = "score + 1" if not flag else "score - 1"
query: str = f"UPDATE karma SET score = {modifier}, last_change = ? WHERE keyword LIKE ?"
new_keyword_query: str = "INSERT INTO karma(keyword, score, last_change) VALUES(?, ?, ?)"
query: str = (
f"UPDATE karma SET score = {modifier}, last_change = ? WHERE keyword LIKE ?"
)
new_keyword_query: str = (
"INSERT INTO karma(keyword, score, last_change) VALUES(?, ?, ?)"
)
friendly_flag: str = "++" if not flag else "--"
audit_message: str = f"{granter} adjusted karma for {keyword} @ {datetime.datetime.now().isoformat()}: {friendly_flag}"
audit_query: str = "INSERT INTO karma_audit(impacted_keyword, comment) VALUES(?, ?)"
audit_message: str = (
f"{granter} adjusted karma for {keyword} @ {datetime.datetime.now().isoformat()}: {friendly_flag}"
)
audit_query: str = (
"INSERT INTO karma_audit(impacted_keyword, comment) VALUES(?, ?)"
)
now: int = int(time.time())
logging.debug("Audit message: %s{audit_message}\nKeyword: %s{keyword}")
async with sqlite3.connect(self.db_path, timeout=2) as db_conn:
async with await db_conn.execute(audit_query, (keyword, audit_message,)) as db_cursor:
await db_conn.commit()
async with await db_conn.execute(query, (now, keyword,)) as db_cursor:
async with await db_conn.execute(
audit_query,
(
keyword,
audit_message,
),
) as db_cursor:
await db_conn.commit()
async with await db_conn.execute(
query,
(
now,
keyword,
),
) as db_cursor:
if db_cursor.rowcount:
await db_conn.commit()
return True
if db_cursor.rowcount < 1: # Keyword does not already exist
await db_cursor.close()
new_val = 1 if not flag else -1
async with await db_conn.execute(new_keyword_query, (keyword, new_val, now,)) as db_cursor:
async with await db_conn.execute(
new_keyword_query,
(
keyword,
new_val,
now,
),
) as db_cursor:
if db_cursor.rowcount >= 1:
await db_conn.commit()
return True
else:
return False
return False
class Karma(FastAPI):
"""
Karma Endpoints
"""
"""
def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app
self.util = util
@ -107,86 +147,111 @@ class Karma(FastAPI):
"karma/get": self.get_karma_handler,
"karma/modify": self.modify_karma_handler,
"karma/top": self.top_karma_handler,
}
}
for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["POST"],
include_in_schema=True)
app.add_api_route(
f"/{endpoint}", handler, methods=["POST"], include_in_schema=True
)
async def top_karma_handler(self, request: Request,
data: Optional[ValidTopKarmaRequest] = None) -> JSONResponse:
async def top_karma_handler(
self, request: Request, data: Optional[ValidTopKarmaRequest] = None
) -> JSONResponse:
"""
Get top keywords for karma
- **n**: Number of top results to return (default: 10)
"""
if not self.util.check_key(request.url.path, request.headers.get('X-Authd-With')):
if not self.util.check_key(
request.url.path, request.headers.get("X-Authd-With")
):
raise HTTPException(status_code=403, detail="Unauthorized")
n: int = 10
if data and data.n:
n = int(data.n)
try:
top10: Optional[list[tuple]] = await self.db.get_top(n=n)
if not top10:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'General failure',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "General failure",
},
)
return JSONResponse(content=top10)
except:
traceback.print_exc()
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Exception occurred.',
})
async def get_karma_handler(self, data: ValidKarmaRetrievalRequest,
request: Request) -> JSONResponse:
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Exception occurred.",
},
)
async def get_karma_handler(
self, data: ValidKarmaRetrievalRequest, request: Request
) -> JSONResponse:
"""
Get current karma value
- **keyword**: Keyword to retrieve karma value for
"""
if not self.util.check_key(request.url.path, request.headers.get('X-Authd-With')):
raise HTTPException(status_code=403, detail="Unauthorized")
if not self.util.check_key(
request.url.path, request.headers.get("X-Authd-With")
):
raise HTTPException(status_code=403, detail="Unauthorized")
keyword: str = data.keyword
try:
count: Union[int, dict] = await self.db.get_karma(keyword)
return JSONResponse(content={
'keyword': keyword,
'count': count,
})
return JSONResponse(
content={
"keyword": keyword,
"count": count,
}
)
except:
traceback.print_exc()
return JSONResponse(status_code=500, content={
'err': True,
'errorText': "Exception occurred.",
})
async def modify_karma_handler(self, data: ValidKarmaUpdateRequest,
request: Request) -> JSONResponse:
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Exception occurred.",
},
)
async def modify_karma_handler(
self, data: ValidKarmaUpdateRequest, request: Request
) -> JSONResponse:
"""
Update karma count
- **granter**: User who granted the karma
- **keyword**: The keyword to modify
- **flag**: 0 to decrement (--), 1 to increment (++)
- **flag**: 0 to decrement (--), 1 to increment (++)
"""
if not self.util.check_key(request.url.path, request.headers.get('X-Authd-With'), 2):
if not self.util.check_key(
request.url.path, request.headers.get("X-Authd-With"), 2
):
raise HTTPException(status_code=403, detail="Unauthorized")
if not data.flag in [0, 1]:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request',
})
return JSONResponse(content={
'success': await self.db.update_karma(data.granter,
data.keyword, data.flag)
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request",
},
)
return JSONResponse(
content={
"success": await self.db.update_karma(
data.granter, data.keyword, data.flag
)
}
)

View File

@ -3,13 +3,18 @@ import traceback
from typing import Optional, Union
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from .constructors import (ValidArtistSearchRequest, ValidAlbumDetailRequest,
ValidTrackInfoRequest, LastFMException)
from .constructors import (
ValidArtistSearchRequest,
ValidAlbumDetailRequest,
ValidTrackInfoRequest,
LastFMException,
)
class LastFM(FastAPI):
"""Last.FM Endpoints"""
def __init__(self, app: FastAPI,
util, constants) -> None:
"""Last.FM Endpoints"""
def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app
self.util = util
self.constants = constants
@ -21,72 +26,94 @@ class LastFM(FastAPI):
"lastfm/get_release": self.release_detail_handler,
"lastfm/get_release_tracklist": self.release_tracklist_handler,
"lastfm/get_track_info": self.track_info_handler,
#tbd
}
# tbd
}
for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["POST"],
include_in_schema=True)
async def artist_by_name_handler(self, data: ValidArtistSearchRequest) -> JSONResponse:
app.add_api_route(
f"/{endpoint}", handler, methods=["POST"], include_in_schema=True
)
async def artist_by_name_handler(
self, data: ValidArtistSearchRequest
) -> JSONResponse:
"""
Get artist info
- **a**: Artist to search
"""
artist: Optional[str] = data.a.strip()
if not artist:
return JSONResponse(content={
'err': True,
'errorText': 'No artist specified',
})
return JSONResponse(
content={
"err": True,
"errorText": "No artist specified",
}
)
artist_result = await self.lastfm.search_artist(artist=artist)
if not artist_result or not artist_result.get('bio')\
or "err" in artist_result.keys():
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Search failed (no results?)',
})
return JSONResponse(content={
'success': True,
'result': artist_result,
})
async def artist_album_handler(self, data: ValidArtistSearchRequest) -> JSONResponse:
if (
not artist_result
or not artist_result.get("bio")
or "err" in artist_result.keys()
):
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Search failed (no results?)",
},
)
return JSONResponse(
content={
"success": True,
"result": artist_result,
}
)
async def artist_album_handler(
self, data: ValidArtistSearchRequest
) -> JSONResponse:
"""
Get artist's albums/releases
- **a**: Artist to search
"""
artist: str = data.a.strip()
if not artist:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request: No artist specified',
})
album_result: Union[dict, list[dict]] = await self.lastfm.get_artist_albums(artist=artist)
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request: No artist specified",
},
)
album_result: Union[dict, list[dict]] = await self.lastfm.get_artist_albums(
artist=artist
)
if isinstance(album_result, dict):
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'General failure.',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "General failure.",
},
)
album_result_out: list = []
seen_release_titles: list = []
for release in album_result:
release_title: str = release.get('title', 'Unknown')
release_title: str = release.get("title", "Unknown")
if release_title.lower() in seen_release_titles:
continue
seen_release_titles.append(release_title.lower())
album_result_out.append(release)
return JSONResponse(content={
'success': True,
'result': album_result_out
})
return JSONResponse(content={"success": True, "result": album_result_out})
async def release_detail_handler(self, data: ValidAlbumDetailRequest) -> JSONResponse:
async def release_detail_handler(
self, data: ValidAlbumDetailRequest
) -> JSONResponse:
"""
Get details of a particular release by an artist
- **a**: Artist to search
@ -94,85 +121,105 @@ class LastFM(FastAPI):
"""
artist: str = data.a.strip()
release: str = data.release.strip()
if not artist or not release:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request",
},
)
release_result = await self.lastfm.get_release(artist=artist, album=release)
ret_obj = {
'id': release_result.get('id'),
'artists': release_result.get('artists'),
'title': release_result.get('title'),
'summary': release_result.get('summary'),
'tracks': release_result.get('tracks'),
}
"id": release_result.get("id"),
"artists": release_result.get("artists"),
"title": release_result.get("title"),
"summary": release_result.get("summary"),
"tracks": release_result.get("tracks"),
}
return JSONResponse(content={
'success': True,
'result': ret_obj,
})
async def release_tracklist_handler(self, data: ValidAlbumDetailRequest) -> JSONResponse:
return JSONResponse(
content={
"success": True,
"result": ret_obj,
}
)
async def release_tracklist_handler(
self, data: ValidAlbumDetailRequest
) -> JSONResponse:
"""
Get track list for a particular release by an artist
- **a**: Artist to search
- **release**: Release title to search
- **release**: Release title to search
"""
artist: str = data.a.strip()
release: str = data.release.strip()
if not artist or not release:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request',
})
tracklist_result: dict = await self.lastfm.get_album_tracklist(artist=artist, album=release)
return JSONResponse(content={
'success': True,
'id': tracklist_result.get('id'),
'artists': tracklist_result.get('artists'),
'title': tracklist_result.get('title'),
'summary': tracklist_result.get('summary'),
'tracks': tracklist_result.get('tracks'),
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request",
},
)
tracklist_result: dict = await self.lastfm.get_album_tracklist(
artist=artist, album=release
)
return JSONResponse(
content={
"success": True,
"id": tracklist_result.get("id"),
"artists": tracklist_result.get("artists"),
"title": tracklist_result.get("title"),
"summary": tracklist_result.get("summary"),
"tracks": tracklist_result.get("tracks"),
}
)
async def track_info_handler(self, data: ValidTrackInfoRequest) -> JSONResponse:
"""
Get track info from Last.FM given an artist/track
- **a**: Artist to search
- **t**: Track title to search
"""
Get track info from Last.FM given an artist/track
- **a**: Artist to search
- **t**: Track title to search
"""
try:
artist: str = data.a
track: str = data.t
if not artist or not track:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request'
})
track_info_result: Optional[dict] = await self.lastfm.get_track_info(artist=artist,
track=track)
return JSONResponse(
status_code=500,
content={"err": True, "errorText": "Invalid request"},
)
track_info_result: Optional[dict] = await self.lastfm.get_track_info(
artist=artist, track=track
)
if not track_info_result:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Not found.',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Not found.",
},
)
if "err" in track_info_result:
raise LastFMException("Unknown error occurred: %s",
track_info_result.get('errorText', '??'))
return JSONResponse(content={
'success': True,
'result': track_info_result
})
raise LastFMException(
"Unknown error occurred: %s",
track_info_result.get("errorText", "??"),
)
return JSONResponse(content={"success": True, "result": track_info_result})
except:
traceback.print_exc()
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'General error',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "General error",
},
)

View File

@ -9,23 +9,25 @@ from typing import LiteralString, Optional, Union, Iterable
from regex import Pattern
from .constructors import ValidTypeAheadRequest, ValidLyricRequest
from lyric_search.constructors import LyricsResult
from lyric_search.sources import aggregate
from lyric_search.sources import aggregate
from lyric_search import notifier
class CacheUtils:
"""
Lyrics Cache DB Utils
"""
def __init__(self) -> None:
self.lyrics_db_path: LiteralString = os.path.join("/usr/local/share",
"sqlite_dbs", "cached_lyrics.db")
self.lyrics_db_path: LiteralString = os.path.join(
"/usr/local/share", "sqlite_dbs", "cached_lyrics.db"
)
async def check_typeahead(self, query: str) -> Optional[list[str]]:
"""Lyric Search Typeahead DB Handler"""
if not query:
return None
async with sqlite3.connect(self.lyrics_db_path,
timeout=1) as _db:
async with sqlite3.connect(self.lyrics_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 ret FROM lyrics WHERE\
@ -33,9 +35,7 @@ class CacheUtils:
db_params: tuple[str] = (f"%%%{query}%%%",)
async with _db.execute(db_query, db_params) as _cursor:
result: Iterable[sqlite3.Row] = await _cursor.fetchall()
out_result = [
str(r['ret']) for r in result
]
out_result = [str(r["ret"]) for r in result]
return out_result
@ -43,18 +43,17 @@ class LyricSearch(FastAPI):
"""
Lyric Search Endpoint
"""
def __init__(self, app: FastAPI,
util, constants) -> None:
def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app
self.util = util
self.constants = constants
self.cache_utils = CacheUtils()
self.notifier = notifier.DiscordNotifier()
self.endpoints: dict = {
"typeahead/lyrics": self.typeahead_handler,
"lyric_search": self.lyric_search_handler, # Preserving old endpoint path temporarily
"lyric_search": self.lyric_search_handler, # Preserving old endpoint path temporarily
"lyric/search": self.lyric_search_handler,
}
@ -66,11 +65,18 @@ class LyricSearch(FastAPI):
"IRC-SHARED",
]
self.lrc_regex: Pattern = regex.compile(r'\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}')
self.lrc_regex: Pattern = regex.compile(
r"\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}"
)
for endpoint, handler in self.endpoints.items():
_schema_include = endpoint in ["lyric/search"]
app.add_api_route(f"/{endpoint}", handler, methods=["POST"], include_in_schema=_schema_include)
app.add_api_route(
f"/{endpoint}",
handler,
methods=["POST"],
include_in_schema=_schema_include,
)
async def typeahead_handler(self, data: ValidTypeAheadRequest) -> JSONResponse:
"""
@ -78,104 +84,133 @@ class LyricSearch(FastAPI):
- **query**: Typeahead query
"""
if not isinstance(data.query, str):
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request.',
})
typeahead: Optional[list[str]] = await self.cache_utils.check_typeahead(data.query)
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request.",
},
)
typeahead: Optional[list[str]] = await self.cache_utils.check_typeahead(
data.query
)
if not typeahead:
return JSONResponse(content=[])
return JSONResponse(content=typeahead)
async def lyric_search_handler(self, data: ValidLyricRequest) -> JSONResponse:
"""
Search for lyrics
- **a**: artist
- **s**: song
- **t**: track (artist and song combined) [used only if a & s are not used]
- **t**: track (artist and song combined) [used only if a & s are not used]
- **extra**: include extra details in response [optional, default: false]
- **lrc**: Request LRCs?
- **lrc**: Request LRCs?
- **sub**: text to search within lyrics, if found lyrics will begin at found verse [optional, default: none]
- **src**: the script/utility which initiated the request
- **excluded_sources**: sources to exclude [optional, default: none]
"""
if (not data.a or not data.s) and not data.t or not data.src:
raise HTTPException(detail="Invalid request", status_code=500)
if data.src.upper() not in self.acceptable_request_sources:
await self.notifier.send(f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}",
f"Unknown request source: {data.src}")
return JSONResponse(status_code=500, content={
'err': True,
'errorText': f'Unknown request source: {data.src}',
})
await self.notifier.send(
f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}",
f"Unknown request source: {data.src}",
)
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": f"Unknown request source: {data.src}",
},
)
if not data.t:
search_artist: Optional[str] = data.a
search_song: Optional[str] = data.s
else:
t_split: tuple = tuple(data.t.split(" - ", maxsplit=1))
(search_artist, search_song) = t_split
if search_artist and search_song:
search_artist = str(self.constants.DOUBLE_SPACE_REGEX.sub(" ", search_artist.strip()))
search_song = str(self.constants.DOUBLE_SPACE_REGEX.sub(" ", search_song.strip()))
search_artist = str(
self.constants.DOUBLE_SPACE_REGEX.sub(" ", search_artist.strip())
)
search_song = str(
self.constants.DOUBLE_SPACE_REGEX.sub(" ", search_song.strip())
)
search_artist = urllib.parse.unquote(search_artist)
search_song = urllib.parse.unquote(search_song)
if not isinstance(search_artist, str) or not isinstance(search_song, str):
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request",
},
)
excluded_sources: Optional[list] = data.excluded_sources
aggregate_search = aggregate.Aggregate(exclude_methods=excluded_sources)
plain_lyrics: bool = not data.lrc
result: Optional[Union[LyricsResult, dict]] = await aggregate_search.search(search_artist, search_song, plain_lyrics)
result: Optional[Union[LyricsResult, dict]] = await aggregate_search.search(
search_artist, search_song, plain_lyrics
)
if not result:
return JSONResponse(content={
'err': True,
'errorText': 'Sources exhausted, lyrics not located.',
})
return JSONResponse(
content={
"err": True,
"errorText": "Sources exhausted, lyrics not located.",
}
)
result = vars(result)
if data.sub and not data.lrc:
seeked_found_line: Optional[int] = None
lyric_lines: list[str] = result['lyrics'].strip().split(" / ")
lyric_lines: list[str] = result["lyrics"].strip().split(" / ")
for i, line in enumerate(lyric_lines):
line = regex.sub(r'\u2064', '', line.strip())
line = regex.sub(r"\u2064", "", line.strip())
if data.sub.strip().lower() in line.strip().lower():
seeked_found_line = i
logging.debug("Found %s at %s, match for %s!",
line, seeked_found_line, data.sub) # REMOVEME: DEBUG
logging.debug(
"Found %s at %s, match for %s!",
line,
seeked_found_line,
data.sub,
) # REMOVEME: DEBUG
break
if not seeked_found_line:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Seek (a.k.a. subsearch) failed.',
'failed_seek': True,
})
result['lyrics'] = " / ".join(lyric_lines[seeked_found_line:])
result['confidence'] = int(result['confidence'])
result['time'] = f'{float(result['time']):.4f}'
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Seek (a.k.a. subsearch) failed.",
"failed_seek": True,
},
)
result["lyrics"] = " / ".join(lyric_lines[seeked_found_line:])
result["confidence"] = int(result["confidence"])
result["time"] = f"{float(result['time']):.4f}"
if plain_lyrics:
result['lyrics'] = regex.sub(r'(\s/\s|\n)', '<br>', result['lyrics']).strip()
result["lyrics"] = regex.sub(
r"(\s/\s|\n)", "<br>", result["lyrics"]
).strip()
else:
# Swap lyrics key for 'lrc'
result['lrc'] = result['lyrics']
result.pop('lyrics')
result["lrc"] = result["lyrics"]
result.pop("lyrics")
if "cache" in result['src']:
result['from_cache'] = True
if "cache" in result["src"]:
result["from_cache"] = True
if not data.extra:
result.pop('src')
return JSONResponse(content=result)
result.pop("src")
return JSONResponse(content=result)

View File

@ -2,20 +2,18 @@ import logging
import time
import os
from typing import Optional, Annotated
from fastapi import (
FastAPI, Request, UploadFile,
Response, HTTPException, Form
)
from fastapi import FastAPI, Request, UploadFile, Response, HTTPException, Form
from fastapi.responses import JSONResponse
import redis.asyncio as redis
from lyric_search.sources import private, cache as LyricsCache, redis_cache
class Misc(FastAPI):
"""
Misc Endpoints
"""
def __init__(self, app: FastAPI, my_util,
constants, radio) -> None:
def __init__(self, app: FastAPI, my_util, constants, radio) -> None:
self.app: FastAPI = app
self.util = my_util
self.constants = constants
@ -28,47 +26,55 @@ class Misc(FastAPI):
"widget/redis": self.homepage_redis_widget,
"widget/sqlite": self.homepage_sqlite_widget,
"widget/lyrics": self.homepage_lyrics_widget,
"widget/radio": self.homepage_radio_widget,
"widget/radio": self.homepage_radio_widget,
"misc/get_activity_image": self.get_activity_image,
}
for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["GET"],
include_in_schema=True)
app.add_api_route("/misc/upload_activity_image",
self.upload_activity_image, methods=["POST"])
async def upload_activity_image(self,
image: UploadFile,
key: Annotated[str, Form()], request: Request) -> Response:
if not key or not isinstance(key, str)\
or not self.util.check_key(path=request.url.path, req_type=2, key=key):
raise HTTPException(status_code=403, detail="Unauthorized")
app.add_api_route(
f"/{endpoint}", handler, methods=["GET"], include_in_schema=True
)
app.add_api_route(
"/misc/upload_activity_image", self.upload_activity_image, methods=["POST"]
)
async def upload_activity_image(
self, image: UploadFile, key: Annotated[str, Form()], request: Request
) -> Response:
if (
not key
or not isinstance(key, str)
or not self.util.check_key(path=request.url.path, req_type=2, key=key)
):
raise HTTPException(status_code=403, detail="Unauthorized")
if not image:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request",
},
)
self.activity_image = await image.read()
return JSONResponse(content={
'success': True,
})
return JSONResponse(
content={
"success": True,
}
)
async def get_activity_image(self, request: Request) -> Response:
if isinstance(self.activity_image, bytes):
return Response(content=self.activity_image,
media_type="image/png")
return Response(content=self.activity_image, media_type="image/png")
# Fallback
fallback_path = os.path.join("/var/www/codey.lol/public",
"images", "plex_placeholder.png")
with open(fallback_path, 'rb') as f:
return Response(content=f.read(),
media_type="image/png")
fallback_path = os.path.join(
"/var/www/codey.lol/public", "images", "plex_placeholder.png"
)
with open(fallback_path, "rb") as f:
return Response(content=f.read(), media_type="image/png")
async def get_radio_np(self) -> tuple[str, str, str]:
"""
Get radio now playing
@ -77,76 +83,94 @@ class Misc(FastAPI):
Returns:
str: Radio now playing in artist - song format
"""
np: dict = self.radio.radio_util.now_playing
artistsong: str = np.get('artistsong', 'N/A - N/A')
album: str = np.get('album', 'N/A')
genre: str = np.get('genre', 'N/A')
artistsong: str = np.get("artistsong", "N/A - N/A")
album: str = np.get("album", "N/A")
genre: str = np.get("genre", "N/A")
return (artistsong, album, genre)
async def homepage_redis_widget(self) -> JSONResponse:
"""
Homepage Redis Widget Handler
"""
# Measure response time w/ test lyric search
time_start: float = time.time() # Start time for response_time
test_lyrics_result = await self.redis_client.ft().search("@artist: test @song: test")
time_start: float = time.time() # Start time for response_time
test_lyrics_result = await self.redis_client.ft().search(
"@artist: test @song: test"
)
time_end: float = time.time()
# End response time test
total_keys = await self.redis_client.dbsize()
response_time: float = time_end - time_start
(_, ci_keys) = await self.redis_client.scan(cursor=0, match="ci_session*", count=10000000)
response_time: float = time_end - time_start
(_, ci_keys) = await self.redis_client.scan(
cursor=0, match="ci_session*", count=10000000
)
num_ci_keys = len(ci_keys)
index_info = await self.redis_client.ft().info()
indexed_lyrics: int = index_info.get('num_docs')
return JSONResponse(content={
'responseTime': round(response_time, 7),
'storedKeys': total_keys,
'indexedLyrics': indexed_lyrics,
'sessions': num_ci_keys,
})
indexed_lyrics: int = index_info.get("num_docs")
return JSONResponse(
content={
"responseTime": round(response_time, 7),
"storedKeys": total_keys,
"indexedLyrics": indexed_lyrics,
"sessions": num_ci_keys,
}
)
async def homepage_sqlite_widget(self) -> JSONResponse:
"""
Homepage SQLite Widget Handler
"""
"""
row_count: int = await self.lyr_cache.sqlite_rowcount()
distinct_artists: int = await self.lyr_cache.sqlite_distinct("artist")
lyrics_length: int = await self.lyr_cache.sqlite_lyrics_length()
return JSONResponse(content={
'storedRows': row_count,
'distinctArtists': distinct_artists,
'lyricsLength': lyrics_length,
})
return JSONResponse(
content={
"storedRows": row_count,
"distinctArtists": distinct_artists,
"lyricsLength": lyrics_length,
}
)
async def homepage_lyrics_widget(self) -> JSONResponse:
"""
Homepage Lyrics Widget Handler
"""
found_counts: Optional[dict] = await self.redis_cache.get_found_counts()
if not isinstance(found_counts, dict):
logging.info("DEBUG: Type of found counts from redis: %s\nContents: %s",
type(found_counts), found_counts)
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'General failure.',
})
logging.info(
"DEBUG: Type of found counts from redis: %s\nContents: %s",
type(found_counts),
found_counts,
)
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "General failure.",
},
)
return JSONResponse(content=found_counts)
async def homepage_radio_widget(self) -> JSONResponse:
"""
Homepage Radio Widget Handler
"""
radio_np: tuple = await self.get_radio_np()
if not radio_np:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'General failure.',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "General failure.",
},
)
(artistsong, album, genre) = radio_np
return JSONResponse(content={
'now_playing': artistsong,
'album': album,
'genre': genre,
})
return JSONResponse(
content={
"now_playing": artistsong,
"album": album,
"genre": genre,
}
)

View File

@ -4,21 +4,26 @@ import time
import random
import asyncio
from utils import radio_util
from .constructors import (ValidRadioNextRequest, ValidRadioReshuffleRequest,
ValidRadioQueueShiftRequest, ValidRadioQueueRemovalRequest,
ValidRadioSongRequest, ValidRadioTypeaheadRequest,
RadioException)
from .constructors import (
ValidRadioNextRequest,
ValidRadioReshuffleRequest,
ValidRadioQueueShiftRequest,
ValidRadioQueueRemovalRequest,
ValidRadioSongRequest,
ValidRadioTypeaheadRequest,
RadioException,
)
from uuid import uuid4 as uuid
from typing import Optional
from fastapi import (FastAPI, BackgroundTasks, Request,
Response, HTTPException)
from fastapi import FastAPI, BackgroundTasks, Request, Response, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse
class Radio(FastAPI):
"""Radio Endpoints"""
def __init__(self, app: FastAPI,
my_util, constants) -> None:
def __init__(self, app: FastAPI, my_util, constants) -> None:
self.app: FastAPI = app
self.util = my_util
self.constants = constants
@ -33,22 +38,28 @@ class Radio(FastAPI):
"radio/queue_shift": self.radio_queue_shift,
"radio/reshuffle": self.radio_reshuffle,
"radio/queue_remove": self.radio_queue_remove,
"radio/ls._next_": self.radio_get_next,
"radio/ls._next_": self.radio_get_next,
}
for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["POST"],
include_in_schema=True)
# NOTE: Not in loop because method is GET for this endpoint
app.add_api_route("/radio/album_art", self.album_art_handler, methods=["GET"],
include_in_schema=True)
app.add_api_route(
f"/{endpoint}", handler, methods=["POST"], include_in_schema=True
)
# NOTE: Not in loop because method is GET for this endpoint
app.add_api_route(
"/radio/album_art",
self.album_art_handler,
methods=["GET"],
include_in_schema=True,
)
asyncio.get_event_loop().run_until_complete(self.radio_util.load_playlist())
asyncio.get_event_loop().run_until_complete(self.radio_util._ls_skip())
async def radio_skip(self, data: ValidRadioNextRequest,
request: Request) -> JSONResponse:
asyncio.get_event_loop().run_until_complete(self.radio_util._ls_skip())
async def radio_skip(
self, data: ValidRadioNextRequest, request: Request
) -> JSONResponse:
"""
Skip to the next track in the queue, or to uuid specified in skipTo if provided
- **key**: API key
@ -58,45 +69,54 @@ 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")
if data.skipTo:
queue_item = self.radio_util.get_queue_item_by_uuid(data.skipTo)
queue_item = self.radio_util.get_queue_item_by_uuid(data.skipTo)
if not queue_item:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'No such queue item.',
})
self.radio_util.active_playlist = self.radio_util.active_playlist[queue_item[0]:]
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "No such queue item.",
},
)
self.radio_util.active_playlist = self.radio_util.active_playlist[
queue_item[0] :
]
if not self.radio_util.active_playlist:
await self.radio_util.load_playlist()
skip_result: bool = await self.radio_util._ls_skip()
status_code = 200 if skip_result else 500
return JSONResponse(status_code=status_code, content={
'success': skip_result,
})
return JSONResponse(
status_code=status_code,
content={
"success": skip_result,
},
)
except Exception as e:
traceback.print_exc()
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'General failure.',
})
async def radio_reshuffle(self, data: ValidRadioReshuffleRequest,
request: Request) -> JSONResponse:
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "General failure.",
},
)
async def radio_reshuffle(
self, data: ValidRadioReshuffleRequest, request: Request
) -> JSONResponse:
"""
Reshuffle the play queue
- **key**: API key
"""
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)
return JSONResponse(content={
'ok': True
})
async def radio_get_queue(self, request: Request,
limit: Optional[int] = 15_000) -> JSONResponse:
return JSONResponse(content={"ok": True})
async def radio_get_queue(
self, request: Request, limit: Optional[int] = 15_000
) -> JSONResponse:
"""
Get current play queue, up to limit [default: 15k]
- **limit**: Number of queue items to return, default 15k
@ -104,23 +124,24 @@ class Radio(FastAPI):
queue: list = self.radio_util.active_playlist[0:limit]
queue_out: list[dict] = []
for x, item in enumerate(queue):
queue_out.append({
'pos': x,
'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'),
})
return JSONResponse(content={
'items': queue_out
})
async def radio_queue_shift(self, data: ValidRadioQueueShiftRequest,
request: Request) -> JSONResponse:
queue_out.append(
{
"pos": x,
"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"),
}
)
return JSONResponse(content={"items": queue_out})
async def radio_queue_shift(
self, data: ValidRadioQueueShiftRequest, request: Request
) -> JSONResponse:
"""
Shift position of a UUID within the queue
[currently limited to playing next or immediately]
@ -130,24 +151,30 @@ 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)
if not queue_item:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Queue item not found.',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Queue item not found.",
},
)
(x, item) = queue_item
self.radio_util.active_playlist.pop(x)
self.radio_util.active_playlist.pop(x)
self.radio_util.active_playlist.insert(0, item)
if not data.next:
await self.radio_util._ls_skip()
return JSONResponse(content={
'ok': True,
})
async def radio_queue_remove(self, data: ValidRadioQueueRemovalRequest,
request: Request) -> JSONResponse:
return JSONResponse(
content={
"ok": True,
}
)
async def radio_queue_remove(
self, data: ValidRadioQueueRemovalRequest, request: Request
) -> JSONResponse:
"""
Remove an item from the current play queue
- **key**: API key
@ -155,19 +182,26 @@ 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)
if not queue_item:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Queue item not found.',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Queue item not found.",
},
)
self.radio_util.active_playlist.pop(queue_item[0])
return JSONResponse(content={
'ok': True,
})
async def album_art_handler(self, request: Request, track_id: Optional[int] = None) -> Response:
return JSONResponse(
content={
"ok": True,
}
)
async def album_art_handler(
self, request: Request, track_id: Optional[int] = None
) -> Response:
"""
Get album art, optional parameter track_id may be specified.
Otherwise, current track album art will be pulled.
@ -175,35 +209,42 @@ class Radio(FastAPI):
"""
try:
if not track_id:
track_id = self.radio_util.now_playing.get('id')
track_id = self.radio_util.now_playing.get("id")
logging.debug("Seeking album art with trackId: %s", track_id)
album_art: Optional[bytes] = await self.radio_util.get_album_art(track_id=track_id)
album_art: Optional[bytes] = await self.radio_util.get_album_art(
track_id=track_id
)
if not album_art:
return RedirectResponse(url="https://codey.lol/images/radio_art_default.jpg",
status_code=302)
return Response(content=album_art,
media_type="image/png")
return RedirectResponse(
url="https://codey.lol/images/radio_art_default.jpg",
status_code=302,
)
return Response(content=album_art, media_type="image/png")
except Exception as e:
traceback.print_exc()
return RedirectResponse(url="https://codey.lol/images/radio_art_default.jpg",
status_code=302)
return RedirectResponse(
url="https://codey.lol/images/radio_art_default.jpg", status_code=302
)
async def radio_now_playing(self, request: Request) -> JSONResponse:
"""
Get currently playing track info
"""
ret_obj: dict = {**self.radio_util.now_playing}
try:
ret_obj['elapsed'] = int(time.time()) - ret_obj['start']
ret_obj["elapsed"] = int(time.time()) - ret_obj["start"]
except KeyError:
traceback.print_exc()
ret_obj['elapsed'] = 0
ret_obj.pop('file_path')
ret_obj["elapsed"] = 0
ret_obj.pop("file_path")
return JSONResponse(content=ret_obj)
async def radio_get_next(self, data: ValidRadioNextRequest, request: Request,
background_tasks: BackgroundTasks) -> JSONResponse:
async def radio_get_next(
self,
data: ValidRadioNextRequest,
request: Request,
background_tasks: BackgroundTasks,
) -> JSONResponse:
"""
Get next track
Track will be removed from the queue in the process.
@ -211,54 +252,65 @@ class Radio(FastAPI):
- **skipTo**: Optional UUID to skip to
"""
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:
raise HTTPException(status_code=403, detail="Unauthorized")
if (
not isinstance(self.radio_util.active_playlist, list)
or not self.radio_util.active_playlist
):
await self.radio_util.load_playlist()
await self.radio_util._ls_skip()
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'General failure occurred, prompting playlist reload.',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "General failure occurred, prompting playlist reload.",
},
)
next = self.radio_util.active_playlist.pop(0)
if not isinstance(next, dict):
logging.critical("next is of type: %s, reloading playlist...", type(next))
await self.radio_util.load_playlist()
await self.radio_util._ls_skip()
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'General failure occurred, prompting playlist reload.',
})
duration: int = next['duration']
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "General failure occurred, prompting playlist reload.",
},
)
duration: int = next["duration"]
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) > 1:
self.radio_util.active_playlist.append(next) # Push to end of playlist
self.radio_util.active_playlist.append(next) # Push to end of playlist
else:
await self.radio_util.load_playlist()
self.radio_util.now_playing = next
next['start'] = time_started
next['end'] = time_ends
next["start"] = time_started
next["end"] = time_ends
try:
background_tasks.add_task(self.radio_util.webhook_song_change, next)
except Exception as e:
traceback.print_exc()
try:
if not await self.radio_util.get_album_art(file_path=next['file_path']):
album_art = await self.radio_util.get_album_art(file_path=next['file_path'])
if not await self.radio_util.get_album_art(file_path=next["file_path"]):
album_art = await self.radio_util.get_album_art(
file_path=next["file_path"]
)
if album_art:
await self.radio_util.cache_album_art(next['id'], album_art)
await self.radio_util.cache_album_art(next["id"], album_art)
else:
logging.debug("Could not read album art for %s",
next['file_path'])
logging.debug("Could not read album art for %s", next["file_path"])
except:
traceback.print_exc()
return JSONResponse(content=next)
async def radio_request(self, data: ValidRadioSongRequest, request: Request) -> JSONResponse:
async def radio_request(
self, data: ValidRadioSongRequest, request: Request
) -> JSONResponse:
"""
Song request handler
- **key**: API key
@ -268,42 +320,52 @@ class Radio(FastAPI):
- **alsoSkip**: If True, skips to the track; otherwise, track will be placed next up in queue
"""
if not self.util.check_key(path=request.url.path, req_type=4, key=data.key):
raise HTTPException(status_code=403, detail="Unauthorized")
raise HTTPException(status_code=403, detail="Unauthorized")
artistsong: Optional[str] = data.artistsong
artist: Optional[str] = data.artist
song: Optional[str] = data.song
if artistsong and (artist or song):
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request",
},
)
if not artistsong and (not artist or not song):
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request',
})
search: bool = await self.radio_util.search_playlist(artistsong=artistsong,
artist=artist,
song=song)
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request",
},
)
search: bool = await self.radio_util.search_playlist(
artistsong=artistsong, artist=artist, song=song
)
if data.alsoSkip:
await self.radio_util._ls_skip()
return JSONResponse(content={
'result': search
})
async def radio_typeahead(self, data: ValidRadioTypeaheadRequest,
request: Request) -> JSONResponse:
return JSONResponse(content={"result": search})
async def radio_typeahead(
self, data: ValidRadioTypeaheadRequest, request: Request
) -> JSONResponse:
"""
Radio typeahead handler
- **query**: Typeahead query
"""
if not isinstance(data.query, str):
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request.',
})
typeahead: Optional[list[str]] = await self.radio_util.trackdb_typeahead(data.query)
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request.",
},
)
typeahead: Optional[list[str]] = await self.radio_util.trackdb_typeahead(
data.query
)
if not typeahead:
return JSONResponse(content=[])
return JSONResponse(content=typeahead)
return JSONResponse(content=typeahead)

View File

@ -6,20 +6,25 @@ from fastapi import FastAPI
from fastapi.responses import JSONResponse
from .constructors import RandMsgRequest
class RandMsg(FastAPI):
"""
Random Message Endpoint
"""
def __init__(self, app: FastAPI,
util, constants) -> None:
"""
def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app
self.util = util
self.constants = constants
self.endpoint_name = "randmsg"
app.add_api_route(f"/{self.endpoint_name}", self.randmsg_handler, methods=["POST"])
async def randmsg_handler(self, data: Optional[RandMsgRequest] = None) -> JSONResponse:
app.add_api_route(
f"/{self.endpoint_name}", self.randmsg_handler, methods=["POST"]
)
async def randmsg_handler(
self, data: Optional[RandMsgRequest] = None
) -> JSONResponse:
"""
Get a randomly generated message
- **short**: Optional, if True, will limit length of returned random messages to <=126 characters (Discord restriction related)
@ -35,58 +40,61 @@ class RandMsg(FastAPI):
match db_rand_selected:
case 0:
randmsg_db_path: Union[str, LiteralString] = os.path.join("/usr/local/share",
"sqlite_dbs", "qajoke.db") # For qajoke db
db_query: str = "SELECT id, ('<b>Q:</b> ' || question || '<br/><b>A:</b> ' \
|| answer) FROM jokes ORDER BY RANDOM() LIMIT 1" # For qajoke db
randmsg_db_path: Union[str, LiteralString] = os.path.join(
"/usr/local/share", "sqlite_dbs", "qajoke.db"
) # For qajoke db
db_query: str = (
"SELECT id, ('<b>Q:</b> ' || question || '<br/><b>A:</b> ' \
|| answer) FROM jokes ORDER BY RANDOM() LIMIT 1" # For qajoke db
)
title_attr = "QA Joke DB"
case 1 | 9:
randmsg_db_path = os.path.join("/usr/local/share",
"sqlite_dbs",
"randmsg.db") # For randmsg db
randmsg_db_path = os.path.join(
"/usr/local/share", "sqlite_dbs", "randmsg.db"
) # For randmsg db
db_query = "SELECT id, msg FROM msgs WHERE \
LENGTH(msg) <= 180 ORDER BY RANDOM() LIMIT 1" # For randmsg db
LENGTH(msg) <= 180 ORDER BY RANDOM() LIMIT 1" # For randmsg db
if db_rand_selected == 9:
db_query = db_query.replace("<= 180", "<= 126")
title_attr = "Random Msg DB"
case 2:
randmsg_db_path = os.path.join("/usr/local/share",
"sqlite_dbs",
"trump.db") # For Trump Tweet DB
randmsg_db_path = os.path.join(
"/usr/local/share", "sqlite_dbs", "trump.db"
) # For Trump Tweet DB
db_query = "SELECT id, content FROM tweets \
ORDER BY RANDOM() LIMIT 1" # For Trump Tweet DB
ORDER BY RANDOM() LIMIT 1" # For Trump Tweet DB
title_attr = "Trump Tweet DB"
case 3:
randmsg_db_path = os.path.join("/usr/local/share",
"sqlite_dbs",
"philo.db") # For Philo DB
randmsg_db_path = os.path.join(
"/usr/local/share", "sqlite_dbs", "philo.db"
) # For Philo DB
db_query = "SELECT id, (content || '<br> - ' || speaker) FROM quotes \
ORDER BY RANDOM() LIMIT 1" # For Philo DB
title_attr = "Philosophical Quotes DB"
case 4:
randmsg_db_path = os.path.join("/usr/local/share",
"sqlite_dbs",
"hate.db") # For Hate DB
randmsg_db_path = os.path.join(
"/usr/local/share", "sqlite_dbs", "hate.db"
) # For Hate DB
db_query = """SELECT id, ("<font color='#FF0000'>" || comment) FROM hate_speech \
WHERE length(comment) <= 180 ORDER BY RANDOM() LIMIT 1"""
title_attr = "Hate Speech DB"
case 5:
randmsg_db_path = os.path.join("/usr/local/share",
"sqlite_dbs",
"rjokes.db") # r/jokes DB
randmsg_db_path = os.path.join(
"/usr/local/share", "sqlite_dbs", "rjokes.db"
) # r/jokes DB
db_query = """SELECT id, (title || "<br>" || body) FROM jokes \
WHERE score >= 10000 ORDER BY RANDOM() LIMIT 1"""
title_attr = "r/jokes DB"
async with sqlite3.connect(database=randmsg_db_path, timeout=1) as _db:
async with await _db.execute(db_query) as _cursor:
result: sqlite3.Row = await _cursor.fetchone()
(result_id, result_msg) = result
result_msg = result_msg.strip()
return JSONResponse(content=
{
"id": result_id,
"msg": result_msg,
"title": title_attr,
})
return JSONResponse(
content={
"id": result_id,
"msg": result_msg,
"title": title_attr,
}
)

View File

@ -5,10 +5,12 @@ from fastapi.responses import JSONResponse
from typing import Optional, LiteralString, Union
from .constructors import ValidShowEpisodeLineRequest, ValidShowEpisodeListRequest
class Transcriptions(FastAPI):
"""
Transcription Endpoints
"""
"""
def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app
self.util = util
@ -17,14 +19,17 @@ class Transcriptions(FastAPI):
self.endpoints: dict = {
"transcriptions/get_episodes": self.get_episodes_handler,
"transcriptions/get_episode_lines": self.get_episode_lines_handler,
#tbd
}
# tbd
}
for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["POST"],
include_in_schema=True)
async def get_episodes_handler(self, data: ValidShowEpisodeListRequest) -> JSONResponse:
app.add_api_route(
f"/{endpoint}", handler, methods=["POST"], include_in_schema=True
)
async def get_episodes_handler(
self, data: ValidShowEpisodeListRequest
) -> JSONResponse:
"""
Get list of episodes by show id
- **s**: Show ID to query
@ -33,56 +38,68 @@ class Transcriptions(FastAPI):
db_path: Optional[Union[str, LiteralString]] = None
db_query: Optional[str] = None
show_title: Optional[str] = None
if not isinstance(show_id, int):
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Invalid request',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Invalid request",
},
)
show_id = int(show_id)
if not(str(show_id).isnumeric()) or show_id not in [0, 1, 2]:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Show not found.',
})
if not (str(show_id).isnumeric()) or show_id not in [0, 1, 2]:
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Show not found.",
},
)
match show_id:
case 0:
db_path = os.path.join("/usr/local/share",
"sqlite_dbs", "sp.db")
db_path = os.path.join("/usr/local/share", "sqlite_dbs", "sp.db")
db_query = """SELECT DISTINCT(("S" || Season || "E" || Episode || " " || Title)), ID FROM SP_DAT ORDER BY Season, Episode"""
show_title = "South Park"
case 1:
db_path = os.path.join("/usr/local/share",
"sqlite_dbs", "futur.db")
db_path = os.path.join("/usr/local/share", "sqlite_dbs", "futur.db")
db_query = """SELECT DISTINCT(("S" || EP_S || "E" || EP_EP || " " || EP_TITLE)), EP_ID FROM clean_dialog ORDER BY EP_S, EP_EP"""
show_title = "Futurama"
case 2:
db_path = os.path.join("/usr/local/share",
"sqlite_dbs", "parks.db")
db_path = os.path.join("/usr/local/share", "sqlite_dbs", "parks.db")
db_query = """SELECT DISTINCT(("S" || EP_S || "E" || EP_EP || " " || EP_TITLE)), EP_ID FROM clean_dialog ORDER BY EP_S, EP_EP"""
show_title = "Parks And Rec"
case _:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Unknown error.',
})
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Unknown error.",
},
)
async with sqlite3.connect(database=db_path, timeout=1) as _db:
async with await _db.execute(db_query) as _cursor:
result: list[tuple] = await _cursor.fetchall()
return JSONResponse(content={
"show_title": show_title,
"episodes": [
{
'id': item[1],
'ep_friendly': item[0],
} for item in result],
})
async def get_episode_lines_handler(self, data: ValidShowEpisodeLineRequest) -> JSONResponse:
return JSONResponse(
content={
"show_title": show_title,
"episodes": [
{
"id": item[1],
"ep_friendly": item[0],
}
for item in result
],
}
)
async def get_episode_lines_handler(
self, data: ValidShowEpisodeLineRequest
) -> JSONResponse:
"""
Get lines for a particular episode
- **s**: Show ID to query
@ -90,39 +107,46 @@ class Transcriptions(FastAPI):
"""
show_id: int = int(data.s)
episode_id: int = int(data.e)
match show_id:
case 0:
db_path: Union[str, LiteralString] = os.path.join("/usr/local/share",
"sqlite_dbs", "sp.db")
db_query: str = """SELECT ("S" || Season || "E" || Episode || " " || Title), Character, Line FROM SP_DAT WHERE ID = ?"""
db_path: Union[str, LiteralString] = os.path.join(
"/usr/local/share", "sqlite_dbs", "sp.db"
)
db_query: str = (
"""SELECT ("S" || Season || "E" || Episode || " " || Title), Character, Line FROM SP_DAT WHERE ID = ?"""
)
case 1:
db_path = os.path.join("/usr/local/share",
"sqlite_dbs", "futur.db")
db_path = os.path.join("/usr/local/share", "sqlite_dbs", "futur.db")
db_query = """SELECT ("S" || EP_S || "E" || EP_EP || " " || EP_TITLE || "<br><em>Opener: " || EP_OPENER || "</em>"), EP_LINE_SPEAKER, EP_LINE FROM clean_dialog WHERE EP_ID = ? ORDER BY LINE_ID ASC"""
case 2:
db_path = os.path.join("/usr/local/share",
"sqlite_dbs", "parks.db")
db_path = os.path.join("/usr/local/share", "sqlite_dbs", "parks.db")
db_query = """SELECT ("S" || EP_S || "E" || EP_EP || " " || EP_TITLE), EP_LINE_SPEAKER, EP_LINE FROM clean_dialog WHERE EP_ID = ? ORDER BY id ASC"""
case _:
return JSONResponse(status_code=500, content={
'err': True,
'errorText': 'Unknown error',
})
async with sqlite3.connect(database=db_path,
timeout=1) as _db:
return JSONResponse(
status_code=500,
content={
"err": True,
"errorText": "Unknown error",
},
)
async with sqlite3.connect(database=db_path, timeout=1) as _db:
params: tuple = (episode_id,)
async with await _db.execute(db_query, params) as _cursor:
result: list[tuple] = await _cursor.fetchall()
first_result: tuple = result[0]
return JSONResponse(content={
'episode_id': episode_id,
'ep_friendly': first_result[0].strip(),
'lines': [
{
'speaker': item[1].strip(),
'line': item[2].strip(),
} for item in result],
})
return JSONResponse(
content={
"episode_id": episode_id,
"ep_friendly": first_result[0].strip(),
"lines": [
{
"speaker": item[1].strip(),
"line": item[2].strip(),
}
for item in result
],
}
)

View File

@ -4,12 +4,13 @@ from fastapi.responses import JSONResponse
from typing import Optional, Union
from .constructors import ValidYTSearchRequest
class YT(FastAPI):
"""
YT Endpoints
"""
def __init__(self, app: FastAPI, util,
constants) -> None:
"""
def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app
self.util = util
self.constants = constants
@ -17,28 +18,34 @@ class YT(FastAPI):
self.endpoints: dict = {
"yt/search": self.yt_video_search_handler,
}
}
for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["POST"],
include_in_schema=True)
app.add_api_route(
f"/{endpoint}", handler, methods=["POST"], include_in_schema=True
)
async def yt_video_search_handler(self, data: ValidYTSearchRequest) -> JSONResponse:
"""
Search for YT Video by Title (closest match returned)
- **t**: Title to search
"""
title: str = data.t
yts_res: Optional[list[dict]] = await self.ytsearch.search(title)
if not yts_res:
return JSONResponse(status_code=404, content={
'err': True,
'errorText': 'No result.',
})
yt_video_id: Union[str, bool] = yts_res[0].get('id', False)
return JSONResponse(
status_code=404,
content={
"err": True,
"errorText": "No result.",
},
)
yt_video_id: Union[str, bool] = yts_res[0].get("id", False)
return JSONResponse(content={
'video_id': yt_video_id,
'extras': yts_res[0],
})
return JSONResponse(
content={
"video_id": yt_video_id,
"extras": yts_res[0],
}
)

View File

@ -1,6 +1,7 @@
from typing import Optional
from openai import AsyncOpenAI
class GPT:
def __init__(self, constants) -> None:
self.constants = constants
@ -12,8 +13,9 @@ class GPT:
self.default_system_prompt: str = """You are a helpful assistant who will provide only totally accurate tidbits of \
info on the specific songs the user may listen to."""
async def get_completion(self, prompt: str,
system_prompt: Optional[str] = None) -> Optional[str]:
async def get_completion(
self, prompt: str, system_prompt: Optional[str] = None
) -> Optional[str]:
if not system_prompt:
system_prompt = self.default_system_prompt
chat_completion = await self.client.chat.completions.create(
@ -25,10 +27,10 @@ class GPT:
{
"role": "user",
"content": prompt,
}
},
],
model="gpt-4o-mini",
temperature=0.35,
)
response: Optional[str] = chat_completion.choices[0].message.content
return response
return response

View File

@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Union
@dataclass
class LyricsResult:
"""
@ -12,31 +13,37 @@ class LyricsResult:
lyrics (Union[str, list]): str if plain lyrics, list for lrc
time (float): time taken to retrieve lyrics from source
"""
artist: str
song: str
src: str
lyrics: Union[str, list]
confidence: int
time: float = 0.00
"""
Generic
"""
class InvalidLyricSearchResponseException(Exception):
pass
"""
Genius
"""
class InvalidGeniusResponseException(
InvalidLyricSearchResponseException):
class InvalidGeniusResponseException(InvalidLyricSearchResponseException):
pass
"""
LRCLib
"""
class InvalidLRCLibResponseException(
InvalidLyricSearchResponseException):
pass
class InvalidLRCLibResponseException(InvalidLyricSearchResponseException):
pass

View File

@ -4,23 +4,26 @@ from lyric_search import notifier
import sys
import logging
import traceback
sys.path.insert(1,'..')
sys.path.insert(1, "..")
from . import cache, redis_cache, genius, lrclib
class Aggregate:
"""
Aggregate all source methods
"""
def __init__(self, exclude_methods=None) -> None:
if not exclude_methods:
exclude_methods: list = []
self.exclude_methods = exclude_methods
self.redis_cache = redis_cache.RedisCache()
self.notifier = notifier.DiscordNotifier()
async def search(self, artist: str, song: str,
plain: Optional[bool] = True) -> Optional[LyricsResult]:
async def search(
self, artist: str, song: str, plain: Optional[bool] = True
) -> Optional[LyricsResult]:
"""
Aggregate Search
Args:
@ -41,37 +44,41 @@ class Aggregate:
cache_search,
lrclib_search,
genius_search,
]
]
if not plain:
sources = [lrclib_search] # Only LRCLib supported for synced lyrics
sources = [lrclib_search] # Only LRCLib supported for synced lyrics
search_result: Optional[LyricsResult] = None
for source in sources:
if source.label.lower() in self.exclude_methods:
if not plain:
logging.info("Exclude conditions rejected - source requested to exclude: %s, plain: %s",
source.label, plain)
logging.info(
"Exclude conditions rejected - source requested to exclude: %s, plain: %s",
source.label,
plain,
)
else:
if plain:
logging.info("Skipping source: %s, excluded.", source.label)
continue
search_result = await source.search(artist=artist, song=song,
plain=plain)
search_result = await source.search(artist=artist, song=song, plain=plain)
if search_result:
break
logging.info("%s: NOT FOUND!", source.label)
if not search_result:
logging.info("%s - %s: all sources exhausted, not found.",
artist, song)
if plain: # do not record LRC fails
try:
logging.info("%s - %s: all sources exhausted, not found.", artist, song)
if plain: # do not record LRC fails
try:
await self.redis_cache.increment_found_count("failed")
self.notifier.send("WARNING",
f"Could not find {artist} - {song} via queried sources.")
self.notifier.send(
"WARNING",
f"Could not find {artist} - {song} via queried sources.",
)
except Exception as e:
traceback.print_exc()
logging.info("Could not increment redis failed counter: %s",
str(e))
self.notifier.send(f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}",
f"Could not increment redis failed counter: {str(e)}")
return search_result
logging.info("Could not increment redis failed counter: %s", str(e))
self.notifier.send(
f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}",
f"Could not increment redis failed counter: {str(e)}",
)
return search_result

View File

@ -4,8 +4,9 @@ import regex
import logging
import sys
import traceback
sys.path.insert(1,'..')
sys.path.insert(1,'.')
sys.path.insert(1, "..")
sys.path.insert(1, ".")
from typing import Optional, Union, LiteralString
import aiosqlite as sqlite3
from . import redis_cache
@ -15,27 +16,38 @@ from lyric_search.constructors import LyricsResult
logger = logging.getLogger()
log_level = logging.getLevelName(logger.level)
class Cache:
"""Cache Search Module"""
def __init__(self) -> None:
self.cache_db: Union[str, LiteralString] = os.path.join("/", "usr", "local", "share",
"sqlite_dbs", "cached_lyrics.db")
self.cache_db: Union[str, LiteralString] = os.path.join(
"/", "usr", "local", "share", "sqlite_dbs", "cached_lyrics.db"
)
self.redis_cache = redis_cache.RedisCache()
self.notifier = notifier.DiscordNotifier()
self.cache_pre_query: str = "pragma journal_mode = WAL; pragma synchronous = normal;\
self.cache_pre_query: str = (
"pragma journal_mode = WAL; pragma synchronous = normal;\
pragma temp_store = memory; pragma mmap_size = 30000000000;"
self.sqlite_exts: list[str] = ['/home/api/api/solibs/spellfix1.cpython-311-x86_64-linux-gnu.so']
)
self.sqlite_exts: list[str] = [
"/home/api/api/solibs/spellfix1.cpython-311-x86_64-linux-gnu.so"
]
self.label: str = "Cache"
def get_matched(self, matched_candidate: tuple, confidence: int,
sqlite_rows: Optional[list[sqlite3.Row]] = None,
redis_results: Optional[list] = None) -> Optional[LyricsResult]:
def get_matched(
self,
matched_candidate: tuple,
confidence: int,
sqlite_rows: Optional[list[sqlite3.Row]] = None,
redis_results: Optional[list] = None,
) -> Optional[LyricsResult]:
"""
Get Matched Result
Args:
matched_candidate (tuple): the correctly matched candidate returned by matcher.best_match
confidence (int): % confidence
confidence (int): % confidence
sqlite_rows (Optional[list[sqlite3.Row]]): List of returned rows from SQLite DB, or None if Redis
redis_results (Any): List of Redis returned data, or None if SQLite
Returns:
@ -47,11 +59,11 @@ class Cache:
(key, row) = res
if key == matched_id:
return LyricsResult(
artist=row['artist'],
song=row['song'],
lyrics=row['lyrics'],
artist=row["artist"],
song=row["song"],
lyrics=row["lyrics"],
src=f"{row['src']} (redis cache, id: {key})",
confidence=row['confidence']
confidence=row["confidence"],
)
else:
for row in sqlite_rows:
@ -62,9 +74,10 @@ class Cache:
song=song,
lyrics=lyrics,
src=f"{original_src} (cached, id: {_id})",
confidence=confidence)
confidence=confidence,
)
return None
async def check_existence(self, artistsong: str) -> Optional[bool]:
"""
Check whether lyrics are already stored for track
@ -73,10 +86,13 @@ class Cache:
Returns:
bool: Whether track was found in cache
"""
logging.debug("Checking whether %s is already stored",
artistsong.replace("\n", " - "))
check_query: str = 'SELECT id, artist, song FROM lyrics WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
logging.debug(
"Checking whether %s is already stored", artistsong.replace("\n", " - ")
)
check_query: str = (
'SELECT id, artist, song FROM lyrics WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
<= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 1'
)
artistsong_split = artistsong.split("\n", maxsplit=1)
artist = artistsong_split[0].lower()
song = artistsong_split[1].lower()
@ -84,39 +100,45 @@ class Cache:
async with sqlite3.connect(self.cache_db, timeout=2) as db_conn:
await db_conn.enable_load_extension(True)
for ext in self.sqlite_exts:
await db_conn.load_extension(ext)
async with await db_conn.executescript(self.cache_pre_query) as _db_cursor:
await db_conn.load_extension(ext)
async with await db_conn.executescript(self.cache_pre_query) as _db_cursor:
async with await db_conn.execute(check_query, params) as db_cursor:
result = await db_cursor.fetchone()
if result:
logging.debug("%s is already stored.",
artistsong.replace("\n", " - "))
logging.debug(
"%s is already stored.", artistsong.replace("\n", " - ")
)
return True
logging.debug("%s cleared to be stored.",
artistsong)
logging.debug("%s cleared to be stored.", artistsong)
return False
async def store(self, lyr_result: LyricsResult) -> None:
"""
Store lyrics (SQLite, then Redis)
Args:
lyr_result (LyricsResult): the returned lyrics to cache
Returns: None
Returns: None
"""
try:
sqlite_insert_id = await self.sqlite_store(lyr_result)
if sqlite_insert_id:
await self.redis_cache.redis_store(sqlite_insert_id, lyr_result)
except Exception as e:
traceback.print_exc()
logging.error("ERROR @ %s: %s",
__file__.rsplit("/", maxsplit=1)[-1], f"cache::store >> {str(e)}")
await self.notifier.send(f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}",
f"cache::store >> `{str(e)}`")
async def sqlite_rowcount(self, where: Optional[str] = None,
params: Optional[tuple] = None) -> int:
logging.error(
"ERROR @ %s: %s",
__file__.rsplit("/", maxsplit=1)[-1],
f"cache::store >> {str(e)}",
)
await self.notifier.send(
f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}",
f"cache::store >> `{str(e)}`",
)
async def sqlite_rowcount(
self, where: Optional[str] = None, params: Optional[tuple] = None
) -> int:
"""
Get rowcount for cached_lyrics DB
Args:
@ -130,8 +152,8 @@ class Cache:
query = f"SELECT count(id) AS rowcount FROM lyrics {where}".strip()
async with await db_conn.execute(query, params) as db_cursor:
result = await db_cursor.fetchone()
return result['rowcount']
return result["rowcount"]
async def sqlite_distinct(self, column: str) -> int:
"""
Get count of distinct values for a column
@ -145,8 +167,8 @@ class Cache:
query = f"SELECT COUNT(DISTINCT {column}) as distinct_items FROM lyrics"
async with await db_conn.execute(query) as db_cursor:
result = await db_cursor.fetchone()
return result['distinct_items']
return result["distinct_items"]
async def sqlite_lyrics_length(self) -> int:
"""
Get total length of text stored for lyrics
@ -160,9 +182,8 @@ class Cache:
query = "SELECT SUM(LENGTH(lyrics)) as lyrics_len FROM lyrics"
async with await db_conn.execute(query) as db_cursor:
result = await db_cursor.fetchone()
return result['lyrics_len']
return result["lyrics_len"]
async def sqlite_store(self, lyr_result: LyricsResult) -> int:
"""
Store lyrics to SQLite Cache
@ -172,30 +193,42 @@ class Cache:
int: the inserted row id
"""
logging.info("Storing %s",
f"{lyr_result.artist} - {lyr_result.song}")
logging.info("Storing %s", f"{lyr_result.artist} - {lyr_result.song}")
if lyr_result.src.lower() == "cache":
logging.info("Skipping cache storage - returned LyricsResult originated from cache")
logging.info(
"Skipping cache storage - returned LyricsResult originated from cache"
)
return
artistsong = f"{lyr_result.artist}\n{lyr_result.song}"
if await self.check_existence(artistsong):
logging.info("Skipping cache storage - %s is already stored.",
artistsong.replace("\n", " - "))
logging.info(
"Skipping cache storage - %s is already stored.",
artistsong.replace("\n", " - "),
)
return
try:
lyrics = regex.sub(r'(<br>|\n|\r\n)', ' / ', lyr_result.lyrics.strip())
lyrics = regex.sub(r'\s{2,}', ' ', lyrics)
lyrics = regex.sub(r"(<br>|\n|\r\n)", " / ", lyr_result.lyrics.strip())
lyrics = regex.sub(r"\s{2,}", " ", lyrics)
insert_query = "INSERT INTO lyrics (src, date_retrieved, artist, song, artistsong, confidence, lyrics)\
VALUES(?, ?, ?, ?, ?, ?, ?)"
params = (lyr_result.src, time.time(), lyr_result.artist,
lyr_result.song, artistsong, lyr_result.confidence, lyrics)
params = (
lyr_result.src,
time.time(),
lyr_result.artist,
lyr_result.song,
artistsong,
lyr_result.confidence,
lyrics,
)
async with sqlite3.connect(self.cache_db, timeout=2) as db_conn:
async with await db_conn.executescript(self.cache_pre_query) as _db_cursor:
async with await db_conn.executescript(
self.cache_pre_query
) as _db_cursor:
async with await db_conn.execute(insert_query, params) as _cursor:
await db_conn.commit()
logging.info("Stored %s to SQLite!", artistsong.replace("\n", " - "))
@ -203,7 +236,7 @@ class Cache:
except:
logging.critical("Cache storage error!")
traceback.print_exc()
async def search(self, artist: str, song: str, **kwargs) -> Optional[LyricsResult]:
"""
Cache Search
@ -214,8 +247,8 @@ class Cache:
Optional[LyricsResult]: The result, if found - None otherwise.
"""
try:
artist: str = artist.strip().lower()
song: str = song.strip().lower()
artist: str = artist.strip().lower()
song: str = song.strip().lower()
input_track: str = f"{artist} - {song}"
search_query = None
search_params: Optional[tuple] = None
@ -225,87 +258,105 @@ class Cache:
if artist == "!" and song == "!":
random_search = True
search_query: str = 'SELECT id, artist, song, lyrics, src, confidence\
FROM lyrics ORDER BY RANDOM() LIMIT 1'
search_query: str = (
"SELECT id, artist, song, lyrics, src, confidence\
FROM lyrics ORDER BY RANDOM() LIMIT 1"
)
logging.info("Searching %s - %s on %s", artist, song, self.label)
logging.info("Searching %s - %s on %s",
artist, song, self.label)
"""Check Redis First"""
logging.debug("Checking redis cache for %s...",
f"{artist} - {song}")
logging.debug("Checking redis cache for %s...", f"{artist} - {song}")
try:
redis_result = await self.redis_cache.search(artist=artist,
song=song)
redis_result = await self.redis_cache.search(artist=artist, song=song)
if redis_result:
result_tracks: list = []
for returned in redis_result:
(key, track) = returned
result_tracks.append((key, f"{track['artist']} - {track['song']}"))
result_tracks.append(
(key, f"{track['artist']} - {track['song']}")
)
if not random_search:
best_match: Optional[tuple] = matcher.find_best_match(input_track=input_track,
candidate_tracks=result_tracks)
best_match: Optional[tuple] = matcher.find_best_match(
input_track=input_track, candidate_tracks=result_tracks
)
else:
best_match = (result_tracks[0], 100)
if best_match:
(candidate, confidence) = best_match
matched = self.get_matched(redis_results=redis_result, matched_candidate=candidate,
confidence=confidence)
matched = self.get_matched(
redis_results=redis_result,
matched_candidate=candidate,
confidence=confidence,
)
if matched and confidence >= 90:
time_end: float = time.time()
time_diff: float = time_end - time_start
matched.confidence = confidence
matched.time = time_diff
logging.info("Found %s on redis cache, skipping SQLite...",
f"{artist} - {song}")
await self.redis_cache.increment_found_count(self.label)
logging.info(
"Found %s on redis cache, skipping SQLite...",
f"{artist} - {song}",
)
await self.redis_cache.increment_found_count(self.label)
return matched
except:
pass
"""SQLite: Fallback"""
async with sqlite3.connect(self.cache_db, timeout=2) as db_conn:
await db_conn.enable_load_extension(True)
for ext in self.sqlite_exts:
await db_conn.load_extension(ext)
async with await db_conn.executescript(self.cache_pre_query) as _db_cursor:
async with await db_conn.executescript(
self.cache_pre_query
) as _db_cursor:
if not random_search:
search_query: str = 'SELECT id, artist, song, lyrics, src, confidence FROM lyrics\
search_query: str = (
'SELECT id, artist, song, lyrics, src, confidence FROM lyrics\
WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
<= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 10'
search_params: tuple = (artist.strip(), song.strip(),
f"{artist.strip()} {song.strip()}")
async with await _db_cursor.execute(search_query, search_params) as db_cursor:
)
search_params: tuple = (
artist.strip(),
song.strip(),
f"{artist.strip()} {song.strip()}",
)
async with await _db_cursor.execute(
search_query, search_params
) as db_cursor:
results: list = await db_cursor.fetchall()
result_tracks: list = []
for track in results:
(_id, _artist, _song, _lyrics, _src, _confidence) = track
result_tracks.append((_id, f"{_artist} - {_song}"))
if not random_search:
best_match: Optional[tuple] = matcher.find_best_match(input_track=input_track,
candidate_tracks=result_tracks)
best_match: Optional[tuple] = matcher.find_best_match(
input_track=input_track, candidate_tracks=result_tracks
)
else:
best_match = (result_tracks[0], 100)
if not best_match or confidence < 90:
return None
(candidate, confidence) = best_match
logging.info("Result found on %s", self.label)
matched = self.get_matched(sqlite_rows=results,
matched_candidate=candidate,
confidence=confidence)
matched = self.get_matched(
sqlite_rows=results,
matched_candidate=candidate,
confidence=confidence,
)
time_end: float = time.time()
time_diff: float = time_end - time_start
matched.time = time_diff
await self.redis_cache.increment_found_count(self.label)
return matched
except:
traceback.print_exc()
traceback.print_exc()

View File

@ -1,4 +1,4 @@
SCRAPE_HEADERS: dict[str, str] = {
'accept': '*/*',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0',
}
"accept": "*/*",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
}

View File

@ -1,29 +1,31 @@
import sys
sys.path.insert(1,'..')
sys.path.insert(1, "..")
import traceback
import logging
import time
import re
from typing import Optional
from aiohttp import ClientTimeout, ClientSession
from bs4 import BeautifulSoup, ResultSet # type: ignore
from bs4 import BeautifulSoup, ResultSet # type: ignore
import html as htm
from . import private, common, cache, redis_cache
from lyric_search import utils
from lyric_search.constructors import (
LyricsResult, InvalidGeniusResponseException)
from lyric_search.constructors import LyricsResult, InvalidGeniusResponseException
logger = logging.getLogger()
log_level = logging.getLevelName(logger.level)
class Genius:
"""
Genius Search Module
"""
def __init__(self) -> None:
self.label: str = "Genius"
self.genius_url: str = private.GENIUS_URL
self.genius_search_url: str = f'{self.genius_url}api/search/song?q='
self.genius_search_url: str = f"{self.genius_url}api/search/song?q="
self.headers: dict = common.SCRAPE_HEADERS
self.timeout = ClientTimeout(connect=3, sock_read=5)
self.datautils = utils.DataUtils()
@ -31,8 +33,7 @@ class Genius:
self.cache = cache.Cache()
self.redis_cache = redis_cache.RedisCache()
async def search(self, artist: str, song: str,
**kwargs) -> Optional[LyricsResult]:
async def search(self, artist: str, song: str, **kwargs) -> Optional[LyricsResult]:
"""
Genius Search
Args:
@ -45,96 +46,125 @@ class Genius:
artist: str = artist.strip().lower()
song: str = song.strip().lower()
time_start: float = time.time()
logging.info("Searching %s - %s on %s",
artist, song, self.label)
search_term: str = f'{artist}%20{song}'
returned_lyrics: str = ''
logging.info("Searching %s - %s on %s", artist, song, self.label)
search_term: str = f"{artist}%20{song}"
returned_lyrics: str = ""
async with ClientSession() as client:
async with client.get(f'{self.genius_search_url}{search_term}',
timeout=self.timeout,
headers=self.headers) as request:
async with client.get(
f"{self.genius_search_url}{search_term}",
timeout=self.timeout,
headers=self.headers,
) as request:
request.raise_for_status()
text: Optional[str] = await request.text()
if not text:
raise InvalidGeniusResponseException("No search response.")
if len(text) < 100:
raise InvalidGeniusResponseException("Search response text was invalid (len < 100 chars.)")
raise InvalidGeniusResponseException(
"Search response text was invalid (len < 100 chars.)"
)
search_data = await request.json()
if not isinstance(search_data, dict):
raise InvalidGeniusResponseException("Invalid JSON.")
if not isinstance(search_data['response'], dict):
raise InvalidGeniusResponseException(f"Invalid JSON: Cannot find response key.\n{search_data}")
if not isinstance(search_data['response']['sections'], list):
raise InvalidGeniusResponseException(f"Invalid JSON: Cannot find response->sections key.\n{search_data}")
if not isinstance(search_data['response']['sections'][0]['hits'], list):
raise InvalidGeniusResponseException("Invalid JSON: Cannot find response->sections[0]->hits key.")
possible_matches: list = search_data['response']['sections'][0]['hits']
if not isinstance(search_data["response"], dict):
raise InvalidGeniusResponseException(
f"Invalid JSON: Cannot find response key.\n{search_data}"
)
if not isinstance(search_data["response"]["sections"], list):
raise InvalidGeniusResponseException(
f"Invalid JSON: Cannot find response->sections key.\n{search_data}"
)
if not isinstance(
search_data["response"]["sections"][0]["hits"], list
):
raise InvalidGeniusResponseException(
"Invalid JSON: Cannot find response->sections[0]->hits key."
)
possible_matches: list = search_data["response"]["sections"][0][
"hits"
]
to_scrape: list[tuple] = [
(
returned['result']['path'],
f'{returned['result']['artist_names']} - {returned['result']['title']}',
) for returned in possible_matches
returned["result"]["path"],
f"{returned['result']['artist_names']} - {returned['result']['title']}",
)
for returned in possible_matches
]
searched: str = f"{artist} - {song}"
best_match: tuple = self.matcher.find_best_match(input_track=searched,
candidate_tracks=to_scrape)
best_match: tuple = self.matcher.find_best_match(
input_track=searched, candidate_tracks=to_scrape
)
((scrape_stub, track), confidence) = best_match
scrape_url: str = f'{self.genius_url}{scrape_stub[1:]}'
async with client.get(scrape_url,
timeout=self.timeout,
headers=self.headers) as scrape_request:
scrape_url: str = f"{self.genius_url}{scrape_stub[1:]}"
async with client.get(
scrape_url, timeout=self.timeout, headers=self.headers
) as scrape_request:
scrape_request.raise_for_status()
scrape_text: Optional[str] = await scrape_request.text()
if not scrape_text:
raise InvalidGeniusResponseException("No scrape response.")
if len(scrape_text) < 100:
raise InvalidGeniusResponseException("Scrape response was invalid (len < 100 chars.)")
html = BeautifulSoup(htm.unescape(scrape_text).replace('<br/>', '\n'), "html.parser")
header_tags_genius: Optional[ResultSet] = html.find_all(class_=re.compile(r'.*Header.*'))
raise InvalidGeniusResponseException(
"Scrape response was invalid (len < 100 chars.)"
)
html = BeautifulSoup(
htm.unescape(scrape_text).replace("<br/>", "\n"),
"html.parser",
)
header_tags_genius: Optional[ResultSet] = html.find_all(
class_=re.compile(r".*Header.*")
)
if header_tags_genius:
for tag in header_tags_genius:
tag.extract()
divs: Optional[ResultSet] = html.find_all("div", {"data-lyrics-container": "true"})
divs: Optional[ResultSet] = html.find_all(
"div", {"data-lyrics-container": "true"}
)
if not divs:
return
for div in divs:
header_tags: Optional[ResultSet] = div.find_all(['h1', 'h2', 'h3', 'h4', 'h5'])
header_tags: Optional[ResultSet] = div.find_all(
["h1", "h2", "h3", "h4", "h5"]
)
if header_tags:
for tag in header_tags:
tag.extract()
tag.extract()
returned_lyrics += div.get_text()
returned_lyrics: str = self.datautils.scrub_lyrics(returned_lyrics)
returned_lyrics: str = self.datautils.scrub_lyrics(
returned_lyrics
)
artist: str = track.split(" - ", maxsplit=1)[0]
song: str = track.split(" - ", maxsplit=1)[1]
logging.info("Result found on %s", self.label)
time_end: float = time.time()
time_diff: float = time_end - time_start
matched = LyricsResult(artist=artist,
song=song,
src=self.label,
lyrics=returned_lyrics,
confidence=confidence,
time=time_diff)
matched = LyricsResult(
artist=artist,
song=song,
src=self.label,
lyrics=returned_lyrics,
confidence=confidence,
time=time_diff,
)
await self.redis_cache.increment_found_count(self.label)
await self.cache.store(matched)
return matched
except:
traceback.print_exc()
traceback.print_exc()

View File

@ -1,6 +1,7 @@
import sys
import time
sys.path.insert(1,'..')
sys.path.insert(1, "..")
import traceback
import logging
from typing import Optional, Union
@ -13,20 +14,23 @@ from lyric_search.constructors import InvalidLRCLibResponseException
logger = logging.getLogger()
log_level = logging.getLevelName(logger.level)
class LRCLib:
"""LRCLib Search Module"""
def __init__(self) -> None:
self.label: str = "LRCLib"
self.lrclib_url: str = "https://lrclib.net/api/search"
self.headers: dict = common.SCRAPE_HEADERS
self.timeout = ClientTimeout(connect=2, sock_read=4)
self.datautils = utils.DataUtils()
self.matcher = utils.TrackMatcher()
self.matcher = utils.TrackMatcher()
self.cache = cache.Cache()
self.redis_cache = redis_cache.RedisCache()
async def search(self, artist: str, song: str,
plain: Optional[bool] = True) -> Optional[LyricsResult]:
async def search(
self, artist: str, song: str, plain: Optional[bool] = True
) -> Optional[LyricsResult]:
"""
LRCLib Search
Args:
@ -35,92 +39,124 @@ class LRCLib:
Returns:
Optional[LyricsResult]: The result, if found - None otherwise.
"""
try:
try:
artist: str = artist.strip().lower()
song: str = song.strip().lower()
time_start: float = time.time()
lrc_obj: Optional[list[dict]] = None
logging.info("Searching %s - %s on %s",
artist, song, self.label)
input_track: str = f"{artist} - {song}"
returned_lyrics: str = ''
logging.info("Searching %s - %s on %s", artist, song, self.label)
input_track: str = f"{artist} - {song}"
returned_lyrics: str = ""
async with ClientSession() as client:
async with await client.get(self.lrclib_url,
params = {
'artist_name': artist,
'track_name': song,
},
timeout=self.timeout,
headers=self.headers) as request:
async with await client.get(
self.lrclib_url,
params={
"artist_name": artist,
"track_name": song,
},
timeout=self.timeout,
headers=self.headers,
) as request:
request.raise_for_status()
text: Optional[str] = await request.text()
if not text:
raise InvalidLRCLibResponseException("No search response.")
raise InvalidLRCLibResponseException("No search response.")
if len(text) < 100:
raise InvalidLRCLibResponseException("Search response text was invalid (len < 100 chars.)")
raise InvalidLRCLibResponseException(
"Search response text was invalid (len < 100 chars.)"
)
search_data: Optional[Union[list, dict]] = await request.json()
if not isinstance(search_data, list|dict):
if not isinstance(search_data, list | dict):
raise InvalidLRCLibResponseException("No JSON search data.")
# logging.info("Search Data:\n%s", search_data)
if not isinstance(search_data, list):
raise InvalidLRCLibResponseException("Invalid JSON.")
if plain:
possible_matches = [(x, f"{result.get('artistName')} - {result.get('trackName')}")
for x, result in enumerate(search_data)]
possible_matches = [
(
x,
f"{result.get('artistName')} - {result.get('trackName')}",
)
for x, result in enumerate(search_data)
]
else:
logging.info("Limiting possible matches to only those with non-null syncedLyrics")
possible_matches = [(x, f"{result.get('artistName')} - {result.get('trackName')}")
for x, result in enumerate(search_data) if isinstance(result['syncedLyrics'], str)]
logging.info(
"Limiting possible matches to only those with non-null syncedLyrics"
)
possible_matches = [
(
x,
f"{result.get('artistName')} - {result.get('trackName')}",
)
for x, result in enumerate(search_data)
if isinstance(result["syncedLyrics"], str)
]
best_match = self.matcher.find_best_match(input_track,
possible_matches)[0]
best_match = self.matcher.find_best_match(
input_track, possible_matches
)[0]
if not best_match:
return
best_match_id = best_match[0]
if not isinstance(search_data[best_match_id]['artistName'], str):
raise InvalidLRCLibResponseException(f"Invalid JSON: Cannot find artistName key.\n{search_data}")
if not isinstance(search_data[best_match_id]['trackName'], str):
raise InvalidLRCLibResponseException(f"Invalid JSON: Cannot find trackName key.\n{search_data}")
returned_artist: str = search_data[best_match_id]['artistName']
returned_song: str = search_data[best_match_id]['trackName']
if not isinstance(search_data[best_match_id]["artistName"], str):
raise InvalidLRCLibResponseException(
f"Invalid JSON: Cannot find artistName key.\n{search_data}"
)
if not isinstance(search_data[best_match_id]["trackName"], str):
raise InvalidLRCLibResponseException(
f"Invalid JSON: Cannot find trackName key.\n{search_data}"
)
returned_artist: str = search_data[best_match_id]["artistName"]
returned_song: str = search_data[best_match_id]["trackName"]
if plain:
if not isinstance(search_data[best_match_id]['plainLyrics'], str):
raise InvalidLRCLibResponseException(f"Invalid JSON: Cannot find plainLyrics key.\n{search_data}")
returned_lyrics: str = search_data[best_match_id]['plainLyrics']
if not isinstance(
search_data[best_match_id]["plainLyrics"], str
):
raise InvalidLRCLibResponseException(
f"Invalid JSON: Cannot find plainLyrics key.\n{search_data}"
)
returned_lyrics: str = search_data[best_match_id]["plainLyrics"]
returned_lyrics = self.datautils.scrub_lyrics(returned_lyrics)
else:
if not isinstance(search_data[best_match_id]['syncedLyrics'], str):
raise InvalidLRCLibResponseException(f"Invalid JSON: Cannot find syncedLyrics key.\n{search_data}")
returned_lyrics: str = search_data[best_match_id]['syncedLyrics']
if not isinstance(
search_data[best_match_id]["syncedLyrics"], str
):
raise InvalidLRCLibResponseException(
f"Invalid JSON: Cannot find syncedLyrics key.\n{search_data}"
)
returned_lyrics: str = search_data[best_match_id][
"syncedLyrics"
]
lrc_obj = self.datautils.create_lrc_object(returned_lyrics)
returned_track: str = f"{returned_artist} - {returned_song}"
(_matched, confidence) = self.matcher.find_best_match(input_track=input_track,
candidate_tracks=[(0, returned_track)])
(_matched, confidence) = self.matcher.find_best_match(
input_track=input_track, candidate_tracks=[(0, returned_track)]
)
if not confidence:
return # No suitable match found
return # No suitable match found
logging.info("Result found on %s", self.label)
time_end: float = time.time()
time_diff: float = time_end - time_start
matched = LyricsResult(artist=returned_artist,
song=returned_song,
src=self.label,
lyrics=returned_lyrics if plain else lrc_obj,
confidence=confidence,
time=time_diff)
matched = LyricsResult(
artist=returned_artist,
song=returned_song,
src=self.label,
lyrics=returned_lyrics if plain else lrc_obj,
confidence=confidence,
time=time_diff,
)
await self.redis_cache.increment_found_count(self.label)
await self.cache.store(matched)
return matched
except:
traceback.print_exc()
traceback.print_exc()

View File

@ -7,24 +7,27 @@ import regex
from regex import Pattern
import asyncio
from typing import Union, Optional
sys.path.insert(1,'..')
sys.path.insert(1, "..")
from lyric_search import notifier
from lyric_search.constructors import LyricsResult
import redis.asyncio as redis
from redis.commands.search.query import Query # type: ignore
from redis.commands.search.indexDefinition import IndexDefinition, IndexType # type: ignore
from redis.commands.search.field import TextField, TagField # type: ignore
from redis.commands.json.path import Path # type: ignore
from redis.commands.search.query import Query # type: ignore
from redis.commands.search.indexDefinition import IndexDefinition, IndexType # type: ignore
from redis.commands.search.field import TextField, TagField # type: ignore
from redis.commands.json.path import Path # type: ignore
from . import private
logger = logging.getLogger()
log_level = logging.getLevelName(logger.level)
class RedisException(Exception):
"""
Redis Exception
"""
class RedisCache:
"""
Redis Cache Methods
@ -35,34 +38,37 @@ class RedisCache:
self.notifier = notifier.DiscordNotifier()
self.notify_warnings = False
self.regexes: list[Pattern] = [
regex.compile(r'\-'),
regex.compile(r'[^a-zA-Z0-9\s]'),
regex.compile(r"\-"),
regex.compile(r"[^a-zA-Z0-9\s]"),
]
try:
asyncio.get_event_loop().create_task(self.create_index())
except Exception as e:
logging.debug("Failed to create redis create_index task: %s",
str(e))
logging.debug("Failed to create redis create_index task: %s", str(e))
async def create_index(self) -> None:
"""Create Index"""
try:
schema = (
TextField("$.search_artist", as_name="artist"),
TextField("$.search_song", as_name="song"),
TextField("$.src", as_name="src"),
TextField("$.lyrics", as_name="lyrics")
)
TextField("$.search_artist", as_name="artist"),
TextField("$.search_song", as_name="song"),
TextField("$.src", as_name="src"),
TextField("$.lyrics", as_name="lyrics"),
)
result = await self.redis_client.ft().create_index(
schema, definition=IndexDefinition(prefix=["lyrics:"], index_type=IndexType.JSON))
schema,
definition=IndexDefinition(
prefix=["lyrics:"], index_type=IndexType.JSON
),
)
if str(result) != "OK":
raise RedisException(f"Redis: Failed to create index: {result}")
except Exception as e:
logging.debug("Failed to create redis index: %s",
str(e))
def sanitize_input(self, artist: str, song: str,
fuzzy: Optional[bool] = False) -> tuple[str, str]:
logging.debug("Failed to create redis index: %s", str(e))
def sanitize_input(
self, artist: str, song: str, fuzzy: Optional[bool] = False
) -> tuple[str, str]:
"""
Sanitize artist/song input (convert to redis matchable fuzzy query)
Args:
@ -77,10 +83,12 @@ class RedisCache:
song = self.regexes[0].sub("", song)
song = self.regexes[1].sub("", song).strip()
if fuzzy:
artist = " ".join([f"(%{artist_word}%)" for artist_word in artist.split(" ")])
artist = " ".join(
[f"(%{artist_word}%)" for artist_word in artist.split(" ")]
)
song = " ".join([f"(%{song_word}%)" for song_word in song.split(" ")])
return (artist, song)
async def increment_found_count(self, src: str) -> None:
"""
Increment the found count for a source
@ -94,13 +102,13 @@ class RedisCache:
await self.redis_client.incr(f"returned:{src}")
except Exception as e:
file: str = __file__.rsplit("/", maxsplit=1)[-1]
await self.notifier.send(f"ERROR @ {file}", str(e))
await self.notifier.send(f"ERROR @ {file}", str(e))
traceback.print_exc()
async def get_found_counts(self) -> Optional[dict]:
"""
Get found counts for all sources (and failed count)
Returns:
dict: In the form {'source': count, 'source2': count, ...}
"""
@ -109,18 +117,20 @@ class RedisCache:
counts: dict[str, int] = {}
for src in sources:
src_found_count = await self.redis_client.get(f"returned:{src}")
counts[src] = int(src_found_count) # Redis returns bytes
counts[src] = int(src_found_count) # Redis returns bytes
return counts
except Exception as e:
file: str = __file__.rsplit("/", maxsplit=1)[-1]
await self.notifier.send(f"ERROR @ {file}", str(e))
await self.notifier.send(f"ERROR @ {file}", str(e))
traceback.print_exc()
return None
async def search(self, artist: Optional[str] = None,
song: Optional[str] = None,
lyrics: Optional[str] = None) -> Optional[list[tuple]]:
async def search(
self,
artist: Optional[str] = None,
song: Optional[str] = None,
lyrics: Optional[str] = None,
) -> Optional[list[tuple]]:
"""
Search Redis Cache
Args:
@ -133,57 +143,72 @@ class RedisCache:
try:
fuzzy_artist = None
fuzzy_song = None
fuzzy_song = None
is_random_search = artist == "!" and song == "!"
if lyrics:
# to code later
raise RedisException("Lyric search not yet implemented")
if not is_random_search:
logging.debug("Redis: Searching normally first")
if not artist or not song:
logging.info("redis_cache:: search failed: No artist or song provided.")
logging.info(
"redis_cache:: search failed: No artist or song provided."
)
return None
(artist, song) = self.sanitize_input(artist, song)
logging.debug("Seeking: %s - %s", artist, song)
search_res: Union[dict, list] = await self.redis_client.ft().search(Query( # type: ignore
f"@artist:{artist} @song:{song}"
))
search_res_out: list[tuple] = [(result['id'].split(":",
maxsplit=1)[1], dict(json.loads(result['json'])))
for result in search_res.docs] # type: ignore
search_res: Union[dict, list] = await self.redis_client.ft().search(
Query(f"@artist:{artist} @song:{song}") # type: ignore
)
search_res_out: list[tuple] = [
(
result["id"].split(":", maxsplit=1)[1],
dict(json.loads(result["json"])),
)
for result in search_res.docs
] # type: ignore
if not search_res_out:
logging.debug("Redis: Normal search failed, trying with fuzzy search")
logging.debug(
"Redis: Normal search failed, trying with fuzzy search"
)
short_artist = " ".join(artist.split(" ")[0:5])
short_song = " ".join(song.split(" ")[0:5])
(fuzzy_artist, fuzzy_song) = self.sanitize_input(artist=short_artist.strip(),
song=short_song.strip(), fuzzy=True)
search_res = await self.redis_client.ft().search(Query( # type: ignore
f"@artist:{fuzzy_artist} @song:{fuzzy_song}"
))
search_res_out = [(result['id'].split(":",
maxsplit=1)[1], dict(json.loads(result['json'])))
for result in search_res.docs] # type: ignore
short_song = " ".join(song.split(" ")[0:5])
(fuzzy_artist, fuzzy_song) = self.sanitize_input(
artist=short_artist.strip(), song=short_song.strip(), fuzzy=True
)
search_res = await self.redis_client.ft().search(
Query( # type: ignore
f"@artist:{fuzzy_artist} @song:{fuzzy_song}"
)
)
search_res_out = [
(
result["id"].split(":", maxsplit=1)[1],
dict(json.loads(result["json"])),
)
for result in search_res.docs
] # type: ignore
else:
random_redis_key: str = await self.redis_client.randomkey()
out_id: str = str(random_redis_key).split(":",
maxsplit=1)[1][:-1]
out_id: str = str(random_redis_key).split(":", maxsplit=1)[1][:-1]
search_res = await self.redis_client.json().get(random_redis_key)
search_res_out = [(out_id, search_res)]
if not search_res_out and self.notify_warnings:
await self.notifier.send("WARNING", f"Redis cache miss for: `{artist} - {song}`")
await self.notifier.send(
"WARNING", f"Redis cache miss for: `{artist} - {song}`"
)
return search_res_out
except Exception as e:
traceback.print_exc()
# await self.notifier.send(f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}", f"{str(e)}\nSearch was: {artist} - {song}; fuzzy: {fuzzy_artist} - {fuzzy_song}")
# await self.notifier.send(f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}", f"{str(e)}\nSearch was: {artist} - {song}; fuzzy: {fuzzy_artist} - {fuzzy_song}")
return None
async def redis_store(self, sqlite_id: int,
lyr_result: LyricsResult) -> None:
async def redis_store(self, sqlite_id: int, lyr_result: LyricsResult) -> None:
"""
Store lyrics to redis cache
Args:
@ -193,34 +218,47 @@ class RedisCache:
None
"""
try:
(search_artist, search_song) = self.sanitize_input(lyr_result.artist,
lyr_result.song)
(search_artist, search_song) = self.sanitize_input(
lyr_result.artist, lyr_result.song
)
redis_mapping: dict = {
'id': sqlite_id,
'src': lyr_result.src,
'date_retrieved': time.time(),
'artist': lyr_result.artist,
'search_artist': search_artist,
'search_song': search_song,
'search_artistsong': f'{search_artist}\n{search_song}',
'song': lyr_result.song,
'artistsong': f"{lyr_result.artist}\n{lyr_result.song}",
'confidence': lyr_result.confidence,
'lyrics': lyr_result.lyrics,
'tags': '(none)',
'liked': 0,
}
"id": sqlite_id,
"src": lyr_result.src,
"date_retrieved": time.time(),
"artist": lyr_result.artist,
"search_artist": search_artist,
"search_song": search_song,
"search_artistsong": f"{search_artist}\n{search_song}",
"song": lyr_result.song,
"artistsong": f"{lyr_result.artist}\n{lyr_result.song}",
"confidence": lyr_result.confidence,
"lyrics": lyr_result.lyrics,
"tags": "(none)",
"liked": 0,
}
newkey: str = f"lyrics:000{sqlite_id}"
jsonset: bool = await self.redis_client.json().set(newkey, Path.root_path(),
redis_mapping)
jsonset: bool = await self.redis_client.json().set(
newkey, Path.root_path(), redis_mapping
)
if not jsonset:
raise RedisException(f"Failed to store {lyr_result.artist} - {lyr_result.song} (SQLite id: {sqlite_id}) to redis:\n{jsonset}")
logging.info("Stored %s - %s (related SQLite Row ID: %s) to %s",
lyr_result.artist, lyr_result.song, sqlite_id, newkey)
await self.notifier.send("INFO",
f"Stored `{lyr_result.artist} - {lyr_result.song}` (related SQLite Row ID: `{sqlite_id}`) to redis: `{newkey}`")
raise RedisException(
f"Failed to store {lyr_result.artist} - {lyr_result.song} (SQLite id: {sqlite_id}) to redis:\n{jsonset}"
)
logging.info(
"Stored %s - %s (related SQLite Row ID: %s) to %s",
lyr_result.artist,
lyr_result.song,
sqlite_id,
newkey,
)
await self.notifier.send(
"INFO",
f"Stored `{lyr_result.artist} - {lyr_result.song}` (related SQLite Row ID: `{sqlite_id}`) to redis: `{newkey}`",
)
except Exception as e:
file: str = __file__.rsplit("/", maxsplit=1)[-1]
await self.notifier.send(f"ERROR @ {file}",
f"Failed to store `{lyr_result.artist} - {lyr_result.song}`\
(SQLite id: `{sqlite_id}`) to Redis:\n`{str(e)}`")
await self.notifier.send(
f"ERROR @ {file}",
f"Failed to store `{lyr_result.artist} - {lyr_result.song}`\
(SQLite id: `{sqlite_id}`) to Redis:\n`{str(e)}`",
)

View File

@ -4,38 +4,41 @@ import logging
import regex
from regex import Pattern
class TrackMatcher:
"""Track Matcher"""
def __init__(self, threshold: float = 0.85):
"""
Initialize the TrackMatcher with a similarity threshold.
Args:
threshold (float): Minimum similarity score to consider a match valid
(between 0 and 1, default 0.85)
"""
self.threshold = threshold
def find_best_match(self, input_track: str, candidate_tracks: List[tuple[int|str, str]]) -> Optional[tuple]:
def find_best_match(
self, input_track: str, candidate_tracks: List[tuple[int | str, str]]
) -> Optional[tuple]:
"""
Find the best matching track from the candidate list.
Args:
input_track (str): Input track in "ARTIST - SONG" format
candidate_tracks (List[tuple[int|str, str]]): List of candidate tracks
Returns:
Optional[tuple[int, str, float]]: Tuple of (best matching track, similarity score)
or None if no good match found
"""
if not input_track or not candidate_tracks:
return None
# Normalize input track
input_track = self._normalize_string(input_track)
best_match = None
best_score: float = 0.0
@ -43,12 +46,16 @@ class TrackMatcher:
normalized_candidate = self._normalize_string(candidate[1])
if normalized_candidate.strip().lower() == input_track.strip().lower():
return (candidate, 100.0)
# Calculate various similarity scores
exact_score = 1.0 if input_track == normalized_candidate else 0.0
sequence_score = SequenceMatcher(None, input_track, normalized_candidate).ratio()
token_score = self._calculate_token_similarity(input_track, normalized_candidate)
sequence_score = SequenceMatcher(
None, input_track, normalized_candidate
).ratio()
token_score = self._calculate_token_similarity(
input_track, normalized_candidate
)
# Take the maximum of the different scoring methods
final_score = max(exact_score, sequence_score, token_score)
@ -59,7 +66,7 @@ class TrackMatcher:
# Return the match only if it meets the threshold
if best_score < self.threshold:
return None
match: tuple = (best_match, round(best_score * 100))
match: tuple = (best_match, round(best_score * 100))
return match
def _normalize_string(self, text: str) -> str:
@ -72,9 +79,9 @@ class TrackMatcher:
str: Normalized text
"""
# Remove special characters and convert to lowercase
text = regex.sub(r'[^\w\s-]', '', text).lower()
text = regex.sub(r"[^\w\s-]", "", text).lower()
# Normalize spaces
text = ' '.join(text.split())
text = " ".join(text.split())
return text
def _calculate_token_similarity(self, str1: str, str2: str) -> float:
@ -88,28 +95,32 @@ class TrackMatcher:
"""
tokens1 = set(str1.split())
tokens2 = set(str2.split())
if not tokens1 or not tokens2:
return 0.0
intersection = tokens1.intersection(tokens2)
union = tokens1.union(tokens2)
return len(intersection) / len(union)
class DataUtils:
"""
Data Utils
"""
def __init__(self) -> None:
self.lrc_regex = regex.compile(r'\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}')
self.scrub_regex_1: Pattern = regex.compile(r'(\[.*?\])(\s){0,}(\:){0,1}')
self.scrub_regex_2: Pattern = regex.compile(r'(\d?)(Embed\b)',
flags=regex.IGNORECASE)
self.scrub_regex_3: Pattern = regex.compile(r'\n{2}')
self.scrub_regex_4: Pattern = regex.compile(r'[0-9]\b$')
self.lrc_regex = regex.compile(
r"\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}"
)
self.scrub_regex_1: Pattern = regex.compile(r"(\[.*?\])(\s){0,}(\:){0,1}")
self.scrub_regex_2: Pattern = regex.compile(
r"(\d?)(Embed\b)", flags=regex.IGNORECASE
)
self.scrub_regex_3: Pattern = regex.compile(r"\n{2}")
self.scrub_regex_4: Pattern = regex.compile(r"[0-9]\b$")
def scrub_lyrics(self, lyrics: str) -> str:
"""
Lyric Scrub Regex Chain
@ -118,11 +129,11 @@ class DataUtils:
Returns:
str: Regex scrubbed lyrics
"""
lyrics = self.scrub_regex_1.sub('', lyrics)
lyrics = self.scrub_regex_2.sub('', lyrics)
lyrics = self.scrub_regex_3.sub('\n', lyrics) # Gaps between verses
lyrics = self.scrub_regex_3.sub('', lyrics)
return lyrics
lyrics = self.scrub_regex_1.sub("", lyrics)
lyrics = self.scrub_regex_2.sub("", lyrics)
lyrics = self.scrub_regex_3.sub("\n", lyrics) # Gaps between verses
lyrics = self.scrub_regex_3.sub("", lyrics)
return lyrics
def create_lrc_object(self, lrc_str: str) -> list[dict]:
"""
@ -142,15 +153,21 @@ class DataUtils:
if not reg_helper:
continue
reg_helper = reg_helper[0]
logging.debug("Reg helper: %s for line: %s; len: %s",
reg_helper, line, len(reg_helper))
logging.debug(
"Reg helper: %s for line: %s; len: %s",
reg_helper,
line,
len(reg_helper),
)
_timetag = reg_helper[0]
if not reg_helper[1].strip():
_words = ""
else:
_words = reg_helper[1].strip()
lrc_out.append({
"timeTag": _timetag,
"words": _words,
})
return lrc_out
lrc_out.append(
{
"timeTag": _timetag,
"words": _words,
}
)
return lrc_out

0
py.typed Normal file
View File

21
util.py
View File

@ -10,7 +10,8 @@ class Utilities:
"""
API Utilities
"""
def __init__(self, app: FastAPI, constants):
def __init__(self, app: FastAPI, constants):
self.constants = constants
self.blocked_redirect_uri = "https://codey.lol"
self.app = app
@ -22,35 +23,31 @@ class Utilities:
logging.error("Rejected request: Blocked")
return RedirectResponse(url=self.blocked_redirect_uri)
def get_no_endpoint_found(self, path: Optional[str] = None):
def get_no_endpoint_found(self, path: Optional[str] = None):
"""
Get 404 Response
"""
logging.error("Rejected request: No such endpoint")
raise HTTPException(detail="Unknown endpoint", status_code=404)
def check_key(self, path: str, key: str,
req_type: int = 0) -> bool:
def check_key(self, path: str, key: str, req_type: int = 0) -> bool:
"""
Accepts path as an argument to allow fine tuning access for each API key, not currently in use.
"""
if not key or not key.startswith("Bearer "):
return False
_key: str = key.split("Bearer ", maxsplit=1)[1].strip()
if not _key in self.constants.API_KEYS:
return False
if req_type == 2:
return _key.startswith("PRV-")
elif req_type == 4:
return _key.startswith("RAD-")
if path.lower().startswith("/xc/")\
and not key.startswith("XC-"):
return False
if path.lower().startswith("/xc/") and not key.startswith("XC-"):
return False
return True

View File

@ -2,5 +2,6 @@
LastFM
"""
class InvalidLastFMResponseException(Exception):
pass
pass

View File

@ -6,54 +6,60 @@ from aiohttp import ClientSession, ClientTimeout
from constants import Constants
from .constructors import InvalidLastFMResponseException
class LastFM:
"""LastFM Endpoints"""
def __init__(self,
noInit: Optional[bool] = False) -> None:
def __init__(self, noInit: Optional[bool] = False) -> None:
self.creds = Constants().LFM_CREDS
self.api_base_url: str = "https://ws.audioscrobbler.com/2.0"
async def search_artist(self, artist: Optional[str] = None) -> dict:
"""Search LastFM for an artist"""
try:
if not artist:
return {
'err': 'No artist specified.',
"err": "No artist specified.",
}
request_params: list[tuple] = [
("method", "artist.getInfo"),
("artist", artist),
("api_key", self.creds.get('key')),
("api_key", self.creds.get("key")),
("autocorrect", "1"),
("format", "json"),
]
async with ClientSession() as session:
async with await session.get(self.api_base_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request:
async with ClientSession() as session:
async with await session.get(
self.api_base_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status()
data: dict = await request.json()
data = data.get('artist', 'N/A')
data = data.get("artist", "N/A")
ret_obj: dict = {
'id': data.get('mbid'),
'touring': data.get('ontour'),
'name': data.get('name'),
'bio': data.get('bio', None).get('summary').strip()\
.split("<a href")[0],
}
"id": data.get("mbid"),
"touring": data.get("ontour"),
"name": data.get("name"),
"bio": data.get("bio", None)
.get("summary")
.strip()
.split("<a href")[0],
}
return ret_obj
except:
traceback.print_exc()
return {
'err': 'Failed',
"err": "Failed",
}
async def get_track_info(self, artist: Optional[str] = None,
track: Optional[str] = None) -> Optional[dict]:
async def get_track_info(
self, artist: Optional[str] = None, track: Optional[str] = None
) -> Optional[dict]:
"""
Get Track Info from LastFM
Args:
@ -66,12 +72,12 @@ class LastFM:
if not artist or not track:
logging.info("inv request")
return {
'err': 'Invalid/No artist or track specified',
"err": "Invalid/No artist or track specified",
}
request_params: list[tuple] = [
("method", "track.getInfo"),
("api_key", self.creds.get('key')),
("api_key", self.creds.get("key")),
("autocorrect", "1"),
("artist", artist),
("track", track),
@ -79,29 +85,32 @@ class LastFM:
]
async with ClientSession() as session:
async with await session.get(self.api_base_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request:
async with await session.get(
self.api_base_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status()
data: dict = await request.json()
data = data.get('track', None)
if not isinstance(data.get('artist'), dict):
data = data.get("track", None)
if not isinstance(data.get("artist"), dict):
return None
artist_mbid: int = data.get('artist', None).get('mbid')
album: str = data.get('album', None).get('title')
artist_mbid: int = data.get("artist", None).get("mbid")
album: str = data.get("album", None).get("title")
ret_obj: dict = {
'artist_mbid': artist_mbid,
'album': album,
"artist_mbid": artist_mbid,
"album": album,
}
return ret_obj
except:
traceback.print_exc()
return {
'err': 'General Failure',
"err": "General Failure",
}
async def get_album_tracklist(self, artist: Optional[str] = None,
album: Optional[str] = None) -> dict:
async def get_album_tracklist(
self, artist: Optional[str] = None, album: Optional[str] = None
) -> dict:
"""
Get Album Tracklist
Args:
@ -113,24 +122,26 @@ class LastFM:
try:
if not artist or not album:
return {
'err': 'No artist or album specified',
"err": "No artist or album specified",
}
tracks: dict = await self.get_release(artist=artist, album=album)
tracks = tracks.get('tracks', None)
tracks = tracks.get("tracks", None)
ret_obj: dict = {
'tracks': tracks,
}
"tracks": tracks,
}
return ret_obj
except:
traceback.print_exc()
return {
'err': 'General Failure',
}
async def get_artist_albums(self, artist: Optional[str] = None) -> Union[dict, list[dict]]:
"err": "General Failure",
}
async def get_artist_albums(
self, artist: Optional[str] = None
) -> Union[dict, list[dict]]:
"""
Get Artists Albums from LastFM
Args:
@ -141,37 +152,39 @@ class LastFM:
try:
if not artist:
return {
'err': 'No artist specified.',
"err": "No artist specified.",
}
request_params: list[tuple] = [
("method", "artist.gettopalbums"),
("artist", artist),
("api_key", self.creds.get('key')),
("api_key", self.creds.get("key")),
("autocorrect", "1"),
("format", "json"),
]
async with ClientSession() as session:
async with await session.get(self.api_base_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request:
async with await session.get(
self.api_base_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status()
json_data: dict = await request.json()
data: dict = json_data.get('topalbums', None).get('album')
data: dict = json_data.get("topalbums", None).get("album")
ret_obj: list = [
{
'title': item.get('name')
} for item in data if not(item.get('name').lower() == "(null)")\
and int(item.get('playcount')) >= 50
{"title": item.get("name")}
for item in data
if not (item.get("name").lower() == "(null)")
and int(item.get("playcount")) >= 50
]
return ret_obj
return ret_obj
except:
traceback.print_exc()
return {
'err': 'Failed',
"err": "Failed",
}
async def get_artist_id(self, artist: Optional[str] = None) -> int:
"""
Get Artist ID from LastFM
@ -183,16 +196,16 @@ class LastFM:
try:
if not artist:
return -1
artist_search: dict = await self.search_artist(artist=artist)
artist_search: dict = await self.search_artist(artist=artist)
if not artist_search:
logging.debug("[get_artist_id] Throwing no result error")
return -1
artist_id: int = int(artist_search[0].get('id', 0))
artist_id: int = int(artist_search[0].get("id", 0))
return artist_id
except:
traceback.print_exc()
return -1
async def get_artist_info_by_id(self, artist_id: Optional[int] = None) -> dict:
"""
Get Artist info by ID from LastFM
@ -204,44 +217,48 @@ class LastFM:
try:
if not artist_id or not str(artist_id).isnumeric():
return {
'err': 'Invalid/no artist_id specified.',
"err": "Invalid/no artist_id specified.",
}
req_url: str = f"{self.api_base_url}/artists/{artist_id}"
request_params: list[tuple] = [
("key", self.creds.get('key')),
("secret", self.creds.get('secret')),
("key", self.creds.get("key")),
("secret", self.creds.get("secret")),
]
async with ClientSession() as session:
async with await session.get(req_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request:
async with await session.get(
req_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status()
data: dict = await request.json()
if not data.get('profile'):
raise InvalidLastFMResponseException("Data did not contain 'profile' key.")
_id: int = data.get('id', None)
name: str = data.get('name', None)
profile: str = data.get('profile', '')
if not data.get("profile"):
raise InvalidLastFMResponseException(
"Data did not contain 'profile' key."
)
_id: int = data.get("id", None)
name: str = data.get("name", None)
profile: str = data.get("profile", "")
profile = regex.sub(r"(\[(\/{0,})(u|b|i)])", "", profile)
members: list = data.get('members', None)
members: list = data.get("members", None)
ret_obj: dict = {
'id': _id,
'name': name,
'profile': profile,
'members': members,
"id": _id,
"name": name,
"profile": profile,
"members": members,
}
return ret_obj
except:
traceback.print_exc()
return {
'err': 'Failed',
"err": "Failed",
}
async def get_artist_info(self, artist: Optional[str] = None) -> dict:
"""
Get Artist Info from LastFM
@ -253,27 +270,30 @@ class LastFM:
try:
if not artist:
return {
'err': 'No artist specified.',
"err": "No artist specified.",
}
artist_id: Optional[int] = await self.get_artist_id(artist=artist)
if not artist_id:
return {
'err': 'Failed',
"err": "Failed",
}
artist_info: Optional[dict] = await self.get_artist_info_by_id(artist_id=artist_id)
artist_info: Optional[dict] = await self.get_artist_info_by_id(
artist_id=artist_id
)
if not artist_info:
return {
'err': 'Failed',
"err": "Failed",
}
return artist_info
except:
traceback.print_exc()
return {
'err': 'Failed',
"err": "Failed",
}
async def get_release(self, artist: Optional[str] = None,
album: Optional[str] = None) -> dict:
async def get_release(
self, artist: Optional[str] = None, album: Optional[str] = None
) -> dict:
"""
Get Release info from LastFM
Args:
@ -285,55 +305,61 @@ class LastFM:
try:
if not artist or not album:
return {
'err': 'Invalid artist/album pair',
"err": "Invalid artist/album pair",
}
request_params: list[tuple] = [
("method", "album.getinfo"),
("artist", artist),
("album", album),
("api_key", self.creds.get('key')),
("api_key", self.creds.get("key")),
("autocorrect", "1"),
("format", "json"),
]
async with ClientSession() as session:
async with await session.get(self.api_base_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request:
async with await session.get(
self.api_base_url,
params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status()
json_data: dict = await request.json()
data: dict = json_data.get('album', None)
data: dict = json_data.get("album", None)
ret_obj: dict = {
'id': data.get('mbid'),
'artists': data.get('artist'),
'tags': data.get('tags'),
'title': data.get('name'),
'summary': data.get('wiki', None).get('summary').split("<a href")[0]\
if "wiki" in data.keys()\
else "No summary available for this release.",
"id": data.get("mbid"),
"artists": data.get("artist"),
"tags": data.get("tags"),
"title": data.get("name"),
"summary": (
data.get("wiki", None).get("summary").split("<a href")[0]
if "wiki" in data.keys()
else "No summary available for this release."
),
}
try:
track_key: list = data.get('tracks', None).get('track')
except:
track_key: list = data.get("tracks", None).get("track")
except:
track_key = []
if isinstance(track_key, list):
ret_obj['tracks'] = [
ret_obj["tracks"] = [
{
'duration': item.get('duration', 'N/A'),
'title': item.get('name'),
} for item in track_key]
"duration": item.get("duration", "N/A"),
"title": item.get("name"),
}
for item in track_key
]
else:
ret_obj['tracks'] = [
{
'duration': data.get('tracks').get('track')\
.get('duration'),
'title': data.get('tracks').get('track')\
.get('name'),
}
]
ret_obj["tracks"] = [
{
"duration": data.get("tracks")
.get("track")
.get("duration"),
"title": data.get("tracks").get("track").get("name"),
}
]
return ret_obj
except:
traceback.print_exc()
return {
'err': 'Failed',
}
"err": "Failed",
}

View File

@ -12,45 +12,48 @@ from typing import Union, Optional, LiteralString, Iterable
from uuid import uuid4 as uuid
from endpoints.constructors import RadioException
double_space: Pattern = regex.compile(r'\s{2,}')
double_space: Pattern = regex.compile(r"\s{2,}")
class RadioUtil:
"""
Radio Utils
"""
def __init__(self, constants) -> None:
self.constants = constants
self.gpt = gpt.GPT(self.constants)
self.ls_uri: str = self.constants.LS_URI
self.sqlite_exts: list[str] = ['/home/api/api/solibs/spellfix1.cpython-311-x86_64-linux-gnu.so']
self.active_playlist_path: Union[str, LiteralString] = os.path\
.join("/usr/local/share",
"sqlite_dbs", "track_file_map.db")
self.active_playlist_name = "default" # not used
self.ls_uri: str = self.constants.LS_URI
self.sqlite_exts: list[str] = [
"/home/api/api/solibs/spellfix1.cpython-311-x86_64-linux-gnu.so"
]
self.active_playlist_path: Union[str, LiteralString] = os.path.join(
"/usr/local/share", "sqlite_dbs", "track_file_map.db"
)
self.active_playlist_name = "default" # not used
self.active_playlist: list[dict] = []
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,
}
"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.webhooks: dict = {
'gpt': {
'hook': self.constants.GPT_WEBHOOK,
},
'sfm': {
'hook': self.constants.SFM_WEBHOOK,
}
}
def duration_conv(self,
s: Union[int, float]) -> str:
"gpt": {
"hook": self.constants.GPT_WEBHOOK,
},
"sfm": {
"hook": self.constants.SFM_WEBHOOK,
},
}
def duration_conv(self, s: Union[int, float]) -> str:
"""
Convert duration given in seconds to hours, minutes, and seconds (h:m:s)
Args:
@ -58,14 +61,12 @@ class RadioUtil:
Returns:
str
"""
return str(datetime.timedelta(seconds=s))\
.split(".", maxsplit=1)[0]
return str(datetime.timedelta(seconds=s)).split(".", maxsplit=1)[0]
async def trackdb_typeahead(self, query: str) -> Optional[list[str]]:
if not query:
return None
async with sqlite3.connect(self.active_playlist_path,
timeout=1) as _db:
async with sqlite3.connect(self.active_playlist_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\
@ -73,14 +74,15 @@ class RadioUtil:
db_params: tuple[str] = (f"%{query}%",)
async with _db.execute(db_query, db_params) as _cursor:
result: Iterable[sqlite3.Row] = await _cursor.fetchall()
out_result = [
str(r['artistsong']) for r in result
]
out_result = [str(r["artistsong"]) for r in result]
return out_result
async def search_playlist(self, artistsong: Optional[str] = None,
artist: Optional[str] = None,
song: Optional[str] = None) -> bool:
async def search_playlist(
self,
artistsong: Optional[str] = None,
artist: Optional[str] = None,
song: Optional[str] = None,
) -> bool:
"""
Search for track, add it up next in play queue if found
Args:
@ -95,9 +97,11 @@ class RadioUtil:
if not artistsong and (not artist or not song):
raise RadioException("No query provided")
try:
search_query: str = 'SELECT id, artist, song, (artist || " - " || song) AS artistsong, album, genre, file_path, duration FROM tracks\
search_query: str = (
'SELECT id, artist, song, (artist || " - " || song) AS artistsong, album, genre, file_path, duration FROM tracks\
WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
<= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 1'
)
if artistsong:
artistsong_split: list = artistsong.split(" - ", maxsplit=1)
(search_artist, search_song) = tuple(artistsong_split)
@ -106,26 +110,31 @@ class RadioUtil:
search_song = song
if not artistsong:
artistsong = f"{search_artist} - {search_song}"
search_params = (search_artist.lower(), search_song.lower(), artistsong.lower(),)
async with sqlite3.connect(self.active_playlist_path,
timeout=2) as db_conn:
search_params = (
search_artist.lower(),
search_song.lower(),
artistsong.lower(),
)
async with sqlite3.connect(self.active_playlist_path, timeout=2) as db_conn:
await db_conn.enable_load_extension(True)
for ext in self.sqlite_exts:
await db_conn.load_extension(ext)
db_conn.row_factory = sqlite3.Row
async with await db_conn.execute(search_query, search_params) as db_cursor:
result: Optional[sqlite3.Row|bool] = await db_cursor.fetchone()
async with await db_conn.execute(
search_query, search_params
) as db_cursor:
result: Optional[sqlite3.Row | bool] = await db_cursor.fetchone()
if not result or not isinstance(result, sqlite3.Row):
return False
pushObj: dict = {
'id': result['id'],
'uuid': str(uuid().hex),
'artist': result['artist'].strip(),
'song': result['song'].strip(),
'artistsong': result['artistsong'].strip(),
'genre': result['genre'],
'file_path': result['file_path'],
'duration': result['duration'],
"id": result["id"],
"uuid": str(uuid().hex),
"artist": result["artist"].strip(),
"song": result["song"].strip(),
"artistsong": result["artistsong"].strip(),
"genre": result["genre"],
"file_path": result["file_path"],
"duration": result["duration"],
}
self.active_playlist.insert(0, pushObj)
return True
@ -133,7 +142,7 @@ class RadioUtil:
logging.critical("search_playlist:: Search error occurred: %s", str(e))
traceback.print_exc()
return False
async def load_playlist(self) -> None:
"""Load Playlist"""
try:
@ -141,11 +150,11 @@ class RadioUtil:
self.active_playlist.clear()
# db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
# GROUP BY artistdashsong ORDER BY RANDOM()'
"""
LIMITED GENRES
"""
db_query: str = """SELECT distinct(LOWER(TRIM(artist)) || " - " || LOWER(TRIM(song))), (TRIM(artist) || " - " || TRIM(song)) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
WHERE (genre LIKE "%metalcore%"\
OR genre LIKE "%math rock%"\
@ -176,44 +185,57 @@ class RadioUtil:
OR genre LIKE "%indie pop%"\
OR genre LIKE "%dnb%")\
GROUP BY artistdashsong ORDER BY RANDOM()"""
"""
LIMITED TO ONE/SMALL SUBSET OF GENRES
"""
db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
WHERE (artist LIKE "%winds of plague%" OR artist LIKE "%acacia st%" OR artist LIKE "%suicide si%" OR artist LIKE "%in dying%") AND (NOT song LIKE "%(live%") ORDER BY RANDOM()' #ORDER BY artist DESC, album ASC, song ASC'
# db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
# WHERE (artist LIKE "%winds of plague%" OR artist LIKE "%acacia st%" OR artist LIKE "%suicide si%" OR artist LIKE "%in dying%") AND (NOT song LIKE "%(live%") ORDER BY RANDOM()' #ORDER BY artist DESC, album ASC, song ASC'
"""
LIMITED TO ONE/SOME ARTISTS...
"""
# db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
# WHERE (artist LIKE "%we butter%" OR artist LIKE "%eisbrecher%" OR artist LIKE "%black ang%" OR artist LIKE "%madison affair%") AND (NOT song LIKE "%%stripped%%" AND NOT song LIKE "%(2022)%" AND NOT song LIKE "%(live%%" AND NOT song LIKE "%%acoustic%%" AND NOT song LIKE "%%instrumental%%" AND NOT song LIKE "%%remix%%" AND NOT song LIKE "%%reimagined%%" AND NOT song LIKE "%%alternative%%" AND NOT song LIKE "%%unzipped%%") GROUP BY artistdashsong ORDER BY RANDOM()'# ORDER BY album ASC, id ASC'
async with sqlite3.connect(self.active_playlist_path,
timeout=2) as db_conn:
# WHERE (artist LIKE "%rise against%" OR artist LIKE "%i prevail%" OR artist LIKE "%volumes%" OR artist LIKE "%movements%" OR artist LIKE "%woe%" OR artist LIKE "%smittyztop%" OR artist LIKE "%chunk! no,%" OR artist LIKE "%fame on fire%" OR artist LIKE "%our last night%" OR artist LIKE "%animal in me%") AND (NOT song LIKE "%%stripped%%" AND NOT song LIKE "%(2022)%" AND NOT song LIKE "%(live%%" AND NOT song LIKE "%%acoustic%%" AND NOT song LIKE "%%instrumental%%" AND NOT song LIKE "%%remix%%" AND NOT song LIKE "%%reimagined%%" AND NOT song LIKE "%%alternative%%" AND NOT song LIKE "%%unzipped%%") GROUP BY artistdashsong ORDER BY RANDOM()'# ORDER BY album ASC, id ASC'
# db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
# WHERE (artist LIKE "%%" OR artist LIKE "%belmont%" OR artist LIKE "%in dying arms%" OR artist LIKE "%iwrestleda%" OR artist LIKE "%winds of p%") AND (NOT song LIKE "%%stripped%%" AND NOT song LIKE "%(2022)%" AND NOT song LIKE "%(live%%" AND NOT song LIKE "%%acoustic%%" AND NOT song LIKE "%%instrumental%%" AND NOT song LIKE "%%remix%%" AND NOT song LIKE "%%reimagined%%" AND NOT song LIKE "%%alternative%%" AND NOT song LIKE "%%unzipped%%") GROUP BY artistdashsong ORDER BY RANDOM()'# ORDER BY album ASC, id ASC'
# db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
# WHERE (artist LIKE "%akira the don%") AND (NOT song LIKE "%%stripped%%" AND NOT song LIKE "%(2022)%" AND NOT song LIKE "%(live%%" AND NOT song LIKE "%%acoustic%%" AND NOT song LIKE "%%instrumental%%" AND NOT song LIKE "%%remix%%" AND NOT song LIKE "%%reimagined%%" AND NOT song LIKE "%%alternative%%" AND NOT song LIKE "%%unzipped%%") GROUP BY artistdashsong ORDER BY RANDOM()'# ORDER BY album ASC, id ASC'
async with sqlite3.connect(
f"file:{self.active_playlist_path}?mode=readonly", uri=True, timeout=2
) as db_conn:
db_conn.row_factory = sqlite3.Row
async with await db_conn.execute(db_query) as db_cursor:
results: list[sqlite3.Row] = await 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'] if r['genre'] else 'Unknown',
'artistsong': double_space.sub(' ', r['artistdashsong']).strip(),
'file_path': r['file_path'],
'duration': r['duration'],
} for r in results]
logging.info("Populated active playlists with %s items",
len(self.active_playlist))
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"] if r["genre"] else "Unknown",
"artistsong": double_space.sub(
" ", r["artistdashsong"]
).strip(),
"file_path": r["file_path"],
"duration": r["duration"],
}
for r in results
]
logging.info(
"Populated active playlists with %s items",
len(self.active_playlist),
)
except:
traceback.print_exc()
async def cache_album_art(self, track_id: int,
album_art: bytes) -> None:
async def cache_album_art(self, track_id: int, album_art: bytes) -> None:
"""
Cache Album Art to SQLite DB
Args:
@ -223,16 +245,21 @@ class RadioUtil:
None
"""
try:
async with sqlite3.connect(self.active_playlist_path,
timeout=2) as db_conn:
async with await db_conn.execute("UPDATE tracks SET album_art = ? WHERE id = ?",
(album_art, track_id,)) as db_cursor:
async with sqlite3.connect(self.active_playlist_path, timeout=2) as db_conn:
async with await db_conn.execute(
"UPDATE tracks SET album_art = ? WHERE id = ?",
(
album_art,
track_id,
),
) as db_cursor:
await db_conn.commit()
except:
traceback.print_exc()
async def get_album_art(self, track_id: Optional[int] = None,
file_path: Optional[str] = None) -> Optional[bytes]:
async def get_album_art(
self, track_id: Optional[int] = None, file_path: Optional[str] = None
) -> Optional[bytes]:
"""
Get Album Art
Args:
@ -242,28 +269,27 @@ class RadioUtil:
bytes
"""
try:
async with sqlite3.connect(self.active_playlist_path,
timeout=2) as db_conn:
async with sqlite3.connect(self.active_playlist_path, timeout=2) as db_conn:
db_conn.row_factory = sqlite3.Row
query: str = "SELECT album_art FROM tracks WHERE id = ?"
query_params: tuple = (track_id,)
if file_path and not track_id:
query = "SELECT album_art FROM tracks WHERE file_path = ?"
query_params = (file_path,)
async with await db_conn.execute(query,
query_params) as db_cursor:
result: Optional[Union[sqlite3.Row, bool]] = await db_cursor.fetchone()
async with await db_conn.execute(query, query_params) as db_cursor:
result: Optional[Union[sqlite3.Row, bool]] = (
await db_cursor.fetchone()
)
if not result or not isinstance(result, sqlite3.Row):
return None
return result['album_art']
return result["album_art"]
except:
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) -> Optional[tuple[int, dict]]:
"""
Get queue item by UUID
Args:
@ -272,10 +298,10 @@ class RadioUtil:
Optional[tuple[int, dict]]
"""
for x, item in enumerate(self.active_playlist):
if item.get('uuid') == uuid:
if item.get("uuid") == uuid:
return (x, item)
return None
async def _ls_skip(self) -> bool:
"""
Ask LiquidSoap server to skip to the next track
@ -286,18 +312,18 @@ class RadioUtil:
"""
try:
async with ClientSession() as session:
async with session.get(f"{self.ls_uri}/next",
timeout=ClientTimeout(connect=2, sock_read=2)) as request:
request.raise_for_status()
text: Optional[str] = await request.text()
return text == "OK"
async with session.get(
f"{self.ls_uri}/next", timeout=ClientTimeout(connect=2, sock_read=2)
) as request:
request.raise_for_status()
text: Optional[str] = await request.text()
return text == "OK"
except Exception as e:
logging.debug("Skip failed: %s", str(e))
return False # failsafe
async def get_ai_song_info(self, artist: str,
song: str) -> Optional[str]:
logging.debug("Skip failed: %s", str(e))
return False # failsafe
async def get_ai_song_info(self, artist: str, song: str) -> Optional[str]:
"""
Get AI Song Info
Args:
@ -312,44 +338,50 @@ class RadioUtil:
logging.critical("No response received from GPT?")
return None
return response
async def webhook_song_change(self, track: dict) -> None:
try:
"""
Handles Song Change Outbounds (Webhooks)
Args:
Args:
track (dict)
Returns:
None
"""
# First, send track info
friendly_track_start: str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(track['start']))
friendly_track_end: str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(track['end']))
friendly_track_start: str = time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(track["start"])
)
friendly_track_end: str = time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(track["end"])
)
hook_data: dict = {
'username': 'serious.FM',
"embeds": [{
"username": "serious.FM",
"embeds": [
{
"title": "Now Playing",
"description": f'## {track['song']}\nby\n## {track['artist']}',
"color": 0x30c56f,
"description": f"## {track['song']}\nby\n## {track['artist']}",
"color": 0x30C56F,
"thumbnail": {
"url": f"https://api.codey.lol/radio/album_art?track_id={track['id']}&{int(time.time())}",
},
"fields": [
{
"name": "Duration",
"value": self.duration_conv(track['duration']),
"value": self.duration_conv(track["duration"]),
"inline": True,
},
{
"name": "Genre",
"value": track['genre'] if track['genre'] else 'Unknown',
"value": (
track["genre"] if track["genre"] else "Unknown"
),
"inline": True,
},
{
"name": "Filetype",
"value": track['file_path'].rsplit(".", maxsplit=1)[1],
"value": track["file_path"].rsplit(".", maxsplit=1)[1],
"inline": True,
},
{
@ -359,42 +391,56 @@ class RadioUtil:
},
{
"name": "Album",
"value": track['album'] if track['album'] else "Unknown",
"value": (
track["album"] if track["album"] else "Unknown"
),
},
]
}]
}
sfm_hook: str = self.webhooks['sfm'].get('hook')
],
}
],
}
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()
# Next, AI feedback
ai_response: Optional[str] = await self.get_ai_song_info(track['artist'],
track['song'])
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()
# Next, AI feedback
ai_response: Optional[str] = await self.get_ai_song_info(
track["artist"], track["song"]
)
if not ai_response:
return
hook_data = {
'username': 'GPT',
"embeds": [{
"username": "GPT",
"embeds": [
{
"title": "AI Feedback",
"color": 0x35d0ff,
"color": 0x35D0FF,
"description": ai_response.strip(),
}]
}
ai_hook: str = self.webhooks['gpt'].get('hook')
}
],
}
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()
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:
traceback.print_exc()