spots-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spots_cli/__init__.py +125 -0
- spots_cli/app/__init__.py +5 -0
- spots_cli/app/domain_resolver.py +157 -0
- spots_cli/app/downloader.py +78 -0
- spots_cli/app/media_resolver.py +180 -0
- spots_cli/app/spotify_playlist_compilation.py +251 -0
- spots_cli/app/youtube_user_playlist.py +215 -0
- spots_cli/bootstrap/__init__.py +1 -0
- spots_cli/bootstrap/container.py +230 -0
- spots_cli/cli.py +42 -0
- spots_cli/clients/__init__.py +5 -0
- spots_cli/clients/deezer.py +80 -0
- spots_cli/clients/secrets_manager.py +25 -0
- spots_cli/clients/spotify.py +79 -0
- spots_cli/clients/youtube.py +66 -0
- spots_cli/clients/ytdl.py +72 -0
- spots_cli/config/__init__.py +0 -0
- spots_cli/core/__init__.py +6 -0
- spots_cli/core/history_manager.py +39 -0
- spots_cli/core/lyrics_finder.py +58 -0
- spots_cli/core/pattern_matcher.py +226 -0
- spots_cli/core/video_converter.py +146 -0
- spots_cli/core/web_scraper.py +82 -0
- spots_cli/core/youtube_extractor.py +162 -0
- spots_cli/engine/__init__.py +3 -0
- spots_cli/engine/file_storage.py +347 -0
- spots_cli/engine/persistence_model.py +3 -0
- spots_cli/engine/retry.py +34 -0
- spots_cli/integrations/__init__.py +1 -0
- spots_cli/integrations/spotify_user_playlist_modify.py +47 -0
- spots_cli/models/__init__.py +24 -0
- spots_cli/models/artist_metadata.py +23 -0
- spots_cli/models/errors.py +69 -0
- spots_cli/models/helper_models.py +25 -0
- spots_cli/models/media_resource.py +20 -0
- spots_cli/models/metadata.py +36 -0
- spots_cli/models/metadata_provider.py +52 -0
- spots_cli/models/playlist_info.py +27 -0
- spots_cli/models/search_provider.py +65 -0
- spots_cli/models/sentinel.py +4 -0
- spots_cli/models/yt_video_info.py +29 -0
- spots_cli/scripts/__init__.py +2 -0
- spots_cli/scripts/add_to_history.py +45 -0
- spots_cli/scripts/bootstrap.py +92 -0
- spots_cli/scripts/duplicate_songs_remover.py +54 -0
- spots_cli/services/__init__.py +8 -0
- spots_cli/services/deezer_metadata_service.py +108 -0
- spots_cli/services/deezer_search_service.py +132 -0
- spots_cli/services/provider_search_container.py +67 -0
- spots_cli/services/providers_metadata.py +22 -0
- spots_cli/services/spotify_metadata_service.py +151 -0
- spots_cli/services/spotify_search_service.py +228 -0
- spots_cli/services/youtube_metadata_service.py +73 -0
- spots_cli/services/youtube_search_service.py +211 -0
- spots_cli/setup_env.py +118 -0
- spots_cli/utils/__init__.py +4 -0
- spots_cli/utils/config_dir.py +17 -0
- spots_cli/utils/detect_browser.py +30 -0
- spots_cli/utils/fetch.py +73 -0
- spots_cli/utils/search_fallbacks.py +21 -0
- spots_cli-0.1.0.dist-info/METADATA +146 -0
- spots_cli-0.1.0.dist-info/RECORD +65 -0
- spots_cli-0.1.0.dist-info/WHEEL +4 -0
- spots_cli-0.1.0.dist-info/entry_points.txt +2 -0
- spots_cli-0.1.0.dist-info/licenses/LICENSE +675 -0
spots_cli/cli.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from os.path import exists
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
from typing import Callable
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from spots_cli import setup_env, Spots
|
|
7
|
+
from spots_cli.scripts import remove_duplicate_songs, update_history
|
|
8
|
+
|
|
9
|
+
logger = getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
app = typer.Typer()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def setup():
|
|
16
|
+
setup_env.main()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def download(query: str):
|
|
21
|
+
app = Spots()
|
|
22
|
+
app.download(query)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def migrate_likes():
|
|
27
|
+
app = Spots()
|
|
28
|
+
app.transfer_spotify_likes()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def add_to_history(path_to_music: str):
|
|
33
|
+
update_history(path_to_music)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def remove_duplicates(path_to_music: str):
|
|
38
|
+
remove_duplicate_songs(path_to_music)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
app()
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from spots_cli.clients.deezer import DeezerClient
|
|
2
|
+
from spots_cli.clients.ytdl import YtDlpClient
|
|
3
|
+
from spots_cli.clients.spotify import SpotifyClient
|
|
4
|
+
from spots_cli.clients.youtube import YouTubeApiClient
|
|
5
|
+
from spots_cli.clients.secrets_manager import SecretsManager
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
from requests.exceptions import HTTPError
|
|
3
|
+
from tenacity import stop_after_delay
|
|
4
|
+
from typing import Any, TypedDict, cast
|
|
5
|
+
|
|
6
|
+
from spots_cli.engine import retry
|
|
7
|
+
from spots_cli.models import SongNotFound, InvalidURL, ArtistInfo
|
|
8
|
+
from spots_cli.utils import FetchResponseFailure, FetchResponseSuccess, fetch_data
|
|
9
|
+
|
|
10
|
+
logger = getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeezerResponseBase(TypedDict):
|
|
14
|
+
total: int
|
|
15
|
+
data: list[dict[str, Any]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeezerClient:
|
|
19
|
+
|
|
20
|
+
@retry(stop=stop_after_delay(60))
|
|
21
|
+
def _get_resource_by_url(self, resource_link: str) -> dict[str, Any]:
|
|
22
|
+
base_url = "https://api.deezer.com/"
|
|
23
|
+
if not resource_link.startswith(base_url):
|
|
24
|
+
raise InvalidURL(resource_link)
|
|
25
|
+
|
|
26
|
+
resource_response = fetch_data(resource_link)
|
|
27
|
+
|
|
28
|
+
if isinstance(resource_response, FetchResponseFailure):
|
|
29
|
+
resource_error = cast(FetchResponseFailure, resource_response)
|
|
30
|
+
error_msg = resource_error.error
|
|
31
|
+
|
|
32
|
+
if error_msg == "no data":
|
|
33
|
+
raise SongNotFound(f"Resource not found: {resource_link}")
|
|
34
|
+
else:
|
|
35
|
+
raise Exception(f"Unknown error occured: {error_msg}")
|
|
36
|
+
|
|
37
|
+
resource_success = cast(FetchResponseSuccess, resource_response)
|
|
38
|
+
resource_payload = cast(dict[str, Any], resource_success.data)
|
|
39
|
+
return resource_payload
|
|
40
|
+
|
|
41
|
+
def artist_top_tracks(
|
|
42
|
+
self, artist_id: int
|
|
43
|
+
) -> tuple[ArtistInfo, list[dict[str, Any]]]:
|
|
44
|
+
base_url = f"https://api.deezer.com/artist/{artist_id}"
|
|
45
|
+
top_tracks_res = self._get_resource_by_url(f"{base_url}/top")
|
|
46
|
+
|
|
47
|
+
artist_info = self._get_resource_by_url(base_url)
|
|
48
|
+
|
|
49
|
+
top_tracks = cast(list[dict[str, Any]], top_tracks_res["data"])
|
|
50
|
+
return (
|
|
51
|
+
ArtistInfo(
|
|
52
|
+
name=artist_info["name"],
|
|
53
|
+
cover=artist_info["picture"],
|
|
54
|
+
id=artist_info["id"],
|
|
55
|
+
),
|
|
56
|
+
top_tracks,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def track(self, track_id: int) -> dict[str, Any]:
|
|
60
|
+
track_url = f"https://api.deezer.com/track/{track_id}"
|
|
61
|
+
return self._get_resource_by_url(track_url)
|
|
62
|
+
|
|
63
|
+
@retry(stop=stop_after_delay(60))
|
|
64
|
+
def search(self, query: str) -> str:
|
|
65
|
+
try:
|
|
66
|
+
logger.debug(f"Searching for {query} on Deezer")
|
|
67
|
+
search_url = f"https://api.deezer.com/search?q={query}&limit=1"
|
|
68
|
+
res = fetch_data(search_url)
|
|
69
|
+
if res.success and res.data is not None:
|
|
70
|
+
res_json = cast(DeezerResponseBase, res.data)
|
|
71
|
+
if res_json["total"] == 0:
|
|
72
|
+
logger.error("No results found")
|
|
73
|
+
raise SongNotFound(query)
|
|
74
|
+
|
|
75
|
+
return str(res_json["data"][0]["id"])
|
|
76
|
+
else:
|
|
77
|
+
raise SongNotFound(query)
|
|
78
|
+
except HTTPError as e:
|
|
79
|
+
logger.error("An error occurred", e)
|
|
80
|
+
raise SongNotFound(query)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from os import getenv, environ
|
|
2
|
+
from typing import Literal, overload
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
from spots_cli.utils import get_config_path
|
|
6
|
+
|
|
7
|
+
load_dotenv(dotenv_path=get_config_path() / ".env")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SecretsManager:
|
|
11
|
+
@overload
|
|
12
|
+
@staticmethod
|
|
13
|
+
def read(*, key: str, alt: str) -> str: ...
|
|
14
|
+
|
|
15
|
+
@overload
|
|
16
|
+
@staticmethod
|
|
17
|
+
def read(*, key: str, alt: Literal[None] = None) -> str | None: ...
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def read(*, key, alt=None):
|
|
21
|
+
return getenv(key) or alt
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def write(*, key: str, value: str):
|
|
25
|
+
environ[key] = value
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from dotenv import load_dotenv
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
from os import getenv
|
|
4
|
+
from spotipy.exceptions import SpotifyException
|
|
5
|
+
from spotipy.oauth2 import SpotifyClientCredentials
|
|
6
|
+
from spotipy import Spotify
|
|
7
|
+
from spotipy.util import prompt_for_user_token
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
logger = getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
DEFAULT_USER_SCOPE = "user-library-read"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpotifyClient:
|
|
17
|
+
"""A class to retrieve metadata for a spotify track, album or playlist
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
track_url (str): The spotify url to be processed
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
"""Initializes a spotify.spotify"""
|
|
25
|
+
|
|
26
|
+
auth_manager = SpotifyClientCredentials()
|
|
27
|
+
self.client = Spotify(auth_manager=auth_manager)
|
|
28
|
+
|
|
29
|
+
def get_user(self) -> str | None:
|
|
30
|
+
"""Retrieves the current user
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
str: The user's display name
|
|
34
|
+
"""
|
|
35
|
+
if not getenv("username"):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
user = None
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
self.signin()
|
|
42
|
+
user = self.client.current_user()
|
|
43
|
+
except SpotifyException as e:
|
|
44
|
+
logger.error(f"An error occured while signin user in::\n\t{e.msg}")
|
|
45
|
+
|
|
46
|
+
if user:
|
|
47
|
+
return user["display_name"]
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def signin(self) -> None:
|
|
52
|
+
"""signs into a user's spotify account"""
|
|
53
|
+
# sign user in if username present in env
|
|
54
|
+
username = getenv("username")
|
|
55
|
+
if not username:
|
|
56
|
+
logger.info("Set Spotify `username` in `.env`")
|
|
57
|
+
raise Exception("No `username` declared in environment variables")
|
|
58
|
+
|
|
59
|
+
scope = getenv("scope") or DEFAULT_USER_SCOPE
|
|
60
|
+
|
|
61
|
+
logger.info(f"Signing in to {username} on Spotify with scope: {scope}")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# throws error if user not signed in
|
|
65
|
+
self.client.current_user()
|
|
66
|
+
return
|
|
67
|
+
except SpotifyException as e:
|
|
68
|
+
if e.code == -1:
|
|
69
|
+
token = prompt_for_user_token(username, scope)
|
|
70
|
+
else:
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
if token:
|
|
74
|
+
logger.info("Signed in")
|
|
75
|
+
self.client = Spotify(auth=token)
|
|
76
|
+
|
|
77
|
+
return
|
|
78
|
+
else:
|
|
79
|
+
raise Exception("Can't get token for", username)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pickle
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
from os.path import exists
|
|
5
|
+
from googleapiclient.discovery import build
|
|
6
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
7
|
+
from google.auth.transport.requests import Request
|
|
8
|
+
|
|
9
|
+
from spots_cli.utils import get_config_path
|
|
10
|
+
|
|
11
|
+
SCOPES = ["https://www.googleapis.com/auth/youtube.force-ssl"]
|
|
12
|
+
config_dir = get_config_path()
|
|
13
|
+
PICKLE_TOKEN = f"{config_dir}/token.pickle"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class YouTubeApiClient:
|
|
17
|
+
"""
|
|
18
|
+
Service for interacting with the YouTube API
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
@service (Unknown): The youtube api service.
|
|
22
|
+
|
|
23
|
+
Methods
|
|
24
|
+
@like_video
|
|
25
|
+
|
|
26
|
+
This service handles:
|
|
27
|
+
- Adding a video to YouTube likes library
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""
|
|
32
|
+
Initialize the YouTubeApiClient.
|
|
33
|
+
|
|
34
|
+
Sets up the api client.
|
|
35
|
+
"""
|
|
36
|
+
self.secrets_file = str(files("spots_cli.config").joinpath("client_secrets.json"))
|
|
37
|
+
if not exists(self.secrets_file):
|
|
38
|
+
raise RuntimeError("Provide path to cookies for YouTube account access.")
|
|
39
|
+
|
|
40
|
+
self.service = self._build_service()
|
|
41
|
+
|
|
42
|
+
def _build_service(self):
|
|
43
|
+
"""
|
|
44
|
+
Build service for interacting with YouTube API
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Unknown: YouTube client.
|
|
48
|
+
"""
|
|
49
|
+
creds = None
|
|
50
|
+
if os.path.exists(PICKLE_TOKEN):
|
|
51
|
+
with open(PICKLE_TOKEN, "rb") as f:
|
|
52
|
+
creds = pickle.load(f)
|
|
53
|
+
|
|
54
|
+
if not creds or not creds.valid:
|
|
55
|
+
if creds and creds.expired and creds.refresh_token:
|
|
56
|
+
creds.refresh(Request())
|
|
57
|
+
else:
|
|
58
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
59
|
+
self.secrets_file, SCOPES
|
|
60
|
+
)
|
|
61
|
+
creds = flow.run_local_server(port=0)
|
|
62
|
+
|
|
63
|
+
with open(PICKLE_TOKEN, "wb") as f:
|
|
64
|
+
pickle.dump(creds, f)
|
|
65
|
+
|
|
66
|
+
return build("youtube", "v3", credentials=creds)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from yt_dlp import YoutubeDL
|
|
6
|
+
|
|
7
|
+
from spots_cli.utils import get_config_path, detect_browser
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from yt_dlp import _Params
|
|
11
|
+
from spots_cli.clients import SecretsManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class YtDlpClient:
|
|
18
|
+
"""
|
|
19
|
+
A service for interacting with the YouTube DL library.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
@ydl (YoutubeDL): YouTube DL client.
|
|
23
|
+
@video_to_mp3 (VideoToMp3Service): Service for post-video-downloaded processing.
|
|
24
|
+
@directory_path (str, optional): The directory to save the audio. Defaults to ''.
|
|
25
|
+
|
|
26
|
+
Methods:
|
|
27
|
+
@download_youtube_video
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
extra_options: _Params = {},
|
|
34
|
+
secrets: SecretsManager,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the YtDlpClient.
|
|
38
|
+
|
|
39
|
+
Sets up youtube clients.
|
|
40
|
+
"""
|
|
41
|
+
self.default_options: _Params = {
|
|
42
|
+
"js_runtimes": {"node": {}},
|
|
43
|
+
"remote_components": {"ejs:github"},
|
|
44
|
+
"format": "bestaudio/best",
|
|
45
|
+
"outtmpl": "%(title)s.%(ext)s",
|
|
46
|
+
"quiet": True,
|
|
47
|
+
"noplaylist": True,
|
|
48
|
+
"cachedir": str(get_config_path() / "yt_cache"),
|
|
49
|
+
}
|
|
50
|
+
self.client_options: _Params = self.default_options | extra_options
|
|
51
|
+
|
|
52
|
+
browser = detect_browser()
|
|
53
|
+
if browser:
|
|
54
|
+
self.client_options["cookiesfrombrowser"] = browser
|
|
55
|
+
cookies_path = secrets.read(key="YOUTUBE_COOKIES_PATH")
|
|
56
|
+
if cookies_path:
|
|
57
|
+
self.client_options["cookiefile"] = cookies_path
|
|
58
|
+
|
|
59
|
+
self.client = YoutubeDL(self.client_options)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def options(self) -> _Params:
|
|
63
|
+
return self.client_options
|
|
64
|
+
|
|
65
|
+
@options.setter
|
|
66
|
+
def options(self, extra_options: _Params) -> None:
|
|
67
|
+
self.client_options = self.client_options | extra_options
|
|
68
|
+
self.client = YoutubeDL(self.client_options)
|
|
69
|
+
|
|
70
|
+
def reset_options(self) -> None:
|
|
71
|
+
self.client_options = self.default_options
|
|
72
|
+
self.client = YoutubeDL(self.default_options)
|
|
File without changes
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from spots_cli.core.web_scraper import WebScraper
|
|
2
|
+
from spots_cli.core.lyrics_finder import LyricsFinder
|
|
3
|
+
from spots_cli.core.history_manager import HistoryManager
|
|
4
|
+
from spots_cli.core.pattern_matcher import PatternMatcher
|
|
5
|
+
from spots_cli.core.video_converter import VideoConverter
|
|
6
|
+
from spots_cli.core.youtube_extractor import YouTubeExtractor
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from os import path
|
|
2
|
+
|
|
3
|
+
from spots_cli.utils import get_config_path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HistoryManager:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.__history_file = get_config_path() / ".spots_download_history.txt"
|
|
9
|
+
|
|
10
|
+
def history_file_exists(self):
|
|
11
|
+
if not path.isfile(self.__history_file):
|
|
12
|
+
with open(self.__history_file, "w", newline=""):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
def read(self, title: str) -> bool:
|
|
16
|
+
"""Checks if a title is in history
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
title (str, optional): The title to be checked.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
title_exists = False
|
|
23
|
+
|
|
24
|
+
with open(self.__history_file, "r") as file:
|
|
25
|
+
history = file.read().split("\n")
|
|
26
|
+
for song in history:
|
|
27
|
+
if title == song:
|
|
28
|
+
title_exists = True
|
|
29
|
+
|
|
30
|
+
return title_exists
|
|
31
|
+
|
|
32
|
+
def write(self, title: str):
|
|
33
|
+
"""Adds a downloaded song's title to history
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
title (str, optional): The title to be added.
|
|
37
|
+
"""
|
|
38
|
+
with open(self.__history_file, "a", newline="") as file:
|
|
39
|
+
file.write(f"{title}\n")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from lyricsgenius import Genius
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from spots_cli.clients import SecretsManager
|
|
9
|
+
from spots_cli.core import WebScraper
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
logger = getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LyricsFinder:
|
|
16
|
+
"""
|
|
17
|
+
A service for retrieving lyrics for a song
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
scraper (WebScrapeService): The web scraper client.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
genius: Genius api client.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, scraper: WebScraper, secrets_manager: SecretsManager):
|
|
27
|
+
self.secrets_manager = secrets_manager
|
|
28
|
+
self.scraper = scraper
|
|
29
|
+
genius_key = self.secrets_manager.read(key="lyricsgenius_key")
|
|
30
|
+
self.genius = Genius(genius_key) if genius_key else None
|
|
31
|
+
|
|
32
|
+
def get_lyrics(self, *, artist: str, title: str) -> str:
|
|
33
|
+
"""Retrieve lyrics using Genius. Falls back to using alternatives provided by the scraper.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
artist (str): The artist.
|
|
37
|
+
title (str): The title.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
str: The lyrics if found, else an empty string.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
if self.genius:
|
|
44
|
+
logger.debug("Searching for lyrics on Genius")
|
|
45
|
+
song = self.genius.search_song(title, artist)
|
|
46
|
+
|
|
47
|
+
if not song or "Verse" not in song.lyrics or title not in song.title:
|
|
48
|
+
logger.debug("Genius lyrics not found. Searching AZLyrics")
|
|
49
|
+
lyrics = self.scraper.scrape_azlyrics(artist=artist, title=title)
|
|
50
|
+
else:
|
|
51
|
+
lyrics = song.lyrics
|
|
52
|
+
else:
|
|
53
|
+
logger.debug("Genius API not available. Falling back to AZLyrics")
|
|
54
|
+
lyrics = self.scraper.scrape_azlyrics(artist=artist, title=title)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(e)
|
|
57
|
+
lyrics = ""
|
|
58
|
+
return lyrics
|