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,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,3 @@
1
+ from spots_cli.engine import FileStorage
2
+
3
+ storage = FileStorage()
@@ -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