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
|
@@ -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
|