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.
Files changed (65) hide show
  1. spots_cli/__init__.py +125 -0
  2. spots_cli/app/__init__.py +5 -0
  3. spots_cli/app/domain_resolver.py +157 -0
  4. spots_cli/app/downloader.py +78 -0
  5. spots_cli/app/media_resolver.py +180 -0
  6. spots_cli/app/spotify_playlist_compilation.py +251 -0
  7. spots_cli/app/youtube_user_playlist.py +215 -0
  8. spots_cli/bootstrap/__init__.py +1 -0
  9. spots_cli/bootstrap/container.py +230 -0
  10. spots_cli/cli.py +42 -0
  11. spots_cli/clients/__init__.py +5 -0
  12. spots_cli/clients/deezer.py +80 -0
  13. spots_cli/clients/secrets_manager.py +25 -0
  14. spots_cli/clients/spotify.py +79 -0
  15. spots_cli/clients/youtube.py +66 -0
  16. spots_cli/clients/ytdl.py +72 -0
  17. spots_cli/config/__init__.py +0 -0
  18. spots_cli/core/__init__.py +6 -0
  19. spots_cli/core/history_manager.py +39 -0
  20. spots_cli/core/lyrics_finder.py +58 -0
  21. spots_cli/core/pattern_matcher.py +226 -0
  22. spots_cli/core/video_converter.py +146 -0
  23. spots_cli/core/web_scraper.py +82 -0
  24. spots_cli/core/youtube_extractor.py +162 -0
  25. spots_cli/engine/__init__.py +3 -0
  26. spots_cli/engine/file_storage.py +347 -0
  27. spots_cli/engine/persistence_model.py +3 -0
  28. spots_cli/engine/retry.py +34 -0
  29. spots_cli/integrations/__init__.py +1 -0
  30. spots_cli/integrations/spotify_user_playlist_modify.py +47 -0
  31. spots_cli/models/__init__.py +24 -0
  32. spots_cli/models/artist_metadata.py +23 -0
  33. spots_cli/models/errors.py +69 -0
  34. spots_cli/models/helper_models.py +25 -0
  35. spots_cli/models/media_resource.py +20 -0
  36. spots_cli/models/metadata.py +36 -0
  37. spots_cli/models/metadata_provider.py +52 -0
  38. spots_cli/models/playlist_info.py +27 -0
  39. spots_cli/models/search_provider.py +65 -0
  40. spots_cli/models/sentinel.py +4 -0
  41. spots_cli/models/yt_video_info.py +29 -0
  42. spots_cli/scripts/__init__.py +2 -0
  43. spots_cli/scripts/add_to_history.py +45 -0
  44. spots_cli/scripts/bootstrap.py +92 -0
  45. spots_cli/scripts/duplicate_songs_remover.py +54 -0
  46. spots_cli/services/__init__.py +8 -0
  47. spots_cli/services/deezer_metadata_service.py +108 -0
  48. spots_cli/services/deezer_search_service.py +132 -0
  49. spots_cli/services/provider_search_container.py +67 -0
  50. spots_cli/services/providers_metadata.py +22 -0
  51. spots_cli/services/spotify_metadata_service.py +151 -0
  52. spots_cli/services/spotify_search_service.py +228 -0
  53. spots_cli/services/youtube_metadata_service.py +73 -0
  54. spots_cli/services/youtube_search_service.py +211 -0
  55. spots_cli/setup_env.py +118 -0
  56. spots_cli/utils/__init__.py +4 -0
  57. spots_cli/utils/config_dir.py +17 -0
  58. spots_cli/utils/detect_browser.py +30 -0
  59. spots_cli/utils/fetch.py +73 -0
  60. spots_cli/utils/search_fallbacks.py +21 -0
  61. spots_cli-0.1.0.dist-info/METADATA +146 -0
  62. spots_cli-0.1.0.dist-info/RECORD +65 -0
  63. spots_cli-0.1.0.dist-info/WHEEL +4 -0
  64. spots_cli-0.1.0.dist-info/entry_points.txt +2 -0
  65. spots_cli-0.1.0.dist-info/licenses/LICENSE +675 -0
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from logging import getLogger
5
+ from spotipy.exceptions import SpotifyException
6
+ from typing import Any, overload, TYPE_CHECKING
7
+
8
+ from spots_cli.engine import storage
9
+ from spots_cli.models import (
10
+ SongNotFound,
11
+ Metadata,
12
+ Sentinel,
13
+ MetadataProvider,
14
+ SpotifyUnavailableError,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from spots_cli.bootstrap.container import Core, Clients
19
+ from spots_cli.clients import SpotifyClient
20
+
21
+
22
+ logger = getLogger(__name__)
23
+
24
+
25
+ class SpotifyMetadataService(MetadataProvider):
26
+ def __init__(
27
+ self,
28
+ *,
29
+ clients: Clients,
30
+ core: Core,
31
+ ):
32
+ self.clients = clients
33
+ self.core = core
34
+
35
+ def _spotify(self) -> SpotifyClient:
36
+ if not self.clients.spotify:
37
+ raise SpotifyUnavailableError(
38
+ "Spotify client is not configured. Enable Spotify features in your environment."
39
+ )
40
+ return self.clients.spotify
41
+
42
+ @overload
43
+ def get(self, *, track_id: str, search_result: None = None) -> Metadata: ...
44
+
45
+ @overload
46
+ def get(
47
+ self, *, track_id: None = None, search_result: dict[str, Any]
48
+ ) -> Metadata: ...
49
+
50
+ def get(
51
+ self,
52
+ *,
53
+ track_id: str | None = None,
54
+ search_result: dict[str, Any] | None = None,
55
+ ) -> Metadata:
56
+ logger.debug("Searching for metadata on Spotify...")
57
+
58
+ if track_id is not None:
59
+ query_id = track_id
60
+ if search_result is not None:
61
+ query_id = search_result["id"]
62
+ else:
63
+ raise ValueError("Either track_id or search_result must be provided")
64
+
65
+ # check cache first
66
+ url = "https://open.spotify.com/track/" + query_id
67
+ cache = storage.get(query=url, query_type="metadata")
68
+ if isinstance(cache, Sentinel):
69
+ raise SongNotFound(query_id)
70
+ elif isinstance(cache, Metadata):
71
+ return cache
72
+
73
+ # search song if id provided
74
+ if track_id is not None:
75
+ try:
76
+ # retrieve track from spotify
77
+ track = self._spotify().client.track(track_id)
78
+
79
+ except SpotifyException as e:
80
+ if e.http_status == 401:
81
+ if "Invalid access token" in e.msg:
82
+ raise RuntimeError("Authentication Failed") from e
83
+ else:
84
+ self._spotify().signin()
85
+ track = self._spotify().client.track(track_id)
86
+ else:
87
+ logger.error(
88
+ f"Unexpected error occurred when retrieving Spotify metadata"
89
+ )
90
+ raise RuntimeError("Unhandled error in get") from e
91
+
92
+ if not track:
93
+ storage.new(query=url, result=Sentinel(), query_type="metadata")
94
+ raise SongNotFound(f"Spotify id: {track_id}")
95
+ elif search_result is not None:
96
+ track = search_result
97
+
98
+ album = track["album"]
99
+
100
+ # get track number
101
+ total_track = album["total_tracks"]
102
+ track_position = track["track_number"]
103
+ track_number = f"{track_position}/{total_track}"
104
+
105
+ # cover image
106
+ cover = album["images"][0]["url"]
107
+
108
+ # get release date
109
+ try:
110
+ release_date_str = album["release_date"]
111
+ release_date_obj = datetime.strptime(release_date_str, "%Y-%m-%d")
112
+ # release_date = release_date_obj.strftime("%Y")
113
+ release_date = release_date_obj.strftime("%Y-%m-%d")
114
+ except ValueError:
115
+ release_date = None
116
+
117
+ # get track name and artist
118
+ track_name = track["name"]
119
+
120
+ # artists
121
+ artist_list = [artist["name"] for artist in track["artists"]]
122
+ # remove featured artists from artists list
123
+ for artist in artist_list:
124
+ if artist.lower() in track_name.lower():
125
+ artist_list.remove(artist)
126
+
127
+ artist = ", ".join(artist_list)
128
+
129
+ track_url = track["external_urls"]["spotify"]
130
+ album_name = album["name"]
131
+
132
+ # get track lyrics
133
+ lyrics = self.core.lyrics.get_lyrics(title=track_name, artist=artist)
134
+
135
+ preview_url = track["preview_url"] or ""
136
+
137
+ first_artist = track["artists"][0]
138
+ metadata = Metadata(
139
+ track_name,
140
+ artist_id=first_artist["id"],
141
+ artist=artist,
142
+ link=track_url,
143
+ cover=cover,
144
+ tracknumber=track_number,
145
+ album=album_name,
146
+ lyrics=lyrics,
147
+ release_date=release_date,
148
+ preview_url=preview_url,
149
+ )
150
+
151
+ return metadata
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+ from logging import getLogger
3
+ from re import escape, search
4
+ from tenacity import stop_after_delay
5
+ from typing import Any, TYPE_CHECKING
6
+
7
+ from spots_cli.engine import retry
8
+ from spots_cli.models import (
9
+ SongNotFound,
10
+ Metadata,
11
+ PlaylistInfo,
12
+ SearchProvider,
13
+ Sentinel,
14
+ ArtistInfo,
15
+ SpotifyUnavailableError,
16
+ InvalidSearchFormat,
17
+ )
18
+ from spots_cli.utils import search_fallbacks
19
+
20
+ if TYPE_CHECKING:
21
+ from spotipy import Spotify
22
+ from spots_cli.bootstrap.container import Clients, Core
23
+ from spots_cli.models import MetadataProvider
24
+
25
+
26
+ logger = getLogger(__name__)
27
+
28
+
29
+ class SpotifySearchService(SearchProvider):
30
+ def __init__(
31
+ self,
32
+ *,
33
+ metadata: MetadataProvider,
34
+ clients: Clients,
35
+ core: Core,
36
+ fallback_providers: list[SearchProvider] = [],
37
+ ):
38
+ self.fallback_providers = fallback_providers
39
+ self.clients = clients
40
+ self.core = core
41
+ self.metadata = metadata
42
+
43
+ def _spotify(self) -> Spotify:
44
+ if not self.clients.spotify:
45
+ raise SpotifyUnavailableError(
46
+ "Spotify client is not configured. Enable Spotify features in your environment."
47
+ )
48
+ return self.clients.spotify.client
49
+
50
+ def search_artist(self, artist_query: str) -> ArtistInfo:
51
+ artist_url = "https://open.spotify.com/artist/"
52
+ artist_id = ""
53
+
54
+ # get artist by id
55
+ if artist_url in artist_query:
56
+ artist_id = artist_query.replace(artist_url, "").split("?")[0]
57
+ result = self._spotify().artist(artist_id)
58
+
59
+ if not result:
60
+ raise SongNotFound(artist_query)
61
+
62
+ else:
63
+ # search for the artist
64
+ result = self._spotify().search(artist_query, 1, type="artist")
65
+
66
+ if not result:
67
+ raise SongNotFound(artist_query)
68
+
69
+ result = result["artists"]["items"][0]
70
+
71
+ return result
72
+
73
+ def search_artist_top_tracks(self, *, artist_id: str) -> PlaylistInfo:
74
+ artist_details = self._spotify().artist(artist_id)
75
+ if not artist_details:
76
+ raise SongNotFound(artist_id)
77
+
78
+ artist_name = artist_details["name"]
79
+ artist_cover = artist_details["images"][0]["url"]
80
+
81
+ top_tracks_search = self._spotify().artist_top_tracks(artist_id)
82
+ if not top_tracks_search:
83
+ raise SongNotFound(artist_id)
84
+
85
+ # get metadata for each top track
86
+ top_tracks_playlist = [
87
+ self.metadata.get(track_id=top_track["id"])
88
+ for top_track in top_tracks_search["tracks"]
89
+ ]
90
+
91
+ return PlaylistInfo(
92
+ cover=artist_cover,
93
+ name="Top Tracks",
94
+ artist=artist_name,
95
+ provider_metadata=top_tracks_playlist,
96
+ youtube_metadata=[],
97
+ )
98
+
99
+ def search_playlist(self, playlist_url: str) -> PlaylistInfo:
100
+ playlist_result = self._spotify().playlist(playlist_url)
101
+
102
+ if not playlist_result:
103
+ raise SongNotFound(playlist_url)
104
+
105
+ # get playlist tracks
106
+ playlist_tracks: list[dict[str, Any]] = playlist_result["tracks"]["items"]
107
+
108
+ playlist_metadata = [
109
+ self.metadata.get(search_result=track) for track in playlist_tracks
110
+ ]
111
+
112
+ cover = playlist_result["images"][0]["url"]
113
+ playlist_name = playlist_result["name"]
114
+ artist = None
115
+
116
+ return PlaylistInfo(
117
+ cover=cover,
118
+ artist=artist,
119
+ name=playlist_name,
120
+ provider_metadata=playlist_metadata,
121
+ youtube_metadata=[],
122
+ )
123
+
124
+ def search_album(self, album_url: str) -> PlaylistInfo:
125
+ album_result = self._spotify().album(album_url)
126
+ if not album_result:
127
+ raise SongNotFound(album_url)
128
+
129
+ # artists
130
+ artist_list = [artist["name"] for artist in album_result["artists"]]
131
+
132
+ # get album tracks
133
+ playlist_tracks: list[dict[str, Any]] = album_result["tracks"]["items"]
134
+
135
+ playlist_metadata = [
136
+ self.metadata.get(search_result=track) for track in playlist_tracks
137
+ ]
138
+
139
+ cover = album_result["images"][0]["url"]
140
+ album_name = album_result["name"]
141
+ artist = ", ".join(artist_list)
142
+
143
+ return PlaylistInfo(
144
+ cover=cover,
145
+ artist=artist,
146
+ name=album_name,
147
+ provider_metadata=playlist_metadata,
148
+ youtube_metadata=[],
149
+ )
150
+
151
+ @retry(stop=stop_after_delay(60))
152
+ def search_track(self, query: str) -> Metadata:
153
+ """Searches for a title on spotify
154
+
155
+ Args:
156
+ query (str): the title to be searched for
157
+
158
+ Raises:
159
+ SongNotFound: the `query` is not found on spotify.
160
+ InvalidSearchFormat: if query format not: `Artist - Title`
161
+
162
+ Returns:
163
+ Metadata: The metadata of the title.
164
+ """
165
+ query = query.replace("–", "-") # `–` != `-`
166
+
167
+ if "-" not in query:
168
+ error_txt = "Search format: `Artist` - `Title`"
169
+ logger.error(error_txt)
170
+ raise InvalidSearchFormat()
171
+
172
+ cache = self.core.storage.get(query=query, query_type="metadata")
173
+ if isinstance(cache, Sentinel):
174
+ raise SongNotFound(query)
175
+ elif isinstance(cache, Metadata):
176
+ return cache
177
+
178
+ # search for a single
179
+ single_result = self._spotify().search(query)
180
+
181
+ if not single_result:
182
+ return search_fallbacks(query=query, providers=self.fallback_providers)
183
+
184
+ try:
185
+ track_id = single_result["tracks"]["items"][0]["id"]
186
+ except IndexError:
187
+ raise SongNotFound(query)
188
+
189
+ single_data = self.metadata.get(track_id=track_id)
190
+
191
+ # get query artist
192
+ query_artist = query.split(" - ")[0].lower()
193
+ result_artist = single_data.artist.lower()
194
+ if len(query_artist) < len(result_artist):
195
+ artist_match = search(escape(query_artist), result_artist)
196
+ else:
197
+ artist_match = search(escape(result_artist), query_artist)
198
+
199
+ # if search result does not match query
200
+ # then query may be a single album
201
+ if not artist_match and not search(single_data.title.lower(), query.lower()):
202
+ logger.info("Searching for album version...")
203
+ # get album id of first result
204
+ results = self._spotify().search(query, type="album")
205
+
206
+ if not results:
207
+ logger.info(f"{query} not found")
208
+ return search_fallbacks(query=query, providers=self.fallback_providers)
209
+
210
+ try:
211
+ album_id = results["albums"]["items"][0]["id"]
212
+ except IndexError:
213
+ return search_fallbacks(query=query, providers=self.fallback_providers)
214
+
215
+ # get album search function
216
+ album_url = "https://open.spotify.com/album/" + album_id
217
+ album = self._spotify().album(album_url)
218
+
219
+ if not album:
220
+ logger.info(f"{query} not found")
221
+ return search_fallbacks(query=query, providers=self.fallback_providers)
222
+
223
+ # get id of first result
224
+ track_id = album["tracks"]["items"][0]["id"]
225
+
226
+ return self.metadata.get(track_id=track_id)
227
+
228
+ return single_data
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from logging import getLogger
4
+ from typing import TYPE_CHECKING
5
+
6
+ from spots_cli.models import YTVideoInfo, Metadata, SongNotFound
7
+
8
+ if TYPE_CHECKING:
9
+ from spots_cli.bootstrap.container import Core, Clients
10
+ from spots_cli.models import SearchProvider
11
+
12
+
13
+ logger = getLogger(__name__)
14
+
15
+
16
+ class YouTubeMetadataService:
17
+
18
+ def __init__(self, search: SearchProvider, core: Core, clients: Clients):
19
+ self.core = core
20
+ self.clients = clients
21
+ self.search = search
22
+
23
+ def get(self, video_info: YTVideoInfo) -> Metadata:
24
+ """Retrieves the metadata for a video
25
+
26
+ Args:
27
+ video_info (YTVideoInfo): The youtube video info.
28
+
29
+ Returns:
30
+ Metadata: the metadata of youtube song
31
+ """
32
+ logger.debug(f"Retrieving metadata for YouTube video: {video_info.title}")
33
+
34
+ # get cover from static folder
35
+ cover = self.clients.secrets.read(
36
+ key="static_cover_name", alt="single-cover.jpg"
37
+ )
38
+
39
+ search_title = video_info.full_title
40
+ youtube_url = f"https://youtube.com/watch?v={video_info.id}"
41
+
42
+ try:
43
+ logger.info("Searching for metadata from Provider")
44
+ search_result = self.search.search_track(search_title)
45
+ except SongNotFound:
46
+ spotify_title = self.core.extractor.remove_odd_keywords(video_info.title)
47
+ spotify_artist = self.core.extractor.remove_odd_keywords(
48
+ video_info.uploader
49
+ )
50
+ return Metadata(
51
+ title=spotify_title,
52
+ artist=spotify_artist,
53
+ link=youtube_url,
54
+ cover=cover,
55
+ artist_id="",
56
+ )
57
+
58
+ metadata = search_result
59
+
60
+ tracks_match = self.core.matcher.match_tracks(
61
+ video_info=video_info, metadata=metadata
62
+ )
63
+
64
+ if tracks_match:
65
+ return metadata
66
+ else:
67
+ return Metadata(
68
+ title=video_info.title,
69
+ artist=video_info.uploader,
70
+ link=youtube_url,
71
+ cover=cover,
72
+ artist_id="",
73
+ )
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ from logging import getLogger
4
+ from math import ceil
5
+ from tenacity import stop_after_delay
6
+ from typing import Any, Literal, cast, overload, TYPE_CHECKING
7
+
8
+ from spots_cli.engine import retry
9
+ from spots_cli.models import (
10
+ SongNotFound,
11
+ SearchResponseSingle,
12
+ SearchResponseMultiple,
13
+ PlaylistInfo,
14
+ Sentinel,
15
+ YTVideoInfo,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from yt_dlp import _Params
20
+ from spots_cli.bootstrap.container import Clients, Core
21
+
22
+
23
+ logger = getLogger(__name__)
24
+
25
+
26
+ class YoutubeSearchService:
27
+ """Responsible for searching for query on YouTube"""
28
+
29
+ def __init__(self, *, clients: Clients, core: Core):
30
+ self.clients = clients
31
+ self.core = core
32
+
33
+ def get_video_size(self, data: dict[str, Any]) -> int:
34
+ size = data.get("filesize") or data.get("filesize_approx")
35
+ return ceil(size / (1024 * 1024)) if size else 0
36
+
37
+ def youtube_playlist_search(self, link: str) -> PlaylistInfo:
38
+ playlist_opts: _Params = {
39
+ "quiet": True,
40
+ "extract_flat": True,
41
+ "playlistend": None, # Extract full playlist,
42
+ }
43
+ self.clients.ytdlp.options = playlist_opts
44
+
45
+ try:
46
+ playlist_search = self.clients.ytdlp.client.extract_info(
47
+ link, download=False
48
+ )
49
+ except Exception as e:
50
+ logger.error(str(e))
51
+ raise
52
+
53
+ if not playlist_search:
54
+ raise SongNotFound(link)
55
+
56
+ playlist_search = cast(dict[str, Any], playlist_search)
57
+ playlist_name = playlist_search["title"]
58
+ playlist_cover = "youtube-playlist(mdesigns).jpg"
59
+
60
+ videos_list = [
61
+ YTVideoInfo(
62
+ title=result["title"],
63
+ uploader=result["uploader"],
64
+ filesize=self.get_video_size(result),
65
+ audio_ext=result["audio_ext"],
66
+ id=result["id"],
67
+ )
68
+ for result in playlist_search["entries"]
69
+ ]
70
+
71
+ self.clients.ytdlp.reset_options()
72
+
73
+ return PlaylistInfo(
74
+ name=playlist_name,
75
+ cover=playlist_cover,
76
+ youtube_metadata=videos_list,
77
+ provider_metadata=[],
78
+ )
79
+
80
+ @overload
81
+ def video_search(
82
+ self, *, query: str, is_general_search: Literal[False]
83
+ ) -> SearchResponseSingle: ...
84
+
85
+ @overload
86
+ def video_search(
87
+ self, *, query: str, is_general_search: Literal[True] = True
88
+ ) -> SearchResponseMultiple: ...
89
+
90
+ @retry(stop=stop_after_delay(60))
91
+ def video_search(
92
+ self, *, query, is_general_search=True
93
+ ) -> SearchResponseSingle | SearchResponseMultiple:
94
+ cache = self.core.storage.get(query=query, query_type="youtube")
95
+ if isinstance(cache, Sentinel):
96
+ raise SongNotFound(query)
97
+ elif isinstance(cache, YTVideoInfo):
98
+ return (
99
+ SearchResponseSingle(result=cache, is_cached=True)
100
+ if not is_general_search
101
+ else SearchResponseMultiple(result=[cache], is_cached=True)
102
+ )
103
+ else:
104
+ search_term = f"ytsearch5:{query}" if is_general_search else query
105
+ search_result = self.clients.ytdlp.client.extract_info(
106
+ search_term, download=False
107
+ )
108
+
109
+ if not search_result:
110
+ self.core.storage.new(
111
+ query=query, result=Sentinel(), query_type="youtube"
112
+ )
113
+ raise SongNotFound(query)
114
+
115
+ if is_general_search and not search_result.get("entries"):
116
+ self.core.storage.new(
117
+ query=query, result=Sentinel(), query_type="youtube"
118
+ )
119
+ raise SongNotFound(query)
120
+
121
+ search_result = cast(dict[str, Any], search_result)
122
+
123
+ if not is_general_search:
124
+
125
+ return SearchResponseSingle(
126
+ result=YTVideoInfo(
127
+ title=search_result["title"],
128
+ uploader=search_result["uploader"],
129
+ id=search_result["id"],
130
+ filesize=self.get_video_size(search_result),
131
+ audio_ext=search_result.get("audio_ext", "webm"),
132
+ ),
133
+ is_cached=False,
134
+ )
135
+ else:
136
+ result_objects: list[YTVideoInfo] = []
137
+ for result in search_result["entries"]:
138
+ result_objects.append(
139
+ YTVideoInfo(
140
+ title=result["title"],
141
+ uploader=result["uploader"],
142
+ id=result["id"],
143
+ filesize=self.get_video_size(result),
144
+ audio_ext=result.get("audio_ext", "webm"),
145
+ )
146
+ )
147
+
148
+ return SearchResponseMultiple(result=result_objects, is_cached=False)
149
+
150
+ def artist_search(self, artist: str):
151
+ """
152
+ Search for for all videos by an artist name on youtube
153
+
154
+ Args:
155
+ artist (str): The name of the artist
156
+ default_cover (str): Fallback cover url
157
+
158
+ Returns:
159
+ List[Metadata]: A list of songs by `artist` found on YT
160
+ """
161
+ logger.info(f"[Search Artist on YouTube] Searching for {artist}'s songs on YT")
162
+
163
+ ydl_opts: _Params = {
164
+ "quiet": True,
165
+ "extract_flat": True,
166
+ }
167
+
168
+ self.clients.ytdlp.options = ydl_opts
169
+ search_results = self.clients.ytdlp.client.extract_info(artist, download=False)
170
+
171
+ if not search_results:
172
+ raise SongNotFound(artist)
173
+
174
+ video_record: dict[str, YTVideoInfo] = {}
175
+
176
+ search_results = cast(dict[str, Any], search_results)
177
+
178
+ playlist_len = len(search_results["entries"])
179
+ for index, entry in enumerate(search_results["entries"]):
180
+ logger.info(
181
+ f"[Search Artist on YouTube] Searching for track {index + 1}/{playlist_len} on YouTube..."
182
+ )
183
+ title = entry.get("title")
184
+
185
+ if not video_record.get(title):
186
+ try:
187
+ id = entry.get("id")
188
+ watch_url = f"https://www.youtube.com/watch?v={id}"
189
+ metadata = self.video_search(
190
+ query=watch_url, is_general_search=False
191
+ )
192
+ video_record[title] = metadata.result
193
+
194
+ if (index % 10 == 0) or (index == (playlist_len - 1)):
195
+ self.core.storage.save()
196
+
197
+ except SongNotFound:
198
+ continue
199
+ else:
200
+ continue
201
+
202
+ self.clients.ytdlp.reset_options()
203
+
204
+ artist_playlist = list(video_record.values())
205
+ self.core.storage.new(
206
+ query=artist, result=artist_playlist, query_type="artist"
207
+ )
208
+
209
+ self.core.storage.save()
210
+
211
+ return artist_playlist