""" API for lrclib"""
import os
import warnings
from http import HTTPStatus
from typing import Any, Dict, Optional
import requests
from .cryptographic_challenge_solver import CryptoChallengeSolver
from .exceptions import (
APIError,
IncorrectPublishTokenError,
NotFoundError,
RateLimitError,
ServerError,
)
from .models import CryptographicChallenge, Lyrics, SearchResult
BASE_URL = "https://lrclib.net/api"
ENDPOINTS: Dict[str, str] = {
"get": "/get",
"get_cached": "/get-cached",
"get_by_id": "/get/{id}",
"search": "/search",
"publish": "/publish",
"request_challenge": "/request-challenge",
}
[docs]class LrcLibAPI:
"""
Create a new LrcLibAPI instance. You can optionally pass a custom \
base URL and a custom requests session.
.. note::
setting `user_agent` is not required, but it is recommended by LRCLIB.
Parameters
----------
user_agent : str
User agent to use for the requests
base_url : str, optional
Base URL to use for the requests
session : requests.Session, optional
Requests session to use for the requests
Raises
------
UserWarning
If user_agent is not set
Examples
--------
See the :doc:`examples/fetch_lyrics` section for usage examples.
"""
def __init__(
self,
user_agent: str,
base_url: "str | None" = None,
session: "requests.Session | None" = None,
):
self._base_url = base_url or BASE_URL
self.session = session or requests.Session()
if not user_agent:
warnings.warn(
"Missing user agent, please set it with the `user_agent`"
" argument",
UserWarning,
)
else:
self.session.headers.update({"User-Agent": user_agent})
def _make_request(
self,
method: str,
endpoint: str,
**kwargs: Any,
) -> requests.Response:
url = self._base_url + endpoint
try:
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
except requests.exceptions.HTTPError as exc:
response = exc.response # type: ignore
if response.status_code == HTTPStatus.NOT_FOUND:
raise NotFoundError(response) from exc
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
raise RateLimitError(response) from exc
if response.status_code == HTTPStatus.BAD_REQUEST:
raise IncorrectPublishTokenError(response) from exc
if 500 <= response.status_code < 600:
raise ServerError(response) from exc
raise APIError(response) from exc
return response
[docs] def get_lyrics( # pylint: disable=too-many-arguments
self,
track_name: str,
artist_name: str,
album_name: str,
duration: int,
cached: bool = False,
) -> Lyrics:
"""
Get lyrics from LRCLIB by track name, artist name, album name and \
duration.
.. note::
All parameters are required except `cached`.
Parameters
----------
track_name : str
Track name
artist_name : str
Artist name
album_name : str
Album name
duration : int
Duration of the track in seconds
cached : bool, optional
Whether to get cached lyrics or not, defaults to False
Returns
-------
Lyrics
Raises
------
NotFoundError
If no lyrics are found
APIError
If the request fails
"""
endpoint = ENDPOINTS["get_cached" if cached else "get"]
params = {
"track_name": track_name,
"artist_name": artist_name,
"album_name": album_name,
"duration": duration,
}
response = self._make_request("GET", endpoint, params=params)
return Lyrics.from_dict(response.json())
[docs] def get_lyrics_by_id(self, lrclib_id: "str | int") -> Lyrics:
"""
Get lyrics from LRCLIB by ID.
Parameters
----------
lrclib_id : str | int
ID of the lyrics
Returns
-------
Lyrics
Raises
------
NotFoundError
If no lyrics are found
APIError
If the request fails
"""
endpoint = ENDPOINTS["get_by_id"].format(id=lrclib_id)
response = self._make_request("GET", endpoint)
return Lyrics.from_dict(response.json())
[docs] def search_lyrics(
self,
query: "str | None" = None,
track_name: "str | None" = None,
artist_name: "str | None" = None,
album_name: "str | None" = None,
) -> SearchResult:
"""
Search lyrics on LRCLIB by query, track name, artist name and/or \
album name.
.. note::
Either `query` or `track_name` is required.
Parameters
----------
query : str, optional
Search query
track_name : str, optional
Track name
artist_name : str, optional
Artist name
album_name : str, optional
Album name
Returns
-------
SearchResult
Raises
------
APIError
If the request fails
"""
# either query or track_name is required
if not query and not track_name:
raise ValueError(
"Either query or track_name is required to search lyrics"
)
endpoint = ENDPOINTS["search"]
params = {
"q": query,
"track_name": track_name,
"artist_name": artist_name,
"album_name": album_name,
}
params = {k: v for k, v in params.items() if v is not None}
try:
response = self._make_request("GET", endpoint, params=params)
except NotFoundError:
return SearchResult([])
return SearchResult.from_list(response.json())
[docs] def request_challenge(self) -> CryptographicChallenge:
"""
Generate a pair of prefix and target strings for the \
cryptographic challenge. Each challenge has an \
expiration time of 5 minutes.
The challenge's solution is a nonce, which can be used \
to create a Publish Token for submitting lyrics to LRCLIB.
Returns
-------
CryptographicChallenge
See Also
--------
publish_lyrics : Submit lyrics to LRCLIB directly without \
using the `request_challenge` method
:obj:`~lrclib.cryptographic_challenge_solver` : Use one of the \
available solvers to solve the challenge
Raises
------
APIError
If the request fails
"""
endpoint = ENDPOINTS["request_challenge"]
try:
response = self._make_request("POST", endpoint)
except APIError as exc:
raise exc
return CryptographicChallenge.from_dict(response.json())
def _obtain_publish_token(self) -> str:
"""
Obtain a Publish Token for submitting lyrics to LRCLIB.
Returns
-------
publish_token : str
"""
num_threads = os.cpu_count() or 1
challenge = self.request_challenge()
nonce = CryptoChallengeSolver.solve(
challenge.prefix, challenge.target, num_threads=num_threads
)
return f"{challenge.prefix}:{nonce}"
[docs] def publish_lyrics( # pylint: disable=too-many-arguments
self,
track_name: str,
artist_name: str,
album_name: str,
duration: int,
plain_lyrics: Optional[str] = None,
synced_lyrics: Optional[str] = None,
publish_token: Optional[str] = None,
) -> Dict[str, Any]:
"""
Publish lyrics to LRCLIB. All parameters are required.
.. note::
If no lyrics are provided, the track will be marked as \
instrumental.
Parameters
----------
track_name : str
Track name
artist_name : str
Artist name
album_name : str
Album name
duration : int
Duration of the track in seconds
plain_lyrics : str, optional
Plain lyrics
synced_lyrics : str, optional
Synced lyrics
publish_token : str, optional
Publish token to use for publishing lyrics, if not provided, \
a new one will be generated
Returns
-------
Dict[str, Any]
Response from the API
Raises
------
IncorrectPublishTokenError
If the publish token is incorrect
APIError
If the request fails
"""
endpoint = ENDPOINTS["publish"]
if not publish_token:
publish_token = self._obtain_publish_token()
headers = {"X-Publish-Token": publish_token}
data = {
"trackName": track_name,
"artistName": artist_name,
"albumName": album_name,
"duration": duration,
"plainLyrics": plain_lyrics,
"syncedLyrics": synced_lyrics,
}
try:
response = self._make_request(
"POST", endpoint, headers=headers, json=data
)
return response.json()
except APIError as exc:
raise exc