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,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from spots_cli.models.errors import EmptySpotifyLikes, SongNotFound
|
|
6
|
+
from spotipy.exceptions import SpotifyException
|
|
7
|
+
from tenacity import stop_after_delay
|
|
8
|
+
from spots_cli.engine import retry
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from spots_cli.models import PlaylistInfo, SpotifyUnavailableError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from spots_cli.bootstrap.container import Core, Domain, Clients
|
|
15
|
+
from spots_cli.app import DomainResolver
|
|
16
|
+
from spots_cli.services.spotify_search_service import SpotifySearchService
|
|
17
|
+
from spots_cli.services.spotify_metadata_service import SpotifyMetadataService
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ArtistResult:
|
|
25
|
+
playlist: list[PlaylistInfo]
|
|
26
|
+
name: str
|
|
27
|
+
cover: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SpotifyPlaylistCompilation:
|
|
31
|
+
"""A service for retrieving a collection of playlists from Spotify"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
core: Core,
|
|
37
|
+
domain: Domain,
|
|
38
|
+
clients: Clients,
|
|
39
|
+
metadata: SpotifyMetadataService,
|
|
40
|
+
spotify_search: SpotifySearchService,
|
|
41
|
+
domain_resolver: DomainResolver,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.domain_resolver = domain_resolver
|
|
44
|
+
self.spotify_search = spotify_search
|
|
45
|
+
self.core = core
|
|
46
|
+
self.domain = domain
|
|
47
|
+
self.clients = clients
|
|
48
|
+
self.metadata = metadata
|
|
49
|
+
|
|
50
|
+
@retry(stop=stop_after_delay(60))
|
|
51
|
+
def artist_albums(
|
|
52
|
+
self, *, artist: str, essentials_playlist: str | None = None
|
|
53
|
+
) -> ArtistResult:
|
|
54
|
+
"""
|
|
55
|
+
Retrieves the albums of a given artist.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
artist (str): The name of the artist. Can be either then name of artist or spotify url to artist
|
|
59
|
+
essentials_playlist (str | None, optional): A spotify playlist to scrape. Defaults to None.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
artist_search = self.spotify_search.search_artist(artist)
|
|
63
|
+
artist_name = artist_search.name
|
|
64
|
+
artist_id = artist_search.id
|
|
65
|
+
artist_cover = artist_search.cover
|
|
66
|
+
|
|
67
|
+
all_artist_albums: list[PlaylistInfo] = []
|
|
68
|
+
|
|
69
|
+
# get artist's This Is playlist
|
|
70
|
+
if essentials_playlist:
|
|
71
|
+
essentials_playlist_data = self.core.scraper.scrape_spotify_playlist(
|
|
72
|
+
essentials_playlist
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
scraped_playlist = [
|
|
76
|
+
track
|
|
77
|
+
for track_id in essentials_playlist_data.ids
|
|
78
|
+
if (track := self.metadata.get(track_id=track_id))
|
|
79
|
+
]
|
|
80
|
+
domain_matches = self.domain_resolver.filter_matching_domain_results(
|
|
81
|
+
provider_results=scraped_playlist
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
scraped_cover = essentials_playlist_data.cover
|
|
85
|
+
scraped_title = essentials_playlist_data.name
|
|
86
|
+
essential_cover = (
|
|
87
|
+
scraped_cover
|
|
88
|
+
if scraped_cover
|
|
89
|
+
else self.clients.secrets.read(
|
|
90
|
+
key="static_playlist_cover_name", alt="youtube-playlist.jpg"
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
essential_title = (
|
|
94
|
+
scraped_title if scraped_title else f"This is {artist_name}"
|
|
95
|
+
)
|
|
96
|
+
scraped_data = {
|
|
97
|
+
"cover": essential_cover,
|
|
98
|
+
"name": essential_title,
|
|
99
|
+
"artist": artist_name,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
all_artist_albums.append(
|
|
103
|
+
PlaylistInfo(
|
|
104
|
+
cover=scraped_data["cover"],
|
|
105
|
+
name=scraped_data["name"],
|
|
106
|
+
artist=scraped_data["artist"],
|
|
107
|
+
provider_metadata=domain_matches.provider,
|
|
108
|
+
youtube_metadata=domain_matches.youtube,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# top tracks
|
|
113
|
+
try:
|
|
114
|
+
top_tracks = self.spotify_search.search_artist_top_tracks(
|
|
115
|
+
artist_id=artist_id
|
|
116
|
+
)
|
|
117
|
+
all_artist_albums.append(top_tracks)
|
|
118
|
+
except SongNotFound:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
# retrieve artist albums
|
|
122
|
+
if not self.clients.spotify:
|
|
123
|
+
raise SpotifyUnavailableError(
|
|
124
|
+
"Spotify client is not configured. Enable Spotify features in your environment."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
result = self.clients.spotify.client.artist_albums(artist_id)
|
|
128
|
+
if not result:
|
|
129
|
+
return ArtistResult(
|
|
130
|
+
playlist=all_artist_albums, name=artist_name, cover=artist_cover
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# filter albums for the specified artist
|
|
134
|
+
artist_albums = list(
|
|
135
|
+
filter(
|
|
136
|
+
lambda item: any(
|
|
137
|
+
artist_obj["name"].lower() == artist.lower()
|
|
138
|
+
for artist_obj in item["artists"]
|
|
139
|
+
),
|
|
140
|
+
result["items"],
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
artist_albums = result["items"]
|
|
145
|
+
|
|
146
|
+
for item in artist_albums:
|
|
147
|
+
# get all tracks of album
|
|
148
|
+
album_url = "https://open.spotify.com/album/" + item["id"]
|
|
149
|
+
|
|
150
|
+
all_artist_albums.append(self.spotify_search.search_album(album_url))
|
|
151
|
+
|
|
152
|
+
return ArtistResult(
|
|
153
|
+
playlist=all_artist_albums, name=artist_name, cover=artist_cover
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def user_saved_tracks(self) -> PlaylistInfo:
|
|
157
|
+
"""retrieves a user's saved tracks
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
list[Metadata]: A list of Metadata objects or None
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
EmptySpotifyLikes: When no likes returned
|
|
164
|
+
"""
|
|
165
|
+
cached_likes = self.core.storage.get_spotify_likes()
|
|
166
|
+
cover = "avatar.jpg"
|
|
167
|
+
username = self.clients.secrets.read(key="username")
|
|
168
|
+
if not username:
|
|
169
|
+
raise RuntimeError("No username provided in `.env` file")
|
|
170
|
+
if not cached_likes:
|
|
171
|
+
raise SongNotFound("Spotify likes")
|
|
172
|
+
domain_matches = self.domain_resolver.filter_matching_domain_results(
|
|
173
|
+
provider_results=list(cached_likes.values())
|
|
174
|
+
)
|
|
175
|
+
return PlaylistInfo(
|
|
176
|
+
cover=cover,
|
|
177
|
+
name=username,
|
|
178
|
+
provider_metadata=domain_matches.provider,
|
|
179
|
+
youtube_metadata=domain_matches.youtube,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# NOTE: Spotify now requires a paid subscription to use the API
|
|
183
|
+
logger.info("Searching for user saved tracks...")
|
|
184
|
+
limit = 50
|
|
185
|
+
user_tracks = None
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
user_tracks = self.clients.spotify.spotify.current_user_saved_tracks(
|
|
189
|
+
limit=limit
|
|
190
|
+
)
|
|
191
|
+
except SpotifyException:
|
|
192
|
+
self.clients.spotify.signin()
|
|
193
|
+
user_tracks = self.clients.spotify.spotify.current_user_saved_tracks(
|
|
194
|
+
limit=limit
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if not user_tracks:
|
|
198
|
+
raise EmptySpotifyLikes
|
|
199
|
+
|
|
200
|
+
total_tracks = user_tracks["total"]
|
|
201
|
+
# get first 50 tracks's ids
|
|
202
|
+
id_list = [track["track"]["id"] for track in user_tracks["items"]]
|
|
203
|
+
|
|
204
|
+
# check if there are more tracks
|
|
205
|
+
next_page = user_tracks["next"]
|
|
206
|
+
if next_page:
|
|
207
|
+
index = limit
|
|
208
|
+
|
|
209
|
+
# paginate through the rest of the tracks
|
|
210
|
+
while index <= total_tracks:
|
|
211
|
+
user_tracks = self.clients.spotify.spotify.current_user_saved_tracks(
|
|
212
|
+
offset=index, limit=limit
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if user_tracks:
|
|
216
|
+
# append the next set of ids
|
|
217
|
+
for track in user_tracks["items"]:
|
|
218
|
+
id_list.append(track["track"]["id"])
|
|
219
|
+
|
|
220
|
+
index += limit
|
|
221
|
+
|
|
222
|
+
# get metadata for each track
|
|
223
|
+
metadata_list = []
|
|
224
|
+
for index, track_id in enumerate(id_list):
|
|
225
|
+
print(f"Getting liked song {index + 1}/{len(id_list)}")
|
|
226
|
+
# get track data
|
|
227
|
+
metadata = self.orchestration.metadata.spotify_metadata.get(
|
|
228
|
+
track_id=track_id
|
|
229
|
+
)
|
|
230
|
+
metadata_list.append(metadata)
|
|
231
|
+
|
|
232
|
+
# add to likes cache
|
|
233
|
+
title = f"{metadata.artist} - {metadata.title}"
|
|
234
|
+
storage.new(query=title, result=metadata, query_type="spotify_likes")
|
|
235
|
+
|
|
236
|
+
if (index % 10 == 0) or (index == (len(id_list) - 1)):
|
|
237
|
+
storage.save()
|
|
238
|
+
|
|
239
|
+
domain_matches = self.orchestration.search.filter_matching_domain_results(
|
|
240
|
+
spotify_results=metadata_list
|
|
241
|
+
)
|
|
242
|
+
cover = "avatar.jpg"
|
|
243
|
+
username = self.clients.secrets.read(key="username")
|
|
244
|
+
if not username:
|
|
245
|
+
raise RuntimeError("No username provided in `.env` file")
|
|
246
|
+
return PlaylistInfo(
|
|
247
|
+
cover=cover,
|
|
248
|
+
name=username,
|
|
249
|
+
spotify_metadata=domain_matches.spotify,
|
|
250
|
+
youtube_metadata=domain_matches.youtube,
|
|
251
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from googleapiclient.errors import HttpError
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from socket import timeout as SocketTimeout
|
|
6
|
+
from tenacity import stop_after_attempt, wait_exponential
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from spots_cli.engine import retry
|
|
10
|
+
from spots_cli.models import (
|
|
11
|
+
SongNotFound,
|
|
12
|
+
YouTubeQuotaExceeded,
|
|
13
|
+
Sentinel,
|
|
14
|
+
YTVideoInfo,
|
|
15
|
+
Metadata,
|
|
16
|
+
YouTubeUnavailableError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from spots_cli.bootstrap.container import Core, Clients
|
|
21
|
+
from spots_cli.app.spotify_playlist_compilation import SpotifyPlaylistCompilation
|
|
22
|
+
from spots_cli.services.youtube_search_service import YoutubeSearchService
|
|
23
|
+
from spots_cli.clients import YouTubeApiClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class YouTubeUserPlaylist:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
clients: Clients,
|
|
34
|
+
core: Core,
|
|
35
|
+
spotify_playlist_compiler: SpotifyPlaylistCompilation,
|
|
36
|
+
search: YoutubeSearchService,
|
|
37
|
+
):
|
|
38
|
+
self.search = search
|
|
39
|
+
self.core = core
|
|
40
|
+
self.clients = clients
|
|
41
|
+
self.spotify_playlist_compiler = spotify_playlist_compiler
|
|
42
|
+
|
|
43
|
+
def _youtube(self) -> YouTubeApiClient:
|
|
44
|
+
if not self.clients.youtube:
|
|
45
|
+
raise YouTubeUnavailableError(
|
|
46
|
+
"YouTube client is not configured. Enable YouTube features in your environment."
|
|
47
|
+
)
|
|
48
|
+
return self.clients.youtube
|
|
49
|
+
|
|
50
|
+
def get_liked_videos(self, *, max_results=50) -> list[str]:
|
|
51
|
+
"""
|
|
52
|
+
Fetch videos from the user's YouTube liked videos playlist.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
max_results (int, Optional): The max items per pagination. Defaults to 50.
|
|
56
|
+
"""
|
|
57
|
+
liked_videos = []
|
|
58
|
+
next_page_token = None
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
request = (
|
|
62
|
+
self._youtube()
|
|
63
|
+
.service.playlistItems()
|
|
64
|
+
.list(
|
|
65
|
+
part="snippet,contentDetails",
|
|
66
|
+
playlistId="LL",
|
|
67
|
+
maxResults=max_results,
|
|
68
|
+
pageToken=next_page_token,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
response = request.execute()
|
|
73
|
+
|
|
74
|
+
for item in response.get("items", []):
|
|
75
|
+
video_id = item["contentDetails"]["videoId"]
|
|
76
|
+
watch_url = f"https://www.youtube.com/watch?v={video_id}"
|
|
77
|
+
liked_videos.append(watch_url)
|
|
78
|
+
|
|
79
|
+
next_page_token = response.get("nextPageToken")
|
|
80
|
+
if not next_page_token:
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
return liked_videos
|
|
84
|
+
|
|
85
|
+
@retry(stop=stop_after_attempt(5), wait=wait_exponential(min=1, max=10))
|
|
86
|
+
def _like_video(self, video_id: str):
|
|
87
|
+
"""
|
|
88
|
+
Like a video with retry on transient failures.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
video_id (str): the id of the video to be added.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
self._youtube().service.videos().rate(id=video_id, rating="like").execute()
|
|
95
|
+
except (SocketTimeout, TimeoutError) as e:
|
|
96
|
+
# Retryable network error
|
|
97
|
+
logger.info(f"[Network Timeout] Retrying like for {video_id}: {e}")
|
|
98
|
+
raise # Tenacity will retry
|
|
99
|
+
except HttpError as e:
|
|
100
|
+
# API-level error (bad ID, auth, quota, etc.)
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
def like_video(self, *, video: YTVideoInfo, metadata: Metadata) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Adds a video to YouTube likes.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
video (YTVideoInfo): the video to be added.
|
|
109
|
+
metadata (Metadata): the metadata for the video.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
bool: True if added to likes
|
|
113
|
+
"""
|
|
114
|
+
video_id = video.id
|
|
115
|
+
title = f"{video.uploader} - {video.title}"
|
|
116
|
+
titles_match = self.core.matcher.match_tracks(
|
|
117
|
+
video_info=video, metadata=metadata
|
|
118
|
+
)
|
|
119
|
+
if titles_match:
|
|
120
|
+
try:
|
|
121
|
+
logger.info(f"Trying to add {title} to likes")
|
|
122
|
+
self._like_video(video_id)
|
|
123
|
+
return True
|
|
124
|
+
except HttpError as e:
|
|
125
|
+
status = getattr(e.resp, "status", None)
|
|
126
|
+
details = getattr(e, "error_details", None)
|
|
127
|
+
if status == 403 and isinstance(details, list) and details:
|
|
128
|
+
reason = details[0].get("reason")
|
|
129
|
+
|
|
130
|
+
if reason == "videoRatingDisabled":
|
|
131
|
+
return False
|
|
132
|
+
elif reason == "quotaExceeded":
|
|
133
|
+
raise YouTubeQuotaExceeded()
|
|
134
|
+
else:
|
|
135
|
+
logger.error("Unexpected reason:")
|
|
136
|
+
logger.error(reason)
|
|
137
|
+
raise
|
|
138
|
+
raise
|
|
139
|
+
except Exception as e:
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
logger.debug(f"No match found for {title}")
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
def transfer_spotify_likes_to_yt(self):
|
|
146
|
+
"""
|
|
147
|
+
Adds each track in a users Spotify liked library, to the users YouTube likes
|
|
148
|
+
"""
|
|
149
|
+
# get user liked tracks from spotify
|
|
150
|
+
tracks = self.spotify_playlist_compiler.user_saved_tracks()
|
|
151
|
+
|
|
152
|
+
# save newly added liked tracks
|
|
153
|
+
self.core.storage.save()
|
|
154
|
+
|
|
155
|
+
unexpected_errors = []
|
|
156
|
+
playlist_len = len(tracks.provider_metadata)
|
|
157
|
+
|
|
158
|
+
# try to find the youtube video for each track
|
|
159
|
+
for index, track in enumerate(tracks.provider_metadata):
|
|
160
|
+
spotify_title = f"{track.artist} - {track.title}"
|
|
161
|
+
|
|
162
|
+
if self.core.storage.get(query=spotify_title, query_type="yt_likes"):
|
|
163
|
+
logger.info(f"🗄 Already Liked: {spotify_title}")
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
video_added_to_likes = False
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
search_results = self.search.video_search(
|
|
170
|
+
query=spotify_title, is_general_search=True
|
|
171
|
+
)
|
|
172
|
+
except SongNotFound:
|
|
173
|
+
continue
|
|
174
|
+
except Exception as e:
|
|
175
|
+
unexpected_errors.append(str(e))
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
best_match = self.core.matcher.find_best_match(
|
|
180
|
+
search_results=search_results.result, metadata=track
|
|
181
|
+
)
|
|
182
|
+
video_added_to_likes = self.like_video(video=best_match, metadata=track)
|
|
183
|
+
if video_added_to_likes:
|
|
184
|
+
self.core.storage.new(
|
|
185
|
+
query=spotify_title,
|
|
186
|
+
result=best_match,
|
|
187
|
+
query_type="yt_likes",
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
logger.info(f"Failed to add {spotify_title} to likes")
|
|
191
|
+
self.core.storage.new(
|
|
192
|
+
query=spotify_title, result=Sentinel(), query_type="yt_likes"
|
|
193
|
+
)
|
|
194
|
+
continue
|
|
195
|
+
except SongNotFound:
|
|
196
|
+
continue
|
|
197
|
+
except YouTubeQuotaExceeded:
|
|
198
|
+
self.core.storage.save()
|
|
199
|
+
logger.error("Quota exceeded")
|
|
200
|
+
raise
|
|
201
|
+
except Exception as e:
|
|
202
|
+
unexpected_errors.append(str(e))
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if (index % 10 == 0) or (index == (playlist_len - 1)):
|
|
206
|
+
self.core.storage.save()
|
|
207
|
+
|
|
208
|
+
logger.info(f"✅ Liked: {spotify_title}")
|
|
209
|
+
|
|
210
|
+
self.core.storage.save()
|
|
211
|
+
|
|
212
|
+
if len(unexpected_errors):
|
|
213
|
+
logger.error("Unexpected errors occurred")
|
|
214
|
+
for unexpected in unexpected_errors:
|
|
215
|
+
logger.error(unexpected)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from spots_cli.bootstrap.container import Container
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from logging import DEBUG, ERROR, basicConfig, INFO, getLogger
|
|
3
|
+
from os.path import exists
|
|
4
|
+
|
|
5
|
+
from spots_cli.app import (
|
|
6
|
+
Downloader,
|
|
7
|
+
MediaResolver,
|
|
8
|
+
YouTubeUserPlaylist,
|
|
9
|
+
SpotifyPlaylistCompilation,
|
|
10
|
+
DomainResolver,
|
|
11
|
+
)
|
|
12
|
+
from spots_cli.clients import (
|
|
13
|
+
YtDlpClient,
|
|
14
|
+
SpotifyClient,
|
|
15
|
+
SecretsManager,
|
|
16
|
+
YouTubeApiClient,
|
|
17
|
+
DeezerClient,
|
|
18
|
+
)
|
|
19
|
+
from spots_cli.core import (
|
|
20
|
+
WebScraper,
|
|
21
|
+
LyricsFinder,
|
|
22
|
+
HistoryManager,
|
|
23
|
+
PatternMatcher,
|
|
24
|
+
VideoConverter,
|
|
25
|
+
YouTubeExtractor,
|
|
26
|
+
)
|
|
27
|
+
from spots_cli.engine import FileStorage
|
|
28
|
+
from spots_cli.models import MetadataProvider, SearchProvider
|
|
29
|
+
from spots_cli.services import (
|
|
30
|
+
YoutubeSearchService,
|
|
31
|
+
YouTubeMetadataService,
|
|
32
|
+
ProvidersMetadata,
|
|
33
|
+
ProviderSearchContainer,
|
|
34
|
+
SpotifySearchService,
|
|
35
|
+
SpotifyMetadataService,
|
|
36
|
+
)
|
|
37
|
+
from spots_cli.integrations import SpotifyUserPlaylistModify
|
|
38
|
+
from spots_cli.utils import get_config_path
|
|
39
|
+
|
|
40
|
+
logger = getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Core:
|
|
45
|
+
history: HistoryManager
|
|
46
|
+
lyrics: LyricsFinder
|
|
47
|
+
matcher: PatternMatcher
|
|
48
|
+
converter: VideoConverter
|
|
49
|
+
scraper: WebScraper
|
|
50
|
+
extractor: YouTubeExtractor
|
|
51
|
+
storage: FileStorage
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Clients:
|
|
56
|
+
spotify: SpotifyClient | None
|
|
57
|
+
youtube: YouTubeApiClient | None
|
|
58
|
+
ytdlp: YtDlpClient
|
|
59
|
+
secrets: SecretsManager
|
|
60
|
+
deezer: DeezerClient
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Domain:
|
|
65
|
+
provider_search: SearchProvider
|
|
66
|
+
provider_metadata: MetadataProvider
|
|
67
|
+
youtube_search: YoutubeSearchService
|
|
68
|
+
youtube_metadata: YouTubeMetadataService
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class App:
|
|
73
|
+
downloader: Downloader
|
|
74
|
+
resolver: MediaResolver
|
|
75
|
+
spotify_playlist_compiler: SpotifyPlaylistCompilation
|
|
76
|
+
youtube_user_playlist: YouTubeUserPlaylist
|
|
77
|
+
spotify_playlist_modify: SpotifyUserPlaylistModify
|
|
78
|
+
domain_resolver: DomainResolver
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Container:
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self.__path = get_config_path() / ".bootstrapped"
|
|
84
|
+
|
|
85
|
+
secrets = SecretsManager()
|
|
86
|
+
environment_mode = secrets.read(key="environment_mode", alt="PRODUCTION")
|
|
87
|
+
basicConfig(level=INFO if environment_mode.lower() == "production" else DEBUG)
|
|
88
|
+
getLogger("googleapiclient.discovery_cache").setLevel(ERROR)
|
|
89
|
+
|
|
90
|
+
logger.debug("Bootstrapping app...")
|
|
91
|
+
|
|
92
|
+
self.initial_setup()
|
|
93
|
+
|
|
94
|
+
logger.debug("Setting up core components...")
|
|
95
|
+
self.core = self._build_core()
|
|
96
|
+
self.clients = self._build_clients()
|
|
97
|
+
self.domain = self._build_domain()
|
|
98
|
+
self.app = self._build_application()
|
|
99
|
+
logger.debug("Components setup complete.")
|
|
100
|
+
|
|
101
|
+
# cache
|
|
102
|
+
logger.debug("Loading cache into memory...")
|
|
103
|
+
self.core.storage.cache_file_exists()
|
|
104
|
+
self.core.storage.reload()
|
|
105
|
+
logger.debug("Cache loaded.")
|
|
106
|
+
|
|
107
|
+
logger.debug("Bootstrap complete. App ready for use.")
|
|
108
|
+
|
|
109
|
+
def initial_setup(self) -> None:
|
|
110
|
+
if not exists(self.__path):
|
|
111
|
+
logger.debug("Running initial setup...")
|
|
112
|
+
logger.debug("Creating internal files used for tracking downloads...")
|
|
113
|
+
|
|
114
|
+
# downloads history
|
|
115
|
+
logger.debug("Creating downloads history file...")
|
|
116
|
+
history = HistoryManager()
|
|
117
|
+
history.history_file_exists()
|
|
118
|
+
logger.debug("History file created.")
|
|
119
|
+
|
|
120
|
+
with open(self.__path, "w"):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
logger.debug("Download manager files setup complete.")
|
|
124
|
+
|
|
125
|
+
def _build_clients(self) -> Clients:
|
|
126
|
+
secrets = SecretsManager()
|
|
127
|
+
|
|
128
|
+
youtube = None
|
|
129
|
+
if secrets.read(key="youtube_account_access", alt="false").lower() == "true":
|
|
130
|
+
youtube = YouTubeApiClient()
|
|
131
|
+
|
|
132
|
+
spotify = None
|
|
133
|
+
if (
|
|
134
|
+
secrets.read(key="spotify_features_available", alt="false").lower()
|
|
135
|
+
== "true"
|
|
136
|
+
):
|
|
137
|
+
spotify = SpotifyClient()
|
|
138
|
+
|
|
139
|
+
return Clients(
|
|
140
|
+
spotify=spotify,
|
|
141
|
+
youtube=youtube,
|
|
142
|
+
ytdlp=YtDlpClient(secrets=secrets),
|
|
143
|
+
secrets=secrets,
|
|
144
|
+
deezer=DeezerClient(),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _build_core(self) -> Core:
|
|
148
|
+
scraper = WebScraper()
|
|
149
|
+
secrets_manager = SecretsManager()
|
|
150
|
+
extractor = YouTubeExtractor()
|
|
151
|
+
|
|
152
|
+
return Core(
|
|
153
|
+
storage=FileStorage(),
|
|
154
|
+
history=HistoryManager(),
|
|
155
|
+
lyrics=LyricsFinder(scraper=scraper, secrets_manager=secrets_manager),
|
|
156
|
+
matcher=PatternMatcher(extractor=extractor),
|
|
157
|
+
converter=VideoConverter(),
|
|
158
|
+
scraper=WebScraper(),
|
|
159
|
+
extractor=YouTubeExtractor(),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _build_domain(self) -> Domain:
|
|
163
|
+
youtube_search = YoutubeSearchService(clients=self.clients, core=self.core)
|
|
164
|
+
|
|
165
|
+
provider_metadata = ProvidersMetadata(
|
|
166
|
+
clients=self.clients,
|
|
167
|
+
core=self.core,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
provider_search = ProviderSearchContainer(
|
|
171
|
+
clients=self.clients, metadata=provider_metadata.metadata, core=self.core
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
youtube_metadata = YouTubeMetadataService(
|
|
175
|
+
search=provider_search.main, clients=self.clients, core=self.core
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return Domain(
|
|
179
|
+
youtube_search=youtube_search,
|
|
180
|
+
provider_search=provider_search.main,
|
|
181
|
+
provider_metadata=provider_metadata.metadata,
|
|
182
|
+
youtube_metadata=youtube_metadata,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _build_application(self) -> App:
|
|
186
|
+
downloader = Downloader(core=self.core, clients=self.clients)
|
|
187
|
+
|
|
188
|
+
youtube_search = YoutubeSearchService(clients=self.clients, core=self.core)
|
|
189
|
+
domain_resolver = DomainResolver(
|
|
190
|
+
core=self.core,
|
|
191
|
+
youtube_search=youtube_search,
|
|
192
|
+
provider_search=self.domain.provider_search,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
resolver = MediaResolver(
|
|
196
|
+
core=self.core,
|
|
197
|
+
domain=self.domain,
|
|
198
|
+
clients=self.clients,
|
|
199
|
+
domain_resolver=domain_resolver,
|
|
200
|
+
)
|
|
201
|
+
spotify_playlist_modify = SpotifyUserPlaylistModify(clients=self.clients)
|
|
202
|
+
|
|
203
|
+
spotify_metadata = SpotifyMetadataService(clients=self.clients, core=self.core)
|
|
204
|
+
|
|
205
|
+
spotify_search = SpotifySearchService(
|
|
206
|
+
metadata=spotify_metadata, clients=self.clients, core=self.core
|
|
207
|
+
)
|
|
208
|
+
spotify_playlist_compiler = SpotifyPlaylistCompilation(
|
|
209
|
+
core=self.core,
|
|
210
|
+
domain=self.domain,
|
|
211
|
+
clients=self.clients,
|
|
212
|
+
metadata=spotify_metadata,
|
|
213
|
+
spotify_search=spotify_search,
|
|
214
|
+
domain_resolver=domain_resolver,
|
|
215
|
+
)
|
|
216
|
+
youtube_user_playlist = YouTubeUserPlaylist(
|
|
217
|
+
search=youtube_search,
|
|
218
|
+
clients=self.clients,
|
|
219
|
+
core=self.core,
|
|
220
|
+
spotify_playlist_compiler=spotify_playlist_compiler,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return App(
|
|
224
|
+
downloader=downloader,
|
|
225
|
+
resolver=resolver,
|
|
226
|
+
spotify_playlist_modify=spotify_playlist_modify,
|
|
227
|
+
spotify_playlist_compiler=spotify_playlist_compiler,
|
|
228
|
+
youtube_user_playlist=youtube_user_playlist,
|
|
229
|
+
domain_resolver=domain_resolver,
|
|
230
|
+
)
|