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,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
+ )