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

69
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(
title="codey.lol API",
version="1.0", version="1.0",
contact={ contact={"name": "codey"},
'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(
CORSMiddleware, # type: ignore
allow_origins=origins, allow_origins=origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["POST", "GET", "HEAD"], allow_methods=["POST", "GET", "HEAD"],
allow_headers=["*"]) # type: ignore 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,19 +26,24 @@ 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
@ -50,10 +56,12 @@ class ValidArtistSearchRequest(BaseModel):
"examples": [ "examples": [
{ {
"a": "eminem", "a": "eminem",
}] }
]
} }
} }
class ValidAlbumDetailRequest(BaseModel): class ValidAlbumDetailRequest(BaseModel):
""" """
- **a**: artist name - **a**: artist name
@ -69,10 +77,12 @@ class ValidAlbumDetailRequest(BaseModel):
{ {
"a": "eminem", "a": "eminem",
"release": "houdini", "release": "houdini",
}] }
]
} }
} }
class ValidTrackInfoRequest(BaseModel): class ValidTrackInfoRequest(BaseModel):
""" """
- **a**: artist name - **a**: artist name
@ -88,14 +98,17 @@ class ValidTrackInfoRequest(BaseModel):
{ {
"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
@ -131,10 +148,12 @@ class ValidXCRequest(BaseModel):
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
@ -186,7 +208,8 @@ class ValidLyricRequest(BaseModel):
"extra": True, "extra": True,
"lrc": False, "lrc": False,
"excluded_sources": [], "excluded_sources": [],
}] }
]
} }
} }
@ -195,15 +218,19 @@ 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,16 +239,19 @@ 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
@ -234,33 +264,40 @@ class ValidRadioQueueGetRequest(BaseModel):
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,14 +7,20 @@ 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: def __init__(self) -> None:
self.db_path: LiteralString = os.path.join("/", "usr", "local", "share", self.db_path: LiteralString = os.path.join(
"sqlite_dbs", "karma.db") "/", "usr", "local", "share", "sqlite_dbs", "karma.db"
)
async def get_karma(self, keyword: str) -> Union[int, dict]: async def get_karma(self, keyword: str) -> Union[int, dict]:
"""Get Karma Value for Keyword """Get Karma Value for Keyword
@ -24,14 +30,16 @@ 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]]:
@ -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:
@ -66,26 +77,53 @@ class KarmaDB:
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(
audit_query,
(
keyword,
audit_message,
),
) as db_cursor:
await db_conn.commit() await db_conn.commit()
async with await db_conn.execute(query, (now, keyword,)) as db_cursor: 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
@ -93,10 +131,12 @@ class KarmaDB:
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
@ -110,66 +150,83 @@ class Karma(FastAPI):
} }
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, async def get_karma_handler(
request: Request) -> JSONResponse: 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(
request.url.path, request.headers.get("X-Authd-With")
):
raise HTTPException(status_code=403, detail="Unauthorized") 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, async def modify_karma_handler(
request: Request) -> JSONResponse: self, data: ValidKarmaUpdateRequest, request: Request
) -> JSONResponse:
""" """
Update karma count Update karma count
- **granter**: User who granted the karma - **granter**: User who granted the karma
@ -177,16 +234,24 @@ class Karma(FastAPI):
- **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={ return JSONResponse(
'success': await self.db.update_karma(data.granter, content={
data.keyword, data.flag) "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
@ -25,68 +30,90 @@ class LastFM(FastAPI):
} }
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,
content={
"err": True,
"errorText": "Search failed (no results?)",
},
)
return JSONResponse(content={ return JSONResponse(
'success': True, content={
'result': artist_result, "success": True,
}) "result": artist_result,
}
)
async def artist_album_handler(self, data: ValidArtistSearchRequest) -> JSONResponse: 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
@ -96,26 +123,33 @@ class LastFM(FastAPI):
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
@ -125,20 +159,27 @@ class LastFM(FastAPI):
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) tracklist_result: dict = await self.lastfm.get_album_tracklist(
return JSONResponse(content={ artist=artist, album=release
'success': True, )
'id': tracklist_result.get('id'), return JSONResponse(
'artists': tracklist_result.get('artists'), content={
'title': tracklist_result.get('title'), "success": True,
'summary': tracklist_result.get('summary'), "id": tracklist_result.get("id"),
'tracks': tracklist_result.get('tracks'), "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:
""" """
@ -151,28 +192,34 @@ class LastFM(FastAPI):
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

@ -12,20 +12,22 @@ 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,15 +43,14 @@ 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
@ -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,16 +84,20 @@ 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
@ -104,12 +114,17 @@ class LyricSearch(FastAPI):
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
@ -119,63 +134,83 @@ class LyricSearch(FastAPI):
(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["lyrics"] = " / ".join(lyric_lines[seeked_found_line:])
result['confidence'] = int(result['confidence']) result["confidence"] = int(result["confidence"])
result['time'] = f'{float(result['time']):.4f}' 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
@ -33,41 +31,49 @@ class Misc(FastAPI):
} }
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", app.add_api_route(
self.upload_activity_image, methods=["POST"]) "/misc/upload_activity_image", self.upload_activity_image, methods=["POST"]
)
async def upload_activity_image(self, async def upload_activity_image(
image: UploadFile, self, image: UploadFile, key: Annotated[str, Form()], request: Request
key: Annotated[str, Form()], request: Request) -> Response: ) -> Response:
if not key or not isinstance(key, str)\ if (
or not self.util.check_key(path=request.url.path, req_type=2, key=key): 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") 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: with open(fallback_path, "rb") as f:
return Response(content=f.read(), return Response(content=f.read(), media_type="image/png")
media_type="image/png")
async def get_radio_np(self) -> tuple[str, str, str]: async def get_radio_np(self) -> tuple[str, str, str]:
""" """
@ -79,33 +85,38 @@ class Misc(FastAPI):
""" """
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:
""" """
@ -114,11 +125,13 @@ class Misc(FastAPI):
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:
""" """
@ -126,12 +139,18 @@ class Misc(FastAPI):
""" """
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:
@ -140,13 +159,18 @@ class Misc(FastAPI):
""" """
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
@ -37,18 +42,24 @@ class Radio(FastAPI):
} }
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 # NOTE: Not in loop because method is GET for this endpoint
app.add_api_route("/radio/album_art", self.album_art_handler, methods=["GET"], app.add_api_route(
include_in_schema=True) "/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
@ -60,28 +71,39 @@ class Radio(FastAPI):
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(
async def radio_reshuffle(self, data: ValidRadioReshuffleRequest, self, data: ValidRadioReshuffleRequest, request: Request
request: Request) -> JSONResponse: ) -> JSONResponse:
""" """
Reshuffle the play queue Reshuffle the play queue
- **key**: API key - **key**: API key
@ -90,13 +112,11 @@ class Radio(FastAPI):
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(
async def radio_get_queue(self, request: Request, self, request: Request, limit: Optional[int] = 15_000
limit: Optional[int] = 15_000) -> JSONResponse: ) -> 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]
@ -133,21 +154,27 @@ class Radio(FastAPI):
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, async def radio_queue_remove(
request: Request) -> JSONResponse: 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
@ -158,16 +185,23 @@ class Radio(FastAPI):
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,18 +209,22 @@ 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:
""" """
@ -194,16 +232,19 @@ class Radio(FastAPI):
""" """
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.
@ -212,24 +253,33 @@ 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 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)
@ -239,26 +289,28 @@ class Radio(FastAPI):
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
@ -273,37 +325,47 @@ class Radio(FastAPI):
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, search: bool = await self.radio_util.search_playlist(
artist=artist, artistsong=artistsong, artist=artist, song=song
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, async def radio_typeahead(
request: Request) -> JSONResponse: self, data: ValidRadioTypeaheadRequest, 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,45 +40,48 @@ 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
db_query: str = (
"SELECT id, ('<b>Q:</b> ' || question || '<br/><b>A:</b> ' \
|| answer) FROM jokes ORDER BY RANDOM() LIMIT 1" # For qajoke db || 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"
@ -83,10 +91,10 @@ class RandMsg(FastAPI):
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
@ -21,10 +23,13 @@ class Transcriptions(FastAPI):
} }
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
@ -35,54 +40,66 @@ class Transcriptions(FastAPI):
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(
content={
"show_title": show_title, "show_title": show_title,
"episodes": [ "episodes": [
{ {
'id': item[1], "id": item[1],
'ep_friendly': item[0], "ep_friendly": item[0],
} for item in result], }
}) 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
@ -93,36 +110,43 @@ class Transcriptions(FastAPI):
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, async with sqlite3.connect(database=db_path, timeout=1) as _db:
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(), "speaker": item[1].strip(),
'line': item[2].strip(), "line": item[2].strip(),
} for item in result], }
}) 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
@ -20,8 +21,9 @@ class YT(FastAPI):
} }
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:
""" """
@ -32,13 +34,18 @@ class YT(FastAPI):
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,7 +27,7 @@ class GPT:
{ {
"role": "user", "role": "user",
"content": prompt, "content": prompt,
} },
], ],
model="gpt-4o-mini", model="gpt-4o-mini",
temperature=0.35, temperature=0.35,

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,6 +13,7 @@ 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
@ -23,20 +25,25 @@ class LyricsResult:
""" """
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,9 +4,11 @@ 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
@ -19,8 +21,9 @@ class Aggregate:
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:
@ -48,30 +51,34 @@ class Aggregate:
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,22 +16,33 @@ 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(
self,
matched_candidate: tuple,
confidence: int,
sqlite_rows: Optional[list[sqlite3.Row]] = None, sqlite_rows: Optional[list[sqlite3.Row]] = None,
redis_results: Optional[list] = None) -> Optional[LyricsResult]: redis_results: Optional[list] = None,
) -> Optional[LyricsResult]:
""" """
Get Matched Result Get Matched Result
Args: Args:
@ -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,7 +74,8 @@ 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]:
@ -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()
@ -89,11 +105,11 @@ class Cache:
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:
@ -110,13 +126,19 @@ class Cache:
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)}",
)
await self.notifier.send(
f"ERROR @ {__file__.rsplit("/", maxsplit=1)[-1]}",
f"cache::store >> `{str(e)}`",
)
async def sqlite_rowcount(self, where: Optional[str] = None, async def sqlite_rowcount(
params: Optional[tuple] = None) -> int: 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,7 +152,7 @@ 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:
""" """
@ -145,7 +167,7 @@ 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:
""" """
@ -160,8 +182,7 @@ 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:
""" """
@ -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", " - "))
@ -225,37 +258,41 @@ 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", logging.info("Searching %s - %s on %s", artist, song, self.label)
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()
@ -263,8 +300,10 @@ class Cache:
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...",
f"{artist} - {song}",
)
await self.redis_cache.increment_found_count(self.label) await self.redis_cache.increment_found_count(self.label)
return matched return matched
except: except:
@ -276,32 +315,44 @@ class Cache:
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(),
song.strip(),
f"{artist.strip()} {song.strip()}",
)
async with await _db_cursor.execute(search_query, search_params) as db_cursor: 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(
sqlite_rows=results,
matched_candidate=candidate, matched_candidate=candidate,
confidence=confidence) 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

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,5 +1,6 @@
import sys import sys
sys.path.insert(1,'..')
sys.path.insert(1, "..")
import traceback import traceback
import logging import logging
import time import time
@ -10,20 +11,21 @@ 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,14 +46,15 @@ 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(
f"{self.genius_search_url}{search_term}",
timeout=self.timeout, timeout=self.timeout,
headers=self.headers) as request: 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()
@ -60,37 +62,51 @@ class Genius:
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): if not isinstance(search_data["response"]["sections"], list):
raise InvalidGeniusResponseException(f"Invalid JSON: Cannot find response->sections key.\n{search_data}") raise InvalidGeniusResponseException(
f"Invalid JSON: Cannot find response->sections key.\n{search_data}"
)
if not isinstance(search_data['response']['sections'][0]['hits'], list): if not isinstance(
raise InvalidGeniusResponseException("Invalid JSON: Cannot find response->sections[0]->hits key.") 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'] 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()
@ -98,41 +114,55 @@ class Genius:
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(htm.unescape(scrape_text).replace('<br/>', '\n'), "html.parser") header_tags_genius: Optional[ResultSet] = html.find_all(
class_=re.compile(r".*Header.*")
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(
artist=artist,
song=song, song=song,
src=self.label, src=self.label,
lyrics=returned_lyrics, lyrics=returned_lyrics,
confidence=confidence, confidence=confidence,
time=time_diff) 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

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,8 +14,10 @@ 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"
@ -25,8 +28,9 @@ class LRCLib:
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:
@ -41,26 +45,29 @@ class LRCLib:
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(
self.lrclib_url,
params={ params={
'artist_name': artist, "artist_name": artist,
'track_name': song, "track_name": song,
}, },
timeout=self.timeout, timeout=self.timeout,
headers=self.headers) as request: 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):
@ -72,53 +79,82 @@ class LRCLib:
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): if not isinstance(search_data[best_match_id]["trackName"], str):
raise InvalidLRCLibResponseException(f"Invalid JSON: Cannot find trackName key.\n{search_data}") raise InvalidLRCLibResponseException(
f"Invalid JSON: Cannot find trackName key.\n{search_data}"
)
returned_artist: str = search_data[best_match_id]['artistName'] returned_artist: str = search_data[best_match_id]["artistName"]
returned_song: str = search_data[best_match_id]['trackName'] 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(
artist=returned_artist,
song=returned_song, song=returned_song,
src=self.label, src=self.label,
lyrics=returned_lyrics if plain else lrc_obj, lyrics=returned_lyrics if plain else lrc_obj,
confidence=confidence, confidence=confidence,
time=time_diff) 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

View File

@ -7,7 +7,8 @@ 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
@ -20,11 +21,13 @@ 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,14 +38,13 @@ 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"""
@ -51,18 +53,22 @@ class RedisCache:
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(self, artist: str, song: str, def sanitize_input(
fuzzy: Optional[bool] = False) -> tuple[str, str]: self, artist: str, song: str, fuzzy: Optional[bool] = False
) -> 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,7 +83,9 @@ 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)
@ -117,10 +125,12 @@ class RedisCache:
traceback.print_exc() traceback.print_exc()
return None return None
async def search(
async def search(self, artist: Optional[str] = None, self,
artist: Optional[str] = None,
song: Optional[str] = None, song: Optional[str] = None,
lyrics: Optional[str] = None) -> Optional[list[tuple]]: lyrics: Optional[str] = None,
) -> Optional[list[tuple]]:
""" """
Search Redis Cache Search Redis Cache
Args: Args:
@ -143,47 +153,62 @@ class RedisCache:
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 )
search_res = await self.redis_client.ft().search(
Query( # type: ignore
f"@artist:{fuzzy_artist} @song:{fuzzy_song}" f"@artist:{fuzzy_artist} @song:{fuzzy_song}"
)) )
search_res_out = [(result['id'].split(":", )
maxsplit=1)[1], dict(json.loads(result['json']))) search_res_out = [
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
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"ERROR @ {file}",
f"Failed to store `{lyr_result.artist} - {lyr_result.song}`\ f"Failed to store `{lyr_result.artist} - {lyr_result.song}`\
(SQLite id: `{sqlite_id}`) to Redis:\n`{str(e)}`") (SQLite id: `{sqlite_id}`) to Redis:\n`{str(e)}`",
)

View File

@ -4,8 +4,10 @@ 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.
@ -16,7 +18,9 @@ class TrackMatcher:
""" """
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.
@ -29,7 +33,6 @@ class TrackMatcher:
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
@ -46,8 +49,12 @@ class TrackMatcher:
# 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)
@ -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:
@ -97,18 +104,22 @@ class TrackMatcher:
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:
""" """
@ -118,10 +129,10 @@ 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, "timeTag": _timetag,
"words": _words, "words": _words,
}) }
)
return lrc_out return lrc_out

0
py.typed Normal file
View File

View File

@ -10,6 +10,7 @@ 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"
@ -29,8 +30,7 @@ class Utilities:
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.
""" """
@ -48,9 +48,6 @@ class Utilities:
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,10 +6,11 @@ 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"
@ -18,42 +19,47 @@ 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.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(
self.api_base_url,
params=request_params, params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: 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)
.get("summary")
.strip()
.split("<a href")[0], .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(
self.api_base_url,
params=request_params, params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: 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,13 +122,13 @@ 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
@ -127,10 +136,12 @@ class LastFM:
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,35 +152,37 @@ 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(
self.api_base_url,
params=request_params, params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: 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:
@ -187,7 +200,7 @@ class LastFM:
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()
@ -204,42 +217,46 @@ 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(
req_url,
params=request_params, params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: 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) _id: int = data.get("id", None)
name: str = data.get('name', None) name: str = data.get("name", None)
profile: str = data.get('profile', '') 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:
@ -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(
self.api_base_url,
params=request_params, params=request_params,
timeout=ClientTimeout(connect=3, sock_read=8)) as request: 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(
"/usr/local/share", "sqlite_dbs", "track_file_map.db"
)
self.active_playlist_name = "default" # not used 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": {
"hook": self.constants.SFM_WEBHOOK,
}, },
'sfm': {
'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(
self,
artistsong: Optional[str] = None,
artist: Optional[str] = None, artist: Optional[str] = None,
song: Optional[str] = None) -> bool: 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(
search_query, search_params
) as db_cursor:
result: Optional[sqlite3.Row | bool] = await db_cursor.fetchone() 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
@ -181,39 +190,52 @@ class RadioUtil:
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,8 +269,7 @@ 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,)
@ -252,18 +278,18 @@ class RadioUtil:
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,7 +298,7 @@ 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
@ -286,8 +312,9 @@ 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)
) as request:
request.raise_for_status() request.raise_for_status()
text: Optional[str] = await request.text() text: Optional[str] = await request.text()
return text == "OK" return text == "OK"
@ -296,8 +323,7 @@ class RadioUtil:
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:
@ -323,33 +349,39 @@ class RadioUtil:
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,
timeout=ClientTimeout(connect=5, sock_read=5),
headers={
"content-type": "application/json; charset=utf-8",
},
) as request:
request.raise_for_status() request.raise_for_status()
# Next, AI feedback # Next, AI feedback
ai_response: Optional[str] = await self.get_ai_song_info(track['artist'], ai_response: Optional[str] = await self.get_ai_song_info(
track['song']) 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,
timeout=ClientTimeout(connect=5, sock_read=5),
headers={
"content-type": "application/json; charset=utf-8",
},
) as request:
request.raise_for_status() request.raise_for_status()
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()