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 playlist_creator.py
artist_genre_tag.py artist_genre_tag.py
uv.lock uv.lock
py.typed
pyproject.toml pyproject.toml
mypy.ini mypy.ini
.python-version .python-version

79
base.py
View File

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

View File

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

View File

@ -7,16 +7,22 @@ import aiosqlite as sqlite3
from typing import LiteralString, Optional, Union from typing import LiteralString, Optional, Union
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .constructors import (ValidTopKarmaRequest, ValidKarmaRetrievalRequest, from .constructors import (
ValidKarmaUpdateRequest) ValidTopKarmaRequest,
ValidKarmaRetrievalRequest,
ValidKarmaUpdateRequest,
)
class KarmaDB: class KarmaDB:
"""Karma DB Util""" """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 """Get Karma Value for Keyword
Args: Args:
keyword (str): The keyword to search keyword (str): The keyword to search
@ -24,16 +30,18 @@ class KarmaDB:
Union[int, dict] Union[int, dict]
""" """
async with sqlite3.connect(self.db_path, timeout=2) as db_conn: 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: try:
(score,) = await db_cursor.fetchone() (score,) = await db_cursor.fetchone()
return score return score
except TypeError: except TypeError:
return { return {
'err': True, "err": True,
'errorText': f'No records for {keyword}', "errorText": f"No records for {keyword}",
} }
async def get_top(self, n: Optional[int] = 10) -> Optional[list[tuple]]: async def get_top(self, n: Optional[int] = 10) -> Optional[list[tuple]]:
""" """
Get Top n=10 Karma Entries Get Top n=10 Karma Entries
@ -44,14 +52,17 @@ class KarmaDB:
""" """
try: try:
async with sqlite3.connect(self.db_path, timeout=2) as db_conn: 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() return await db_cursor.fetchall()
except: except:
traceback.print_exc() traceback.print_exc()
return None return None
async def update_karma(self, granter: str, keyword: str, async def update_karma(
flag: int) -> Optional[bool]: self, granter: str, keyword: str, flag: int
) -> Optional[bool]:
""" """
Update Karma for Keyword Update Karma for Keyword
Args: Args:
@ -61,42 +72,71 @@ class KarmaDB:
Returns: Returns:
Optional[bool] Optional[bool]
""" """
if not flag in [0, 1]: if not flag in [0, 1]:
return None return None
modifier: str = "score + 1" if not flag else "score - 1" modifier: str = "score + 1" if not flag else "score - 1"
query: str = f"UPDATE karma SET score = {modifier}, last_change = ? WHERE keyword LIKE ?" query: str = (
new_keyword_query: str = "INSERT INTO karma(keyword, score, last_change) VALUES(?, ?, ?)" 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 "--" friendly_flag: str = "++" if not flag else "--"
audit_message: str = f"{granter} adjusted karma for {keyword} @ {datetime.datetime.now().isoformat()}: {friendly_flag}" audit_message: str = (
audit_query: str = "INSERT INTO karma_audit(impacted_keyword, comment) VALUES(?, ?)" 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()) now: int = int(time.time())
logging.debug("Audit message: %s{audit_message}\nKeyword: %s{keyword}") logging.debug("Audit message: %s{audit_message}\nKeyword: %s{keyword}")
async with sqlite3.connect(self.db_path, timeout=2) as db_conn: 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: async with await db_conn.execute(
await db_conn.commit() audit_query,
async with await db_conn.execute(query, (now, keyword,)) as db_cursor: (
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: if db_cursor.rowcount:
await db_conn.commit() await db_conn.commit()
return True return True
if db_cursor.rowcount < 1: # Keyword does not already exist if db_cursor.rowcount < 1: # Keyword does not already exist
await db_cursor.close() await db_cursor.close()
new_val = 1 if not flag else -1 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: if db_cursor.rowcount >= 1:
await db_conn.commit() await db_conn.commit()
return True return True
else: else:
return False return False
return False return False
class Karma(FastAPI): class Karma(FastAPI):
""" """
Karma Endpoints Karma Endpoints
""" """
def __init__(self, app: FastAPI, util, constants) -> None: def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app self.app: FastAPI = app
self.util = util self.util = util
@ -107,86 +147,111 @@ class Karma(FastAPI):
"karma/get": self.get_karma_handler, "karma/get": self.get_karma_handler,
"karma/modify": self.modify_karma_handler, "karma/modify": self.modify_karma_handler,
"karma/top": self.top_karma_handler, "karma/top": self.top_karma_handler,
} }
for endpoint, handler in self.endpoints.items(): for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["POST"], app.add_api_route(
include_in_schema=True) f"/{endpoint}", handler, methods=["POST"], include_in_schema=True
)
async def top_karma_handler(
async def top_karma_handler(self, request: Request, self, request: Request, data: Optional[ValidTopKarmaRequest] = None
data: Optional[ValidTopKarmaRequest] = None) -> JSONResponse: ) -> JSONResponse:
""" """
Get top keywords for karma Get top keywords for karma
- **n**: Number of top results to return (default: 10) - **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") raise HTTPException(status_code=403, detail="Unauthorized")
n: int = 10 n: int = 10
if data and data.n: if data and data.n:
n = int(data.n) n = int(data.n)
try: try:
top10: Optional[list[tuple]] = await self.db.get_top(n=n) top10: Optional[list[tuple]] = await self.db.get_top(n=n)
if not top10: if not top10:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'General failure', content={
}) "err": True,
"errorText": "General failure",
},
)
return JSONResponse(content=top10) return JSONResponse(content=top10)
except: except:
traceback.print_exc() traceback.print_exc()
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Exception occurred.', content={
}) "err": True,
"errorText": "Exception occurred.",
async def get_karma_handler(self, data: ValidKarmaRetrievalRequest, },
request: Request) -> JSONResponse: )
async def get_karma_handler(
self, data: ValidKarmaRetrievalRequest, request: Request
) -> JSONResponse:
""" """
Get current karma value Get current karma value
- **keyword**: Keyword to retrieve karma value for - **keyword**: Keyword to retrieve karma value for
""" """
if not self.util.check_key(request.url.path, request.headers.get('X-Authd-With')): if not self.util.check_key(
raise HTTPException(status_code=403, detail="Unauthorized") request.url.path, request.headers.get("X-Authd-With")
):
raise HTTPException(status_code=403, detail="Unauthorized")
keyword: str = data.keyword keyword: str = data.keyword
try: try:
count: Union[int, dict] = await self.db.get_karma(keyword) count: Union[int, dict] = await self.db.get_karma(keyword)
return JSONResponse(content={ return JSONResponse(
'keyword': keyword, content={
'count': count, "keyword": keyword,
}) "count": count,
}
)
except: except:
traceback.print_exc() traceback.print_exc()
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': "Exception occurred.", content={
}) "err": True,
"errorText": "Exception occurred.",
async def modify_karma_handler(self, data: ValidKarmaUpdateRequest, },
request: Request) -> JSONResponse: )
async def modify_karma_handler(
self, data: ValidKarmaUpdateRequest, request: Request
) -> JSONResponse:
""" """
Update karma count Update karma count
- **granter**: User who granted the karma - **granter**: User who granted the karma
- **keyword**: The keyword to modify - **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") raise HTTPException(status_code=403, detail="Unauthorized")
if not data.flag in [0, 1]: if not data.flag in [0, 1]:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Invalid request', content={
}) "err": True,
"errorText": "Invalid request",
return JSONResponse(content={ },
'success': await self.db.update_karma(data.granter, )
data.keyword, data.flag)
}) 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 typing import Optional, Union
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .constructors import (ValidArtistSearchRequest, ValidAlbumDetailRequest, from .constructors import (
ValidTrackInfoRequest, LastFMException) ValidArtistSearchRequest,
ValidAlbumDetailRequest,
ValidTrackInfoRequest,
LastFMException,
)
class LastFM(FastAPI): class LastFM(FastAPI):
"""Last.FM Endpoints""" """Last.FM Endpoints"""
def __init__(self, app: FastAPI,
util, constants) -> None: def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app self.app: FastAPI = app
self.util = util self.util = util
self.constants = constants self.constants = constants
@ -21,72 +26,94 @@ class LastFM(FastAPI):
"lastfm/get_release": self.release_detail_handler, "lastfm/get_release": self.release_detail_handler,
"lastfm/get_release_tracklist": self.release_tracklist_handler, "lastfm/get_release_tracklist": self.release_tracklist_handler,
"lastfm/get_track_info": self.track_info_handler, "lastfm/get_track_info": self.track_info_handler,
#tbd # tbd
} }
for endpoint, handler in self.endpoints.items(): for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["POST"], app.add_api_route(
include_in_schema=True) f"/{endpoint}", handler, methods=["POST"], include_in_schema=True
)
async def artist_by_name_handler(self, data: ValidArtistSearchRequest) -> JSONResponse:
async def artist_by_name_handler(
self, data: ValidArtistSearchRequest
) -> JSONResponse:
""" """
Get artist info Get artist info
- **a**: Artist to search - **a**: Artist to search
""" """
artist: Optional[str] = data.a.strip() artist: Optional[str] = data.a.strip()
if not artist: if not artist:
return JSONResponse(content={ return JSONResponse(
'err': True, content={
'errorText': 'No artist specified', "err": True,
}) "errorText": "No artist specified",
}
)
artist_result = await self.lastfm.search_artist(artist=artist) artist_result = await self.lastfm.search_artist(artist=artist)
if not artist_result or not artist_result.get('bio')\ if (
or "err" in artist_result.keys(): not artist_result
return JSONResponse(status_code=500, content={ or not artist_result.get("bio")
'err': True, or "err" in artist_result.keys()
'errorText': 'Search failed (no results?)', ):
}) return JSONResponse(
status_code=500,
return JSONResponse(content={ content={
'success': True, "err": True,
'result': artist_result, "errorText": "Search failed (no results?)",
}) },
)
async def artist_album_handler(self, data: ValidArtistSearchRequest) -> JSONResponse:
return JSONResponse(
content={
"success": True,
"result": artist_result,
}
)
async def artist_album_handler(
self, data: ValidArtistSearchRequest
) -> JSONResponse:
""" """
Get artist's albums/releases Get artist's albums/releases
- **a**: Artist to search - **a**: Artist to search
""" """
artist: str = data.a.strip() artist: str = data.a.strip()
if not artist: if not artist:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Invalid request: No artist specified', content={
}) "err": True,
"errorText": "Invalid request: No artist specified",
album_result: Union[dict, list[dict]] = await self.lastfm.get_artist_albums(artist=artist) },
)
album_result: Union[dict, list[dict]] = await self.lastfm.get_artist_albums(
artist=artist
)
if isinstance(album_result, dict): if isinstance(album_result, dict):
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'General failure.', content={
}) "err": True,
"errorText": "General failure.",
},
)
album_result_out: list = [] album_result_out: list = []
seen_release_titles: list = [] seen_release_titles: list = []
for release in album_result: 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: if release_title.lower() in seen_release_titles:
continue continue
seen_release_titles.append(release_title.lower()) seen_release_titles.append(release_title.lower())
album_result_out.append(release) album_result_out.append(release)
return JSONResponse(content={ return JSONResponse(content={"success": True, "result": album_result_out})
'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 Get details of a particular release by an artist
- **a**: Artist to search - **a**: Artist to search
@ -94,85 +121,105 @@ class LastFM(FastAPI):
""" """
artist: str = data.a.strip() artist: str = data.a.strip()
release: str = data.release.strip() release: str = data.release.strip()
if not artist or not release: if not artist or not release:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Invalid request', content={
}) "err": True,
"errorText": "Invalid request",
},
)
release_result = await self.lastfm.get_release(artist=artist, album=release) release_result = await self.lastfm.get_release(artist=artist, album=release)
ret_obj = { ret_obj = {
'id': release_result.get('id'), "id": release_result.get("id"),
'artists': release_result.get('artists'), "artists": release_result.get("artists"),
'title': release_result.get('title'), "title": release_result.get("title"),
'summary': release_result.get('summary'), "summary": release_result.get("summary"),
'tracks': release_result.get('tracks'), "tracks": release_result.get("tracks"),
} }
return JSONResponse(content={ return JSONResponse(
'success': True, content={
'result': ret_obj, "success": True,
}) "result": ret_obj,
}
async def release_tracklist_handler(self, data: ValidAlbumDetailRequest) -> JSONResponse: )
async def release_tracklist_handler(
self, data: ValidAlbumDetailRequest
) -> JSONResponse:
""" """
Get track list for a particular release by an artist Get track list for a particular release by an artist
- **a**: Artist to search - **a**: Artist to search
- **release**: Release title to search - **release**: Release title to search
""" """
artist: str = data.a.strip() artist: str = data.a.strip()
release: str = data.release.strip() release: str = data.release.strip()
if not artist or not release: if not artist or not release:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Invalid request', 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'), tracklist_result: dict = await self.lastfm.get_album_tracklist(
'artists': tracklist_result.get('artists'), artist=artist, album=release
'title': tracklist_result.get('title'), )
'summary': tracklist_result.get('summary'), return JSONResponse(
'tracks': tracklist_result.get('tracks'), 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: async def track_info_handler(self, data: ValidTrackInfoRequest) -> JSONResponse:
""" """
Get track info from Last.FM given an artist/track Get track info from Last.FM given an artist/track
- **a**: Artist to search - **a**: Artist to search
- **t**: Track title to search - **t**: Track title to search
""" """
try: try:
artist: str = data.a artist: str = data.a
track: str = data.t track: str = data.t
if not artist or not track: if not artist or not track:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Invalid request' content={"err": True, "errorText": "Invalid request"},
}) )
track_info_result: Optional[dict] = await self.lastfm.get_track_info(artist=artist, track_info_result: Optional[dict] = await self.lastfm.get_track_info(
track=track) artist=artist, track=track
)
if not track_info_result: if not track_info_result:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Not found.', content={
}) "err": True,
"errorText": "Not found.",
},
)
if "err" in track_info_result: if "err" in track_info_result:
raise LastFMException("Unknown error occurred: %s", raise LastFMException(
track_info_result.get('errorText', '??')) "Unknown error occurred: %s",
return JSONResponse(content={ track_info_result.get("errorText", "??"),
'success': True, )
'result': track_info_result return JSONResponse(content={"success": True, "result": track_info_result})
})
except: except:
traceback.print_exc() traceback.print_exc()
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'General error', content={
}) "err": True,
"errorText": "General error",
},
)

View File

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

View File

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

View File

@ -4,21 +4,26 @@ import time
import random import random
import asyncio import asyncio
from utils import radio_util from utils import radio_util
from .constructors import (ValidRadioNextRequest, ValidRadioReshuffleRequest, from .constructors import (
ValidRadioQueueShiftRequest, ValidRadioQueueRemovalRequest, ValidRadioNextRequest,
ValidRadioSongRequest, ValidRadioTypeaheadRequest, ValidRadioReshuffleRequest,
RadioException) ValidRadioQueueShiftRequest,
ValidRadioQueueRemovalRequest,
ValidRadioSongRequest,
ValidRadioTypeaheadRequest,
RadioException,
)
from uuid import uuid4 as uuid from uuid import uuid4 as uuid
from typing import Optional from typing import Optional
from fastapi import (FastAPI, BackgroundTasks, Request, from fastapi import FastAPI, BackgroundTasks, Request, Response, HTTPException
Response, HTTPException)
from fastapi.responses import RedirectResponse, JSONResponse from fastapi.responses import RedirectResponse, JSONResponse
class Radio(FastAPI): class Radio(FastAPI):
"""Radio Endpoints""" """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.app: FastAPI = app
self.util = my_util self.util = my_util
self.constants = constants self.constants = constants
@ -33,22 +38,28 @@ class Radio(FastAPI):
"radio/queue_shift": self.radio_queue_shift, "radio/queue_shift": self.radio_queue_shift,
"radio/reshuffle": self.radio_reshuffle, "radio/reshuffle": self.radio_reshuffle,
"radio/queue_remove": self.radio_queue_remove, "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(): for endpoint, handler in self.endpoints.items():
app.add_api_route(f"/{endpoint}", handler, methods=["POST"], app.add_api_route(
include_in_schema=True) 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"], # NOTE: Not in loop because method is GET for this endpoint
include_in_schema=True) 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.load_playlist())
asyncio.get_event_loop().run_until_complete(self.radio_util._ls_skip()) asyncio.get_event_loop().run_until_complete(self.radio_util._ls_skip())
async def radio_skip(self, data: ValidRadioNextRequest, async def radio_skip(
request: Request) -> JSONResponse: self, data: ValidRadioNextRequest, request: Request
) -> JSONResponse:
""" """
Skip to the next track in the queue, or to uuid specified in skipTo if provided Skip to the next track in the queue, or to uuid specified in skipTo if provided
- **key**: API key - **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): 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")
if data.skipTo: 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: if not queue_item:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'No such queue item.', content={
}) "err": True,
self.radio_util.active_playlist = self.radio_util.active_playlist[queue_item[0]:] "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: if not self.radio_util.active_playlist:
await self.radio_util.load_playlist() await self.radio_util.load_playlist()
skip_result: bool = await self.radio_util._ls_skip() skip_result: bool = await self.radio_util._ls_skip()
status_code = 200 if skip_result else 500 status_code = 200 if skip_result else 500
return JSONResponse(status_code=status_code, content={ return JSONResponse(
'success': skip_result, status_code=status_code,
}) content={
"success": skip_result,
},
)
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'General failure.', content={
}) "err": True,
"errorText": "General failure.",
},
async def radio_reshuffle(self, data: ValidRadioReshuffleRequest, )
request: Request) -> JSONResponse:
async def radio_reshuffle(
self, data: ValidRadioReshuffleRequest, request: Request
) -> JSONResponse:
""" """
Reshuffle the play queue Reshuffle the play queue
- **key**: API key - **key**: API key
""" """
if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): 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")
random.shuffle(self.radio_util.active_playlist) random.shuffle(self.radio_util.active_playlist)
return JSONResponse(content={ return JSONResponse(content={"ok": True})
'ok': True
}) async def radio_get_queue(
self, request: Request, limit: Optional[int] = 15_000
) -> JSONResponse:
async def radio_get_queue(self, request: Request,
limit: Optional[int] = 15_000) -> JSONResponse:
""" """
Get current play queue, up to limit [default: 15k] Get current play queue, up to limit [default: 15k]
- **limit**: Number of queue items to return, 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: list = self.radio_util.active_playlist[0:limit]
queue_out: list[dict] = [] queue_out: list[dict] = []
for x, item in enumerate(queue): for x, item in enumerate(queue):
queue_out.append({ queue_out.append(
'pos': x, {
'id': item.get('id'), "pos": x,
'uuid': item.get('uuid'), "id": item.get("id"),
'artist': item.get('artist'), "uuid": item.get("uuid"),
'song': item.get('song'), "artist": item.get("artist"),
'album': item.get('album', 'N/A'), "song": item.get("song"),
'genre': item.get('genre', 'N/A'), "album": item.get("album", "N/A"),
'artistsong': item.get('artistsong'), "genre": item.get("genre", "N/A"),
'duration': item.get('duration'), "artistsong": item.get("artistsong"),
}) "duration": item.get("duration"),
return JSONResponse(content={ }
'items': queue_out )
}) return JSONResponse(content={"items": queue_out})
async def radio_queue_shift(self, data: ValidRadioQueueShiftRequest, async def radio_queue_shift(
request: Request) -> JSONResponse: self, data: ValidRadioQueueShiftRequest, request: Request
) -> JSONResponse:
""" """
Shift position of a UUID within the queue Shift position of a UUID within the queue
[currently limited to playing next or immediately] [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): 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")
queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid) queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid)
if not queue_item: if not queue_item:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Queue item not found.', content={
}) "err": True,
"errorText": "Queue item not found.",
},
)
(x, item) = queue_item (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) self.radio_util.active_playlist.insert(0, item)
if not data.next: if not data.next:
await self.radio_util._ls_skip() await self.radio_util._ls_skip()
return JSONResponse(content={ return JSONResponse(
'ok': True, content={
}) "ok": True,
}
async def radio_queue_remove(self, data: ValidRadioQueueRemovalRequest, )
request: Request) -> JSONResponse:
async def radio_queue_remove(
self, data: ValidRadioQueueRemovalRequest, request: Request
) -> JSONResponse:
""" """
Remove an item from the current play queue Remove an item from the current play queue
- **key**: API key - **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): 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")
queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid) queue_item = self.radio_util.get_queue_item_by_uuid(data.uuid)
if not queue_item: if not queue_item:
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Queue item not found.', content={
}) "err": True,
"errorText": "Queue item not found.",
},
)
self.radio_util.active_playlist.pop(queue_item[0]) self.radio_util.active_playlist.pop(queue_item[0])
return JSONResponse(content={ return JSONResponse(
'ok': True, content={
}) "ok": True,
}
async def album_art_handler(self, request: Request, track_id: Optional[int] = None) -> Response: )
async def album_art_handler(
self, request: Request, track_id: Optional[int] = None
) -> Response:
""" """
Get album art, optional parameter track_id may be specified. Get album art, optional parameter track_id may be specified.
Otherwise, current track album art will be pulled. Otherwise, current track album art will be pulled.
@ -175,35 +209,42 @@ class Radio(FastAPI):
""" """
try: try:
if not track_id: 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) 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: if not album_art:
return RedirectResponse(url="https://codey.lol/images/radio_art_default.jpg", return RedirectResponse(
status_code=302) url="https://codey.lol/images/radio_art_default.jpg",
return Response(content=album_art, status_code=302,
media_type="image/png") )
return Response(content=album_art, media_type="image/png")
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return RedirectResponse(url="https://codey.lol/images/radio_art_default.jpg", return RedirectResponse(
status_code=302) url="https://codey.lol/images/radio_art_default.jpg", status_code=302
)
async def radio_now_playing(self, request: Request) -> JSONResponse: async def radio_now_playing(self, request: Request) -> JSONResponse:
""" """
Get currently playing track info Get currently playing track info
""" """
ret_obj: dict = {**self.radio_util.now_playing} ret_obj: dict = {**self.radio_util.now_playing}
try: try:
ret_obj['elapsed'] = int(time.time()) - ret_obj['start'] ret_obj["elapsed"] = int(time.time()) - ret_obj["start"]
except KeyError: except KeyError:
traceback.print_exc() traceback.print_exc()
ret_obj['elapsed'] = 0 ret_obj["elapsed"] = 0
ret_obj.pop('file_path') ret_obj.pop("file_path")
return JSONResponse(content=ret_obj) return JSONResponse(content=ret_obj)
async def radio_get_next(
async def radio_get_next(self, data: ValidRadioNextRequest, request: Request, self,
background_tasks: BackgroundTasks) -> JSONResponse: data: ValidRadioNextRequest,
request: Request,
background_tasks: BackgroundTasks,
) -> JSONResponse:
""" """
Get next track Get next track
Track will be removed from the queue in the process. Track will be removed from the queue in the process.
@ -211,54 +252,65 @@ class Radio(FastAPI):
- **skipTo**: Optional UUID to skip to - **skipTo**: Optional UUID to skip to
""" """
if not self.util.check_key(path=request.url.path, req_type=4, key=data.key): 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")
if not isinstance(self.radio_util.active_playlist, list) or not self.radio_util.active_playlist: 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.load_playlist()
await self.radio_util._ls_skip() await self.radio_util._ls_skip()
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'General failure occurred, prompting playlist reload.', content={
}) "err": True,
"errorText": "General failure occurred, prompting playlist reload.",
},
)
next = self.radio_util.active_playlist.pop(0) next = self.radio_util.active_playlist.pop(0)
if not isinstance(next, dict): if not isinstance(next, dict):
logging.critical("next is of type: %s, reloading playlist...", type(next)) logging.critical("next is of type: %s, reloading playlist...", type(next))
await self.radio_util.load_playlist() await self.radio_util.load_playlist()
await self.radio_util._ls_skip() await self.radio_util._ls_skip()
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'General failure occurred, prompting playlist reload.', content={
}) "err": True,
"errorText": "General failure occurred, prompting playlist reload.",
duration: int = next['duration'] },
)
duration: int = next["duration"]
time_started: int = int(time.time()) time_started: int = int(time.time())
time_ends: int = int(time_started + duration) time_ends: int = int(time_started + duration)
if len(self.radio_util.active_playlist) > 1: 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: else:
await self.radio_util.load_playlist() await self.radio_util.load_playlist()
self.radio_util.now_playing = next self.radio_util.now_playing = next
next['start'] = time_started next["start"] = time_started
next['end'] = time_ends next["end"] = time_ends
try: try:
background_tasks.add_task(self.radio_util.webhook_song_change, next) background_tasks.add_task(self.radio_util.webhook_song_change, next)
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
try: try:
if not 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']) album_art = await self.radio_util.get_album_art(
file_path=next["file_path"]
)
if album_art: 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: else:
logging.debug("Could not read album art for %s", logging.debug("Could not read album art for %s", next["file_path"])
next['file_path'])
except: except:
traceback.print_exc() traceback.print_exc()
return JSONResponse(content=next) return JSONResponse(content=next)
async def radio_request(
async def radio_request(self, data: ValidRadioSongRequest, request: Request) -> JSONResponse: self, data: ValidRadioSongRequest, request: Request
) -> JSONResponse:
""" """
Song request handler Song request handler
- **key**: API key - **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 - **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): 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 artistsong: Optional[str] = data.artistsong
artist: Optional[str] = data.artist artist: Optional[str] = data.artist
song: Optional[str] = data.song song: Optional[str] = data.song
if artistsong and (artist or song): if artistsong and (artist or song):
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Invalid request', content={
}) "err": True,
"errorText": "Invalid request",
},
)
if not artistsong and (not artist or not song): if not artistsong and (not artist or not song):
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Invalid request', content={
}) "err": True,
"errorText": "Invalid request",
search: bool = await self.radio_util.search_playlist(artistsong=artistsong, },
artist=artist, )
song=song)
search: bool = await self.radio_util.search_playlist(
artistsong=artistsong, artist=artist, song=song
)
if data.alsoSkip: if data.alsoSkip:
await self.radio_util._ls_skip() await self.radio_util._ls_skip()
return JSONResponse(content={ return JSONResponse(content={"result": search})
'result': search
}) async def radio_typeahead(
self, data: ValidRadioTypeaheadRequest, request: Request
async def radio_typeahead(self, data: ValidRadioTypeaheadRequest, ) -> JSONResponse:
request: Request) -> JSONResponse:
""" """
Radio typeahead handler Radio typeahead handler
- **query**: Typeahead query - **query**: Typeahead query
""" """
if not isinstance(data.query, str): if not isinstance(data.query, str):
return JSONResponse(status_code=500, content={ return JSONResponse(
'err': True, status_code=500,
'errorText': 'Invalid request.', content={
}) "err": True,
typeahead: Optional[list[str]] = await self.radio_util.trackdb_typeahead(data.query) "errorText": "Invalid request.",
},
)
typeahead: Optional[list[str]] = await self.radio_util.trackdb_typeahead(
data.query
)
if not typeahead: if not typeahead:
return JSONResponse(content=[]) 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 fastapi.responses import JSONResponse
from .constructors import RandMsgRequest from .constructors import RandMsgRequest
class RandMsg(FastAPI): class RandMsg(FastAPI):
""" """
Random Message Endpoint Random Message Endpoint
""" """
def __init__(self, app: FastAPI,
util, constants) -> None: def __init__(self, app: FastAPI, util, constants) -> None:
self.app: FastAPI = app self.app: FastAPI = app
self.util = util self.util = util
self.constants = constants self.constants = constants
self.endpoint_name = "randmsg" self.endpoint_name = "randmsg"
app.add_api_route(f"/{self.endpoint_name}", self.randmsg_handler, methods=["POST"]) app.add_api_route(
f"/{self.endpoint_name}", self.randmsg_handler, methods=["POST"]
async def randmsg_handler(self, data: Optional[RandMsgRequest] = None) -> JSONResponse: )
async def randmsg_handler(
self, data: Optional[RandMsgRequest] = None
) -> JSONResponse:
""" """
Get a randomly generated message Get a randomly generated message
- **short**: Optional, if True, will limit length of returned random messages to <=126 characters (Discord restriction related) - **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: match db_rand_selected:
case 0: case 0:
randmsg_db_path: Union[str, LiteralString] = os.path.join("/usr/local/share", randmsg_db_path: Union[str, LiteralString] = os.path.join(
"sqlite_dbs", "qajoke.db") # For qajoke db "/usr/local/share", "sqlite_dbs", "qajoke.db"
db_query: str = "SELECT id, ('<b>Q:</b> ' || question || '<br/><b>A:</b> ' \ ) # For qajoke db
|| answer) FROM jokes ORDER BY RANDOM() LIMIT 1" # 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" title_attr = "QA Joke DB"
case 1 | 9: case 1 | 9:
randmsg_db_path = os.path.join("/usr/local/share", randmsg_db_path = os.path.join(
"sqlite_dbs", "/usr/local/share", "sqlite_dbs", "randmsg.db"
"randmsg.db") # For randmsg db ) # For randmsg db
db_query = "SELECT id, msg FROM msgs WHERE \ 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: if db_rand_selected == 9:
db_query = db_query.replace("<= 180", "<= 126") db_query = db_query.replace("<= 180", "<= 126")
title_attr = "Random Msg DB" title_attr = "Random Msg DB"
case 2: case 2:
randmsg_db_path = os.path.join("/usr/local/share", randmsg_db_path = os.path.join(
"sqlite_dbs", "/usr/local/share", "sqlite_dbs", "trump.db"
"trump.db") # For Trump Tweet DB ) # For Trump Tweet DB
db_query = "SELECT id, content FROM tweets \ 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" title_attr = "Trump Tweet DB"
case 3: case 3:
randmsg_db_path = os.path.join("/usr/local/share", randmsg_db_path = os.path.join(
"sqlite_dbs", "/usr/local/share", "sqlite_dbs", "philo.db"
"philo.db") # For Philo DB ) # For Philo DB
db_query = "SELECT id, (content || '<br> - ' || speaker) FROM quotes \ db_query = "SELECT id, (content || '<br> - ' || speaker) FROM quotes \
ORDER BY RANDOM() LIMIT 1" # For Philo DB ORDER BY RANDOM() LIMIT 1" # For Philo DB
title_attr = "Philosophical Quotes DB" title_attr = "Philosophical Quotes DB"
case 4: case 4:
randmsg_db_path = os.path.join("/usr/local/share", randmsg_db_path = os.path.join(
"sqlite_dbs", "/usr/local/share", "sqlite_dbs", "hate.db"
"hate.db") # For Hate DB ) # For Hate DB
db_query = """SELECT id, ("<font color='#FF0000'>" || comment) FROM hate_speech \ db_query = """SELECT id, ("<font color='#FF0000'>" || comment) FROM hate_speech \
WHERE length(comment) <= 180 ORDER BY RANDOM() LIMIT 1""" WHERE length(comment) <= 180 ORDER BY RANDOM() LIMIT 1"""
title_attr = "Hate Speech DB" title_attr = "Hate Speech DB"
case 5: case 5:
randmsg_db_path = os.path.join("/usr/local/share", randmsg_db_path = os.path.join(
"sqlite_dbs", "/usr/local/share", "sqlite_dbs", "rjokes.db"
"rjokes.db") # r/jokes DB ) # r/jokes DB
db_query = """SELECT id, (title || "<br>" || body) FROM jokes \ db_query = """SELECT id, (title || "<br>" || body) FROM jokes \
WHERE score >= 10000 ORDER BY RANDOM() LIMIT 1""" WHERE score >= 10000 ORDER BY RANDOM() LIMIT 1"""
title_attr = "r/jokes DB" title_attr = "r/jokes DB"
async with sqlite3.connect(database=randmsg_db_path, timeout=1) as _db: async with sqlite3.connect(database=randmsg_db_path, timeout=1) as _db:
async with await _db.execute(db_query) as _cursor: async with await _db.execute(db_query) as _cursor:
result: sqlite3.Row = await _cursor.fetchone() result: sqlite3.Row = await _cursor.fetchone()
(result_id, result_msg) = result (result_id, result_msg) = result
result_msg = result_msg.strip() result_msg = result_msg.strip()
return JSONResponse(content= return JSONResponse(
{ content={
"id": result_id, "id": result_id,
"msg": result_msg, "msg": result_msg,
"title": title_attr, "title": title_attr,
}) }
)

View File

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

View File

@ -1,6 +1,7 @@
from typing import Optional from typing import Optional
from openai import AsyncOpenAI from openai import AsyncOpenAI
class GPT: class GPT:
def __init__(self, constants) -> None: def __init__(self, constants) -> None:
self.constants = constants 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 \ 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.""" info on the specific songs the user may listen to."""
async def get_completion(self, prompt: str, async def get_completion(
system_prompt: Optional[str] = None) -> Optional[str]: self, prompt: str, system_prompt: Optional[str] = None
) -> Optional[str]:
if not system_prompt: if not system_prompt:
system_prompt = self.default_system_prompt system_prompt = self.default_system_prompt
chat_completion = await self.client.chat.completions.create( chat_completion = await self.client.chat.completions.create(
@ -25,10 +27,10 @@ class GPT:
{ {
"role": "user", "role": "user",
"content": prompt, "content": prompt,
} },
], ],
model="gpt-4o-mini", model="gpt-4o-mini",
temperature=0.35, temperature=0.35,
) )
response: Optional[str] = chat_completion.choices[0].message.content response: Optional[str] = chat_completion.choices[0].message.content
return response return response

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,24 +7,27 @@ import regex
from regex import Pattern from regex import Pattern
import asyncio import asyncio
from typing import Union, Optional from typing import Union, Optional
sys.path.insert(1,'..')
sys.path.insert(1, "..")
from lyric_search import notifier from lyric_search import notifier
from lyric_search.constructors import LyricsResult from lyric_search.constructors import LyricsResult
import redis.asyncio as redis import redis.asyncio as redis
from redis.commands.search.query import Query # 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.indexDefinition import IndexDefinition, IndexType # type: ignore
from redis.commands.search.field import TextField, TagField # type: ignore from redis.commands.search.field import TextField, TagField # type: ignore
from redis.commands.json.path import Path # type: ignore from redis.commands.json.path import Path # type: ignore
from . import private from . import private
logger = logging.getLogger() logger = logging.getLogger()
log_level = logging.getLevelName(logger.level) log_level = logging.getLevelName(logger.level)
class RedisException(Exception): class RedisException(Exception):
""" """
Redis Exception Redis Exception
""" """
class RedisCache: class RedisCache:
""" """
Redis Cache Methods Redis Cache Methods
@ -35,34 +38,37 @@ class RedisCache:
self.notifier = notifier.DiscordNotifier() self.notifier = notifier.DiscordNotifier()
self.notify_warnings = False self.notify_warnings = False
self.regexes: list[Pattern] = [ self.regexes: list[Pattern] = [
regex.compile(r'\-'), regex.compile(r"\-"),
regex.compile(r'[^a-zA-Z0-9\s]'), regex.compile(r"[^a-zA-Z0-9\s]"),
] ]
try: try:
asyncio.get_event_loop().create_task(self.create_index()) asyncio.get_event_loop().create_task(self.create_index())
except Exception as e: except Exception as e:
logging.debug("Failed to create redis create_index task: %s", logging.debug("Failed to create redis create_index task: %s", str(e))
str(e))
async def create_index(self) -> None: async def create_index(self) -> None:
"""Create Index""" """Create Index"""
try: try:
schema = ( schema = (
TextField("$.search_artist", as_name="artist"), TextField("$.search_artist", as_name="artist"),
TextField("$.search_song", as_name="song"), TextField("$.search_song", as_name="song"),
TextField("$.src", as_name="src"), TextField("$.src", as_name="src"),
TextField("$.lyrics", as_name="lyrics") TextField("$.lyrics", as_name="lyrics"),
) )
result = await self.redis_client.ft().create_index( 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": if str(result) != "OK":
raise RedisException(f"Redis: Failed to create index: {result}") raise RedisException(f"Redis: Failed to create index: {result}")
except Exception as e: except Exception as e:
logging.debug("Failed to create redis index: %s", logging.debug("Failed to create redis index: %s", str(e))
str(e))
def sanitize_input(
def sanitize_input(self, artist: str, song: str, self, artist: str, song: str, fuzzy: Optional[bool] = False
fuzzy: Optional[bool] = False) -> tuple[str, str]: ) -> tuple[str, str]:
""" """
Sanitize artist/song input (convert to redis matchable fuzzy query) Sanitize artist/song input (convert to redis matchable fuzzy query)
Args: Args:
@ -77,10 +83,12 @@ class RedisCache:
song = self.regexes[0].sub("", song) song = self.regexes[0].sub("", song)
song = self.regexes[1].sub("", song).strip() song = self.regexes[1].sub("", song).strip()
if fuzzy: 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(" ")]) song = " ".join([f"(%{song_word}%)" for song_word in song.split(" ")])
return (artist, song) return (artist, song)
async def increment_found_count(self, src: str) -> None: async def increment_found_count(self, src: str) -> None:
""" """
Increment the found count for a source Increment the found count for a source
@ -94,13 +102,13 @@ class RedisCache:
await self.redis_client.incr(f"returned:{src}") await self.redis_client.incr(f"returned:{src}")
except Exception as e: except Exception as e:
file: str = __file__.rsplit("/", maxsplit=1)[-1] 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() traceback.print_exc()
async def get_found_counts(self) -> Optional[dict]: async def get_found_counts(self) -> Optional[dict]:
""" """
Get found counts for all sources (and failed count) Get found counts for all sources (and failed count)
Returns: Returns:
dict: In the form {'source': count, 'source2': count, ...} dict: In the form {'source': count, 'source2': count, ...}
""" """
@ -109,18 +117,20 @@ class RedisCache:
counts: dict[str, int] = {} counts: dict[str, int] = {}
for src in sources: for src in sources:
src_found_count = await self.redis_client.get(f"returned:{src}") 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 return counts
except Exception as e: except Exception as e:
file: str = __file__.rsplit("/", maxsplit=1)[-1] 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() traceback.print_exc()
return None return None
async def search(
async def search(self, artist: Optional[str] = None, self,
song: Optional[str] = None, artist: Optional[str] = None,
lyrics: Optional[str] = None) -> Optional[list[tuple]]: song: Optional[str] = None,
lyrics: Optional[str] = None,
) -> Optional[list[tuple]]:
""" """
Search Redis Cache Search Redis Cache
Args: Args:
@ -133,57 +143,72 @@ class RedisCache:
try: try:
fuzzy_artist = None fuzzy_artist = None
fuzzy_song = None fuzzy_song = None
is_random_search = artist == "!" and song == "!" is_random_search = artist == "!" and song == "!"
if lyrics: if lyrics:
# to code later # to code later
raise RedisException("Lyric search not yet implemented") raise RedisException("Lyric search not yet implemented")
if not is_random_search: if not is_random_search:
logging.debug("Redis: Searching normally first") logging.debug("Redis: Searching normally first")
if not artist or not song: 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 return None
(artist, song) = self.sanitize_input(artist, song) (artist, song) = self.sanitize_input(artist, song)
logging.debug("Seeking: %s - %s", artist, song) logging.debug("Seeking: %s - %s", artist, song)
search_res: Union[dict, list] = await self.redis_client.ft().search(Query( # type: ignore search_res: Union[dict, list] = await self.redis_client.ft().search(
f"@artist:{artist} @song:{song}" Query(f"@artist:{artist} @song:{song}") # type: ignore
)) )
search_res_out: list[tuple] = [(result['id'].split(":", search_res_out: list[tuple] = [
maxsplit=1)[1], dict(json.loads(result['json']))) (
for result in search_res.docs] # type: ignore result["id"].split(":", maxsplit=1)[1],
dict(json.loads(result["json"])),
)
for result in search_res.docs
] # type: ignore
if not search_res_out: 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_artist = " ".join(artist.split(" ")[0:5])
short_song = " ".join(song.split(" ")[0:5]) short_song = " ".join(song.split(" ")[0:5])
(fuzzy_artist, fuzzy_song) = self.sanitize_input(artist=short_artist.strip(), (fuzzy_artist, fuzzy_song) = self.sanitize_input(
song=short_song.strip(), fuzzy=True) 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 = await self.redis_client.ft().search(
)) Query( # type: ignore
search_res_out = [(result['id'].split(":", f"@artist:{fuzzy_artist} @song:{fuzzy_song}"
maxsplit=1)[1], dict(json.loads(result['json']))) )
for result in search_res.docs] # type: ignore )
search_res_out = [
(
result["id"].split(":", maxsplit=1)[1],
dict(json.loads(result["json"])),
)
for result in search_res.docs
] # type: ignore
else: else:
random_redis_key: str = await self.redis_client.randomkey() random_redis_key: str = await self.redis_client.randomkey()
out_id: str = str(random_redis_key).split(":", out_id: str = str(random_redis_key).split(":", maxsplit=1)[1][:-1]
maxsplit=1)[1][:-1]
search_res = await self.redis_client.json().get(random_redis_key) search_res = await self.redis_client.json().get(random_redis_key)
search_res_out = [(out_id, search_res)] search_res_out = [(out_id, search_res)]
if not search_res_out and self.notify_warnings: 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 return search_res_out
except Exception as e: except Exception as e:
traceback.print_exc() 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 return None
async def redis_store(self, sqlite_id: int, async def redis_store(self, sqlite_id: int, lyr_result: LyricsResult) -> None:
lyr_result: LyricsResult) -> None:
""" """
Store lyrics to redis cache Store lyrics to redis cache
Args: Args:
@ -193,34 +218,47 @@ class RedisCache:
None None
""" """
try: try:
(search_artist, search_song) = self.sanitize_input(lyr_result.artist, (search_artist, search_song) = self.sanitize_input(
lyr_result.song) lyr_result.artist, lyr_result.song
)
redis_mapping: dict = { redis_mapping: dict = {
'id': sqlite_id, "id": sqlite_id,
'src': lyr_result.src, "src": lyr_result.src,
'date_retrieved': time.time(), "date_retrieved": time.time(),
'artist': lyr_result.artist, "artist": lyr_result.artist,
'search_artist': search_artist, "search_artist": search_artist,
'search_song': search_song, "search_song": search_song,
'search_artistsong': f'{search_artist}\n{search_song}', "search_artistsong": f"{search_artist}\n{search_song}",
'song': lyr_result.song, "song": lyr_result.song,
'artistsong': f"{lyr_result.artist}\n{lyr_result.song}", "artistsong": f"{lyr_result.artist}\n{lyr_result.song}",
'confidence': lyr_result.confidence, "confidence": lyr_result.confidence,
'lyrics': lyr_result.lyrics, "lyrics": lyr_result.lyrics,
'tags': '(none)', "tags": "(none)",
'liked': 0, "liked": 0,
} }
newkey: str = f"lyrics:000{sqlite_id}" newkey: str = f"lyrics:000{sqlite_id}"
jsonset: bool = await self.redis_client.json().set(newkey, Path.root_path(), jsonset: bool = await self.redis_client.json().set(
redis_mapping) newkey, Path.root_path(), redis_mapping
)
if not jsonset: if not jsonset:
raise RedisException(f"Failed to store {lyr_result.artist} - {lyr_result.song} (SQLite id: {sqlite_id}) to redis:\n{jsonset}") raise RedisException(
logging.info("Stored %s - %s (related SQLite Row ID: %s) to %s", f"Failed to store {lyr_result.artist} - {lyr_result.song} (SQLite id: {sqlite_id}) to redis:\n{jsonset}"
lyr_result.artist, lyr_result.song, sqlite_id, newkey) )
await self.notifier.send("INFO", logging.info(
f"Stored `{lyr_result.artist} - {lyr_result.song}` (related SQLite Row ID: `{sqlite_id}`) to redis: `{newkey}`") "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: except Exception as e:
file: str = __file__.rsplit("/", maxsplit=1)[-1] file: str = __file__.rsplit("/", maxsplit=1)[-1]
await self.notifier.send(f"ERROR @ {file}", await self.notifier.send(
f"Failed to store `{lyr_result.artist} - {lyr_result.song}`\ f"ERROR @ {file}",
(SQLite id: `{sqlite_id}`) to Redis:\n`{str(e)}`") 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 import regex
from regex import Pattern from regex import Pattern
class TrackMatcher: class TrackMatcher:
"""Track Matcher""" """Track Matcher"""
def __init__(self, threshold: float = 0.85): def __init__(self, threshold: float = 0.85):
""" """
Initialize the TrackMatcher with a similarity threshold. Initialize the TrackMatcher with a similarity threshold.
Args: Args:
threshold (float): Minimum similarity score to consider a match valid threshold (float): Minimum similarity score to consider a match valid
(between 0 and 1, default 0.85) (between 0 and 1, default 0.85)
""" """
self.threshold = threshold 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. Find the best matching track from the candidate list.
Args: Args:
input_track (str): Input track in "ARTIST - SONG" format input_track (str): Input track in "ARTIST - SONG" format
candidate_tracks (List[tuple[int|str, str]]): List of candidate tracks candidate_tracks (List[tuple[int|str, str]]): List of candidate tracks
Returns: Returns:
Optional[tuple[int, str, float]]: Tuple of (best matching track, similarity score) Optional[tuple[int, str, float]]: Tuple of (best matching track, similarity score)
or None if no good match found or None if no good match found
""" """
if not input_track or not candidate_tracks: if not input_track or not candidate_tracks:
return None return None
# Normalize input track # Normalize input track
input_track = self._normalize_string(input_track) input_track = self._normalize_string(input_track)
best_match = None best_match = None
best_score: float = 0.0 best_score: float = 0.0
@ -43,12 +46,16 @@ class TrackMatcher:
normalized_candidate = self._normalize_string(candidate[1]) normalized_candidate = self._normalize_string(candidate[1])
if normalized_candidate.strip().lower() == input_track.strip().lower(): if normalized_candidate.strip().lower() == input_track.strip().lower():
return (candidate, 100.0) return (candidate, 100.0)
# Calculate various similarity scores # Calculate various similarity scores
exact_score = 1.0 if input_track == normalized_candidate else 0.0 exact_score = 1.0 if input_track == normalized_candidate else 0.0
sequence_score = SequenceMatcher(None, input_track, normalized_candidate).ratio() sequence_score = SequenceMatcher(
token_score = self._calculate_token_similarity(input_track, normalized_candidate) None, input_track, normalized_candidate
).ratio()
token_score = self._calculate_token_similarity(
input_track, normalized_candidate
)
# Take the maximum of the different scoring methods # Take the maximum of the different scoring methods
final_score = max(exact_score, sequence_score, token_score) final_score = max(exact_score, sequence_score, token_score)
@ -59,7 +66,7 @@ class TrackMatcher:
# Return the match only if it meets the threshold # Return the match only if it meets the threshold
if best_score < self.threshold: if best_score < self.threshold:
return None return None
match: tuple = (best_match, round(best_score * 100)) match: tuple = (best_match, round(best_score * 100))
return match return match
def _normalize_string(self, text: str) -> str: def _normalize_string(self, text: str) -> str:
@ -72,9 +79,9 @@ class TrackMatcher:
str: Normalized text str: Normalized text
""" """
# Remove special characters and convert to lowercase # 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 # Normalize spaces
text = ' '.join(text.split()) text = " ".join(text.split())
return text return text
def _calculate_token_similarity(self, str1: str, str2: str) -> float: def _calculate_token_similarity(self, str1: str, str2: str) -> float:
@ -88,28 +95,32 @@ class TrackMatcher:
""" """
tokens1 = set(str1.split()) tokens1 = set(str1.split())
tokens2 = set(str2.split()) tokens2 = set(str2.split())
if not tokens1 or not tokens2: if not tokens1 or not tokens2:
return 0.0 return 0.0
intersection = tokens1.intersection(tokens2) intersection = tokens1.intersection(tokens2)
union = tokens1.union(tokens2) union = tokens1.union(tokens2)
return len(intersection) / len(union) return len(intersection) / len(union)
class DataUtils: class DataUtils:
""" """
Data Utils Data Utils
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.lrc_regex = regex.compile(r'\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}') self.lrc_regex = regex.compile(
self.scrub_regex_1: Pattern = regex.compile(r'(\[.*?\])(\s){0,}(\:){0,1}') r"\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}"
self.scrub_regex_2: Pattern = regex.compile(r'(\d?)(Embed\b)', )
flags=regex.IGNORECASE) self.scrub_regex_1: Pattern = regex.compile(r"(\[.*?\])(\s){0,}(\:){0,1}")
self.scrub_regex_3: Pattern = regex.compile(r'\n{2}') self.scrub_regex_2: Pattern = regex.compile(
self.scrub_regex_4: Pattern = regex.compile(r'[0-9]\b$') 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: def scrub_lyrics(self, lyrics: str) -> str:
""" """
Lyric Scrub Regex Chain Lyric Scrub Regex Chain
@ -118,11 +129,11 @@ class DataUtils:
Returns: Returns:
str: Regex scrubbed lyrics str: Regex scrubbed lyrics
""" """
lyrics = self.scrub_regex_1.sub('', lyrics) lyrics = self.scrub_regex_1.sub("", lyrics)
lyrics = self.scrub_regex_2.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("\n", lyrics) # Gaps between verses
lyrics = self.scrub_regex_3.sub('', lyrics) lyrics = self.scrub_regex_3.sub("", lyrics)
return lyrics return lyrics
def create_lrc_object(self, lrc_str: str) -> list[dict]: def create_lrc_object(self, lrc_str: str) -> list[dict]:
""" """
@ -142,15 +153,21 @@ class DataUtils:
if not reg_helper: if not reg_helper:
continue continue
reg_helper = reg_helper[0] reg_helper = reg_helper[0]
logging.debug("Reg helper: %s for line: %s; len: %s", logging.debug(
reg_helper, line, len(reg_helper)) "Reg helper: %s for line: %s; len: %s",
reg_helper,
line,
len(reg_helper),
)
_timetag = reg_helper[0] _timetag = reg_helper[0]
if not reg_helper[1].strip(): if not reg_helper[1].strip():
_words = "" _words = ""
else: else:
_words = reg_helper[1].strip() _words = reg_helper[1].strip()
lrc_out.append({ lrc_out.append(
"timeTag": _timetag, {
"words": _words, "timeTag": _timetag,
}) "words": _words,
return lrc_out }
)
return lrc_out

0
py.typed Normal file
View File

21
util.py
View File

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

View File

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

View File

@ -6,54 +6,60 @@ from aiohttp import ClientSession, ClientTimeout
from constants import Constants from constants import Constants
from .constructors import InvalidLastFMResponseException from .constructors import InvalidLastFMResponseException
class LastFM: class LastFM:
"""LastFM Endpoints""" """LastFM Endpoints"""
def __init__(self,
noInit: Optional[bool] = False) -> None: def __init__(self, noInit: Optional[bool] = False) -> None:
self.creds = Constants().LFM_CREDS self.creds = Constants().LFM_CREDS
self.api_base_url: str = "https://ws.audioscrobbler.com/2.0" self.api_base_url: str = "https://ws.audioscrobbler.com/2.0"
async def search_artist(self, artist: Optional[str] = None) -> dict: async def search_artist(self, artist: Optional[str] = None) -> dict:
"""Search LastFM for an artist""" """Search LastFM for an artist"""
try: try:
if not artist: if not artist:
return { return {
'err': 'No artist specified.', "err": "No artist specified.",
} }
request_params: list[tuple] = [ request_params: list[tuple] = [
("method", "artist.getInfo"), ("method", "artist.getInfo"),
("artist", artist), ("artist", artist),
("api_key", self.creds.get('key')), ("api_key", self.creds.get("key")),
("autocorrect", "1"), ("autocorrect", "1"),
("format", "json"), ("format", "json"),
] ]
async with ClientSession() as session: async with ClientSession() as session:
async with await session.get(self.api_base_url, async with await session.get(
params=request_params, self.api_base_url,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status() request.raise_for_status()
data: dict = await request.json() data: dict = await request.json()
data = data.get('artist', 'N/A') data = data.get("artist", "N/A")
ret_obj: dict = { ret_obj: dict = {
'id': data.get('mbid'), "id": data.get("mbid"),
'touring': data.get('ontour'), "touring": data.get("ontour"),
'name': data.get('name'), "name": data.get("name"),
'bio': data.get('bio', None).get('summary').strip()\ "bio": data.get("bio", None)
.split("<a href")[0], .get("summary")
} .strip()
.split("<a href")[0],
}
return ret_obj return ret_obj
except: except:
traceback.print_exc() traceback.print_exc()
return { return {
'err': 'Failed', "err": "Failed",
} }
async def get_track_info(self, artist: Optional[str] = None, async def get_track_info(
track: Optional[str] = None) -> Optional[dict]: self, artist: Optional[str] = None, track: Optional[str] = None
) -> Optional[dict]:
""" """
Get Track Info from LastFM Get Track Info from LastFM
Args: Args:
@ -66,12 +72,12 @@ class LastFM:
if not artist or not track: if not artist or not track:
logging.info("inv request") logging.info("inv request")
return { return {
'err': 'Invalid/No artist or track specified', "err": "Invalid/No artist or track specified",
} }
request_params: list[tuple] = [ request_params: list[tuple] = [
("method", "track.getInfo"), ("method", "track.getInfo"),
("api_key", self.creds.get('key')), ("api_key", self.creds.get("key")),
("autocorrect", "1"), ("autocorrect", "1"),
("artist", artist), ("artist", artist),
("track", track), ("track", track),
@ -79,29 +85,32 @@ class LastFM:
] ]
async with ClientSession() as session: async with ClientSession() as session:
async with await session.get(self.api_base_url, async with await session.get(
params=request_params, self.api_base_url,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status() request.raise_for_status()
data: dict = await request.json() data: dict = await request.json()
data = data.get('track', None) data = data.get("track", None)
if not isinstance(data.get('artist'), dict): if not isinstance(data.get("artist"), dict):
return None return None
artist_mbid: int = data.get('artist', None).get('mbid') artist_mbid: int = data.get("artist", None).get("mbid")
album: str = data.get('album', None).get('title') album: str = data.get("album", None).get("title")
ret_obj: dict = { ret_obj: dict = {
'artist_mbid': artist_mbid, "artist_mbid": artist_mbid,
'album': album, "album": album,
} }
return ret_obj return ret_obj
except: except:
traceback.print_exc() traceback.print_exc()
return { return {
'err': 'General Failure', "err": "General Failure",
} }
async def get_album_tracklist(self, artist: Optional[str] = None, async def get_album_tracklist(
album: Optional[str] = None) -> dict: self, artist: Optional[str] = None, album: Optional[str] = None
) -> dict:
""" """
Get Album Tracklist Get Album Tracklist
Args: Args:
@ -113,24 +122,26 @@ class LastFM:
try: try:
if not artist or not album: if not artist or not album:
return { return {
'err': 'No artist or album specified', "err": "No artist or album specified",
} }
tracks: dict = await self.get_release(artist=artist, album=album) tracks: dict = await self.get_release(artist=artist, album=album)
tracks = tracks.get('tracks', None) tracks = tracks.get("tracks", None)
ret_obj: dict = { ret_obj: dict = {
'tracks': tracks, "tracks": tracks,
} }
return ret_obj return ret_obj
except: except:
traceback.print_exc() traceback.print_exc()
return { return {
'err': 'General Failure', "err": "General Failure",
} }
async def get_artist_albums(self, artist: Optional[str] = None) -> Union[dict, list[dict]]: async def get_artist_albums(
self, artist: Optional[str] = None
) -> Union[dict, list[dict]]:
""" """
Get Artists Albums from LastFM Get Artists Albums from LastFM
Args: Args:
@ -141,37 +152,39 @@ class LastFM:
try: try:
if not artist: if not artist:
return { return {
'err': 'No artist specified.', "err": "No artist specified.",
} }
request_params: list[tuple] = [ request_params: list[tuple] = [
("method", "artist.gettopalbums"), ("method", "artist.gettopalbums"),
("artist", artist), ("artist", artist),
("api_key", self.creds.get('key')), ("api_key", self.creds.get("key")),
("autocorrect", "1"), ("autocorrect", "1"),
("format", "json"), ("format", "json"),
] ]
async with ClientSession() as session: async with ClientSession() as session:
async with await session.get(self.api_base_url, async with await session.get(
params=request_params, self.api_base_url,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status() request.raise_for_status()
json_data: dict = await request.json() 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 = [ ret_obj: list = [
{ {"title": item.get("name")}
'title': item.get('name') for item in data
} for item in data if not(item.get('name').lower() == "(null)")\ if not (item.get("name").lower() == "(null)")
and int(item.get('playcount')) >= 50 and int(item.get("playcount")) >= 50
] ]
return ret_obj return ret_obj
except: except:
traceback.print_exc() traceback.print_exc()
return { return {
'err': 'Failed', "err": "Failed",
} }
async def get_artist_id(self, artist: Optional[str] = None) -> int: async def get_artist_id(self, artist: Optional[str] = None) -> int:
""" """
Get Artist ID from LastFM Get Artist ID from LastFM
@ -183,16 +196,16 @@ class LastFM:
try: try:
if not artist: if not artist:
return -1 return -1
artist_search: dict = await self.search_artist(artist=artist) artist_search: dict = await self.search_artist(artist=artist)
if not artist_search: if not artist_search:
logging.debug("[get_artist_id] Throwing no result error") logging.debug("[get_artist_id] Throwing no result error")
return -1 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 return artist_id
except: except:
traceback.print_exc() traceback.print_exc()
return -1 return -1
async def get_artist_info_by_id(self, artist_id: Optional[int] = None) -> dict: async def get_artist_info_by_id(self, artist_id: Optional[int] = None) -> dict:
""" """
Get Artist info by ID from LastFM Get Artist info by ID from LastFM
@ -204,44 +217,48 @@ class LastFM:
try: try:
if not artist_id or not str(artist_id).isnumeric(): if not artist_id or not str(artist_id).isnumeric():
return { return {
'err': 'Invalid/no artist_id specified.', "err": "Invalid/no artist_id specified.",
} }
req_url: str = f"{self.api_base_url}/artists/{artist_id}" req_url: str = f"{self.api_base_url}/artists/{artist_id}"
request_params: list[tuple] = [ request_params: list[tuple] = [
("key", self.creds.get('key')), ("key", self.creds.get("key")),
("secret", self.creds.get('secret')), ("secret", self.creds.get("secret")),
] ]
async with ClientSession() as session: async with ClientSession() as session:
async with await session.get(req_url, async with await session.get(
params=request_params, req_url,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status() request.raise_for_status()
data: dict = await request.json() data: dict = await request.json()
if not data.get('profile'): if not data.get("profile"):
raise InvalidLastFMResponseException("Data did not contain 'profile' key.") 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', '') _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) profile = regex.sub(r"(\[(\/{0,})(u|b|i)])", "", profile)
members: list = data.get('members', None) members: list = data.get("members", None)
ret_obj: dict = { ret_obj: dict = {
'id': _id, "id": _id,
'name': name, "name": name,
'profile': profile, "profile": profile,
'members': members, "members": members,
} }
return ret_obj return ret_obj
except: except:
traceback.print_exc() traceback.print_exc()
return { return {
'err': 'Failed', "err": "Failed",
} }
async def get_artist_info(self, artist: Optional[str] = None) -> dict: async def get_artist_info(self, artist: Optional[str] = None) -> dict:
""" """
Get Artist Info from LastFM Get Artist Info from LastFM
@ -253,27 +270,30 @@ class LastFM:
try: try:
if not artist: if not artist:
return { return {
'err': 'No artist specified.', "err": "No artist specified.",
} }
artist_id: Optional[int] = await self.get_artist_id(artist=artist) artist_id: Optional[int] = await self.get_artist_id(artist=artist)
if not artist_id: if not artist_id:
return { 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: if not artist_info:
return { return {
'err': 'Failed', "err": "Failed",
} }
return artist_info return artist_info
except: except:
traceback.print_exc() traceback.print_exc()
return { return {
'err': 'Failed', "err": "Failed",
} }
async def get_release(self, artist: Optional[str] = None, async def get_release(
album: Optional[str] = None) -> dict: self, artist: Optional[str] = None, album: Optional[str] = None
) -> dict:
""" """
Get Release info from LastFM Get Release info from LastFM
Args: Args:
@ -285,55 +305,61 @@ class LastFM:
try: try:
if not artist or not album: if not artist or not album:
return { return {
'err': 'Invalid artist/album pair', "err": "Invalid artist/album pair",
} }
request_params: list[tuple] = [ request_params: list[tuple] = [
("method", "album.getinfo"), ("method", "album.getinfo"),
("artist", artist), ("artist", artist),
("album", album), ("album", album),
("api_key", self.creds.get('key')), ("api_key", self.creds.get("key")),
("autocorrect", "1"), ("autocorrect", "1"),
("format", "json"), ("format", "json"),
] ]
async with ClientSession() as session: async with ClientSession() as session:
async with await session.get(self.api_base_url, async with await session.get(
params=request_params, self.api_base_url,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8),
) as request:
request.raise_for_status() request.raise_for_status()
json_data: dict = await request.json() json_data: dict = await request.json()
data: dict = json_data.get('album', None) data: dict = json_data.get("album", None)
ret_obj: dict = { ret_obj: dict = {
'id': data.get('mbid'), "id": data.get("mbid"),
'artists': data.get('artist'), "artists": data.get("artist"),
'tags': data.get('tags'), "tags": data.get("tags"),
'title': data.get('name'), "title": data.get("name"),
'summary': data.get('wiki', None).get('summary').split("<a href")[0]\ "summary": (
if "wiki" in data.keys()\ data.get("wiki", None).get("summary").split("<a href")[0]
else "No summary available for this release.", if "wiki" in data.keys()
else "No summary available for this release."
),
} }
try: try:
track_key: list = data.get('tracks', None).get('track') track_key: list = data.get("tracks", None).get("track")
except: except:
track_key = [] track_key = []
if isinstance(track_key, list): if isinstance(track_key, list):
ret_obj['tracks'] = [ ret_obj["tracks"] = [
{ {
'duration': item.get('duration', 'N/A'), "duration": item.get("duration", "N/A"),
'title': item.get('name'), "title": item.get("name"),
} for item in track_key] }
for item in track_key
]
else: else:
ret_obj['tracks'] = [ ret_obj["tracks"] = [
{ {
'duration': data.get('tracks').get('track')\ "duration": data.get("tracks")
.get('duration'), .get("track")
'title': data.get('tracks').get('track')\ .get("duration"),
.get('name'), "title": data.get("tracks").get("track").get("name"),
} }
] ]
return ret_obj return ret_obj
except: except:
traceback.print_exc() traceback.print_exc()
return { 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 uuid import uuid4 as uuid
from endpoints.constructors import RadioException from endpoints.constructors import RadioException
double_space: Pattern = regex.compile(r'\s{2,}') double_space: Pattern = regex.compile(r"\s{2,}")
class RadioUtil: class RadioUtil:
""" """
Radio Utils Radio Utils
""" """
def __init__(self, constants) -> None: def __init__(self, constants) -> None:
self.constants = constants self.constants = constants
self.gpt = gpt.GPT(self.constants) self.gpt = gpt.GPT(self.constants)
self.ls_uri: str = self.constants.LS_URI 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.sqlite_exts: list[str] = [
self.active_playlist_path: Union[str, LiteralString] = os.path\ "/home/api/api/solibs/spellfix1.cpython-311-x86_64-linux-gnu.so"
.join("/usr/local/share", ]
"sqlite_dbs", "track_file_map.db") self.active_playlist_path: Union[str, LiteralString] = os.path.join(
self.active_playlist_name = "default" # not used "/usr/local/share", "sqlite_dbs", "track_file_map.db"
)
self.active_playlist_name = "default" # not used
self.active_playlist: list[dict] = [] self.active_playlist: list[dict] = []
self.now_playing: dict = { self.now_playing: dict = {
'artist': 'N/A', "artist": "N/A",
'song': 'N/A', "song": "N/A",
'album': 'N/A', "album": "N/A",
'genre': 'N/A', "genre": "N/A",
'artistsong': 'N/A - N/A', "artistsong": "N/A - N/A",
'duration': 0, "duration": 0,
'start': 0, "start": 0,
'end': 0, "end": 0,
'file_path': None, "file_path": None,
'id': None, "id": None,
} }
self.webhooks: dict = { self.webhooks: dict = {
'gpt': { "gpt": {
'hook': self.constants.GPT_WEBHOOK, "hook": self.constants.GPT_WEBHOOK,
}, },
'sfm': { "sfm": {
'hook': self.constants.SFM_WEBHOOK, "hook": self.constants.SFM_WEBHOOK,
} },
} }
def duration_conv(self, def duration_conv(self, s: Union[int, float]) -> str:
s: Union[int, float]) -> str:
""" """
Convert duration given in seconds to hours, minutes, and seconds (h:m:s) Convert duration given in seconds to hours, minutes, and seconds (h:m:s)
Args: Args:
@ -58,14 +61,12 @@ class RadioUtil:
Returns: Returns:
str str
""" """
return str(datetime.timedelta(seconds=s))\ return str(datetime.timedelta(seconds=s)).split(".", maxsplit=1)[0]
.split(".", maxsplit=1)[0]
async def trackdb_typeahead(self, query: str) -> Optional[list[str]]: async def trackdb_typeahead(self, query: str) -> Optional[list[str]]:
if not query: if not query:
return None return None
async with sqlite3.connect(self.active_playlist_path, async with sqlite3.connect(self.active_playlist_path, timeout=1) as _db:
timeout=1) as _db:
_db.row_factory = sqlite3.Row _db.row_factory = sqlite3.Row
db_query: str = """SELECT DISTINCT(LOWER(TRIM(artist) || " - " || TRIM(song))),\ db_query: str = """SELECT DISTINCT(LOWER(TRIM(artist) || " - " || TRIM(song))),\
(TRIM(artist) || " - " || TRIM(song)) as artistsong FROM tracks WHERE\ (TRIM(artist) || " - " || TRIM(song)) as artistsong FROM tracks WHERE\
@ -73,14 +74,15 @@ class RadioUtil:
db_params: tuple[str] = (f"%{query}%",) db_params: tuple[str] = (f"%{query}%",)
async with _db.execute(db_query, db_params) as _cursor: async with _db.execute(db_query, db_params) as _cursor:
result: Iterable[sqlite3.Row] = await _cursor.fetchall() result: Iterable[sqlite3.Row] = await _cursor.fetchall()
out_result = [ out_result = [str(r["artistsong"]) for r in result]
str(r['artistsong']) for r in result
]
return out_result return out_result
async def search_playlist(self, artistsong: Optional[str] = None, async def search_playlist(
artist: Optional[str] = None, self,
song: Optional[str] = None) -> bool: 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 Search for track, add it up next in play queue if found
Args: Args:
@ -95,9 +97,11 @@ class RadioUtil:
if not artistsong and (not artist or not song): if not artistsong and (not artist or not song):
raise RadioException("No query provided") raise RadioException("No query provided")
try: 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)), (? || " " || ?))\ WHERE editdist3((lower(artist) || " " || lower(song)), (? || " " || ?))\
<= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 1' <= 410 ORDER BY editdist3((lower(artist) || " " || lower(song)), ?) ASC LIMIT 1'
)
if artistsong: if artistsong:
artistsong_split: list = artistsong.split(" - ", maxsplit=1) artistsong_split: list = artistsong.split(" - ", maxsplit=1)
(search_artist, search_song) = tuple(artistsong_split) (search_artist, search_song) = tuple(artistsong_split)
@ -106,26 +110,31 @@ class RadioUtil:
search_song = song search_song = song
if not artistsong: if not artistsong:
artistsong = f"{search_artist} - {search_song}" artistsong = f"{search_artist} - {search_song}"
search_params = (search_artist.lower(), search_song.lower(), artistsong.lower(),) search_params = (
async with sqlite3.connect(self.active_playlist_path, search_artist.lower(),
timeout=2) as db_conn: 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) await db_conn.enable_load_extension(True)
for ext in self.sqlite_exts: for ext in self.sqlite_exts:
await db_conn.load_extension(ext) await db_conn.load_extension(ext)
db_conn.row_factory = sqlite3.Row db_conn.row_factory = sqlite3.Row
async with await db_conn.execute(search_query, search_params) as db_cursor: async with await db_conn.execute(
result: Optional[sqlite3.Row|bool] = await db_cursor.fetchone() 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): if not result or not isinstance(result, sqlite3.Row):
return False return False
pushObj: dict = { pushObj: dict = {
'id': result['id'], "id": result["id"],
'uuid': str(uuid().hex), "uuid": str(uuid().hex),
'artist': result['artist'].strip(), "artist": result["artist"].strip(),
'song': result['song'].strip(), "song": result["song"].strip(),
'artistsong': result['artistsong'].strip(), "artistsong": result["artistsong"].strip(),
'genre': result['genre'], "genre": result["genre"],
'file_path': result['file_path'], "file_path": result["file_path"],
'duration': result['duration'], "duration": result["duration"],
} }
self.active_playlist.insert(0, pushObj) self.active_playlist.insert(0, pushObj)
return True return True
@ -133,7 +142,7 @@ class RadioUtil:
logging.critical("search_playlist:: Search error occurred: %s", str(e)) logging.critical("search_playlist:: Search error occurred: %s", str(e))
traceback.print_exc() traceback.print_exc()
return False return False
async def load_playlist(self) -> None: async def load_playlist(self) -> None:
"""Load Playlist""" """Load Playlist"""
try: try:
@ -141,11 +150,11 @@ class RadioUtil:
self.active_playlist.clear() self.active_playlist.clear()
# db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\ # db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
# GROUP BY artistdashsong ORDER BY RANDOM()' # GROUP BY artistdashsong ORDER BY RANDOM()'
""" """
LIMITED GENRES 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\ 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%"\ WHERE (genre LIKE "%metalcore%"\
OR genre LIKE "%math rock%"\ OR genre LIKE "%math rock%"\
@ -176,44 +185,57 @@ class RadioUtil:
OR genre LIKE "%indie pop%"\ OR genre LIKE "%indie pop%"\
OR genre LIKE "%dnb%")\ OR genre LIKE "%dnb%")\
GROUP BY artistdashsong ORDER BY RANDOM()""" GROUP BY artistdashsong ORDER BY RANDOM()"""
""" """
LIMITED TO ONE/SMALL SUBSET OF GENRES 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\ # 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' # 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... LIMITED TO ONE/SOME ARTISTS...
""" """
# db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\ # 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' # 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'
async with sqlite3.connect(self.active_playlist_path, # db_query = 'SELECT distinct(artist || " - " || song) AS artistdashsong, id, artist, song, album, genre, file_path, duration FROM tracks\
timeout=2) as db_conn: # 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 db_conn.row_factory = sqlite3.Row
async with await db_conn.execute(db_query) as db_cursor: async with await db_conn.execute(db_query) as db_cursor:
results: list[sqlite3.Row] = await db_cursor.fetchall() results: list[sqlite3.Row] = await db_cursor.fetchall()
self.active_playlist = [{ self.active_playlist = [
'uuid': str(uuid().hex), {
'id': r['id'], "uuid": str(uuid().hex),
'artist': double_space.sub(' ', r['artist']).strip(), "id": r["id"],
'song': double_space.sub(' ', r['song']).strip(), "artist": double_space.sub(" ", r["artist"]).strip(),
'album': double_space.sub(' ', r['album']).strip(), "song": double_space.sub(" ", r["song"]).strip(),
'genre': r['genre'] if r['genre'] else 'Unknown', "album": double_space.sub(" ", r["album"]).strip(),
'artistsong': double_space.sub(' ', r['artistdashsong']).strip(), "genre": r["genre"] if r["genre"] else "Unknown",
'file_path': r['file_path'], "artistsong": double_space.sub(
'duration': r['duration'], " ", r["artistdashsong"]
} for r in results] ).strip(),
logging.info("Populated active playlists with %s items", "file_path": r["file_path"],
len(self.active_playlist)) "duration": r["duration"],
}
for r in results
]
logging.info(
"Populated active playlists with %s items",
len(self.active_playlist),
)
except: except:
traceback.print_exc() traceback.print_exc()
async def cache_album_art(self, track_id: int, async def cache_album_art(self, track_id: int, album_art: bytes) -> None:
album_art: bytes) -> None:
""" """
Cache Album Art to SQLite DB Cache Album Art to SQLite DB
Args: Args:
@ -223,16 +245,21 @@ class RadioUtil:
None None
""" """
try: try:
async with sqlite3.connect(self.active_playlist_path, async with sqlite3.connect(self.active_playlist_path, timeout=2) as db_conn:
timeout=2) as db_conn: async with await db_conn.execute(
async with await db_conn.execute("UPDATE tracks SET album_art = ? WHERE id = ?", "UPDATE tracks SET album_art = ? WHERE id = ?",
(album_art, track_id,)) as db_cursor: (
album_art,
track_id,
),
) as db_cursor:
await db_conn.commit() await db_conn.commit()
except: except:
traceback.print_exc() traceback.print_exc()
async def get_album_art(self, track_id: Optional[int] = None, async def get_album_art(
file_path: Optional[str] = None) -> Optional[bytes]: self, track_id: Optional[int] = None, file_path: Optional[str] = None
) -> Optional[bytes]:
""" """
Get Album Art Get Album Art
Args: Args:
@ -242,28 +269,27 @@ class RadioUtil:
bytes bytes
""" """
try: try:
async with sqlite3.connect(self.active_playlist_path, async with sqlite3.connect(self.active_playlist_path, timeout=2) as db_conn:
timeout=2) as db_conn:
db_conn.row_factory = sqlite3.Row db_conn.row_factory = sqlite3.Row
query: str = "SELECT album_art FROM tracks WHERE id = ?" query: str = "SELECT album_art FROM tracks WHERE id = ?"
query_params: tuple = (track_id,) query_params: tuple = (track_id,)
if file_path and not track_id: if file_path and not track_id:
query = "SELECT album_art FROM tracks WHERE file_path = ?" query = "SELECT album_art FROM tracks WHERE file_path = ?"
query_params = (file_path,) query_params = (file_path,)
async with await db_conn.execute(query, async with await db_conn.execute(query, query_params) as db_cursor:
query_params) as db_cursor: result: Optional[Union[sqlite3.Row, bool]] = (
result: Optional[Union[sqlite3.Row, bool]] = await db_cursor.fetchone() await db_cursor.fetchone()
)
if not result or not isinstance(result, sqlite3.Row): if not result or not isinstance(result, sqlite3.Row):
return None return None
return result['album_art'] return result["album_art"]
except: except:
traceback.print_exc() traceback.print_exc()
return None return None
def get_queue_item_by_uuid(self, def get_queue_item_by_uuid(self, uuid: str) -> Optional[tuple[int, dict]]:
uuid: str) -> Optional[tuple[int, dict]]:
""" """
Get queue item by UUID Get queue item by UUID
Args: Args:
@ -272,10 +298,10 @@ class RadioUtil:
Optional[tuple[int, dict]] Optional[tuple[int, dict]]
""" """
for x, item in enumerate(self.active_playlist): for x, item in enumerate(self.active_playlist):
if item.get('uuid') == uuid: if item.get("uuid") == uuid:
return (x, item) return (x, item)
return None return None
async def _ls_skip(self) -> bool: async def _ls_skip(self) -> bool:
""" """
Ask LiquidSoap server to skip to the next track Ask LiquidSoap server to skip to the next track
@ -286,18 +312,18 @@ class RadioUtil:
""" """
try: try:
async with ClientSession() as session: async with ClientSession() as session:
async with session.get(f"{self.ls_uri}/next", async with session.get(
timeout=ClientTimeout(connect=2, sock_read=2)) as request: f"{self.ls_uri}/next", timeout=ClientTimeout(connect=2, sock_read=2)
request.raise_for_status() ) as request:
text: Optional[str] = await request.text() request.raise_for_status()
return text == "OK" text: Optional[str] = await request.text()
return text == "OK"
except Exception as e: except Exception as e:
logging.debug("Skip failed: %s", str(e)) logging.debug("Skip failed: %s", str(e))
return False # failsafe return False # failsafe
async def get_ai_song_info(self, artist: str, async def get_ai_song_info(self, artist: str, song: str) -> Optional[str]:
song: str) -> Optional[str]:
""" """
Get AI Song Info Get AI Song Info
Args: Args:
@ -312,44 +338,50 @@ class RadioUtil:
logging.critical("No response received from GPT?") logging.critical("No response received from GPT?")
return None return None
return response return response
async def webhook_song_change(self, track: dict) -> None: async def webhook_song_change(self, track: dict) -> None:
try: try:
""" """
Handles Song Change Outbounds (Webhooks) Handles Song Change Outbounds (Webhooks)
Args: Args:
track (dict) track (dict)
Returns: Returns:
None None
""" """
# First, send track info # First, send track info
friendly_track_start: str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(track['start'])) friendly_track_start: str = time.strftime(
friendly_track_end: str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(track['end'])) "%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 = { hook_data: dict = {
'username': 'serious.FM', "username": "serious.FM",
"embeds": [{ "embeds": [
{
"title": "Now Playing", "title": "Now Playing",
"description": f'## {track['song']}\nby\n## {track['artist']}', "description": f"## {track['song']}\nby\n## {track['artist']}",
"color": 0x30c56f, "color": 0x30C56F,
"thumbnail": { "thumbnail": {
"url": f"https://api.codey.lol/radio/album_art?track_id={track['id']}&{int(time.time())}", "url": f"https://api.codey.lol/radio/album_art?track_id={track['id']}&{int(time.time())}",
}, },
"fields": [ "fields": [
{ {
"name": "Duration", "name": "Duration",
"value": self.duration_conv(track['duration']), "value": self.duration_conv(track["duration"]),
"inline": True, "inline": True,
}, },
{ {
"name": "Genre", "name": "Genre",
"value": track['genre'] if track['genre'] else 'Unknown', "value": (
track["genre"] if track["genre"] else "Unknown"
),
"inline": True, "inline": True,
}, },
{ {
"name": "Filetype", "name": "Filetype",
"value": track['file_path'].rsplit(".", maxsplit=1)[1], "value": track["file_path"].rsplit(".", maxsplit=1)[1],
"inline": True, "inline": True,
}, },
{ {
@ -359,42 +391,56 @@ class RadioUtil:
}, },
{ {
"name": "Album", "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 ClientSession() as session:
async with await session.post(sfm_hook, json=hook_data, async with await session.post(
timeout=ClientTimeout(connect=5, sock_read=5), headers={ sfm_hook,
'content-type': 'application/json; charset=utf-8',}) as request: json=hook_data,
request.raise_for_status() timeout=ClientTimeout(connect=5, sock_read=5),
headers={
# Next, AI feedback "content-type": "application/json; charset=utf-8",
},
ai_response: Optional[str] = await self.get_ai_song_info(track['artist'], ) as request:
track['song']) 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: if not ai_response:
return return
hook_data = { hook_data = {
'username': 'GPT', "username": "GPT",
"embeds": [{ "embeds": [
{
"title": "AI Feedback", "title": "AI Feedback",
"color": 0x35d0ff, "color": 0x35D0FF,
"description": ai_response.strip(), "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 ClientSession() as session:
async with await session.post(ai_hook, json=hook_data, async with await session.post(
timeout=ClientTimeout(connect=5, sock_read=5), headers={ ai_hook,
'content-type': 'application/json; charset=utf-8',}) as request: json=hook_data,
request.raise_for_status() timeout=ClientTimeout(connect=5, sock_read=5),
headers={
"content-type": "application/json; charset=utf-8",
},
) as request:
request.raise_for_status()
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()