docstrings / formatting
This commit is contained in:
@@ -15,7 +15,10 @@ class LastFMException(Exception):
|
|||||||
|
|
||||||
class ValidArtistSearchRequest(BaseModel):
|
class ValidArtistSearchRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **a**: artist name
|
Request model for searching an artist by name.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
- **a** (str): Artist name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
a: str
|
a: str
|
||||||
@@ -33,8 +36,11 @@ class ValidArtistSearchRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidAlbumDetailRequest(BaseModel):
|
class ValidAlbumDetailRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **a**: artist name
|
Request model for album details.
|
||||||
- **release**: album/release name (as sourced from here/LastFM)
|
|
||||||
|
Attributes:
|
||||||
|
- **a** (str): Artist name.
|
||||||
|
- **release** (str): Album/release name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
a: str
|
a: str
|
||||||
@@ -54,8 +60,11 @@ class ValidAlbumDetailRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidTrackInfoRequest(BaseModel):
|
class ValidTrackInfoRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **a**: artist name
|
Request model for track info.
|
||||||
- **t**: track
|
|
||||||
|
Attributes:
|
||||||
|
- **a** (str): Artist name.
|
||||||
|
- **t** (str): Track name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
a: str
|
a: str
|
||||||
@@ -80,7 +89,10 @@ Rand Msg
|
|||||||
|
|
||||||
class RandMsgRequest(BaseModel):
|
class RandMsgRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **short**: Short randmsg?
|
Request model for random message.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
- **short** (Optional[bool]): Short randmsg?
|
||||||
"""
|
"""
|
||||||
|
|
||||||
short: Optional[bool] = False
|
short: Optional[bool] = False
|
||||||
@@ -93,7 +105,10 @@ YT
|
|||||||
|
|
||||||
class ValidYTSearchRequest(BaseModel):
|
class ValidYTSearchRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **t**: title to search
|
Request model for YouTube search.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
- **t** (str): Title to search.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
t: str = "rick astley - never gonna give you up"
|
t: str = "rick astley - never gonna give you up"
|
||||||
@@ -106,7 +121,10 @@ Transcriptions
|
|||||||
|
|
||||||
class ValidShowEpisodeListRequest(BaseModel):
|
class ValidShowEpisodeListRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **s**: show id
|
Request model for show episode list.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
- **s** (int): Show ID.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
s: int
|
s: int
|
||||||
@@ -114,8 +132,11 @@ class ValidShowEpisodeListRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidShowEpisodeLineRequest(BaseModel):
|
class ValidShowEpisodeLineRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **s**: show id
|
Request model for show episode lines.
|
||||||
- **e**: episode id
|
|
||||||
|
Attributes:
|
||||||
|
- **s** (int): Show ID.
|
||||||
|
- **e** (int): Episode ID.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
s: int
|
s: int
|
||||||
@@ -129,14 +150,17 @@ Lyric Search
|
|||||||
|
|
||||||
class ValidLyricRequest(BaseModel):
|
class ValidLyricRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **a**: artist
|
Request model for lyric search.
|
||||||
- **s**: song
|
|
||||||
- **t**: track (artist and song combined) [used only if a & s are not used]
|
Attributes:
|
||||||
- **extra**: include extra details in response [optional, default: false]
|
- **a** (Optional[str]): Artist.
|
||||||
- **lrc**: Request LRCs?
|
- **s** (Optional[str]): Song.
|
||||||
- **sub**: text to search within lyrics, if found lyrics will begin at found verse [optional]
|
- **t** (Optional[str]): Track (artist and song combined).
|
||||||
- **src**: the script/utility which initiated the request
|
- **extra** (Optional[bool]): Include extra details in response.
|
||||||
- **excluded_sources**: sources to exclude (new only)
|
- **lrc** (Optional[bool]): Request LRCs?
|
||||||
|
- **sub** (Optional[str]): Text to search within lyrics.
|
||||||
|
- **src** (str): The script/utility which initiated the request.
|
||||||
|
- **excluded_sources** (Optional[list]): Sources to exclude.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
a: Optional[str] = None
|
a: Optional[str] = None
|
||||||
@@ -166,7 +190,10 @@ class ValidLyricRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidTypeAheadRequest(BaseModel):
|
class ValidTypeAheadRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **query**: query string
|
Request model for typeahead query.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
- **query** (str): Query string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
query: str
|
query: str
|
||||||
@@ -183,11 +210,15 @@ class RadioException(Exception):
|
|||||||
|
|
||||||
class ValidRadioSongRequest(BaseModel):
|
class ValidRadioSongRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **key**: API Key
|
Request model for radio song request.
|
||||||
- **artist**: artist to search
|
|
||||||
- **song**: song to search
|
Attributes:
|
||||||
- **artistsong**: may be used IN PLACE OF artist/song to perform a combined/string search in the format "artist - song"
|
- **key** (str): API Key.
|
||||||
- **alsoSkip**: Whether to skip immediately to this track [not implemented]
|
- **artist** (Optional[str]): Artist to search.
|
||||||
|
- **song** (Optional[str]): Song to search.
|
||||||
|
- **artistsong** (Optional[str]): Combined artist-song string.
|
||||||
|
- **alsoSkip** (Optional[bool]): Whether to skip immediately to this track.
|
||||||
|
- **station** (Station): Station name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
@@ -200,7 +231,10 @@ class ValidRadioSongRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidRadioTypeaheadRequest(BaseModel):
|
class ValidRadioTypeaheadRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **query**: Typeahead query
|
Request model for radio typeahead.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
- **query** (str): Typeahead query.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
query: str
|
query: str
|
||||||
@@ -208,8 +242,11 @@ class ValidRadioTypeaheadRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidRadioQueueGetRequest(BaseModel):
|
class ValidRadioQueueGetRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **key**: API key (optional, needed if specifying a non-default limit)
|
Request model for radio queue get.
|
||||||
- **limit**: optional, default: 15k
|
|
||||||
|
Attributes:
|
||||||
|
- **key** (Optional[str]): API key.
|
||||||
|
- **limit** (Optional[int]): Result limit.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key: Optional[str] = None
|
key: Optional[str] = None
|
||||||
@@ -218,9 +255,12 @@ class ValidRadioQueueGetRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidRadioNextRequest(BaseModel):
|
class ValidRadioNextRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **key**: API Key
|
Request model for radio next track.
|
||||||
- **skipTo**: UUID to skip to [optional]
|
|
||||||
- **station**: Station (default: "main")
|
Attributes:
|
||||||
|
- **key** (str): API Key.
|
||||||
|
- **skipTo** (Optional[str]): UUID to skip to.
|
||||||
|
- **station** (Station): Station name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
@@ -230,17 +270,23 @@ class ValidRadioNextRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidRadioReshuffleRequest(ValidRadioNextRequest):
|
class ValidRadioReshuffleRequest(ValidRadioNextRequest):
|
||||||
"""
|
"""
|
||||||
- **key**: API Key
|
Request model for radio reshuffle.
|
||||||
- **station**: Station (default: "main")
|
|
||||||
|
Attributes:
|
||||||
|
- **key** (str): API Key.
|
||||||
|
- **station** (Station): Station name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ValidRadioQueueRequest(BaseModel):
|
class ValidRadioQueueRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **draw**: DataTables draw count, default 1
|
Request model for radio queue.
|
||||||
- **start**: paging start position, default 0
|
|
||||||
- **search**: Optional search query
|
Attributes:
|
||||||
- **station**: Station (default: "main")
|
- **draw** (Optional[int]): DataTables draw count.
|
||||||
|
- **start** (Optional[int]): Paging start position.
|
||||||
|
- **search** (Optional[str]): Search query.
|
||||||
|
- **station** (Station): Station name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
draw: Optional[int] = 1
|
draw: Optional[int] = 1
|
||||||
@@ -251,10 +297,13 @@ class ValidRadioQueueRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidRadioQueueShiftRequest(BaseModel):
|
class ValidRadioQueueShiftRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **key**: API Key
|
Request model for radio queue shift.
|
||||||
- **uuid**: UUID to shift
|
|
||||||
- **next**: Play next if true, immediately if false, default False
|
Attributes:
|
||||||
- **station**: Station (default: "main")
|
- **key** (str): API Key.
|
||||||
|
- **uuid** (str): UUID to shift.
|
||||||
|
- **next** (Optional[bool]): Play next if true.
|
||||||
|
- **station** (Station): Station name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
@@ -265,9 +314,12 @@ class ValidRadioQueueShiftRequest(BaseModel):
|
|||||||
|
|
||||||
class ValidRadioQueueRemovalRequest(BaseModel):
|
class ValidRadioQueueRemovalRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
- **key**: API Key
|
Request model for radio queue removal.
|
||||||
- **uuid**: UUID to remove
|
|
||||||
- **station**: Station (default: "main")
|
Attributes:
|
||||||
|
- **key** (str): API Key.
|
||||||
|
- **uuid** (str): UUID to remove.
|
||||||
|
- **station** (Station): Station name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
|
@@ -17,6 +17,7 @@ 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:
|
||||||
|
"""Initialize LastFM endpoints."""
|
||||||
self.app: FastAPI = app
|
self.app: FastAPI = app
|
||||||
self.util = util
|
self.util = util
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
@@ -44,8 +45,13 @@ class LastFM(FastAPI):
|
|||||||
self, data: ValidArtistSearchRequest
|
self, data: ValidArtistSearchRequest
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Get artist info
|
Get artist information by name.
|
||||||
- **a**: Artist to search
|
|
||||||
|
Parameters:
|
||||||
|
- **data** (ValidArtistSearchRequest): Request containing artist name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains artist information or an error message.
|
||||||
"""
|
"""
|
||||||
artist: Optional[str] = data.a.strip()
|
artist: Optional[str] = data.a.strip()
|
||||||
if not artist:
|
if not artist:
|
||||||
@@ -81,8 +87,13 @@ class LastFM(FastAPI):
|
|||||||
self, data: ValidArtistSearchRequest
|
self, data: ValidArtistSearchRequest
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Get artist's albums/releases
|
Get artist's albums/releases.
|
||||||
- **a**: Artist to search
|
|
||||||
|
Parameters:
|
||||||
|
- **data** (ValidArtistSearchRequest): Request containing artist name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains a list of albums or an error message.
|
||||||
"""
|
"""
|
||||||
artist: str = data.a.strip()
|
artist: str = data.a.strip()
|
||||||
if not artist:
|
if not artist:
|
||||||
@@ -121,9 +132,13 @@ class LastFM(FastAPI):
|
|||||||
self, data: ValidAlbumDetailRequest
|
self, data: ValidAlbumDetailRequest
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Get details of a particular release by an artist
|
Get details of a particular release by an artist.
|
||||||
- **a**: Artist to search
|
|
||||||
- **release**: Release title to search
|
Parameters:
|
||||||
|
- **data** (ValidAlbumDetailRequest): Request containing artist and release name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Release details or error.
|
||||||
"""
|
"""
|
||||||
artist: str = data.a.strip()
|
artist: str = data.a.strip()
|
||||||
release: str = data.release.strip()
|
release: str = data.release.strip()
|
||||||
@@ -157,9 +172,13 @@ class LastFM(FastAPI):
|
|||||||
self, data: ValidAlbumDetailRequest
|
self, data: ValidAlbumDetailRequest
|
||||||
) -> JSONResponse:
|
) -> 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
|
|
||||||
- **release**: Release title to search
|
Parameters:
|
||||||
|
- **data** (ValidAlbumDetailRequest): Request containing artist and release name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Track list or error.
|
||||||
"""
|
"""
|
||||||
artist: str = data.a.strip()
|
artist: str = data.a.strip()
|
||||||
release: str = data.release.strip()
|
release: str = data.release.strip()
|
||||||
@@ -189,9 +208,13 @@ class LastFM(FastAPI):
|
|||||||
|
|
||||||
async def track_info_handler(self, data: ValidTrackInfoRequest) -> JSONResponse:
|
async def track_info_handler(self, data: ValidTrackInfoRequest) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Get track info from Last.FM given an artist/track
|
Get track info from Last.FM given an artist/track.
|
||||||
- **a**: Artist to search
|
|
||||||
- **t**: Track title to search
|
Parameters:
|
||||||
|
- **data** (ValidTrackInfoRequest): Request containing artist and track name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Track info or error.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
artist: str = data.a
|
artist: str = data.a
|
||||||
|
@@ -20,12 +20,21 @@ class CacheUtils:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
"""Initialize CacheUtils."""
|
||||||
self.lyrics_db_path: LiteralString = os.path.join(
|
self.lyrics_db_path: LiteralString = os.path.join(
|
||||||
"/usr/local/share", "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"""
|
"""
|
||||||
|
Check typeahead suggestions for lyric search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching artist-song strings, or None if query is empty.
|
||||||
|
"""
|
||||||
if not query:
|
if not query:
|
||||||
return None
|
return None
|
||||||
async with sqlite3.connect(self.lyrics_db_path, timeout=1) as _db:
|
async with sqlite3.connect(self.lyrics_db_path, timeout=1) as _db:
|
||||||
@@ -46,6 +55,7 @@ class LyricSearch(FastAPI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: FastAPI, util, constants) -> None:
|
def __init__(self, app: FastAPI, util, constants) -> None:
|
||||||
|
"""Initialize LyricSearch endpoints."""
|
||||||
self.app: FastAPI = app
|
self.app: FastAPI = app
|
||||||
self.util = util
|
self.util = util
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
@@ -92,8 +102,13 @@ class LyricSearch(FastAPI):
|
|||||||
|
|
||||||
async def typeahead_handler(self, data: ValidTypeAheadRequest) -> JSONResponse:
|
async def typeahead_handler(self, data: ValidTypeAheadRequest) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Lyric search typeahead handler
|
Handle lyric search typeahead requests.
|
||||||
- **query**: Typeahead query
|
|
||||||
|
Parameters:
|
||||||
|
- **data** (ValidTypeAheadRequest): Request containing the query.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Typeahead suggestions or error.
|
||||||
"""
|
"""
|
||||||
if not isinstance(data.query, str):
|
if not isinstance(data.query, str):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -112,15 +127,13 @@ class LyricSearch(FastAPI):
|
|||||||
|
|
||||||
async def lyric_search_handler(self, data: ValidLyricRequest) -> JSONResponse:
|
async def lyric_search_handler(self, data: ValidLyricRequest) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Search for lyrics
|
Search for lyrics.
|
||||||
- **a**: artist
|
|
||||||
- **s**: song
|
Parameters:
|
||||||
- **t**: track (artist and song combined) [used only if a & s are not used]
|
- **data** (ValidLyricRequest): Request containing artist, song, and other parameters.
|
||||||
- **extra**: include extra details in response [optional, default: false]
|
|
||||||
- **lrc**: Request LRCs?
|
Returns:
|
||||||
- **sub**: text to search within lyrics, if found lyrics will begin at found verse [optional, default: none]
|
- **JSONResponse**: Lyrics data or error.
|
||||||
- **src**: the script/utility which initiated the request
|
|
||||||
- **excluded_sources**: sources to exclude [optional, default: none]
|
|
||||||
"""
|
"""
|
||||||
if (not data.a or not data.s) and not data.t or not data.src:
|
if (not data.a or not data.s) and not data.t or not data.src:
|
||||||
raise HTTPException(detail="Invalid request", status_code=500)
|
raise HTTPException(detail="Invalid request", status_code=500)
|
||||||
|
@@ -6,10 +6,11 @@ from utils.meme_util import MemeUtil
|
|||||||
|
|
||||||
class Meme(FastAPI):
|
class Meme(FastAPI):
|
||||||
"""
|
"""
|
||||||
Misc Endpoints
|
Meme Endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: FastAPI, my_util, constants) -> None:
|
def __init__(self, app: FastAPI, my_util, constants) -> None:
|
||||||
|
"""Initialize Meme endpoints."""
|
||||||
self.app: FastAPI = app
|
self.app: FastAPI = app
|
||||||
self.util = my_util
|
self.util = my_util
|
||||||
self.meme_util = MemeUtil(constants)
|
self.meme_util = MemeUtil(constants)
|
||||||
@@ -35,21 +36,47 @@ class Meme(FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def get_meme_by_id(self, id: int, request: Request) -> Response:
|
async def get_meme_by_id(self, id: int, request: Request) -> Response:
|
||||||
"""Get meme (image) by id"""
|
"""
|
||||||
|
Get meme image by ID.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **id** (int): Meme ID.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: Image response or 404.
|
||||||
|
"""
|
||||||
meme_image = await self.meme_util.get_meme_by_id(id)
|
meme_image = await self.meme_util.get_meme_by_id(id)
|
||||||
if not meme_image:
|
if not meme_image:
|
||||||
return Response(status_code=404, content="Not found")
|
return Response(status_code=404, content="Not found")
|
||||||
return Response(content=meme_image, media_type="image/png")
|
return Response(content=meme_image, media_type="image/png")
|
||||||
|
|
||||||
async def random_meme(self, request: Request) -> Response:
|
async def random_meme(self, request: Request) -> Response:
|
||||||
"""Get random meme (image)"""
|
"""
|
||||||
|
Get a random meme image.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: Image response or 404.
|
||||||
|
"""
|
||||||
meme_image = await self.meme_util.get_random_meme()
|
meme_image = await self.meme_util.get_random_meme()
|
||||||
if not meme_image:
|
if not meme_image:
|
||||||
return Response(status_code=404, content="Not found")
|
return Response(status_code=404, content="Not found")
|
||||||
return Response(content=meme_image, media_type="image/png")
|
return Response(content=meme_image, media_type="image/png")
|
||||||
|
|
||||||
async def list_memes(self, page: int, request: Request) -> Response:
|
async def list_memes(self, page: int, request: Request) -> Response:
|
||||||
"""Get meme (image) by id"""
|
"""
|
||||||
|
List memes with pagination.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **page** (int): Page number.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: List of memes with paging info.
|
||||||
|
"""
|
||||||
meme_list = await self.meme_util.list_memes(page)
|
meme_list = await self.meme_util.list_memes(page)
|
||||||
page_count = await self.meme_util.get_page_count()
|
page_count = await self.meme_util.get_page_count()
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
@@ -24,6 +24,7 @@ class Misc(FastAPI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: FastAPI, my_util, constants, radio) -> None:
|
def __init__(self, app: FastAPI, my_util, constants, radio) -> None:
|
||||||
|
"""Initialize Misc endpoints."""
|
||||||
self.app: FastAPI = app
|
self.app: FastAPI = app
|
||||||
self.util = my_util
|
self.util = my_util
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
@@ -84,12 +85,29 @@ class Misc(FastAPI):
|
|||||||
return "No."
|
return "No."
|
||||||
|
|
||||||
async def no(self) -> JSONResponse:
|
async def no(self) -> JSONResponse:
|
||||||
"""NaaS"""
|
"""
|
||||||
|
Get a random 'no' reason.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains a random 'no' reason.
|
||||||
|
"""
|
||||||
|
|
||||||
return JSONResponse(content={"no": self.get_no()})
|
return JSONResponse(content={"no": self.get_no()})
|
||||||
|
|
||||||
async def upload_activity_image(
|
async def upload_activity_image(
|
||||||
self, image: UploadFile, key: Annotated[str, Form()], request: Request
|
self, image: UploadFile, key: Annotated[str, Form()], request: Request
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Upload activity image.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **image** (UploadFile): The uploaded image file.
|
||||||
|
- **key** (str): The API key for authentication.
|
||||||
|
- **request** (Request): The HTTP request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: Indicates success or failure of the upload.
|
||||||
|
"""
|
||||||
if (
|
if (
|
||||||
not key
|
not key
|
||||||
or not isinstance(key, str)
|
or not isinstance(key, str)
|
||||||
@@ -112,6 +130,15 @@ class Misc(FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def get_activity_image(self, request: Request) -> Response:
|
async def get_activity_image(self, request: Request) -> Response:
|
||||||
|
"""
|
||||||
|
Get the activity image.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **request** (Request): The HTTP request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: The activity image or a fallback image.
|
||||||
|
"""
|
||||||
if isinstance(self.activity_image, bytes):
|
if isinstance(self.activity_image, bytes):
|
||||||
return Response(content=self.activity_image, media_type="image/png")
|
return Response(content=self.activity_image, media_type="image/png")
|
||||||
|
|
||||||
@@ -125,11 +152,13 @@ class Misc(FastAPI):
|
|||||||
|
|
||||||
async def get_radio_np(self, station: str = "main") -> tuple[str, str, str]:
|
async def get_radio_np(self, station: str = "main") -> tuple[str, str, str]:
|
||||||
"""
|
"""
|
||||||
Get radio now playing
|
Get radio now playing info.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
None
|
station: Station name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Radio now playing in artist - song format
|
Tuple of (artistsong, album, genre).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
np: dict = self.radio.radio_util.now_playing[station]
|
np: dict = self.radio.radio_util.now_playing[station]
|
||||||
@@ -145,7 +174,10 @@ class Misc(FastAPI):
|
|||||||
|
|
||||||
async def homepage_redis_widget(self) -> JSONResponse:
|
async def homepage_redis_widget(self) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Homepage Redis Widget Handler
|
Get Redis stats for homepage widget.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains Redis stats.
|
||||||
"""
|
"""
|
||||||
# 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
|
||||||
@@ -169,7 +201,10 @@ class Misc(FastAPI):
|
|||||||
|
|
||||||
async def homepage_rq_widget(self) -> JSONResponse:
|
async def homepage_rq_widget(self) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Homepage RQ Widget Handler
|
Get RQ job stats for homepage widget.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains RQ job stats.
|
||||||
"""
|
"""
|
||||||
queue_name = "dls"
|
queue_name = "dls"
|
||||||
queue = Queue(queue_name, self.redis_client)
|
queue = Queue(queue_name, self.redis_client)
|
||||||
@@ -193,7 +228,10 @@ class Misc(FastAPI):
|
|||||||
|
|
||||||
async def homepage_sqlite_widget(self) -> JSONResponse:
|
async def homepage_sqlite_widget(self) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Homepage SQLite Widget Handler
|
Get SQLite stats for homepage widget.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains SQLite stats.
|
||||||
"""
|
"""
|
||||||
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")
|
||||||
@@ -208,7 +246,10 @@ class Misc(FastAPI):
|
|||||||
|
|
||||||
async def homepage_lyrics_widget(self) -> JSONResponse:
|
async def homepage_lyrics_widget(self) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Homepage Lyrics Widget Handler
|
Get lyrics stats for homepage widget.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains lyrics stats.
|
||||||
"""
|
"""
|
||||||
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):
|
||||||
@@ -228,7 +269,13 @@ class Misc(FastAPI):
|
|||||||
|
|
||||||
async def homepage_radio_widget(self, station: str = "main") -> JSONResponse:
|
async def homepage_radio_widget(self, station: str = "main") -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Homepage Radio Widget Handler
|
Get radio now playing for homepage widget.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **station** (str): Station name. Defaults to "main".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains now playing info.
|
||||||
"""
|
"""
|
||||||
radio_np: tuple = await self.get_radio_np(station)
|
radio_np: tuple = await self.get_radio_np(station)
|
||||||
if not radio_np:
|
if not radio_np:
|
||||||
|
@@ -68,10 +68,14 @@ class Radio(FastAPI):
|
|||||||
self, data: ValidRadioNextRequest, request: Request
|
self, data: ValidRadioNextRequest, request: Request
|
||||||
) -> JSONResponse:
|
) -> 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 the UUID specified in `skipTo` if provided.
|
||||||
- **key**: API key
|
|
||||||
- **skipTo**: Optional UUID to skip to
|
Parameters:
|
||||||
- **station**: default "main"
|
- **data** (ValidRadioNextRequest): Contains the API key, optional UUID to skip to, and station name.
|
||||||
|
- **request** (Request): The HTTP request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Indicates success or failure of the skip operation.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
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):
|
||||||
@@ -114,10 +118,16 @@ class Radio(FastAPI):
|
|||||||
self, data: ValidRadioReshuffleRequest, request: Request
|
self, data: ValidRadioReshuffleRequest, request: Request
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Reshuffle the play queue
|
Reshuffle the play queue.
|
||||||
- **key**: API key
|
|
||||||
- **station**: default "main"
|
Parameters:
|
||||||
|
- **data** (ValidRadioReshuffleRequest): Contains the API key and station name.
|
||||||
|
- **request** (Request): The HTTP request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Indicates success of the reshuffle operation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -130,7 +140,14 @@ class Radio(FastAPI):
|
|||||||
data: Optional[ValidRadioQueueRequest] = None,
|
data: Optional[ValidRadioQueueRequest] = None,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Get current play queue (paged, 20 results per page)
|
Get the current play queue (paged, 20 results per page).
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **request** (Request): The HTTP request object.
|
||||||
|
- **data** (Optional[ValidRadioQueueRequest]): Contains the station name and optional search query.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains the paged queue data.
|
||||||
"""
|
"""
|
||||||
if not (data and data.station):
|
if not (data and data.station):
|
||||||
return JSONResponse(status_code=500,
|
return JSONResponse(status_code=500,
|
||||||
@@ -191,13 +208,16 @@ class Radio(FastAPI):
|
|||||||
self, data: ValidRadioQueueShiftRequest, request: Request
|
self, data: ValidRadioQueueShiftRequest, request: Request
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Shift position of a UUID within the queue
|
Shift the position of a UUID within the queue.
|
||||||
[currently limited to playing next or immediately]
|
|
||||||
- **key**: API key
|
Parameters:
|
||||||
- **uuid**: UUID to shift
|
- **data** (ValidRadioQueueShiftRequest): Contains the API key, UUID to shift, and station name.
|
||||||
- **next**: Play track next? If False, skips to the track
|
- **request** (Request): The HTTP request object.
|
||||||
- **station**: default "main"
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Indicates success of the shift operation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -225,11 +245,16 @@ class Radio(FastAPI):
|
|||||||
self, data: ValidRadioQueueRemovalRequest, request: Request
|
self, data: ValidRadioQueueRemovalRequest, request: Request
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Remove an item from the current play queue
|
Remove an item from the current play queue.
|
||||||
- **key**: API key
|
|
||||||
- **uuid**: UUID of queue item to remove
|
Parameters:
|
||||||
- **station**: default "main"
|
- **data** (ValidRadioQueueRemovalRequest): Contains the API key, UUID of the item to remove, and station name.
|
||||||
|
- **request** (Request): The HTTP request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Indicates success of the removal operation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -254,10 +279,15 @@ class Radio(FastAPI):
|
|||||||
station: Station = "main"
|
station: Station = "main"
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""
|
"""
|
||||||
Get album art, optional parameter track_id may be specified.
|
Get album art for the current or specified track.
|
||||||
Otherwise, current track album art will be pulled.
|
|
||||||
- **track_id**: Optional, if provided, will attempt to retrieve the album art of this track_id. Current track used otherwise.
|
Parameters:
|
||||||
- **station**: default "main"
|
- **request** (Request): The HTTP request object.
|
||||||
|
- **track_id** (Optional[int]): ID of the track to retrieve album art for. Defaults to the current track.
|
||||||
|
- **station** (Station): Name of the station. Defaults to "main".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: Contains the album art image or a default image.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not track_id:
|
if not track_id:
|
||||||
@@ -288,9 +318,16 @@ class Radio(FastAPI):
|
|||||||
async def radio_now_playing(self, request: Request,
|
async def radio_now_playing(self, request: Request,
|
||||||
station: Station = "main") -> JSONResponse:
|
station: Station = "main") -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Get currently playing track info
|
Get information about the currently playing track.
|
||||||
- **station**: default "main"
|
|
||||||
|
Parameters:
|
||||||
|
- **request** (Request): The HTTP request object.
|
||||||
|
- **station** (Station): Name of the station. Defaults to "main".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains the track information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ret_obj: dict = {**self.radio_util.now_playing[station]}
|
ret_obj: dict = {**self.radio_util.now_playing[station]}
|
||||||
ret_obj["station"] = station
|
ret_obj["station"] = station
|
||||||
try:
|
try:
|
||||||
@@ -308,12 +345,17 @@ class Radio(FastAPI):
|
|||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Get next track
|
Get the next track in the queue. The track will be removed from the queue in the process.
|
||||||
(Track will be removed from the queue in the process.)
|
|
||||||
- **key**: API key
|
Parameters:
|
||||||
- **skipTo**: Optional UUID to skip to
|
- **data** (ValidRadioNextRequest): Contains the API key, optional UUID to skip to, and station name.
|
||||||
- **station**: default: "main"
|
- **request** (Request): The HTTP request object.
|
||||||
|
- **background_tasks** (BackgroundTasks): Background tasks for webhook execution.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains the next track information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
logging.info("Radio get next")
|
logging.info("Radio get next")
|
||||||
if data.station not in self.radio_util.active_playlist.keys():
|
if data.station not in self.radio_util.active_playlist.keys():
|
||||||
raise HTTPException(status_code=500, detail="No such station/not ready")
|
raise HTTPException(status_code=500, detail="No such station/not ready")
|
||||||
@@ -372,14 +414,16 @@ class Radio(FastAPI):
|
|||||||
self, data: ValidRadioSongRequest, request: Request
|
self, data: ValidRadioSongRequest, request: Request
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Song request handler
|
Handle song requests.
|
||||||
- **key**: API key
|
|
||||||
- **artist**: Artist to search
|
Parameters:
|
||||||
- **song**: Song to search
|
- **data** (ValidRadioSongRequest): Contains the API key, artist, song, and station name.
|
||||||
- **artistsong**: Optional "Artist - Song" pair to search, in place of artist/song
|
- **request** (Request): The HTTP request object.
|
||||||
- **alsoSkip**: If True, skips to the track; otherwise, track will be placed next up in queue
|
|
||||||
- **station**: default "main"
|
Returns:
|
||||||
|
- **JSONResponse**: Indicates success or failure of the request.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.util.check_key(path=request.url.path, req_type=4, key=data.key):
|
if not self.util.check_key(path=request.url.path, req_type=4, key=data.key):
|
||||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||||
artistsong: Optional[str] = data.artistsong
|
artistsong: Optional[str] = data.artistsong
|
||||||
@@ -413,8 +457,14 @@ class Radio(FastAPI):
|
|||||||
self, data: ValidRadioTypeaheadRequest, request: Request
|
self, data: ValidRadioTypeaheadRequest, request: Request
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Radio typeahead handler
|
Handle typeahead queries for the radio.
|
||||||
- **query**: Typeahead query
|
|
||||||
|
Parameters:
|
||||||
|
- **data** (ValidRadioTypeaheadRequest): Contains the typeahead query.
|
||||||
|
- **request** (Request): The HTTP request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains the typeahead results.
|
||||||
"""
|
"""
|
||||||
if not isinstance(data.query, str):
|
if not isinstance(data.query, str):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
@@ -14,6 +14,7 @@ class RandMsg(FastAPI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: FastAPI, util, constants) -> None:
|
def __init__(self, app: FastAPI, util, constants) -> None:
|
||||||
|
"""Initialize RandMsg endpoint."""
|
||||||
self.app: FastAPI = app
|
self.app: FastAPI = app
|
||||||
self.util = util
|
self.util = util
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
@@ -30,8 +31,13 @@ class RandMsg(FastAPI):
|
|||||||
self, data: Optional[RandMsgRequest] = None
|
self, data: Optional[RandMsgRequest] = None
|
||||||
) -> JSONResponse:
|
) -> 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)
|
|
||||||
|
Parameters:
|
||||||
|
- **data** (Optional[RandMsgRequest]): Optional request data for short messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains a random message.
|
||||||
"""
|
"""
|
||||||
random.seed()
|
random.seed()
|
||||||
short: Optional[bool] = False
|
short: Optional[bool] = False
|
||||||
|
119
endpoints/rip.py
119
endpoints/rip.py
@@ -33,6 +33,7 @@ class RIP(FastAPI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: FastAPI, my_util, constants) -> None:
|
def __init__(self, app: FastAPI, my_util, constants) -> None:
|
||||||
|
"""Initialize RIP endpoints."""
|
||||||
self.app: FastAPI = app
|
self.app: FastAPI = app
|
||||||
self.util = my_util
|
self.util = my_util
|
||||||
self.trip_util = SRUtil()
|
self.trip_util = SRUtil()
|
||||||
@@ -72,7 +73,15 @@ class RIP(FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _format_job(self, job: Job):
|
def _format_job(self, job: Job):
|
||||||
"""Helper to normalize job data into JSON."""
|
"""
|
||||||
|
Helper to normalize job data into JSON.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- job (Job): The job object to format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- dict: Contains normalized job data.
|
||||||
|
"""
|
||||||
job_status: str | JobStatus = job.get_status()
|
job_status: str | JobStatus = job.get_status()
|
||||||
progress = job.meta.get("progress", 0)
|
progress = job.meta.get("progress", 0)
|
||||||
if progress == 100 and not job.meta.get("tarball"):
|
if progress == 100 and not job.meta.get("tarball"):
|
||||||
@@ -82,7 +91,13 @@ class RIP(FastAPI):
|
|||||||
tracks_out = len(job.meta.get("tracks", []))
|
tracks_out = len(job.meta.get("tracks", []))
|
||||||
# `utils/rip_background.py` sets per-track status to 'Success' or 'Failed'
|
# `utils/rip_background.py` sets per-track status to 'Success' or 'Failed'
|
||||||
# so check for 'success' case-insensitively and count matches.
|
# so check for 'success' case-insensitively and count matches.
|
||||||
succeeded_tracks = len([t for t in job.meta.get("tracks", []) if str(t.get("status", "")).lower() == "success"])
|
succeeded_tracks = len(
|
||||||
|
[
|
||||||
|
t
|
||||||
|
for t in job.meta.get("tracks", [])
|
||||||
|
if str(t.get("status", "")).lower() == "success"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": job.id,
|
"id": job.id,
|
||||||
@@ -103,10 +118,20 @@ class RIP(FastAPI):
|
|||||||
async def artists_by_name_handler(
|
async def artists_by_name_handler(
|
||||||
self, artist: str, request: Request, user=Depends(get_current_user)
|
self, artist: str, request: Request, user=Depends(get_current_user)
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Get artists by name"""
|
"""
|
||||||
|
Get artists by name.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **artist** (str): Artist name.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
- **user**: Current user (dependency).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: JSON response with artists or 404.
|
||||||
|
"""
|
||||||
# support optional grouping to return one primary per display name
|
# support optional grouping to return one primary per display name
|
||||||
# with `alternatives` for disambiguation (use ?group=true)
|
# with `alternatives` for disambiguation (use ?group=true)
|
||||||
group = bool(request.query_params.get("group", False))
|
group = bool(request.query_params.get("group", False))
|
||||||
artists = await self.trip_util.get_artists_by_name(artist, group=group)
|
artists = await self.trip_util.get_artists_by_name(artist, group=group)
|
||||||
if not artists:
|
if not artists:
|
||||||
return Response(status_code=404, content="Not found")
|
return Response(status_code=404, content="Not found")
|
||||||
@@ -115,7 +140,17 @@ class RIP(FastAPI):
|
|||||||
async def albums_by_artist_id_handler(
|
async def albums_by_artist_id_handler(
|
||||||
self, artist_id: int, request: Request, user=Depends(get_current_user)
|
self, artist_id: int, request: Request, user=Depends(get_current_user)
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Get albums by artist ID"""
|
"""
|
||||||
|
Get albums by artist ID.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **artist_id** (int): Artist ID.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
- **user**: Current user (dependency).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: JSON response with albums or 404.
|
||||||
|
"""
|
||||||
albums = await self.trip_util.get_albums_by_artist_id(artist_id)
|
albums = await self.trip_util.get_albums_by_artist_id(artist_id)
|
||||||
if not albums:
|
if not albums:
|
||||||
return Response(status_code=404, content="Not found")
|
return Response(status_code=404, content="Not found")
|
||||||
@@ -128,7 +163,18 @@ class RIP(FastAPI):
|
|||||||
user=Depends(get_current_user),
|
user=Depends(get_current_user),
|
||||||
quality: Literal["FLAC", "Lossy"] = "FLAC",
|
quality: Literal["FLAC", "Lossy"] = "FLAC",
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Get tracks by album id"""
|
"""
|
||||||
|
Get tracks by album ID.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **album_id** (int): Album ID.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
- **quality** (Literal): Track quality ("FLAC" or "Lossy").
|
||||||
|
- **user**: Current user (dependency).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: JSON response with tracks or 404.
|
||||||
|
"""
|
||||||
tracks = await self.trip_util.get_tracks_by_album_id(album_id, quality)
|
tracks = await self.trip_util.get_tracks_by_album_id(album_id, quality)
|
||||||
if not tracks:
|
if not tracks:
|
||||||
return Response(status_code=404, content="Not Found")
|
return Response(status_code=404, content="Not Found")
|
||||||
@@ -137,7 +183,18 @@ class RIP(FastAPI):
|
|||||||
async def tracks_by_artist_song_handler(
|
async def tracks_by_artist_song_handler(
|
||||||
self, artist: str, song: str, request: Request, user=Depends(get_current_user)
|
self, artist: str, song: str, request: Request, user=Depends(get_current_user)
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Get tracks by artist and song name"""
|
"""
|
||||||
|
Get tracks by artist and song name.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **artist** (str): Artist name.
|
||||||
|
- **song** (str): Song name.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
- **user**: Current user (dependency).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: JSON response with tracks or 404.
|
||||||
|
"""
|
||||||
logging.critical("Searching for tracks by artist: %s, song: %s", artist, song)
|
logging.critical("Searching for tracks by artist: %s, song: %s", artist, song)
|
||||||
tracks = await self.trip_util.get_tracks_by_artist_song(artist, song)
|
tracks = await self.trip_util.get_tracks_by_artist_song(artist, song)
|
||||||
if not tracks:
|
if not tracks:
|
||||||
@@ -151,7 +208,18 @@ class RIP(FastAPI):
|
|||||||
quality: Literal["FLAC", "Lossy"] = "FLAC",
|
quality: Literal["FLAC", "Lossy"] = "FLAC",
|
||||||
user=Depends(get_current_user),
|
user=Depends(get_current_user),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Get track by ID"""
|
"""
|
||||||
|
Get track by ID.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **track_id** (int): Track ID.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
- **quality** (Literal): Track quality ("FLAC" or "Lossy").
|
||||||
|
- **user**: Current user (dependency).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: JSON response with stream URL or 404.
|
||||||
|
"""
|
||||||
track = await self.trip_util.get_stream_url_by_track_id(track_id, quality)
|
track = await self.trip_util.get_stream_url_by_track_id(track_id, quality)
|
||||||
if not track:
|
if not track:
|
||||||
return Response(status_code=404, content="Not found")
|
return Response(status_code=404, content="Not found")
|
||||||
@@ -163,7 +231,17 @@ class RIP(FastAPI):
|
|||||||
request: Request,
|
request: Request,
|
||||||
user=Depends(get_current_user),
|
user=Depends(get_current_user),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Bulk fetch a list of track IDs"""
|
"""
|
||||||
|
Bulk fetch a list of track IDs.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **data** (ValidBulkFetchRequest): Bulk fetch request data.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
- **user**: Current user (dependency).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: JSON response with job info or error.
|
||||||
|
"""
|
||||||
if not data or not data.track_ids or not data.target:
|
if not data or not data.track_ids or not data.target:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
@@ -204,7 +282,17 @@ class RIP(FastAPI):
|
|||||||
async def job_status_handler(
|
async def job_status_handler(
|
||||||
self, job_id: str, request: Request, user=Depends(get_current_user)
|
self, job_id: str, request: Request, user=Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get status and result of a single job"""
|
"""
|
||||||
|
Get status and result of a single job.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **job_id** (str): Job ID.
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
- **user**: Current user (dependency).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Job status and result or error.
|
||||||
|
"""
|
||||||
|
|
||||||
job = None
|
job = None
|
||||||
try:
|
try:
|
||||||
@@ -233,7 +321,16 @@ class RIP(FastAPI):
|
|||||||
return self._format_job(job)
|
return self._format_job(job)
|
||||||
|
|
||||||
async def job_list_handler(self, request: Request, user=Depends(get_current_user)):
|
async def job_list_handler(self, request: Request, user=Depends(get_current_user)):
|
||||||
"""List all jobs across all registries (queued, started, finished, failed, etc)."""
|
"""
|
||||||
|
List all jobs across all registries.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **request** (Request): The request object.
|
||||||
|
- **user**: Current user (dependency).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: List of jobs.
|
||||||
|
"""
|
||||||
jobs_info = []
|
jobs_info = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
|
@@ -13,6 +13,7 @@ class Transcriptions(FastAPI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: FastAPI, util, constants) -> None:
|
def __init__(self, app: FastAPI, util, constants) -> None:
|
||||||
|
"""Initialize Transcriptions endpoints."""
|
||||||
self.app: FastAPI = app
|
self.app: FastAPI = app
|
||||||
self.util = util
|
self.util = util
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
@@ -36,8 +37,13 @@ class Transcriptions(FastAPI):
|
|||||||
self, data: ValidShowEpisodeListRequest
|
self, data: ValidShowEpisodeListRequest
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Get list of episodes by show id
|
Get list of episodes by show ID.
|
||||||
- **s**: Show ID to query
|
|
||||||
|
Parameters:
|
||||||
|
- **data** (ValidShowEpisodeListRequest): Request containing show ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains a list of episodes.
|
||||||
"""
|
"""
|
||||||
show_id: int = data.s
|
show_id: int = data.s
|
||||||
db_path: Optional[Union[str, LiteralString]] = None
|
db_path: Optional[Union[str, LiteralString]] = None
|
||||||
@@ -106,9 +112,13 @@ class Transcriptions(FastAPI):
|
|||||||
self, data: ValidShowEpisodeLineRequest
|
self, data: ValidShowEpisodeLineRequest
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""
|
"""
|
||||||
Get lines for a particular episode
|
Get lines for a particular episode.
|
||||||
- **s**: Show ID to query
|
|
||||||
- **e**: Episode ID to query
|
Parameters:
|
||||||
|
- **data** (ValidShowEpisodeLineRequest): Request containing show and episode ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **Response**: Episode lines.
|
||||||
"""
|
"""
|
||||||
show_id: int = int(data.s)
|
show_id: int = int(data.s)
|
||||||
episode_id: int = int(data.e)
|
episode_id: int = int(data.e)
|
||||||
|
@@ -12,6 +12,7 @@ class YT(FastAPI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: FastAPI, util, constants) -> None:
|
def __init__(self, app: FastAPI, util, constants) -> None:
|
||||||
|
"""Initialize YT endpoints."""
|
||||||
self.app: FastAPI = app
|
self.app: FastAPI = app
|
||||||
self.util = util
|
self.util = util
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
@@ -31,7 +32,15 @@ class YT(FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def yt_video_search_handler(self, data: ValidYTSearchRequest) -> JSONResponse:
|
async def yt_video_search_handler(self, data: ValidYTSearchRequest) -> JSONResponse:
|
||||||
"""Search for YT Video by Title (closest match returned)"""
|
"""
|
||||||
|
Search for YouTube video by title.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- **data** (ValidYTSearchRequest): Request containing title.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- **JSONResponse**: Contains video ID or an error message.
|
||||||
|
"""
|
||||||
|
|
||||||
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)
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, "..")
|
sys.path.insert(0, "..")
|
||||||
from utils.sr_wrapper import SRUtil
|
from utils.sr_wrapper import SRUtil
|
||||||
|
|
||||||
@@ -8,11 +9,16 @@ from utils.sr_wrapper import SRUtil
|
|||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
logger.setLevel(logging.CRITICAL)
|
logger.setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
sr = SRUtil()
|
sr = SRUtil()
|
||||||
artist_search = await sr.get_artists_by_name("Ren")
|
artist_search = await sr.get_artists_by_name("Ren")
|
||||||
# logging.critical("Artist search: %s", artist_search)
|
# logging.critical("Artist search: %s", artist_search)
|
||||||
res = [dict(x) for x in artist_search if x.get('popularity', 0) and x.get('artist').lower() == 'ren']
|
res = [
|
||||||
|
dict(x)
|
||||||
|
for x in artist_search
|
||||||
|
if x.get("popularity", 0) and x.get("artist").lower() == "ren"
|
||||||
|
]
|
||||||
logging.critical("Results: %s", res)
|
logging.critical("Results: %s", res)
|
||||||
# search_res = await sr.get_album_by_name(artist[:8], album)
|
# search_res = await sr.get_album_by_name(artist[:8], album)
|
||||||
# logging.critical("Search result: %s", search_res)
|
# logging.critical("Search result: %s", search_res)
|
||||||
@@ -23,4 +29,4 @@ async def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
30
util.py
30
util.py
@@ -18,23 +18,45 @@ class Utilities:
|
|||||||
|
|
||||||
def get_blocked_response(self, path: Optional[str] = None):
|
def get_blocked_response(self, path: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Get Blocked HTTP Response
|
Return a redirect response for blocked requests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Optional[str]): The requested path (currently unused).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RedirectResponse: A redirect to the blocked URI.
|
||||||
"""
|
"""
|
||||||
logging.error("Rejected request: Blocked")
|
logging.error("Rejected request: Blocked")
|
||||||
return RedirectResponse(url=self.blocked_redirect_uri)
|
return RedirectResponse(url=self.blocked_redirect_uri)
|
||||||
|
|
||||||
def get_no_endpoint_found(self, path: Optional[str] = None):
|
def get_no_endpoint_found(self, path: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Get 404 Response
|
Raise an HTTP 404 exception for unknown endpoints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Optional[str]): The requested path (currently unused).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: With status code 404 and detail "Unknown endpoint".
|
||||||
"""
|
"""
|
||||||
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, req_type: int = 0) -> bool:
|
def check_key(self, path: str, key: str, req_type: int = 0) -> bool:
|
||||||
"""
|
"""
|
||||||
Accepts path as an argument to allow fine tuning access for each API key, not currently in use.
|
Check if the provided API key is valid and meets the requirements.
|
||||||
"""
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (str): The request path (reserved for future fine-tuning).
|
||||||
|
key (str): The authorization header value, expected to start with "Bearer ".
|
||||||
|
req_type (int): The type of access required.
|
||||||
|
0: Basic access.
|
||||||
|
2: Private access (key must start with "PRV-").
|
||||||
|
4: Radio access (key must start with "RAD-").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the key is valid and meets the requirements, False otherwise.
|
||||||
|
"""
|
||||||
if not key or not key.startswith("Bearer "):
|
if not key or not key.startswith("Bearer "):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@@ -223,6 +223,7 @@ class RadioUtil:
|
|||||||
"artist": double_space.sub(" ", result["artist"].strip()),
|
"artist": double_space.sub(" ", result["artist"].strip()),
|
||||||
"song": double_space.sub(" ", result["song"].strip()),
|
"song": double_space.sub(" ", result["song"].strip()),
|
||||||
"artistsong": result["artistsong"].strip(),
|
"artistsong": result["artistsong"].strip(),
|
||||||
|
"album": result["album"].strip() if result["album"] else "N/A",
|
||||||
"genre": self.get_genre(
|
"genre": self.get_genre(
|
||||||
double_space.sub(" ", result["artist"].strip())
|
double_space.sub(" ", result["artist"].strip())
|
||||||
),
|
),
|
||||||
|
@@ -47,7 +47,13 @@ sr = SRUtil()
|
|||||||
|
|
||||||
|
|
||||||
# ---------- Discord helper ----------
|
# ---------- Discord helper ----------
|
||||||
async def discord_notify(webhook_url: str, title: str, description: str, target: Optional[str] = None, color: int = 0x00FF00):
|
async def discord_notify(
|
||||||
|
webhook_url: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
target: Optional[str] = None,
|
||||||
|
color: int = 0x00FF00,
|
||||||
|
):
|
||||||
embed = {
|
embed = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"description": description[:1900] if description else "",
|
"description": description[:1900] if description else "",
|
||||||
@@ -64,15 +70,20 @@ async def discord_notify(webhook_url: str, title: str, description: str, target:
|
|||||||
while True: # permanent retry
|
while True: # permanent retry
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(webhook_url, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
async with session.post(
|
||||||
|
webhook_url, json=payload, timeout=aiohttp.ClientTimeout(total=10)
|
||||||
|
) as resp:
|
||||||
if resp.status >= 400:
|
if resp.status >= 400:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise RuntimeError(f"Discord webhook failed ({resp.status}): {text}")
|
raise RuntimeError(
|
||||||
|
f"Discord webhook failed ({resp.status}): {text}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Discord send failed, retrying: {e}")
|
print(f"Discord send failed, retrying: {e}")
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
def send_log_to_discord(message: str, level: str, target: Optional[str] = None):
|
def send_log_to_discord(message: str, level: str, target: Optional[str] = None):
|
||||||
colors = {"WARNING": 0xFFA500, "ERROR": 0xFF0000, "CRITICAL": 0xFF0000}
|
colors = {"WARNING": 0xFFA500, "ERROR": 0xFF0000, "CRITICAL": 0xFF0000}
|
||||||
color = colors.get(level.upper(), 0xFFFF00)
|
color = colors.get(level.upper(), 0xFFFF00)
|
||||||
@@ -83,7 +94,7 @@ def send_log_to_discord(message: str, level: str, target: Optional[str] = None):
|
|||||||
title=f"{level} in bulk_download",
|
title=f"{level} in bulk_download",
|
||||||
description=message,
|
description=message,
|
||||||
target=target,
|
target=target,
|
||||||
color=color
|
color=color,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -98,6 +109,7 @@ def send_log_to_discord(message: str, level: str, target: Optional[str] = None):
|
|||||||
# ---------- Helpers ----------
|
# ---------- Helpers ----------
|
||||||
def tag_with_mediafile(file_path: str, meta: dict):
|
def tag_with_mediafile(file_path: str, meta: dict):
|
||||||
f = MediaFile(file_path)
|
f = MediaFile(file_path)
|
||||||
|
|
||||||
def safe_set(attr, value, default=None, cast=None):
|
def safe_set(attr, value, default=None, cast=None):
|
||||||
if value is None:
|
if value is None:
|
||||||
value = default
|
value = default
|
||||||
@@ -106,6 +118,7 @@ def tag_with_mediafile(file_path: str, meta: dict):
|
|||||||
setattr(f, attr, cast(value))
|
setattr(f, attr, cast(value))
|
||||||
else:
|
else:
|
||||||
setattr(f, attr, str(value))
|
setattr(f, attr, str(value))
|
||||||
|
|
||||||
safe_set("title", meta.get("title"), default="Unknown Title")
|
safe_set("title", meta.get("title"), default="Unknown Title")
|
||||||
safe_set("artist", meta.get("artist"), default="Unknown Artist")
|
safe_set("artist", meta.get("artist"), default="Unknown Artist")
|
||||||
safe_set("albumartist", meta.get("album_artist"), default="Unknown Artist")
|
safe_set("albumartist", meta.get("album_artist"), default="Unknown Artist")
|
||||||
@@ -136,6 +149,7 @@ def tag_with_mediafile(file_path: str, meta: dict):
|
|||||||
if not cover_bytes and cover_url:
|
if not cover_bytes and cover_url:
|
||||||
try:
|
try:
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
resp = requests.get(cover_url, timeout=10)
|
resp = requests.get(cover_url, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
cover_bytes = resp.content
|
cover_bytes = resp.content
|
||||||
@@ -188,7 +202,7 @@ def ensure_unique_filename_in_dir(parent: Path, filename: str) -> Path:
|
|||||||
# special-case .tar.gz
|
# special-case .tar.gz
|
||||||
if filename.lower().endswith(".tar.gz"):
|
if filename.lower().endswith(".tar.gz"):
|
||||||
ext = ".tar.gz"
|
ext = ".tar.gz"
|
||||||
base = filename[:-len(ext)]
|
base = filename[: -len(ext)]
|
||||||
else:
|
else:
|
||||||
p = Path(filename)
|
p = Path(filename)
|
||||||
ext = p.suffix
|
ext = p.suffix
|
||||||
@@ -235,13 +249,15 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
send_log_to_discord(f"Failed to init job.meta: {e}", "WARNING", target)
|
send_log_to_discord(f"Failed to init job.meta: {e}", "WARNING", target)
|
||||||
|
|
||||||
# Job started Discord message
|
# Job started Discord message
|
||||||
asyncio.run(discord_notify(
|
asyncio.run(
|
||||||
DISCORD_WEBHOOK,
|
discord_notify(
|
||||||
title=f"Job Started: {job_id}",
|
DISCORD_WEBHOOK,
|
||||||
description=f"Processing `{len(track_list)}` track(s)",
|
title=f"Job Started: {job_id}",
|
||||||
target=target,
|
description=f"Processing `{len(track_list)}` track(s)",
|
||||||
color=0x00FFFF
|
target=target,
|
||||||
))
|
color=0x00FFFF,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def process_tracks():
|
async def process_tracks():
|
||||||
per_track_meta = []
|
per_track_meta = []
|
||||||
@@ -253,7 +269,11 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
# Set up a one-time rate-limit callback to notify on the first 429 seen by SRUtil
|
# Set up a one-time rate-limit callback to notify on the first 429 seen by SRUtil
|
||||||
async def _rate_limit_notify(exc: Exception):
|
async def _rate_limit_notify(exc: Exception):
|
||||||
try:
|
try:
|
||||||
send_log_to_discord(f"Rate limit observed while fetching metadata: {exc}", "WARNING", target)
|
send_log_to_discord(
|
||||||
|
f"Rate limit observed while fetching metadata: {exc}",
|
||||||
|
"WARNING",
|
||||||
|
target,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -265,7 +285,13 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
pass
|
pass
|
||||||
total = len(track_list or [])
|
total = len(track_list or [])
|
||||||
for i, track_id in enumerate(track_list or []):
|
for i, track_id in enumerate(track_list or []):
|
||||||
track_info = {"track_id": str(track_id), "status": "Pending", "file_path": None, "error": None, "attempts": 0}
|
track_info = {
|
||||||
|
"track_id": str(track_id),
|
||||||
|
"status": "Pending",
|
||||||
|
"file_path": None,
|
||||||
|
"error": None,
|
||||||
|
"attempts": 0,
|
||||||
|
}
|
||||||
attempt = 0
|
attempt = 0
|
||||||
|
|
||||||
while attempt < MAX_RETRIES:
|
while attempt < MAX_RETRIES:
|
||||||
@@ -326,13 +352,19 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
# Try to fetch cover art via SRUtil (use album_id from metadata)
|
# Try to fetch cover art via SRUtil (use album_id from metadata)
|
||||||
try:
|
try:
|
||||||
album_field = md.get("album")
|
album_field = md.get("album")
|
||||||
album_id = md.get("album_id") or (album_field.get("id") if isinstance(album_field, dict) else None)
|
album_id = md.get("album_id") or (
|
||||||
|
album_field.get("id")
|
||||||
|
if isinstance(album_field, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
album_id = None
|
album_id = None
|
||||||
|
|
||||||
if album_id:
|
if album_id:
|
||||||
try:
|
try:
|
||||||
cover_url = await sr.get_cover_by_album_id(album_id, size=640)
|
cover_url = await sr.get_cover_by_album_id(
|
||||||
|
album_id, size=640
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
cover_url = None
|
cover_url = None
|
||||||
else:
|
else:
|
||||||
@@ -344,7 +376,9 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
if cover_url:
|
if cover_url:
|
||||||
try:
|
try:
|
||||||
timeout = aiohttp.ClientTimeout(total=15)
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
async with session.get(cover_url, timeout=timeout) as img_resp:
|
async with session.get(
|
||||||
|
cover_url, timeout=timeout
|
||||||
|
) as img_resp:
|
||||||
if img_resp.status == 200:
|
if img_resp.status == 200:
|
||||||
img_bytes = await img_resp.read()
|
img_bytes = await img_resp.read()
|
||||||
else:
|
else:
|
||||||
@@ -375,23 +409,24 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
# Prefer music_tag if available (keeps compatibility with add_cover_art.py)
|
# Prefer music_tag if available (keeps compatibility with add_cover_art.py)
|
||||||
try:
|
try:
|
||||||
from music_tag import load_file as mt_load_file # type: ignore
|
from music_tag import load_file as mt_load_file # type: ignore
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mf = mt_load_file(str(final_file))
|
mf = mt_load_file(str(final_file))
|
||||||
# set basic tags
|
# set basic tags
|
||||||
if md.get('title'):
|
if md.get("title"):
|
||||||
mf['title'] = md.get('title')
|
mf["title"] = md.get("title")
|
||||||
if md.get('artist'):
|
if md.get("artist"):
|
||||||
mf['artist'] = md.get('artist')
|
mf["artist"] = md.get("artist")
|
||||||
if md.get('album'):
|
if md.get("album"):
|
||||||
mf['album'] = md.get('album')
|
mf["album"] = md.get("album")
|
||||||
tracknum = md.get('track_number')
|
tracknum = md.get("track_number")
|
||||||
if tracknum is not None:
|
if tracknum is not None:
|
||||||
try:
|
try:
|
||||||
mf['tracknumber'] = int(tracknum)
|
mf["tracknumber"] = int(tracknum)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if img_bytes:
|
if img_bytes:
|
||||||
mf['artwork'] = img_bytes
|
mf["artwork"] = img_bytes
|
||||||
mf.save()
|
mf.save()
|
||||||
embedded = True
|
embedded = True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -438,7 +473,9 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
wait_time = min(60, 2**attempt)
|
wait_time = min(60, 2**attempt)
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(random.uniform(THROTTLE_MIN, THROTTLE_MAX))
|
await asyncio.sleep(
|
||||||
|
random.uniform(THROTTLE_MIN, THROTTLE_MAX)
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tb = traceback.format_exc()
|
tb = traceback.format_exc()
|
||||||
@@ -447,7 +484,11 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
track_info["error"] = str(e)
|
track_info["error"] = str(e)
|
||||||
if attempt >= MAX_RETRIES:
|
if attempt >= MAX_RETRIES:
|
||||||
track_info["status"] = "Failed"
|
track_info["status"] = "Failed"
|
||||||
send_log_to_discord(f"Track {track_id} failed after {attempt} attempts", "ERROR", target)
|
send_log_to_discord(
|
||||||
|
f"Track {track_id} failed after {attempt} attempts",
|
||||||
|
"ERROR",
|
||||||
|
target,
|
||||||
|
)
|
||||||
await asyncio.sleep(random.uniform(THROTTLE_MIN, THROTTLE_MAX))
|
await asyncio.sleep(random.uniform(THROTTLE_MIN, THROTTLE_MAX))
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
@@ -464,7 +505,11 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
job.meta["tarball"] = None
|
job.meta["tarball"] = None
|
||||||
job.meta["status"] = "Failed"
|
job.meta["status"] = "Failed"
|
||||||
job.save_meta()
|
job.save_meta()
|
||||||
send_log_to_discord(f"No tracks were successfully downloaded for job `{job_id}`", "CRITICAL", target)
|
send_log_to_discord(
|
||||||
|
f"No tracks were successfully downloaded for job `{job_id}`",
|
||||||
|
"CRITICAL",
|
||||||
|
target,
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Tarball creation
|
# Tarball creation
|
||||||
@@ -476,7 +521,11 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
except Exception:
|
except Exception:
|
||||||
artist = "Unknown Artist"
|
artist = "Unknown Artist"
|
||||||
artist_counts[artist] = artist_counts.get(artist, 0) + 1
|
artist_counts[artist] = artist_counts.get(artist, 0) + 1
|
||||||
top_artist = sorted(artist_counts.items(), key=lambda kv: (-kv[1], kv[0]))[0][0] if artist_counts else "Unknown Artist"
|
top_artist = (
|
||||||
|
sorted(artist_counts.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
|
||||||
|
if artist_counts
|
||||||
|
else "Unknown Artist"
|
||||||
|
)
|
||||||
# Prefer `job.meta['target']` when provided by the enqueuer. Fall back to the top artist.
|
# Prefer `job.meta['target']` when provided by the enqueuer. Fall back to the top artist.
|
||||||
target_name = None
|
target_name = None
|
||||||
try:
|
try:
|
||||||
@@ -485,7 +534,11 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
except Exception:
|
except Exception:
|
||||||
target_name = None
|
target_name = None
|
||||||
|
|
||||||
base_label = sanitize_filename(target_name) if target_name else sanitize_filename(top_artist)
|
base_label = (
|
||||||
|
sanitize_filename(target_name)
|
||||||
|
if target_name
|
||||||
|
else sanitize_filename(top_artist)
|
||||||
|
)
|
||||||
staged_tarball = staging_root / f"{base_label}.tar.gz"
|
staged_tarball = staging_root / f"{base_label}.tar.gz"
|
||||||
|
|
||||||
counter = 1
|
counter = 1
|
||||||
@@ -504,14 +557,24 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
job.save_meta()
|
job.save_meta()
|
||||||
|
|
||||||
logging.info("Creating tarball: %s", staged_tarball)
|
logging.info("Creating tarball: %s", staged_tarball)
|
||||||
await discord_notify(DISCORD_WEBHOOK,
|
await discord_notify(
|
||||||
title=f"Compressing: Job {job_id}",
|
DISCORD_WEBHOOK,
|
||||||
description=f"Creating tarball: `{len(all_final_files)}` track(s).\nStaging path: {staged_tarball}",
|
title=f"Compressing: Job {job_id}",
|
||||||
color=0xFFA500,
|
description=f"Creating tarball: `{len(all_final_files)}` track(s).\nStaging path: {staged_tarball}",
|
||||||
target=target)
|
color=0xFFA500,
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["tar", "-I", "pigz -9", "-cf", str(staged_tarball), "-C", str(staging_root)]
|
[
|
||||||
|
"tar",
|
||||||
|
"-I",
|
||||||
|
"pigz -9",
|
||||||
|
"-cf",
|
||||||
|
str(staged_tarball),
|
||||||
|
"-C",
|
||||||
|
str(staging_root),
|
||||||
|
]
|
||||||
+ [str(f.relative_to(staging_root)) for f in all_final_files],
|
+ [str(f.relative_to(staging_root)) for f in all_final_files],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
@@ -521,7 +584,11 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
send_log_to_discord("pigz not available, falling back to tarfile (slower).", "WARNING", target)
|
send_log_to_discord(
|
||||||
|
"pigz not available, falling back to tarfile (slower).",
|
||||||
|
"WARNING",
|
||||||
|
target,
|
||||||
|
)
|
||||||
with tarfile.open(staged_tarball, "w:gz") as tar:
|
with tarfile.open(staged_tarball, "w:gz") as tar:
|
||||||
for f in all_final_files:
|
for f in all_final_files:
|
||||||
try:
|
try:
|
||||||
@@ -535,7 +602,9 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not staged_tarball.exists():
|
if not staged_tarball.exists():
|
||||||
send_log_to_discord(f"Tarball was not created: `{staged_tarball}`", "CRITICAL", target)
|
send_log_to_discord(
|
||||||
|
f"Tarball was not created: `{staged_tarball}`", "CRITICAL", target
|
||||||
|
)
|
||||||
if job:
|
if job:
|
||||||
job.meta["status"] = "compress_failed"
|
job.meta["status"] = "compress_failed"
|
||||||
job.save_meta()
|
job.save_meta()
|
||||||
@@ -556,13 +625,13 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
|
|
||||||
# Job completed Discord message
|
# Job completed Discord message
|
||||||
completed = len(all_final_files)
|
completed = len(all_final_files)
|
||||||
failed = (len(track_list) - completed)
|
failed = len(track_list) - completed
|
||||||
await discord_notify(
|
await discord_notify(
|
||||||
DISCORD_WEBHOOK,
|
DISCORD_WEBHOOK,
|
||||||
title=f"Job Completed: {job_id}",
|
title=f"Job Completed: {job_id}",
|
||||||
description=f"Processed `{len(track_list)}` track(s).\nCompleted: `{completed}`\nFailed: `{failed}`\nTarball: `{final_tarball}`",
|
description=f"Processed `{len(track_list)}` track(s).\nCompleted: `{completed}`\nFailed: `{failed}`\nTarball: `{final_tarball}`",
|
||||||
target=target,
|
target=target,
|
||||||
color=0x00FF00
|
color=0x00FF00,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [str(final_tarball)]
|
return [str(final_tarball)]
|
||||||
@@ -572,7 +641,9 @@ def bulk_download(track_list: list, quality: str = "FLAC"):
|
|||||||
try:
|
try:
|
||||||
return loop.run_until_complete(process_tracks())
|
return loop.run_until_complete(process_tracks())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
send_log_to_discord(f"bulk_download failed: {e}\n{traceback.format_exc()}", "CRITICAL", target)
|
send_log_to_discord(
|
||||||
|
f"bulk_download failed: {e}\n{traceback.format_exc()}", "CRITICAL", target
|
||||||
|
)
|
||||||
if job:
|
if job:
|
||||||
job.meta["status"] = "Failed"
|
job.meta["status"] = "Failed"
|
||||||
job.save_meta()
|
job.save_meta()
|
||||||
|
@@ -30,7 +30,6 @@ for name in [__name__, "utils.sr_wrapper"]:
|
|||||||
logging.getLogger().setLevel(logging.CRITICAL)
|
logging.getLogger().setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +65,9 @@ class SRUtil:
|
|||||||
self.streamrip_client = TidalClient(self.streamrip_config)
|
self.streamrip_client = TidalClient(self.streamrip_config)
|
||||||
self.MAX_CONCURRENT_METADATA_REQUESTS = 2
|
self.MAX_CONCURRENT_METADATA_REQUESTS = 2
|
||||||
self.METADATA_RATE_LIMIT = 1.25
|
self.METADATA_RATE_LIMIT = 1.25
|
||||||
self.METADATA_SEMAPHORE = asyncio.Semaphore(self.MAX_CONCURRENT_METADATA_REQUESTS)
|
self.METADATA_SEMAPHORE = asyncio.Semaphore(
|
||||||
|
self.MAX_CONCURRENT_METADATA_REQUESTS
|
||||||
|
)
|
||||||
self.LAST_METADATA_REQUEST = 0
|
self.LAST_METADATA_REQUEST = 0
|
||||||
self.MAX_METADATA_RETRIES = 5
|
self.MAX_METADATA_RETRIES = 5
|
||||||
self.METADATA_ALBUM_CACHE: dict[str, dict] = {}
|
self.METADATA_ALBUM_CACHE: dict[str, dict] = {}
|
||||||
@@ -77,16 +78,18 @@ class SRUtil:
|
|||||||
self._rate_limit_notified = False
|
self._rate_limit_notified = False
|
||||||
|
|
||||||
async def rate_limited_request(self, func, *args, **kwargs):
|
async def rate_limited_request(self, func, *args, **kwargs):
|
||||||
async with self.METADATA_SEMAPHORE:
|
async with self.METADATA_SEMAPHORE:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
elapsed = now - self.LAST_METADATA_REQUEST
|
elapsed = now - self.LAST_METADATA_REQUEST
|
||||||
if elapsed < self.METADATA_RATE_LIMIT:
|
if elapsed < self.METADATA_RATE_LIMIT:
|
||||||
await asyncio.sleep(self.METADATA_RATE_LIMIT - elapsed)
|
await asyncio.sleep(self.METADATA_RATE_LIMIT - elapsed)
|
||||||
result = await func(*args, **kwargs)
|
result = await func(*args, **kwargs)
|
||||||
self.LAST_METADATA_REQUEST = time.time()
|
self.LAST_METADATA_REQUEST = time.time()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _safe_api_call(self, func, *args, retries: int = 2, backoff: float = 0.5, **kwargs):
|
async def _safe_api_call(
|
||||||
|
self, func, *args, retries: int = 2, backoff: float = 0.5, **kwargs
|
||||||
|
):
|
||||||
"""Call an async API function with resilient retry behavior.
|
"""Call an async API function with resilient retry behavior.
|
||||||
|
|
||||||
- On AttributeError: attempt a `login()` once and retry.
|
- On AttributeError: attempt a `login()` once and retry.
|
||||||
@@ -116,7 +119,11 @@ class SRUtil:
|
|||||||
if ("400" in msg or "429" in msg) and attempt < retries - 1:
|
if ("400" in msg or "429" in msg) and attempt < retries - 1:
|
||||||
# Notify on the first observed 429 (if a callback is set)
|
# Notify on the first observed 429 (if a callback is set)
|
||||||
try:
|
try:
|
||||||
if "429" in msg and not self._rate_limit_notified and self.on_rate_limit:
|
if (
|
||||||
|
"429" in msg
|
||||||
|
and not self._rate_limit_notified
|
||||||
|
and self.on_rate_limit
|
||||||
|
):
|
||||||
self._rate_limit_notified = True
|
self._rate_limit_notified = True
|
||||||
try:
|
try:
|
||||||
if asyncio.iscoroutinefunction(self.on_rate_limit):
|
if asyncio.iscoroutinefunction(self.on_rate_limit):
|
||||||
@@ -128,17 +135,29 @@ class SRUtil:
|
|||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
await asyncio.sleep(backoff * (2 ** attempt))
|
await asyncio.sleep(backoff * (2**attempt))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Connection related errors — try to re-login then retry
|
# Connection related errors — try to re-login then retry
|
||||||
if isinstance(e, (aiohttp.ClientError, OSError, ConnectionError, asyncio.TimeoutError)) or "Connection" in msg or "closed" in msg.lower():
|
if (
|
||||||
|
isinstance(
|
||||||
|
e,
|
||||||
|
(
|
||||||
|
aiohttp.ClientError,
|
||||||
|
OSError,
|
||||||
|
ConnectionError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
or "Connection" in msg
|
||||||
|
or "closed" in msg.lower()
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
await self.streamrip_client.login()
|
await self.streamrip_client.login()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if attempt < retries - 1:
|
if attempt < retries - 1:
|
||||||
await asyncio.sleep(backoff * (2 ** attempt))
|
await asyncio.sleep(backoff * (2**attempt))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Unhandled / permanent error: re-raise after loop ends
|
# Unhandled / permanent error: re-raise after loop ends
|
||||||
@@ -151,10 +170,23 @@ class SRUtil:
|
|||||||
if not expected or not actual:
|
if not expected or not actual:
|
||||||
return False
|
return False
|
||||||
return fuzz.token_set_ratio(expected.lower(), actual.lower()) >= threshold
|
return fuzz.token_set_ratio(expected.lower(), actual.lower()) >= threshold
|
||||||
|
|
||||||
def is_metadata_match(self, expected_artist, expected_album, expected_title, found_artist, found_album, found_title, threshold=80):
|
def is_metadata_match(
|
||||||
|
self,
|
||||||
|
expected_artist,
|
||||||
|
expected_album,
|
||||||
|
expected_title,
|
||||||
|
found_artist,
|
||||||
|
found_album,
|
||||||
|
found_title,
|
||||||
|
threshold=80,
|
||||||
|
):
|
||||||
artist_match = self.is_fuzzy_match(expected_artist, found_artist, threshold)
|
artist_match = self.is_fuzzy_match(expected_artist, found_artist, threshold)
|
||||||
album_match = self.is_fuzzy_match(expected_album, found_album, threshold) if expected_album else True
|
album_match = (
|
||||||
|
self.is_fuzzy_match(expected_album, found_album, threshold)
|
||||||
|
if expected_album
|
||||||
|
else True
|
||||||
|
)
|
||||||
title_match = self.is_fuzzy_match(expected_title, found_title, threshold)
|
title_match = self.is_fuzzy_match(expected_title, found_title, threshold)
|
||||||
return artist_match and album_match and title_match
|
return artist_match and album_match and title_match
|
||||||
|
|
||||||
@@ -166,7 +198,9 @@ class SRUtil:
|
|||||||
deduped[norm] = entry
|
deduped[norm] = entry
|
||||||
return list(deduped.values())
|
return list(deduped.values())
|
||||||
|
|
||||||
def group_artists_by_name(self, entries: list[dict], query: Optional[str] = None) -> list[dict]:
|
def group_artists_by_name(
|
||||||
|
self, entries: list[dict], query: Optional[str] = None
|
||||||
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Group artist entries by normalized display name and pick a primary candidate per name.
|
Group artist entries by normalized display name and pick a primary candidate per name.
|
||||||
|
|
||||||
@@ -199,10 +233,15 @@ class SRUtil:
|
|||||||
score = 0.0
|
score = 0.0
|
||||||
if query:
|
if query:
|
||||||
try:
|
try:
|
||||||
if it.get("artist", "").strip().lower() == query.strip().lower():
|
if (
|
||||||
|
it.get("artist", "").strip().lower()
|
||||||
|
== query.strip().lower()
|
||||||
|
):
|
||||||
score += 1000.0
|
score += 1000.0
|
||||||
else:
|
else:
|
||||||
score += float(fuzz.token_set_ratio(query, it.get("artist", "")))
|
score += float(
|
||||||
|
fuzz.token_set_ratio(query, it.get("artist", ""))
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
score += 0.0
|
score += 0.0
|
||||||
# add small weight for popularity if present
|
# add small weight for popularity if present
|
||||||
@@ -216,12 +255,14 @@ class SRUtil:
|
|||||||
primary = scored[0][1]
|
primary = scored[0][1]
|
||||||
alternatives = [it for _, it in scored[1:]]
|
alternatives = [it for _, it in scored[1:]]
|
||||||
|
|
||||||
out.append({
|
out.append(
|
||||||
"artist": primary.get("artist"),
|
{
|
||||||
"id": primary.get("id"),
|
"artist": primary.get("artist"),
|
||||||
"popularity": primary.get("popularity"),
|
"id": primary.get("id"),
|
||||||
"alternatives": alternatives,
|
"popularity": primary.get("popularity"),
|
||||||
})
|
"alternatives": alternatives,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -230,14 +271,16 @@ class SRUtil:
|
|||||||
return None
|
return None
|
||||||
m, s = divmod(seconds, 60)
|
m, s = divmod(seconds, 60)
|
||||||
return f"{m}:{s:02}"
|
return f"{m}:{s:02}"
|
||||||
|
|
||||||
def _get_tidal_cover_url(self, uuid, size):
|
def _get_tidal_cover_url(self, uuid, size):
|
||||||
"""Generate a tidal cover url.
|
"""Generate a tidal cover url.
|
||||||
|
|
||||||
:param uuid: VALID uuid string
|
:param uuid: VALID uuid string
|
||||||
:param size:
|
:param size:
|
||||||
"""
|
"""
|
||||||
TIDAL_COVER_URL = "https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg"
|
TIDAL_COVER_URL = (
|
||||||
|
"https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg"
|
||||||
|
)
|
||||||
possibles = (80, 160, 320, 640, 1280)
|
possibles = (80, 160, 320, 640, 1280)
|
||||||
assert size in possibles, f"size must be in {possibles}"
|
assert size in possibles, f"size must be in {possibles}"
|
||||||
return TIDAL_COVER_URL.format(
|
return TIDAL_COVER_URL.format(
|
||||||
@@ -246,8 +289,6 @@ class SRUtil:
|
|||||||
width=size,
|
width=size,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def combine_album_track_metadata(
|
def combine_album_track_metadata(
|
||||||
self, album_json: dict | None, track_json: dict
|
self, album_json: dict | None, track_json: dict
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -288,10 +329,14 @@ class SRUtil:
|
|||||||
"peak": track_json.get("peak"),
|
"peak": track_json.get("peak"),
|
||||||
"lyrics": track_json.get("lyrics"),
|
"lyrics": track_json.get("lyrics"),
|
||||||
"track_copyright": track_json.get("copyright"),
|
"track_copyright": track_json.get("copyright"),
|
||||||
"cover_id": track_json.get("album", {}).get("cover") or album_json.get("cover"),
|
"cover_id": track_json.get("album", {}).get("cover")
|
||||||
|
or album_json.get("cover"),
|
||||||
"cover_url": (
|
"cover_url": (
|
||||||
f"https://resources.tidal.com/images/{track_json.get('album', {}).get('cover', album_json.get('cover'))}/1280x1280.jpg"
|
f"https://resources.tidal.com/images/{track_json.get('album', {}).get('cover', album_json.get('cover'))}/1280x1280.jpg"
|
||||||
if (track_json.get("album", {}).get("cover") or album_json.get("cover"))
|
if (
|
||||||
|
track_json.get("album", {}).get("cover")
|
||||||
|
or album_json.get("cover")
|
||||||
|
)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -299,7 +344,6 @@ class SRUtil:
|
|||||||
|
|
||||||
return combined
|
return combined
|
||||||
|
|
||||||
|
|
||||||
def combine_album_with_all_tracks(
|
def combine_album_with_all_tracks(
|
||||||
self, album_json: dict[str, Any]
|
self, album_json: dict[str, Any]
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
@@ -309,7 +353,9 @@ class SRUtil:
|
|||||||
for t in album_json.get("tracks", [])
|
for t in album_json.get("tracks", [])
|
||||||
]
|
]
|
||||||
|
|
||||||
async def get_artists_by_name(self, artist_name: str, group: bool = False) -> Optional[list]:
|
async def get_artists_by_name(
|
||||||
|
self, artist_name: str, group: bool = False
|
||||||
|
) -> Optional[list]:
|
||||||
"""Get artist(s) by name.
|
"""Get artist(s) by name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -324,7 +370,12 @@ class SRUtil:
|
|||||||
delay = 1.0
|
delay = 1.0
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
artists = await self._safe_api_call(self.streamrip_client.search, media_type="artist", query=artist_name, retries=3)
|
artists = await self._safe_api_call(
|
||||||
|
self.streamrip_client.search,
|
||||||
|
media_type="artist",
|
||||||
|
query=artist_name,
|
||||||
|
retries=3,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = str(e)
|
msg = str(e)
|
||||||
@@ -344,7 +395,9 @@ class SRUtil:
|
|||||||
artists_page = artists[0] if len(artists) > 0 else {}
|
artists_page = artists[0] if len(artists) > 0 else {}
|
||||||
else:
|
else:
|
||||||
artists_page = artists
|
artists_page = artists
|
||||||
artists_items = artists_page.get("items", []) if isinstance(artists_page, dict) else []
|
artists_items = (
|
||||||
|
artists_page.get("items", []) if isinstance(artists_page, dict) else []
|
||||||
|
)
|
||||||
if not artists_items:
|
if not artists_items:
|
||||||
return None
|
return None
|
||||||
artists_out = [
|
artists_out = [
|
||||||
@@ -365,13 +418,19 @@ class SRUtil:
|
|||||||
async def get_albums_by_artist_id(self, artist_id: int) -> Optional[list | dict]:
|
async def get_albums_by_artist_id(self, artist_id: int) -> Optional[list | dict]:
|
||||||
"""Get albums by artist ID. Retry login only on authentication failure. Rate limit and retry on 400/429."""
|
"""Get albums by artist ID. Retry login only on authentication failure. Rate limit and retry on 400/429."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
artist_id_str: str = str(artist_id)
|
artist_id_str: str = str(artist_id)
|
||||||
albums_out: list[dict] = []
|
albums_out: list[dict] = []
|
||||||
max_retries = 4
|
max_retries = 4
|
||||||
delay = 1.0
|
delay = 1.0
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, artist_id_str, "artist", retries=3)
|
metadata = await self._safe_api_call(
|
||||||
|
self.streamrip_client.get_metadata,
|
||||||
|
artist_id_str,
|
||||||
|
"artist",
|
||||||
|
retries=3,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = str(e)
|
msg = str(e)
|
||||||
@@ -397,10 +456,10 @@ class SRUtil:
|
|||||||
if "title" in album and "id" in album and "artists" in album
|
if "title" in album and "id" in album and "artists" in album
|
||||||
]
|
]
|
||||||
return albums_out
|
return albums_out
|
||||||
|
|
||||||
async def get_album_by_name(self, artist: str, album: str) -> Optional[dict]:
|
async def get_album_by_name(self, artist: str, album: str) -> Optional[dict]:
|
||||||
"""Get album by artist and album name using artist ID and fuzzy matching. Try first 8 chars, then 12 if no match. Notify on success."""
|
"""Get album by artist and album name using artist ID and fuzzy matching. Try first 8 chars, then 12 if no match. Notify on success."""
|
||||||
# Notification moved to add_cover_art.py as requested
|
# Notification moved to add_cover_art.py as requested
|
||||||
for trunc in (8, 12):
|
for trunc in (8, 12):
|
||||||
search_artist = artist[:trunc]
|
search_artist = artist[:trunc]
|
||||||
artists = await self.get_artists_by_name(search_artist)
|
artists = await self.get_artists_by_name(search_artist)
|
||||||
@@ -429,15 +488,22 @@ class SRUtil:
|
|||||||
if best_album and best_album_score >= 85:
|
if best_album and best_album_score >= 85:
|
||||||
return best_album
|
return best_album
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_cover_by_album_id(self, album_id: int, size: int = 640) -> Optional[str]:
|
async def get_cover_by_album_id(
|
||||||
|
self, album_id: int, size: int = 640
|
||||||
|
) -> Optional[str]:
|
||||||
"""Get cover URL by album ID. Retry login only on authentication failure."""
|
"""Get cover URL by album ID. Retry login only on authentication failure."""
|
||||||
if size not in [80, 160, 320, 640, 1280]:
|
if size not in [80, 160, 320, 640, 1280]:
|
||||||
return None
|
return None
|
||||||
album_id_str: str = str(album_id)
|
album_id_str: str = str(album_id)
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, item_id=album_id_str, media_type="album", retries=2)
|
metadata = await self._safe_api_call(
|
||||||
|
self.streamrip_client.get_metadata,
|
||||||
|
item_id=album_id_str,
|
||||||
|
media_type="album",
|
||||||
|
retries=2,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
if attempt == 1:
|
if attempt == 1:
|
||||||
@@ -452,7 +518,6 @@ class SRUtil:
|
|||||||
cover_url = self._get_tidal_cover_url(cover_id, size)
|
cover_url = self._get_tidal_cover_url(cover_id, size)
|
||||||
return cover_url
|
return cover_url
|
||||||
|
|
||||||
|
|
||||||
async def get_tracks_by_album_id(
|
async def get_tracks_by_album_id(
|
||||||
self, album_id: int, quality: str = "FLAC"
|
self, album_id: int, quality: str = "FLAC"
|
||||||
) -> Optional[list | dict]:
|
) -> Optional[list | dict]:
|
||||||
@@ -464,7 +529,12 @@ class SRUtil:
|
|||||||
"""
|
"""
|
||||||
album_id_str = str(album_id)
|
album_id_str = str(album_id)
|
||||||
try:
|
try:
|
||||||
metadata = await self._safe_api_call(self.streamrip_client.get_metadata, item_id=album_id_str, media_type="album", retries=2)
|
metadata = await self._safe_api_call(
|
||||||
|
self.streamrip_client.get_metadata,
|
||||||
|
item_id=album_id_str,
|
||||||
|
media_type="album",
|
||||||
|
retries=2,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning("get_tracks_by_album_id failed: %s", e)
|
logging.warning("get_tracks_by_album_id failed: %s", e)
|
||||||
return None
|
return None
|
||||||
@@ -486,7 +556,9 @@ class SRUtil:
|
|||||||
]
|
]
|
||||||
return tracks_out
|
return tracks_out
|
||||||
|
|
||||||
async def get_tracks_by_artist_song(self, artist: str, song: str, n: int = 0) -> Optional[list]:
|
async def get_tracks_by_artist_song(
|
||||||
|
self, artist: str, song: str, n: int = 0
|
||||||
|
) -> Optional[list]:
|
||||||
"""Get track by artist and song name
|
"""Get track by artist and song name
|
||||||
Args:
|
Args:
|
||||||
artist (str): The name of the artist.
|
artist (str): The name of the artist.
|
||||||
@@ -496,9 +568,18 @@ class SRUtil:
|
|||||||
TODO: Reimplement using StreamRip
|
TODO: Reimplement using StreamRip
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
search_res = await self._safe_api_call(self.streamrip_client.search, media_type="track", query=f"{artist} - {song}", retries=3)
|
search_res = await self._safe_api_call(
|
||||||
|
self.streamrip_client.search,
|
||||||
|
media_type="track",
|
||||||
|
query=f"{artist} - {song}",
|
||||||
|
retries=3,
|
||||||
|
)
|
||||||
logging.critical("Result: %s", search_res)
|
logging.critical("Result: %s", search_res)
|
||||||
return search_res[0].get('items') if search_res and isinstance(search_res, list) else []
|
return (
|
||||||
|
search_res[0].get("items")
|
||||||
|
if search_res and isinstance(search_res, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
logging.critical("Search Exception: %s", str(e))
|
logging.critical("Search Exception: %s", str(e))
|
||||||
@@ -529,10 +610,15 @@ class SRUtil:
|
|||||||
quality_int = 1
|
quality_int = 1
|
||||||
track_id_str: str = str(track_id)
|
track_id_str: str = str(track_id)
|
||||||
|
|
||||||
# Ensure client is logged in via safe call when needed inside _safe_api_call
|
# Ensure client is logged in via safe call when needed inside _safe_api_call
|
||||||
try:
|
try:
|
||||||
logging.critical("Using quality_int: %s", quality_int)
|
logging.critical("Using quality_int: %s", quality_int)
|
||||||
track = await self._safe_api_call(self.streamrip_client.get_downloadable, track_id=track_id_str, quality=quality_int, retries=3)
|
track = await self._safe_api_call(
|
||||||
|
self.streamrip_client.get_downloadable,
|
||||||
|
track_id=track_id_str,
|
||||||
|
quality=quality_int,
|
||||||
|
retries=3,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning("get_stream_url_by_track_id failed: %s", e)
|
logging.warning("get_stream_url_by_track_id failed: %s", e)
|
||||||
return None
|
return None
|
||||||
@@ -557,7 +643,7 @@ class SRUtil:
|
|||||||
metadata = await self.rate_limited_request(
|
metadata = await self.rate_limited_request(
|
||||||
self.streamrip_client.get_metadata, str(track_id), "track"
|
self.streamrip_client.get_metadata, str(track_id), "track"
|
||||||
)
|
)
|
||||||
|
|
||||||
album_id = metadata.get("album", {}).get("id")
|
album_id = metadata.get("album", {}).get("id")
|
||||||
album_metadata = None
|
album_metadata = None
|
||||||
|
|
||||||
@@ -567,7 +653,11 @@ class SRUtil:
|
|||||||
album_metadata = self.METADATA_ALBUM_CACHE[album_id]
|
album_metadata = self.METADATA_ALBUM_CACHE[album_id]
|
||||||
else:
|
else:
|
||||||
album_metadata = await self.rate_limited_request(
|
album_metadata = await self.rate_limited_request(
|
||||||
lambda i, t: self._safe_api_call(self.streamrip_client.get_metadata, i, t, retries=2), album_id, "album"
|
lambda i, t: self._safe_api_call(
|
||||||
|
self.streamrip_client.get_metadata, i, t, retries=2
|
||||||
|
),
|
||||||
|
album_id,
|
||||||
|
"album",
|
||||||
)
|
)
|
||||||
if not album_metadata:
|
if not album_metadata:
|
||||||
return None
|
return None
|
||||||
@@ -611,11 +701,12 @@ class SRUtil:
|
|||||||
self.MAX_METADATA_RETRIES,
|
self.MAX_METADATA_RETRIES,
|
||||||
)
|
)
|
||||||
# Raise a specific exception so callers can react (e.g. notify)
|
# Raise a specific exception so callers can react (e.g. notify)
|
||||||
raise MetadataFetchError(f"Metadata fetch failed permanently for track {track_id} after {self.MAX_METADATA_RETRIES} attempts: {e}")
|
raise MetadataFetchError(
|
||||||
|
f"Metadata fetch failed permanently for track {track_id} after {self.MAX_METADATA_RETRIES} attempts: {e}"
|
||||||
|
)
|
||||||
# If we reach here without returning, raise a generic metadata error
|
# If we reach here without returning, raise a generic metadata error
|
||||||
raise MetadataFetchError(f"Metadata fetch failed for track {track_id}")
|
raise MetadataFetchError(f"Metadata fetch failed for track {track_id}")
|
||||||
|
|
||||||
|
|
||||||
async def download(self, track_id: int, quality: str = "LOSSLESS") -> bool | str:
|
async def download(self, track_id: int, quality: str = "LOSSLESS") -> bool | str:
|
||||||
"""Download track
|
"""Download track
|
||||||
Args:
|
Args:
|
||||||
|
Reference in New Issue
Block a user