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
spots_cli/__init__.py ADDED
@@ -0,0 +1,125 @@
1
+ from logging import getLogger
2
+ from os.path import exists
3
+
4
+ from .bootstrap.container import Container
5
+ from .models import (
6
+ MediaResourceSingle,
7
+ SongNotFound,
8
+ TitleExistsError,
9
+ InvalidSearchFormat,
10
+ InvalidURL,
11
+ )
12
+
13
+ logger = getLogger(__name__)
14
+
15
+
16
+ class Spots:
17
+ def __init__(self):
18
+ from spots_cli.setup_env import main as setup, FILENAME as setup_file
19
+
20
+ if not exists(setup_file):
21
+ setup()
22
+
23
+ self.container = Container()
24
+ self.downloader = self.container.app.downloader
25
+
26
+ def download(self, query: str):
27
+ """Downloads a song
28
+
29
+ Args:
30
+ query (str): The query to be downloaded. Can be either a query to be searched for, or a direct Spotify or YouTube download url.
31
+ """
32
+ # handle direct link
33
+ if "https://" in query:
34
+ logger.info(f"Processing url: {query}")
35
+ try:
36
+ resolved_url = self.container.app.resolver.resolve(url=query)
37
+ except InvalidURL as e:
38
+ logger.info(e)
39
+ return
40
+
41
+ # handle single
42
+ if isinstance(resolved_url, MediaResourceSingle):
43
+ try:
44
+ logger.info(f"{query} found, downloading now...")
45
+ self.downloader.download(
46
+ video_info=resolved_url.video_info,
47
+ metadata=resolved_url.metadata,
48
+ )
49
+ self.container.core.storage.save()
50
+ except (TitleExistsError, SongNotFound) as e:
51
+ logger.info(e)
52
+ return
53
+ else:
54
+ playlist_info = resolved_url.playlist_info
55
+ playlist_songs = zip(
56
+ playlist_info.provider_metadata, playlist_info.youtube_metadata
57
+ )
58
+
59
+ for index, [provider, youtube] in enumerate(playlist_songs):
60
+ logger.info(
61
+ f"Processing song: {index + 1}/{len(playlist_info.provider_metadata)}"
62
+ )
63
+ try:
64
+ self.downloader.download(video_info=youtube, metadata=provider)
65
+ except (SongNotFound, TitleExistsError) as e:
66
+ logger.info(e)
67
+ continue
68
+ self.container.core.storage.save()
69
+ # search query
70
+ else:
71
+ try:
72
+ logger.info(f"Searching for {query}...")
73
+ provider_result = self.container.domain.provider_search.search_track(
74
+ query
75
+ )
76
+ except SongNotFound:
77
+ logger.debug(
78
+ "Query not found by provider, falling back to YouTube video details."
79
+ )
80
+ provider_result = None
81
+ except InvalidSearchFormat as e:
82
+ logger.error(e)
83
+ return
84
+
85
+ search_result = self.container.domain.youtube_search.video_search(
86
+ query=query, is_general_search=True
87
+ )
88
+
89
+ if search_result.is_cached:
90
+ best_match = search_result.result[0]
91
+ else:
92
+ best_match = (
93
+ self.container.core.matcher.find_best_match(
94
+ search_results=search_result.result, metadata=provider_result
95
+ )
96
+ if provider_result
97
+ else search_result.result[0]
98
+ )
99
+
100
+ metadata = (
101
+ provider_result
102
+ if provider_result
103
+ else self.container.domain.youtube_metadata.get(best_match)
104
+ )
105
+
106
+ try:
107
+ logger.info(f"{query} found, downloading now...")
108
+ self.downloader.download(video_info=best_match, metadata=metadata)
109
+ except (SongNotFound, TitleExistsError) as e:
110
+ logger.info(e)
111
+
112
+ self.container.core.storage.new(
113
+ query=query, result=best_match, query_type="youtube"
114
+ )
115
+ self.container.core.storage.save()
116
+
117
+ def transfer_spotify_likes(self):
118
+ """Transfers all spotify likes to YouTube library"""
119
+ try:
120
+ self.container.app.youtube_user_playlist.transfer_spotify_likes_to_yt()
121
+ except SongNotFound:
122
+ logger.info("No liked songs found.")
123
+
124
+
125
+ __all__ = ["Spots"]
@@ -0,0 +1,5 @@
1
+ from spots_cli.app.media_resolver import MediaResolver
2
+ from spots_cli.app.downloader import Downloader
3
+ from spots_cli.app.youtube_user_playlist import YouTubeUserPlaylist
4
+ from spots_cli.app.spotify_playlist_compilation import SpotifyPlaylistCompilation
5
+ from spots_cli.app.domain_resolver import DomainResolver
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Sequence, overload
5
+
6
+ from spots_cli.models import Metadata, SongNotFound, Sentinel, YTVideoInfo
7
+
8
+ if TYPE_CHECKING:
9
+ from spots_cli.bootstrap.container import Core
10
+ from spots_cli.models import SearchProvider
11
+ from spots_cli.services import YoutubeSearchService
12
+
13
+
14
+ @dataclass
15
+ class MatchingDomainResults:
16
+ provider: list[Metadata]
17
+ youtube: list[YTVideoInfo]
18
+
19
+
20
+ @dataclass
21
+ class ResolvedDomainResult:
22
+ provider: Metadata
23
+ youtube: YTVideoInfo
24
+
25
+
26
+ class DomainResolver:
27
+ def __init__(
28
+ self,
29
+ core: Core,
30
+ provider_search: SearchProvider,
31
+ youtube_search: YoutubeSearchService,
32
+ ) -> None:
33
+ self.core = core
34
+ self.provider_search = provider_search
35
+ self.youtube_search = youtube_search
36
+
37
+ @overload
38
+ def filter_matching_domain_results(
39
+ self,
40
+ *,
41
+ provider_results: Sequence[Metadata | Sentinel],
42
+ youtube_results: None = None,
43
+ ) -> MatchingDomainResults: ...
44
+
45
+ @overload
46
+ def filter_matching_domain_results(
47
+ self,
48
+ *,
49
+ provider_results: None = None,
50
+ youtube_results: Sequence[YTVideoInfo | Sentinel],
51
+ ) -> MatchingDomainResults: ...
52
+
53
+ def filter_matching_domain_results(
54
+ self,
55
+ *,
56
+ provider_results: Sequence[Metadata | Sentinel] | None = None,
57
+ youtube_results: Sequence[YTVideoInfo | Sentinel] | None = None,
58
+ ) -> MatchingDomainResults:
59
+ provider_playlist: list[Metadata] = []
60
+ youtube_videos: list[YTVideoInfo] = []
61
+
62
+ if youtube_results is not None:
63
+ for index, video in enumerate(youtube_results):
64
+ if isinstance(video, Sentinel):
65
+ continue
66
+
67
+ video_title = video.full_title
68
+
69
+ try:
70
+ spotify_search_result = self.provider_search.search_track(
71
+ video_title
72
+ )
73
+ except SongNotFound:
74
+ self.core.storage.new(
75
+ query=video_title, result=Sentinel(), query_type="metadata"
76
+ )
77
+ self.core.storage.new(
78
+ query=video_title, result=Sentinel(), query_type="youtube"
79
+ )
80
+ continue
81
+
82
+ tracks_match = self.core.matcher.match_tracks(
83
+ video_info=video, metadata=spotify_search_result
84
+ )
85
+ if not tracks_match:
86
+ self.core.storage.new(
87
+ query=video_title, result=Sentinel(), query_type="metadata"
88
+ )
89
+ self.core.storage.new(
90
+ query=video_title, result=Sentinel(), query_type="youtube"
91
+ )
92
+ continue
93
+ else:
94
+ self.core.storage.new(
95
+ query=video_title,
96
+ result=spotify_search_result,
97
+ query_type="metadata",
98
+ )
99
+ self.core.storage.new(
100
+ query=video_title, result=video, query_type="youtube"
101
+ )
102
+ provider_playlist.append(spotify_search_result)
103
+ youtube_videos.append(video)
104
+
105
+ if (index % 10 == 0) or index == (len(youtube_results) - 1):
106
+ self.core.storage.save()
107
+
108
+ if provider_results is not None:
109
+ for track in provider_results:
110
+ if isinstance(track, Sentinel):
111
+ continue
112
+
113
+ provider_title = track.full_title
114
+
115
+ try:
116
+ youtube_search_results = self.youtube_search.video_search(
117
+ query=provider_title, is_general_search=True
118
+ )
119
+ except SongNotFound:
120
+ self.core.storage.new(
121
+ query=provider_title, result=Sentinel(), query_type="metadata"
122
+ )
123
+ self.core.storage.new(
124
+ query=provider_title, result=Sentinel(), query_type="youtube"
125
+ )
126
+ continue
127
+
128
+ if youtube_search_results.is_cached:
129
+ cache = youtube_search_results.result[0]
130
+ if isinstance(cache, Sentinel):
131
+ continue
132
+ else:
133
+ provider_playlist.append(track)
134
+ youtube_videos.append(cache)
135
+
136
+ else:
137
+ try:
138
+ best_match = self.core.matcher.find_best_match(
139
+ search_results=youtube_search_results.result, metadata=track
140
+ )
141
+ except SongNotFound:
142
+ self.core.storage.new(
143
+ query=provider_title,
144
+ result=Sentinel(),
145
+ query_type="metadata",
146
+ )
147
+ self.core.storage.new(
148
+ query=provider_title,
149
+ result=Sentinel(),
150
+ query_type="youtube",
151
+ )
152
+ continue
153
+
154
+ provider_playlist.append(track)
155
+ youtube_videos.append(best_match)
156
+
157
+ return MatchingDomainResults(provider=provider_playlist, youtube=youtube_videos)
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from hashlib import md5
4
+ from os.path import join, exists
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from spots_cli.models import TitleExistsError, Metadata, YTVideoInfo
9
+
10
+ if TYPE_CHECKING:
11
+ from spots_cli.bootstrap.container import Core, Clients
12
+
13
+
14
+ class Downloader:
15
+ """A service for interacting with YouTube."""
16
+
17
+ def __init__(self, *, core: Core, clients: Clients):
18
+ self.core = core
19
+ self.clients = clients
20
+
21
+ def download(
22
+ self, *, video_info: YTVideoInfo, metadata: Metadata, directory_path: str = ""
23
+ ) -> bool:
24
+ """Downloads a YouTube video as mp3
25
+
26
+ Args:
27
+ video_info (YTVideoInfo): the extracted info.
28
+ directory_path (str, Optional): the folder to be downloaded to. Defaults to the root of the Music folder.
29
+
30
+ Returns:
31
+ bool: True if downloaded successfully.
32
+ """
33
+ url = "https://youtube.com/watch?v=" + video_info.id
34
+ title = f"{video_info.uploader} - {video_info.title}"
35
+
36
+ # normalize title
37
+ # '/' will read file name as folder in *nix systems
38
+ filename = title.replace("/", "|")
39
+ # Check if the file name length is too long, truncate if necessary
40
+ max_filename_length = 255 # Maximum allowed file name length on most systems
41
+ if len(filename) > max_filename_length:
42
+ file_hash = md5(filename.encode()).hexdigest()
43
+ filename = file_hash[:25]
44
+
45
+ # check if downloaded already
46
+ if self.core.history.read(filename):
47
+ raise TitleExistsError(filename)
48
+
49
+ download_folder = Path.home() / "Downloads"
50
+ download_folder = download_folder if exists(download_folder) else Path.home()
51
+
52
+ download_path = join(
53
+ download_folder, directory_path, f"{filename}.{video_info.audio_ext}"
54
+ )
55
+ converted_path = join(download_folder, directory_path, f"{filename}.mp3")
56
+
57
+ # set template for download titles
58
+ self.clients.ytdlp.options = {"outtmpl": download_path}
59
+
60
+ # download video
61
+ self.clients.ytdlp.client.download([url])
62
+
63
+ self.clients.ytdlp.reset_options()
64
+
65
+ # post download processing
66
+ converted = self.core.converter.convert_to_mp3(
67
+ old_file=download_path, new_file=converted_path
68
+ )
69
+ if converted:
70
+ metadata_updated = self.core.converter.update_metadata(
71
+ audio_path=converted_path, metadata=metadata
72
+ )
73
+ if metadata_updated:
74
+ self.core.history.write(filename)
75
+ else:
76
+ return False
77
+
78
+ return True
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ from logging import getLogger
4
+ from os.path import join
5
+ from tenacity import stop_after_delay
6
+ from typing import TYPE_CHECKING, Any, cast
7
+
8
+ from spots_cli.engine import retry
9
+ from spots_cli.models import (
10
+ InvalidURL,
11
+ SongNotFound,
12
+ MediaResourceSingle,
13
+ MediaResourcePlaylist,
14
+ PlaylistInfo,
15
+ Sentinel,
16
+ YTVideoInfo,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from spots_cli.bootstrap.container import Core, Domain, Clients
21
+ from spots_cli.app import DomainResolver
22
+
23
+
24
+ logger = getLogger(__name__)
25
+
26
+
27
+ class MediaResolver:
28
+ def __init__(
29
+ self,
30
+ core: Core,
31
+ domain: Domain,
32
+ clients: Clients,
33
+ domain_resolver: DomainResolver,
34
+ ) -> None:
35
+ self.domain_resolver = domain_resolver
36
+ self.core = core
37
+ self.domain = domain
38
+ self.clients = clients
39
+
40
+ @retry(stop=stop_after_delay(60))
41
+ def resolve(self, *, url: str) -> MediaResourceSingle | MediaResourcePlaylist:
42
+ """Converts a youtube or spotify url to mp3, or a youtube video to mp3
43
+
44
+ Args:
45
+ url (str): url to be converted
46
+ single: (bool, optional): don't search for recommended tracks if false. Defaults to False
47
+
48
+ Raises:
49
+ InvalidURL: if provided url not available
50
+ """
51
+ # process spotify link
52
+ if "spotify" in url:
53
+ # single
54
+ if "track" in url:
55
+ # retrieve Spotify data
56
+ logger.debug("Resource type: single")
57
+
58
+ track_id = url.split("/")[-1]
59
+ metadata = self.domain.provider_metadata.get(track_id=track_id)
60
+
61
+ # search on YouTube
62
+ youtube_results = self.domain.youtube_search.video_search(
63
+ query=metadata.full_title, is_general_search=True
64
+ )
65
+ best_match = self.core.matcher.find_best_match(
66
+ search_results=youtube_results.result, metadata=metadata
67
+ )
68
+
69
+ return MediaResourceSingle(
70
+ resource_type="single",
71
+ metadata=metadata,
72
+ video_info=best_match,
73
+ )
74
+ # playlist
75
+ else:
76
+ logger.debug("Resource type: playlist")
77
+ playlist_info = self.domain.provider_search.search_playlist(url)
78
+
79
+ return MediaResourcePlaylist(
80
+ resource_type="playlist", playlist_info=playlist_info
81
+ )
82
+
83
+ # process youtube link
84
+ elif "youtu" in url:
85
+ logger.debug("URL type: YouTube")
86
+ # playlist
87
+ if "playlist" in url:
88
+ logger.debug("Resource type: playlist")
89
+ playlist_search = self.clients.ytdlp.client.extract_info(
90
+ url, download=False
91
+ )
92
+
93
+ if not playlist_search:
94
+ raise SongNotFound(url)
95
+
96
+ playlist_search = cast(dict[str, Any], playlist_search)
97
+
98
+ videos_list = [
99
+ YTVideoInfo(
100
+ id=result["id"],
101
+ filesize=self.domain.youtube_search.get_video_size(result),
102
+ title=result["title"],
103
+ uploader=result["uploader"],
104
+ audio_ext=result["audio_ext"],
105
+ )
106
+ for result in playlist_search["entries"]
107
+ ]
108
+
109
+ domain_matches = self.domain_resolver.filter_matching_domain_results(
110
+ youtube_results=videos_list
111
+ )
112
+
113
+ cover = self.clients.secrets.read(
114
+ key="static_playlist_cover_name", alt="youtube-playlist.jpg"
115
+ )
116
+ playlist_name = playlist_search["title"]
117
+
118
+ playlist_info = PlaylistInfo(
119
+ name=playlist_name,
120
+ cover=cover,
121
+ provider_metadata=domain_matches.provider,
122
+ youtube_metadata=domain_matches.youtube,
123
+ )
124
+
125
+ return MediaResourcePlaylist(
126
+ resource_type="playlist", playlist_info=playlist_info
127
+ )
128
+ else:
129
+ logger.debug("Resource type: single")
130
+
131
+ video_info = self.domain.youtube_search.video_search(
132
+ query=url, is_general_search=False
133
+ )
134
+ if video_info.is_cached:
135
+ cached_metadata = self.core.storage.get(
136
+ query=url, query_type="metadata"
137
+ )
138
+ return MediaResourceSingle(
139
+ resource_type="single",
140
+ metadata=cached_metadata,
141
+ video_info=video_info.result,
142
+ )
143
+
144
+ yt_title = video_info.result.full_title
145
+ try:
146
+ metadata = self.domain.provider_search.search_track(yt_title)
147
+ except SongNotFound:
148
+ self.core.storage.new(
149
+ query=yt_title, result=Sentinel(), query_type="metadata"
150
+ )
151
+ self.core.storage.new(
152
+ query=yt_title, result=Sentinel(), query_type="youtube"
153
+ )
154
+ raise
155
+
156
+ tracks_match = self.core.matcher.match_tracks(
157
+ metadata=metadata, video_info=video_info.result
158
+ )
159
+ if not tracks_match:
160
+ self.core.storage.new(
161
+ query=yt_title, result=Sentinel(), query_type="metadata"
162
+ )
163
+ self.core.storage.new(
164
+ query=yt_title, result=Sentinel(), query_type="youtube"
165
+ )
166
+ raise SongNotFound(yt_title)
167
+ else:
168
+ self.core.storage.new(
169
+ query=yt_title, result=metadata, query_type="metadata"
170
+ )
171
+ self.core.storage.new(
172
+ query=yt_title, result=video_info.result, query_type="youtube"
173
+ )
174
+ return MediaResourceSingle(
175
+ resource_type="single",
176
+ metadata=metadata,
177
+ video_info=video_info.result,
178
+ )
179
+ else:
180
+ raise InvalidURL(url)