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,347 @@
|
|
|
1
|
+
from json import load, dump
|
|
2
|
+
from json.decoder import JSONDecodeError
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from os.path import exists
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from shutil import move
|
|
7
|
+
from tempfile import NamedTemporaryFile
|
|
8
|
+
from typing import Any, Literal, Optional, TypedDict, Sequence, overload
|
|
9
|
+
|
|
10
|
+
from spots_cli.models import SongNotFound, Metadata, Sentinel, YTVideoInfo
|
|
11
|
+
from spots_cli.utils import get_config_path
|
|
12
|
+
|
|
13
|
+
logger = getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TMetadataCache = dict[str, Metadata | Sentinel]
|
|
17
|
+
TArtistCache = dict[str, Sequence[YTVideoInfo | Sentinel]]
|
|
18
|
+
|
|
19
|
+
TSerializedRecord = dict[str, dict[str, Any]]
|
|
20
|
+
|
|
21
|
+
MediaProviders = Literal["artist", "youtube", "yt_likes", "spotify_likes", "metadata"]
|
|
22
|
+
|
|
23
|
+
NOT_FOUND = Sentinel()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CacheOptions(TypedDict):
|
|
27
|
+
metadata: TMetadataCache
|
|
28
|
+
artist: TArtistCache
|
|
29
|
+
youtube: dict[str, Any]
|
|
30
|
+
yt_likes: dict[str, YTVideoInfo | Sentinel]
|
|
31
|
+
spotify_likes: TMetadataCache
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SerializedCacheOptions(TypedDict):
|
|
35
|
+
metadata: dict[str, TSerializedRecord]
|
|
36
|
+
artist: dict[str, list[dict[str, Any]]]
|
|
37
|
+
youtube: dict[str, YTVideoInfo]
|
|
38
|
+
yt_likes: dict[str, TSerializedRecord]
|
|
39
|
+
spotify_likes: dict[str, TSerializedRecord]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FileStorage:
|
|
43
|
+
"""A class for caching search results in a JSON file."""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self.__file_path = get_config_path() / ".metadata.json"
|
|
47
|
+
self.__objects: CacheOptions = {
|
|
48
|
+
"artist": {},
|
|
49
|
+
"metadata": {},
|
|
50
|
+
"youtube": {},
|
|
51
|
+
"yt_likes": {},
|
|
52
|
+
"spotify_likes": {},
|
|
53
|
+
}
|
|
54
|
+
self._dirty = False
|
|
55
|
+
|
|
56
|
+
def update_youtube(
|
|
57
|
+
self,
|
|
58
|
+
query: str,
|
|
59
|
+
*,
|
|
60
|
+
title: Optional[str] = None,
|
|
61
|
+
uploader: Optional[str] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Update fields of an existing YT-DLP cache entry."""
|
|
64
|
+
record = self.__objects["youtube"].get(query)
|
|
65
|
+
|
|
66
|
+
if not record:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if title is not None:
|
|
70
|
+
record.title = title
|
|
71
|
+
|
|
72
|
+
if uploader is not None:
|
|
73
|
+
record.uploader = uploader
|
|
74
|
+
|
|
75
|
+
def cache_file_exists(self) -> None:
|
|
76
|
+
if not exists(self.__file_path):
|
|
77
|
+
objects = {
|
|
78
|
+
"metadata": {},
|
|
79
|
+
"artist": {},
|
|
80
|
+
"youtube": {},
|
|
81
|
+
"yt_likes": {},
|
|
82
|
+
"spotify_likes": {},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
with open(self.__file_path, "w") as f:
|
|
86
|
+
dump(objects, f)
|
|
87
|
+
|
|
88
|
+
def get_spotify_likes(self) -> TMetadataCache:
|
|
89
|
+
return self.__objects["spotify_likes"]
|
|
90
|
+
|
|
91
|
+
def all(self) -> CacheOptions:
|
|
92
|
+
"""Returns the stored objects."""
|
|
93
|
+
return self.__objects
|
|
94
|
+
|
|
95
|
+
@overload
|
|
96
|
+
def new(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
query: str,
|
|
100
|
+
result: YTVideoInfo | Sentinel,
|
|
101
|
+
query_type: Literal["yt_likes"],
|
|
102
|
+
) -> None: ...
|
|
103
|
+
|
|
104
|
+
@overload
|
|
105
|
+
def new(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
query: str,
|
|
109
|
+
result: Metadata | Sentinel,
|
|
110
|
+
query_type: Literal["metadata"],
|
|
111
|
+
) -> None: ...
|
|
112
|
+
|
|
113
|
+
@overload
|
|
114
|
+
def new(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
query: str,
|
|
118
|
+
result: Metadata | Sentinel,
|
|
119
|
+
query_type: Literal["spotify_likes"],
|
|
120
|
+
) -> None: ...
|
|
121
|
+
|
|
122
|
+
@overload
|
|
123
|
+
def new(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
query: str,
|
|
127
|
+
result: Sequence[YTVideoInfo | Sentinel],
|
|
128
|
+
query_type: Literal["artist"],
|
|
129
|
+
) -> None: ...
|
|
130
|
+
|
|
131
|
+
@overload
|
|
132
|
+
def new(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
query: str,
|
|
136
|
+
result: YTVideoInfo | Sentinel,
|
|
137
|
+
query_type: Literal["youtube"],
|
|
138
|
+
) -> None: ...
|
|
139
|
+
|
|
140
|
+
def new(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
query: str,
|
|
144
|
+
result,
|
|
145
|
+
query_type: MediaProviders,
|
|
146
|
+
) -> None:
|
|
147
|
+
query = query.replace(" Audio", "")
|
|
148
|
+
|
|
149
|
+
if self.__objects[query_type].get(query):
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
logger.debug(f"[Cache] New entry: {query}")
|
|
153
|
+
self._dirty = True
|
|
154
|
+
|
|
155
|
+
match query_type:
|
|
156
|
+
case "metadata":
|
|
157
|
+
self.__objects["metadata"][query] = result
|
|
158
|
+
case "artist":
|
|
159
|
+
if not self.__objects["artist"][query]:
|
|
160
|
+
self.__objects["artist"][query] = []
|
|
161
|
+
self.__objects["artist"][query] = list(
|
|
162
|
+
self.__objects["artist"][query]
|
|
163
|
+
) + [result]
|
|
164
|
+
case "youtube":
|
|
165
|
+
self.__objects["youtube"][query] = result
|
|
166
|
+
case "spotify_likes":
|
|
167
|
+
self.__objects["spotify_likes"][query] = result
|
|
168
|
+
case "yt_likes":
|
|
169
|
+
self.__objects["yt_likes"][query] = result
|
|
170
|
+
|
|
171
|
+
def save(self) -> None:
|
|
172
|
+
"""Serializes and saves the cache to the JSON file safely."""
|
|
173
|
+
if not self._dirty:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
logger.debug("[Cache] Updating")
|
|
177
|
+
|
|
178
|
+
serialized_objects: SerializedCacheOptions = {
|
|
179
|
+
"metadata": {
|
|
180
|
+
key: value.__dict__ for key, value in self.__objects["metadata"].items()
|
|
181
|
+
},
|
|
182
|
+
"artist": {
|
|
183
|
+
artist: [item.__dict__ for item in playlist]
|
|
184
|
+
for artist, playlist in self.__objects["artist"].items()
|
|
185
|
+
},
|
|
186
|
+
"youtube": {
|
|
187
|
+
key: value.__dict__ for key, value in self.__objects["youtube"].items()
|
|
188
|
+
},
|
|
189
|
+
"yt_likes": {
|
|
190
|
+
key: value.__dict__ for key, value in self.__objects["yt_likes"].items()
|
|
191
|
+
},
|
|
192
|
+
"spotify_likes": {
|
|
193
|
+
key: value.__dict__
|
|
194
|
+
for key, value in self.__objects["spotify_likes"].items()
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Write to temp file in the same directory
|
|
199
|
+
dir_path = Path(self.__file_path).parent
|
|
200
|
+
with NamedTemporaryFile(
|
|
201
|
+
"w", dir=dir_path, delete=False, encoding="utf-8"
|
|
202
|
+
) as tmp_file:
|
|
203
|
+
dump(serialized_objects, tmp_file)
|
|
204
|
+
temp_path = tmp_file.name
|
|
205
|
+
|
|
206
|
+
tmp_file.close()
|
|
207
|
+
|
|
208
|
+
# Atomically replace the original file
|
|
209
|
+
move(temp_path, self.__file_path)
|
|
210
|
+
|
|
211
|
+
self._dirty = False
|
|
212
|
+
|
|
213
|
+
def reload(self) -> None:
|
|
214
|
+
"""Deserializes and reloads the cache from the JSON file."""
|
|
215
|
+
logger.debug("[Cache] Loading into memory")
|
|
216
|
+
try:
|
|
217
|
+
with open(self.__file_path, "r") as file:
|
|
218
|
+
loaded_objects = load(file)
|
|
219
|
+
|
|
220
|
+
if "metadata" in loaded_objects:
|
|
221
|
+
self.__objects["metadata"] = {
|
|
222
|
+
key: (
|
|
223
|
+
Metadata(**value)
|
|
224
|
+
if value != NOT_FOUND.__dict__
|
|
225
|
+
else NOT_FOUND
|
|
226
|
+
)
|
|
227
|
+
for key, value in loaded_objects["metadata"].items()
|
|
228
|
+
}
|
|
229
|
+
else:
|
|
230
|
+
self.__objects["metadata"] = {}
|
|
231
|
+
|
|
232
|
+
if "spotify_likes" in loaded_objects:
|
|
233
|
+
self.__objects["spotify_likes"] = {
|
|
234
|
+
key: (
|
|
235
|
+
Metadata(**value)
|
|
236
|
+
if value != NOT_FOUND.__dict__
|
|
237
|
+
else NOT_FOUND
|
|
238
|
+
)
|
|
239
|
+
for key, value in loaded_objects["spotify_likes"].items()
|
|
240
|
+
}
|
|
241
|
+
else:
|
|
242
|
+
self.__objects["spotify_likes"] = {}
|
|
243
|
+
|
|
244
|
+
if "artist" in loaded_objects:
|
|
245
|
+
self.__objects["artist"] = {
|
|
246
|
+
artist: [
|
|
247
|
+
(
|
|
248
|
+
YTVideoInfo(**item)
|
|
249
|
+
if item != NOT_FOUND.__dict__
|
|
250
|
+
else NOT_FOUND
|
|
251
|
+
)
|
|
252
|
+
for item in playlist
|
|
253
|
+
]
|
|
254
|
+
for artist, playlist in loaded_objects["artist"].items()
|
|
255
|
+
}
|
|
256
|
+
else:
|
|
257
|
+
self.__objects["artist"] = {}
|
|
258
|
+
|
|
259
|
+
if "youtube" in loaded_objects:
|
|
260
|
+
self.__objects["youtube"] = {
|
|
261
|
+
key: (
|
|
262
|
+
YTVideoInfo(**value)
|
|
263
|
+
if value != NOT_FOUND.__dict__
|
|
264
|
+
else NOT_FOUND
|
|
265
|
+
)
|
|
266
|
+
for key, value in loaded_objects["youtube"].items()
|
|
267
|
+
}
|
|
268
|
+
else:
|
|
269
|
+
self.__objects["youtube"] = {}
|
|
270
|
+
|
|
271
|
+
if "yt_likes" in loaded_objects:
|
|
272
|
+
self.__objects["yt_likes"] = {
|
|
273
|
+
key: (
|
|
274
|
+
YTVideoInfo(**value)
|
|
275
|
+
if value != NOT_FOUND.__dict__
|
|
276
|
+
else NOT_FOUND
|
|
277
|
+
)
|
|
278
|
+
for key, value in loaded_objects["yt_likes"].items()
|
|
279
|
+
}
|
|
280
|
+
else:
|
|
281
|
+
self.__objects["yt_likes"] = {}
|
|
282
|
+
except (FileNotFoundError, JSONDecodeError):
|
|
283
|
+
self.__objects = {
|
|
284
|
+
"metadata": {},
|
|
285
|
+
"artist": {},
|
|
286
|
+
"youtube": {},
|
|
287
|
+
"yt_likes": {},
|
|
288
|
+
"spotify_likes": {},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@overload
|
|
292
|
+
def get(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
query: str,
|
|
296
|
+
query_type: Literal["metadata"],
|
|
297
|
+
alt_query: str = "",
|
|
298
|
+
) -> Metadata: ...
|
|
299
|
+
|
|
300
|
+
@overload
|
|
301
|
+
def get(
|
|
302
|
+
self,
|
|
303
|
+
*,
|
|
304
|
+
query: str,
|
|
305
|
+
query_type: Literal["artist"],
|
|
306
|
+
alt_query: str = "",
|
|
307
|
+
) -> list[Metadata]: ...
|
|
308
|
+
|
|
309
|
+
@overload
|
|
310
|
+
def get(
|
|
311
|
+
self,
|
|
312
|
+
*,
|
|
313
|
+
query: str,
|
|
314
|
+
query_type: Literal["youtube"],
|
|
315
|
+
alt_query: str = "",
|
|
316
|
+
) -> YTVideoInfo: ...
|
|
317
|
+
|
|
318
|
+
@overload
|
|
319
|
+
def get(
|
|
320
|
+
self,
|
|
321
|
+
*,
|
|
322
|
+
query: str,
|
|
323
|
+
query_type: Literal["yt_likes"],
|
|
324
|
+
alt_query: str = "",
|
|
325
|
+
) -> YTVideoInfo: ...
|
|
326
|
+
|
|
327
|
+
def get(
|
|
328
|
+
self,
|
|
329
|
+
*,
|
|
330
|
+
query: str,
|
|
331
|
+
query_type: MediaProviders,
|
|
332
|
+
alt_query: str = "",
|
|
333
|
+
):
|
|
334
|
+
logger.debug(f"[Cache] Reading: {query} [{query_type}]")
|
|
335
|
+
query = query.replace(" Audio", "")
|
|
336
|
+
|
|
337
|
+
cache = self.__objects[query_type].get(query) or self.__objects[query_type].get(
|
|
338
|
+
alt_query
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if isinstance(cache, Sentinel):
|
|
342
|
+
logger.debug(f"[Cache] Miss: {query}")
|
|
343
|
+
raise SongNotFound(query)
|
|
344
|
+
|
|
345
|
+
else:
|
|
346
|
+
logger.debug(f"[Cache] Hit: {cache}" if cache else "[Cache] No entry")
|
|
347
|
+
return cache
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from tenacity import retry as _retry
|
|
2
|
+
from tenacity import retry_if_exception
|
|
3
|
+
|
|
4
|
+
from spots_cli.models import SongNotFound
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_retryable_exception(exc: BaseException) -> bool:
|
|
8
|
+
# Only retry transient failures
|
|
9
|
+
return not isinstance(exc, SongNotFound)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def retry(*args, **kwargs):
|
|
13
|
+
"""
|
|
14
|
+
Project-wide retry decorator.
|
|
15
|
+
- Never retries SongNotFound
|
|
16
|
+
- Always re-raises the final exception
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
user_retry = kwargs.pop("retry", None)
|
|
20
|
+
|
|
21
|
+
base_retry = retry_if_exception(is_retryable_exception)
|
|
22
|
+
|
|
23
|
+
if user_retry is not None:
|
|
24
|
+
# Combine user retry conditions with ours
|
|
25
|
+
retry_condition = base_retry & user_retry
|
|
26
|
+
else:
|
|
27
|
+
retry_condition = base_retry
|
|
28
|
+
|
|
29
|
+
return _retry(
|
|
30
|
+
*args,
|
|
31
|
+
retry=retry_condition,
|
|
32
|
+
reraise=True,
|
|
33
|
+
**kwargs,
|
|
34
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from spots_cli.integrations.spotify_user_playlist_modify import SpotifyUserPlaylistModify
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from tenacity import stop_after_delay
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from spots_cli.engine import retry
|
|
8
|
+
from spots_cli.models import SpotifyUnavailableError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from spots_cli.clients import SpotifyClient
|
|
12
|
+
from spots_cli.bootstrap.container import Clients
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SpotifyUserPlaylistModify:
|
|
19
|
+
"""A class to retrieve metadata for a spotify track, album or playlist
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
track_url (str): The spotify url to be processed
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *, clients: Clients) -> None:
|
|
26
|
+
self.clients = clients
|
|
27
|
+
|
|
28
|
+
def _client(self) -> SpotifyClient:
|
|
29
|
+
if not self.clients.spotify:
|
|
30
|
+
raise SpotifyUnavailableError(
|
|
31
|
+
"Spotify client is not configured. Enable Spotify features in your environment."
|
|
32
|
+
)
|
|
33
|
+
return self.clients.spotify
|
|
34
|
+
|
|
35
|
+
@retry(stop=stop_after_delay(30))
|
|
36
|
+
def modify_saved_tracks_playlist(self, action: str, tracks: str):
|
|
37
|
+
# update scope for write
|
|
38
|
+
self.clients.secrets.write(key="scope", value="user-library-modify")
|
|
39
|
+
self._client().signin()
|
|
40
|
+
|
|
41
|
+
logger.info(f"Removing {tracks} from playlist...")
|
|
42
|
+
if action == "add":
|
|
43
|
+
self._client().client.current_user_saved_tracks_add(tracks)
|
|
44
|
+
elif action == "delete":
|
|
45
|
+
self._client().client.current_user_saved_tracks_delete([tracks])
|
|
46
|
+
else:
|
|
47
|
+
raise TypeError("`delete` or `add` actions only")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from spots_cli.models.artist_metadata import ArtistMetadata
|
|
2
|
+
from spots_cli.models.errors import (
|
|
3
|
+
TitleExistsError,
|
|
4
|
+
InvalidURL,
|
|
5
|
+
SongNotFound,
|
|
6
|
+
VersionSkipped,
|
|
7
|
+
EmptySpotifyLikes,
|
|
8
|
+
YouTubeQuotaExceeded,
|
|
9
|
+
SpotifyUnavailableError,
|
|
10
|
+
YouTubeUnavailableError,
|
|
11
|
+
InvalidSearchFormat,
|
|
12
|
+
)
|
|
13
|
+
from spots_cli.models.helper_models import (
|
|
14
|
+
ArtistAndTitle,
|
|
15
|
+
SearchResponseSingle,
|
|
16
|
+
SearchResponseMultiple,
|
|
17
|
+
)
|
|
18
|
+
from spots_cli.models.media_resource import MediaResourceSingle, MediaResourcePlaylist
|
|
19
|
+
from spots_cli.models.metadata import Metadata
|
|
20
|
+
from spots_cli.models.metadata_provider import MetadataProvider
|
|
21
|
+
from spots_cli.models.playlist_info import PlaylistInfo
|
|
22
|
+
from spots_cli.models.search_provider import SearchProvider, ArtistInfo
|
|
23
|
+
from spots_cli.models.sentinel import Sentinel
|
|
24
|
+
from spots_cli.models.yt_video_info import YTVideoInfo
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from spots_cli.models import PlaylistInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ArtistMetadata:
|
|
12
|
+
"""
|
|
13
|
+
Metadata for an artist.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
albums list[PlaylistInfo]: a list of metadata for each of the artist's albums/
|
|
17
|
+
cover (str): The artist's cover url.
|
|
18
|
+
name (str): The artist's name.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
albums: list[PlaylistInfo]
|
|
22
|
+
cover: str
|
|
23
|
+
name: str
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Custom error classes"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TitleExistsError(Exception):
|
|
5
|
+
"""File already downloaded"""
|
|
6
|
+
|
|
7
|
+
def __init__(self, title: str):
|
|
8
|
+
self.message = f"{title} already downloaded"
|
|
9
|
+
super().__init__(self.message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidURL(Exception):
|
|
13
|
+
"""Url not valid"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, url: str):
|
|
16
|
+
self.message = f"{url} is invalid"
|
|
17
|
+
super().__init__(self.message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SongNotFound(Exception):
|
|
21
|
+
"""A search term is not found"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, query: str):
|
|
24
|
+
self.message = f"{query} not found"
|
|
25
|
+
super().__init__(self.message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class VersionSkipped(Exception):
|
|
29
|
+
"""A search term contains blacklisted keywords"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, query: str):
|
|
32
|
+
self.message = f"[Blacklisted] {query} skipped"
|
|
33
|
+
super().__init__(self.message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class YouTubeQuotaExceeded(Exception):
|
|
37
|
+
"""The YouTube API quota has been exceeded"""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self.message = "The YouTube API quota has been exceeded"
|
|
41
|
+
super().__init__(self.message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EmptySpotifyLikes(Exception):
|
|
45
|
+
"""The YouTube API quota has been exceeded"""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self.message = "The YouTube API quota has been exceeded"
|
|
49
|
+
super().__init__(self.message)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InvalidSearchFormat(Exception):
|
|
53
|
+
"""Invalid search query format"""
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self.message = "Use format `Artist - Title` for search queries"
|
|
57
|
+
super().__init__(self.message)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class YouTubeUnavailableError(RuntimeError):
|
|
61
|
+
"""YouTube account features unavailable"""
|
|
62
|
+
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SpotifyUnavailableError(RuntimeError):
|
|
67
|
+
"""Spotify search features unavailable"""
|
|
68
|
+
|
|
69
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from spots_cli.models import YTVideoInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ArtistAndTitle:
|
|
12
|
+
title: str
|
|
13
|
+
artist: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SearchResponseSingle:
|
|
18
|
+
result: YTVideoInfo
|
|
19
|
+
is_cached: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SearchResponseMultiple:
|
|
24
|
+
result: list[YTVideoInfo]
|
|
25
|
+
is_cached: bool
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from spots_cli.models import PlaylistInfo, YTVideoInfo, Metadata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MediaResourceSingle:
|
|
12
|
+
resource_type: Literal["single"]
|
|
13
|
+
metadata: Metadata
|
|
14
|
+
video_info: YTVideoInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MediaResourcePlaylist:
|
|
19
|
+
resource_type: Literal["playlist"]
|
|
20
|
+
playlist_info: PlaylistInfo
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Metadata:
|
|
6
|
+
"""Creates a metadata object
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
title (str): the songs title
|
|
10
|
+
artist (str): the artist(s)
|
|
11
|
+
link (str): the link of the song
|
|
12
|
+
cover (str, optional): the url for the cover image. Defaults to ""
|
|
13
|
+
tracknumber (str, optional): the song's position in an album. `position/album_length`. Defaults to ""
|
|
14
|
+
album (str, optional): the album name. Defaults to ""
|
|
15
|
+
lyrics (str, optional): the lyrics of the song. Defaults to ""
|
|
16
|
+
release_date (str | None, optional): the release date of the song. Defaults to None.
|
|
17
|
+
preview_url (str | None, optional): A link to a 30 second preview (MP3 format) of the track. Defaults to None.
|
|
18
|
+
artist_cover (str | None, optional): The artist's cover image. Defauls to None.
|
|
19
|
+
artist_id (str): The artist's id.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
title: str
|
|
23
|
+
artist: str
|
|
24
|
+
link: str
|
|
25
|
+
artist_id: str
|
|
26
|
+
cover: str | None = ""
|
|
27
|
+
tracknumber: str | None = ""
|
|
28
|
+
album: str | None = ""
|
|
29
|
+
lyrics: str | None = ""
|
|
30
|
+
release_date: str | None = None
|
|
31
|
+
preview_url: str | None = None
|
|
32
|
+
artist_cover: str | None = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def full_title(self):
|
|
36
|
+
return f"{self.artist} - {self.title}"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, overload, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from spots_cli.models import Metadata
|
|
8
|
+
from spots_cli.bootstrap.container import Core, Clients
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MetadataProvider(ABC):
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
core: Core,
|
|
18
|
+
clients: Clients,
|
|
19
|
+
):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@overload
|
|
23
|
+
def get(self, *, track_id: str, search_result: None = None) -> Metadata: ...
|
|
24
|
+
|
|
25
|
+
@overload
|
|
26
|
+
def get(
|
|
27
|
+
self, *, track_id: None = None, search_result: dict[str, Any]
|
|
28
|
+
) -> Metadata: ...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
track_id: str | None = None,
|
|
35
|
+
search_result: dict[str, Any] | None = None,
|
|
36
|
+
) -> Metadata:
|
|
37
|
+
"""
|
|
38
|
+
Retrieves metadata for a track
|
|
39
|
+
|
|
40
|
+
Arguments:
|
|
41
|
+
track_id (str, Optional): the track id to retrieve data from. None if search_result passed. Defaults to None.
|
|
42
|
+
search_result (dict[str, Any], Optional): Pre-fetched result data. None if track_id passed.Defaults to None.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Metadata: an object with retrieved data
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
SongNotFound: if spotify search results empty.
|
|
49
|
+
InvalidURL: if url invalid.
|
|
50
|
+
MetadataNotFound: if metadata not found for id.
|
|
51
|
+
"""
|
|
52
|
+
pass
|