SpotDown 1.0.0__py3-none-any.whl → 1.3.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.
- SpotDown/downloader/youtube_downloader.py +3 -2
- SpotDown/extractor/spotify_extractor.py +165 -302
- SpotDown/main.py +2 -0
- SpotDown/upload/version.py +1 -1
- SpotDown/utils/console_utils.py +1 -1
- SpotDown/utils/ffmpeg_installer.py +374 -0
- SpotDown/utils/file_utils.py +105 -1
- {spotdown-1.0.0.dist-info → spotdown-1.3.0.dist-info}/METADATA +28 -11
- spotdown-1.3.0.dist-info/RECORD +21 -0
- spotdown-1.0.0.dist-info/RECORD +0 -20
- {spotdown-1.0.0.dist-info → spotdown-1.3.0.dist-info}/WHEEL +0 -0
- {spotdown-1.0.0.dist-info → spotdown-1.3.0.dist-info}/entry_points.txt +0 -0
- {spotdown-1.0.0.dist-info → spotdown-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {spotdown-1.0.0.dist-info → spotdown-1.3.0.dist-info}/top_level.txt +0 -0
@@ -15,7 +15,7 @@ from rich.console import Console
|
|
15
15
|
|
16
16
|
# Internal utils
|
17
17
|
from SpotDown.utils.config_json import config_manager
|
18
|
-
from SpotDown.utils.file_utils import
|
18
|
+
from SpotDown.utils.file_utils import file_utils
|
19
19
|
|
20
20
|
|
21
21
|
# Variable
|
@@ -25,7 +25,7 @@ quality = config_manager.get("DOWNLOAD", "quality")
|
|
25
25
|
class YouTubeDownloader:
|
26
26
|
def __init__(self):
|
27
27
|
self.console = Console()
|
28
|
-
self.file_utils =
|
28
|
+
self.file_utils = file_utils
|
29
29
|
|
30
30
|
def download(self, video_info: Dict, spotify_info: Dict) -> bool:
|
31
31
|
"""
|
@@ -87,6 +87,7 @@ class YouTubeDownloader:
|
|
87
87
|
'--no-playlist',
|
88
88
|
'--embed-metadata',
|
89
89
|
'--add-metadata',
|
90
|
+
'--ffmpeg-location', self.file_utils.ffmpeg_path
|
90
91
|
]
|
91
92
|
|
92
93
|
if cover_path and cover_path.exists():
|
@@ -1,355 +1,218 @@
|
|
1
1
|
# 05.04.2024
|
2
2
|
|
3
3
|
import os
|
4
|
+
import re
|
5
|
+
import sys
|
4
6
|
import json
|
5
7
|
import logging
|
6
8
|
from typing import Dict, List, Optional
|
9
|
+
from dotenv import load_dotenv
|
7
10
|
|
8
11
|
|
9
|
-
# External
|
12
|
+
# External library
|
13
|
+
import spotipy
|
14
|
+
from spotipy.oauth2 import SpotifyClientCredentials
|
10
15
|
from rich.console import Console
|
11
|
-
from
|
12
|
-
|
13
|
-
|
14
|
-
# Internal utils
|
15
|
-
from SpotDown.utils.headers import get_userAgent
|
16
|
-
from SpotDown.utils.config_json import config_manager
|
16
|
+
from rich.progress import Progress
|
17
17
|
|
18
18
|
|
19
19
|
# Variable
|
20
20
|
console = Console()
|
21
|
-
|
22
|
-
|
21
|
+
load_dotenv()
|
22
|
+
|
23
|
+
|
24
|
+
def extract_track_id(spotify_url):
|
25
|
+
patterns = [
|
26
|
+
r'track/([a-zA-Z0-9]{22})',
|
27
|
+
r'spotify:track:([a-zA-Z0-9]{22})'
|
28
|
+
]
|
29
|
+
for pattern in patterns:
|
30
|
+
match = re.search(pattern, spotify_url)
|
31
|
+
if match:
|
32
|
+
return match.group(1)
|
33
|
+
return None
|
34
|
+
|
35
|
+
|
36
|
+
def extract_playlist_id(spotify_url):
|
37
|
+
patterns = [
|
38
|
+
r'playlist/([a-zA-Z0-9]{22})',
|
39
|
+
r'spotify:playlist:([a-zA-Z0-9]{22})'
|
40
|
+
]
|
41
|
+
for pattern in patterns:
|
42
|
+
match = re.search(pattern, spotify_url)
|
43
|
+
if match:
|
44
|
+
return match.group(1)
|
45
|
+
return None
|
23
46
|
|
24
47
|
|
25
48
|
class SpotifyExtractor:
|
26
49
|
def __init__(self):
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
50
|
+
client_id = os.getenv("SPOTIPY_CLIENT_ID")
|
51
|
+
client_secret = os.getenv("SPOTIPY_CLIENT_SECRET")
|
52
|
+
|
53
|
+
if not client_id or not client_secret:
|
54
|
+
console.print("[red]Missing Spotify credentials. Please create a .env file with SPOTIFY_CLIENT_ID and SPOTIPY_CLIENT_SECRET from https://developer.spotify.com/dashboard/")
|
55
|
+
sys.exit(1)
|
56
|
+
|
57
|
+
self.sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials(
|
58
|
+
client_id=client_id,
|
59
|
+
client_secret=client_secret
|
60
|
+
))
|
34
61
|
logging.info("SpotifyExtractor initialized")
|
35
62
|
|
36
63
|
def __enter__(self):
|
37
|
-
"""Context manager to automatically handle the browser"""
|
38
|
-
logging.info("Starting Playwright and launching browser")
|
39
|
-
self.playwright = sync_playwright().start()
|
40
|
-
self.browser = self.playwright.chromium.launch(headless=headless)
|
41
|
-
self.context = self.browser.new_context(
|
42
|
-
user_agent=self.user_agent, viewport={'width': 1280, 'height': 800}, ignore_https_errors=True
|
43
|
-
)
|
44
|
-
self.page = self.context.new_page()
|
45
64
|
return self
|
46
65
|
|
47
66
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
48
|
-
|
49
|
-
logging.info("Closing browser and stopping Playwright")
|
50
|
-
if self.browser:
|
51
|
-
self.browser.close()
|
52
|
-
if self.playwright:
|
53
|
-
self.playwright.stop()
|
67
|
+
pass
|
54
68
|
|
55
69
|
def extract_track_info(self, spotify_url: str, save_json: bool = False) -> Optional[Dict]:
|
56
|
-
|
57
|
-
|
70
|
+
track_id = extract_track_id(spotify_url)
|
71
|
+
if not track_id:
|
72
|
+
logging.error("Invalid Spotify track URL")
|
73
|
+
return None
|
58
74
|
|
59
|
-
Args:
|
60
|
-
spotify_url (str): Spotify URL of the track
|
61
|
-
save_json (bool): If True, saves the raw Spotify API JSON response in the 'log' folder
|
62
|
-
|
63
|
-
Returns:
|
64
|
-
Dict: Track information or None if an error occurs
|
65
|
-
"""
|
66
75
|
try:
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
# Extract Spotify data by intercepting API calls
|
71
|
-
spotify_data, raw_json = self._extract_spotify_data(spotify_url, return_raw=True)
|
72
|
-
|
73
|
-
if not spotify_data:
|
74
|
-
logging.info("Unable to extract data from Spotify")
|
75
|
-
console.print("[cyan]Unable to extract data from Spotify")
|
76
|
-
return None
|
77
|
-
|
78
|
-
# Save the JSON response if requested
|
79
|
-
if save_json and raw_json:
|
80
|
-
try:
|
81
|
-
logging.info("Saving Spotify API response JSON")
|
82
|
-
log_dir = os.path.join(os.getcwd(), "log")
|
83
|
-
os.makedirs(log_dir, exist_ok=True)
|
84
|
-
|
85
|
-
# Use title and artist for the filename if available
|
86
|
-
filename = "spotify_response.json"
|
87
|
-
|
88
|
-
if spotify_data.get("artist") and spotify_data.get("title"):
|
89
|
-
safe_artist = "".join(c for c in spotify_data["artist"] if c.isalnum() or c in " _-")
|
90
|
-
safe_title = "".join(c for c in spotify_data["title"] if c.isalnum() or c in " _-")
|
91
|
-
filename = f"{safe_artist} - {safe_title}.json"
|
92
|
-
|
93
|
-
filepath = os.path.join(log_dir, filename)
|
94
|
-
with open(filepath, "w", encoding="utf-8") as f:
|
95
|
-
json.dump(raw_json, f, ensure_ascii=False, indent=2)
|
96
|
-
|
97
|
-
console.print(f"[green]Spotify API response saved to {filepath}")
|
98
|
-
|
99
|
-
except Exception as e:
|
100
|
-
logging.error(f"Could not save JSON file: {e}")
|
101
|
-
console.print(f"[yellow]Warning: Could not save JSON file: {e}")
|
102
|
-
|
103
|
-
logging.info(f"Found track: {spotify_data['artist']} - {spotify_data['title']}")
|
104
|
-
console.print(f"[cyan]Found: [red]{spotify_data['artist']} - {spotify_data['title']}[/red]")
|
105
|
-
return spotify_data
|
76
|
+
# Extract track info
|
77
|
+
track = self.sp.track(track_id)
|
106
78
|
|
107
|
-
|
108
|
-
|
109
|
-
console.print(f"[cyan]Spotify extraction error: {e}")
|
110
|
-
return None
|
79
|
+
# Extract album info
|
80
|
+
album = track['album']
|
111
81
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
logging.info(f"Intercepting API calls for URL: {spotify_url}")
|
116
|
-
api_responses = []
|
117
|
-
|
118
|
-
def handle_request(request):
|
119
|
-
if (request.method == "POST" and "/pathfinder/v2/query" in request.url):
|
120
|
-
try:
|
121
|
-
response = request.response()
|
122
|
-
if response and response.status == 200:
|
123
|
-
try:
|
124
|
-
response_data = response.json()
|
125
|
-
|
126
|
-
if self._is_valid_track_data(response_data):
|
127
|
-
api_responses.append(response_data)
|
128
|
-
console.print("[green]Valid API response found")
|
129
|
-
|
130
|
-
except Exception as e:
|
131
|
-
logging.warning(f"Error parsing API response: {e}")
|
132
|
-
|
133
|
-
except Exception as e:
|
134
|
-
logging.warning(f"Error accessing response: {e}")
|
135
|
-
|
136
|
-
self.page.on("requestfinished", handle_request)
|
137
|
-
self.page.goto(spotify_url)
|
138
|
-
|
139
|
-
# Poll every 100ms, stop waiting as soon as a valid response is found or after 10 seconds
|
140
|
-
# This avoids unnecessary waiting after a valid API response is received
|
141
|
-
for _ in range(timeout * 10): # 100 * 100ms = 10000ms (10 seconds max)
|
142
|
-
if api_responses:
|
143
|
-
logging.info("Valid API response found, stopping polling")
|
144
|
-
break
|
145
|
-
|
146
|
-
self.page.wait_for_timeout(timeout * 10)
|
147
|
-
|
148
|
-
if not api_responses:
|
149
|
-
logging.info("No valid API responses found")
|
150
|
-
console.print("[cyan]No valid API responses found")
|
151
|
-
return (None, None) if return_raw else None
|
152
|
-
|
153
|
-
# Selects the most complete response
|
154
|
-
best_response = max(api_responses, key=lambda x: len(json.dumps(x)))
|
155
|
-
parsed = self._parse_spotify_response(best_response)
|
156
|
-
logging.info("Returning parsed Spotify API response")
|
157
|
-
return (parsed, best_response) if return_raw else parsed
|
82
|
+
# Process extracted data
|
83
|
+
release_date = album['release_date']
|
84
|
+
year = release_date.split('-')[0] if release_date else None
|
158
85
|
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
86
|
+
# Extract duration in seconds and formatted
|
87
|
+
duration_ms = track['duration_ms']
|
88
|
+
duration_seconds = duration_ms // 1000 if duration_ms else None
|
89
|
+
duration_formatted = f"{duration_seconds // 60}:{duration_seconds % 60:02d}" if duration_seconds else None
|
163
90
|
|
164
|
-
|
165
|
-
|
166
|
-
try:
|
167
|
-
track_union = data.get("data", {}).get("trackUnion", {})
|
168
|
-
return bool(track_union.get("name") and track_union.get("firstArtist", {}).get("items"))
|
169
|
-
|
170
|
-
except Exception:
|
171
|
-
logging.error("Error validating track data")
|
172
|
-
return False
|
91
|
+
# Extract cover URL
|
92
|
+
cover_url = album['images'][0]['url'] if album['images'] else None
|
173
93
|
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
#
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
artist_items = track_data.get("firstArtist", {}).get("items", [])
|
183
|
-
artist = artist_items[0].get("profile", {}).get("name", "") if artist_items else ""
|
184
|
-
|
185
|
-
# Extract album
|
186
|
-
album_data = track_data.get("albumOfTrack", {})
|
187
|
-
album = album_data.get("name", "")
|
188
|
-
|
189
|
-
# Extract year
|
190
|
-
release_date = album_data.get("date", {})
|
191
|
-
year = release_date.get("year") if release_date else None
|
192
|
-
|
193
|
-
# Extract duration
|
194
|
-
duration_ms = track_data.get("duration", {}).get("totalMilliseconds")
|
195
|
-
duration_seconds = duration_ms // 1000 if duration_ms else None
|
196
|
-
duration_formatted = self._format_seconds(duration_seconds) if duration_seconds else None
|
197
|
-
|
198
|
-
# Extract cover art
|
199
|
-
cover_url = ""
|
200
|
-
cover_sources = album_data.get("coverArt", {}).get("sources", [])
|
201
|
-
|
202
|
-
if cover_sources:
|
203
|
-
largest = max(
|
204
|
-
cover_sources,
|
205
|
-
key=lambda x: max(x.get("width", 0), x.get("height", 0))
|
206
|
-
)
|
207
|
-
cover_url = largest.get("url", "")
|
208
|
-
|
209
|
-
return {
|
210
|
-
'title': title,
|
211
|
-
'artist': artist,
|
212
|
-
'album': album,
|
94
|
+
# Extract artists
|
95
|
+
artists = [artist['name'] for artist in track['artists']]
|
96
|
+
|
97
|
+
# Compile track info
|
98
|
+
track_info = {
|
99
|
+
'artist': ', '.join(artists),
|
100
|
+
'title': track['name'],
|
101
|
+
'album': album['name'],
|
213
102
|
'year': year,
|
214
103
|
'duration_seconds': duration_seconds,
|
215
104
|
'duration_formatted': duration_formatted,
|
216
105
|
'cover_url': cover_url
|
217
106
|
}
|
218
107
|
|
108
|
+
if save_json:
|
109
|
+
log_dir = os.path.join(os.getcwd(), "log")
|
110
|
+
os.makedirs(log_dir, exist_ok=True)
|
111
|
+
|
112
|
+
# Create JSON file for track info
|
113
|
+
filename = f"{track_info['artist']} - {track_info['title']}.json"
|
114
|
+
filepath = os.path.join(log_dir, filename)
|
115
|
+
|
116
|
+
# Save track info to JSON
|
117
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
118
|
+
json.dump(track_info, f, ensure_ascii=False, indent=2)
|
119
|
+
|
120
|
+
return track_info
|
219
121
|
except Exception as e:
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
secs = seconds % 60
|
229
|
-
return f"{minutes}:{secs:02d}"
|
230
|
-
|
231
|
-
else:
|
232
|
-
hours = seconds // 3600
|
233
|
-
minutes = (seconds % 3600) // 60
|
234
|
-
secs = seconds % 60
|
235
|
-
return f"{hours}:{minutes:02d}:{secs:02d}"
|
122
|
+
error_msg = str(e)
|
123
|
+
logging.error(f"Spotify extraction error: {error_msg}")
|
124
|
+
|
125
|
+
if "invalid_client" in error_msg:
|
126
|
+
console.print("[red]Spotify credentials are invalid. Please check your .env file and obtain valid credentials from https://developer.spotify.com/dashboard/. Exiting.")
|
127
|
+
sys.exit(0)
|
128
|
+
|
129
|
+
return None
|
236
130
|
|
237
131
|
def extract_playlist_tracks(self, playlist_url: str) -> List[Dict]:
|
238
|
-
|
239
|
-
logging.info(f"Extracting playlist tracks from: {playlist_url}")
|
240
|
-
self.total_songs = None
|
241
|
-
self.playlist_items = []
|
242
|
-
console.print("[cyan]Extracting playlist tracks...")
|
132
|
+
playlist_id = extract_playlist_id(playlist_url)
|
243
133
|
|
134
|
+
if not playlist_id:
|
135
|
+
logging.error("Invalid Spotify playlist URL")
|
136
|
+
return []
|
137
|
+
|
244
138
|
try:
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
139
|
+
|
140
|
+
# Extract playlist info
|
141
|
+
playlist = self.sp.playlist(playlist_id)
|
142
|
+
total_tracks = playlist['tracks']['total']
|
143
|
+
tracks_info = []
|
144
|
+
offset = 0
|
145
|
+
limit = 100
|
146
|
+
console.print(f"[green]Playlist has [red]{total_tracks}[/red] tracks.")
|
147
|
+
|
148
|
+
with Progress() as progress:
|
149
|
+
task = progress.add_task("[cyan]Extracting tracks...", total=total_tracks)
|
150
|
+
|
151
|
+
while offset < total_tracks:
|
152
|
+
progress.update(task, advance=0, description=f"[cyan]Loading tracks {offset + 1}-{min(offset + limit, total_tracks)} of {total_tracks}...")
|
153
|
+
results = self.sp.playlist_items(
|
154
|
+
playlist_id,
|
155
|
+
offset=offset,
|
156
|
+
limit=limit,
|
157
|
+
fields='items(track(name,artists(name),album(name,release_date,images),duration_ms))'
|
158
|
+
)
|
159
|
+
|
160
|
+
if not results['items']:
|
161
|
+
break
|
162
|
+
|
163
|
+
for idx, item in enumerate(results['items']):
|
164
|
+
if item['track'] is None:
|
165
|
+
continue
|
166
|
+
|
167
|
+
# Extract track details
|
168
|
+
track = item['track']
|
169
|
+
|
170
|
+
# Extract album info
|
171
|
+
album = track['album']
|
172
|
+
|
173
|
+
# Process extracted data
|
174
|
+
#release_date = album['release_date']
|
175
|
+
#year = release_date.split('-')[0] if release_date else None
|
176
|
+
|
177
|
+
# Extract duration in seconds
|
178
|
+
duration_ms = track['duration_ms']
|
179
|
+
duration_seconds = duration_ms // 1000 if duration_ms else None
|
180
|
+
|
181
|
+
# Extract cover URL
|
182
|
+
cover_url = album['images'][0]['url'] if album['images'] else None
|
183
|
+
|
184
|
+
# Extract artists
|
185
|
+
artists = [artist['name'] for artist in track['artists']]
|
186
|
+
|
187
|
+
# Compile track info
|
188
|
+
track_info = {
|
189
|
+
"title": track['name'],
|
190
|
+
"artist": ', '.join(artists),
|
191
|
+
"album": album['name'],
|
192
|
+
"added_at": None,
|
193
|
+
"cover_art": cover_url,
|
194
|
+
"duration_ms": duration_ms,
|
195
|
+
"duration_seconds": duration_seconds,
|
196
|
+
"play_count": None
|
197
|
+
}
|
198
|
+
|
199
|
+
# Append to list
|
200
|
+
tracks_info.append(track_info)
|
201
|
+
progress.update(task, advance=1)
|
202
|
+
offset += limit
|
296
203
|
|
297
204
|
# Remove duplicates based on title and artist
|
298
205
|
unique = {}
|
299
|
-
for item in
|
206
|
+
for item in tracks_info:
|
300
207
|
key = (item.get("title", ""), item.get("artist", ""))
|
301
208
|
if key not in unique:
|
302
209
|
unique[key] = item
|
303
|
-
|
210
|
+
|
211
|
+
# Convert back to list
|
304
212
|
unique_tracks = list(unique.values())
|
305
|
-
|
213
|
+
console.print(f"[green]Extracted [red]{len(unique_tracks)}[/red] unique tracks from playlist")
|
306
214
|
return unique_tracks
|
307
|
-
|
308
|
-
except Exception as e:
|
309
|
-
logging.error(f"Error extracting playlist: {e}")
|
310
|
-
console.print(f"Error extracting playlist: {e}")
|
311
|
-
return []
|
312
|
-
|
313
|
-
def _parse_spotify_playlist_item(self, item: Dict) -> Dict:
|
314
|
-
"""Parses a single playlist item from Spotify API response"""
|
315
|
-
try:
|
316
|
-
# Extract added date
|
317
|
-
added_at = item.get("addedAt", {}).get("isoString", "")
|
318
|
-
|
319
|
-
# Extract track data
|
320
|
-
track_data = item.get("itemV2", {}).get("data", {})
|
321
|
-
|
322
|
-
# Extract album name
|
323
|
-
album_data = track_data.get("albumOfTrack", {})
|
324
|
-
album_name = album_data.get("name", "")
|
325
|
-
|
326
|
-
# Extract cover art URL
|
327
|
-
cover_art = album_data.get("coverArt", {}).get("sources", [{}])[0].get("url", "")
|
328
|
-
|
329
|
-
# Extract artist name
|
330
|
-
artist_items = album_data.get("artists", {}).get("items", [])
|
331
|
-
artist_name = artist_items[0].get("profile", {}).get("name", "") if artist_items else ""
|
332
|
-
|
333
|
-
# Extract track title
|
334
|
-
track_title = track_data.get("name", "")
|
335
|
-
|
336
|
-
# Extract duration in ms
|
337
|
-
duration_ms = track_data.get("trackDuration", {}).get("totalMilliseconds", 0)
|
338
|
-
|
339
|
-
# Extract play count
|
340
|
-
play_count = track_data.get("playcount", 0)
|
341
|
-
|
342
|
-
return {
|
343
|
-
"title": track_title,
|
344
|
-
"artist": artist_name,
|
345
|
-
"album": album_name,
|
346
|
-
"added_at": added_at,
|
347
|
-
"cover_art": cover_art,
|
348
|
-
"duration_ms": duration_ms,
|
349
|
-
"play_count": play_count
|
350
|
-
}
|
351
215
|
|
352
216
|
except Exception as e:
|
353
|
-
logging.error(f"Error
|
354
|
-
|
355
|
-
return {}
|
217
|
+
logging.error(f"Error extracting playlist: {e}")
|
218
|
+
return []
|
SpotDown/main.py
CHANGED
@@ -6,6 +6,7 @@ from typing import Dict, List, Optional
|
|
6
6
|
|
7
7
|
# Internal utils
|
8
8
|
from SpotDown.utils.logger import Logger
|
9
|
+
from SpotDown.utils.file_utils import file_utils
|
9
10
|
from SpotDown.utils.console_utils import ConsoleUtils
|
10
11
|
from SpotDown.upload.update import update as git_update
|
11
12
|
from SpotDown.extractor.spotify_extractor import SpotifyExtractor
|
@@ -109,6 +110,7 @@ def run():
|
|
109
110
|
console = ConsoleUtils()
|
110
111
|
console.start_message()
|
111
112
|
git_update()
|
113
|
+
file_utils.get_system_summary()
|
112
114
|
|
113
115
|
spotify_url = console.get_spotify_url()
|
114
116
|
max_results = 5
|
SpotDown/upload/version.py
CHANGED
SpotDown/utils/console_utils.py
CHANGED
@@ -157,7 +157,7 @@ class ConsoleUtils:
|
|
157
157
|
str: Entered Spotify URL
|
158
158
|
"""
|
159
159
|
while True:
|
160
|
-
url = Prompt.ask("[purple]Enter Spotify URL[/purple][green]").strip()
|
160
|
+
url = Prompt.ask("\n[purple]Enter Spotify URL[/purple][green]").strip()
|
161
161
|
|
162
162
|
if not url:
|
163
163
|
self.console.print("[red]URL cannot be empty. Please enter a Spotify track URL.[/red]")
|
@@ -0,0 +1,374 @@
|
|
1
|
+
# 24.01.2024
|
2
|
+
|
3
|
+
import os
|
4
|
+
import glob
|
5
|
+
import gzip
|
6
|
+
import shutil
|
7
|
+
import logging
|
8
|
+
import platform
|
9
|
+
import subprocess
|
10
|
+
from typing import Optional, Tuple
|
11
|
+
|
12
|
+
|
13
|
+
# External library
|
14
|
+
import requests
|
15
|
+
from rich.console import Console
|
16
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeRemainingColumn
|
17
|
+
|
18
|
+
|
19
|
+
# Variable
|
20
|
+
console = Console()
|
21
|
+
|
22
|
+
|
23
|
+
FFMPEG_CONFIGURATION = {
|
24
|
+
'windows': {
|
25
|
+
'base_dir': lambda home: os.path.join(os.path.splitdrive(home)[0] + os.path.sep, 'binary'),
|
26
|
+
'download_url': 'https://github.com/eugeneware/ffmpeg-static/releases/latest/download/ffmpeg-win32-{arch}.gz',
|
27
|
+
'file_extension': '.gz',
|
28
|
+
'executables': ['ffmpeg-win32-{arch}', 'ffprobe-win32-{arch}']
|
29
|
+
},
|
30
|
+
'darwin': {
|
31
|
+
'base_dir': lambda home: os.path.join(home, 'Applications', 'binary'),
|
32
|
+
'download_url': 'https://github.com/eugeneware/ffmpeg-static/releases/latest/download/ffmpeg-darwin-{arch}.gz',
|
33
|
+
'file_extension': '.gz',
|
34
|
+
'executables': ['ffmpeg-darwin-{arch}', 'ffprobe-darwin-{arch}']
|
35
|
+
},
|
36
|
+
'linux': {
|
37
|
+
'base_dir': lambda home: os.path.join(home, '.local', 'bin', 'binary'),
|
38
|
+
'download_url': 'https://github.com/eugeneware/ffmpeg-static/releases/latest/download/ffmpeg-linux-{arch}.gz',
|
39
|
+
'file_extension': '.gz',
|
40
|
+
'executables': ['ffmpeg-linux-{arch}', 'ffprobe-linux-{arch}']
|
41
|
+
}
|
42
|
+
}
|
43
|
+
|
44
|
+
|
45
|
+
class FFMPEGDownloader:
|
46
|
+
def __init__(self):
|
47
|
+
self.os_name = self._detect_system()
|
48
|
+
self.arch = self._detect_arch()
|
49
|
+
self.home_dir = os.path.expanduser('~')
|
50
|
+
self.base_dir = self._get_base_directory()
|
51
|
+
|
52
|
+
def _detect_system(self) -> str:
|
53
|
+
"""
|
54
|
+
Detect and normalize the operating system name.
|
55
|
+
|
56
|
+
Returns:
|
57
|
+
str: Normalized operating system name ('windows', 'darwin', or 'linux')
|
58
|
+
"""
|
59
|
+
system = platform.system().lower()
|
60
|
+
if system in FFMPEG_CONFIGURATION:
|
61
|
+
return system
|
62
|
+
raise ValueError(f"Unsupported operating system: {system}")
|
63
|
+
|
64
|
+
def _detect_arch(self) -> str:
|
65
|
+
"""
|
66
|
+
Detect and normalize the system architecture.
|
67
|
+
|
68
|
+
Returns:
|
69
|
+
str: Normalized architecture name (e.g., 'x86_64', 'arm64')
|
70
|
+
"""
|
71
|
+
machine = platform.machine().lower()
|
72
|
+
arch_map = {
|
73
|
+
'amd64': 'x64',
|
74
|
+
'x86_64': 'x64',
|
75
|
+
'x64': 'x64',
|
76
|
+
'arm64': 'arm64',
|
77
|
+
'aarch64': 'arm64',
|
78
|
+
'armv7l': 'arm',
|
79
|
+
'i386': 'ia32',
|
80
|
+
'i686': 'ia32'
|
81
|
+
}
|
82
|
+
return arch_map.get(machine, machine)
|
83
|
+
|
84
|
+
def _get_base_directory(self) -> str:
|
85
|
+
"""
|
86
|
+
Get and create the base directory for storing FFmpeg binaries.
|
87
|
+
|
88
|
+
Returns:
|
89
|
+
str: Path to the base directory
|
90
|
+
"""
|
91
|
+
base_dir = FFMPEG_CONFIGURATION[self.os_name]['base_dir'](self.home_dir)
|
92
|
+
os.makedirs(base_dir, exist_ok=True)
|
93
|
+
return base_dir
|
94
|
+
|
95
|
+
def _check_existing_binaries(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
96
|
+
"""
|
97
|
+
Check if FFmpeg binaries already exist in the base directory.
|
98
|
+
Enhanced to check both the binary directory and system paths on macOS.
|
99
|
+
"""
|
100
|
+
config = FFMPEG_CONFIGURATION[self.os_name]
|
101
|
+
executables = config['executables']
|
102
|
+
found_executables = []
|
103
|
+
|
104
|
+
# For macOS, check both binary directory and system paths
|
105
|
+
if self.os_name == 'darwin':
|
106
|
+
potential_paths = [
|
107
|
+
'/usr/local/bin',
|
108
|
+
'/opt/homebrew/bin',
|
109
|
+
'/usr/bin',
|
110
|
+
self.base_dir
|
111
|
+
]
|
112
|
+
|
113
|
+
for executable in executables:
|
114
|
+
found = None
|
115
|
+
for path in potential_paths:
|
116
|
+
full_path = os.path.join(path, executable)
|
117
|
+
if os.path.exists(full_path) and os.access(full_path, os.X_OK):
|
118
|
+
found = full_path
|
119
|
+
break
|
120
|
+
found_executables.append(found)
|
121
|
+
else:
|
122
|
+
|
123
|
+
# Original behavior for other operating systems
|
124
|
+
for executable in executables:
|
125
|
+
exe_paths = glob.glob(os.path.join(self.base_dir, executable))
|
126
|
+
found_executables.append(exe_paths[0] if exe_paths else None)
|
127
|
+
|
128
|
+
return tuple(found_executables) if len(found_executables) == 3 else (None, None, None)
|
129
|
+
|
130
|
+
def _get_latest_version(self, repo: str) -> Optional[str]:
|
131
|
+
"""
|
132
|
+
Get the latest FFmpeg version from the GitHub releases page.
|
133
|
+
|
134
|
+
Returns:
|
135
|
+
Optional[str]: The latest version string, or None if retrieval fails.
|
136
|
+
"""
|
137
|
+
try:
|
138
|
+
# Use GitHub API to fetch the latest release
|
139
|
+
response = requests.get(f'https://api.github.com/repos/{repo}/releases/latest')
|
140
|
+
response.raise_for_status()
|
141
|
+
latest_release = response.json()
|
142
|
+
|
143
|
+
# Extract the tag name or version from the release
|
144
|
+
return latest_release.get('tag_name')
|
145
|
+
|
146
|
+
except Exception as e:
|
147
|
+
logging.error(f"Unable to get version from GitHub: {e}")
|
148
|
+
return None
|
149
|
+
|
150
|
+
def _download_file(self, url: str, destination: str) -> bool:
|
151
|
+
"""
|
152
|
+
Download a file from URL with a Rich progress bar display.
|
153
|
+
|
154
|
+
Parameters:
|
155
|
+
url (str): The URL to download the file from. Should be a direct download link.
|
156
|
+
destination (str): Local file path where the downloaded file will be saved.
|
157
|
+
|
158
|
+
Returns:
|
159
|
+
bool: True if download was successful, False otherwise.
|
160
|
+
"""
|
161
|
+
try:
|
162
|
+
response = requests.get(url, stream=True)
|
163
|
+
response.raise_for_status()
|
164
|
+
total_size = int(response.headers.get('content-length', 0))
|
165
|
+
|
166
|
+
with open(destination, 'wb') as file, \
|
167
|
+
Progress(
|
168
|
+
SpinnerColumn(),
|
169
|
+
TextColumn("[progress.description]{task.description}"),
|
170
|
+
BarColumn(),
|
171
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
172
|
+
TimeRemainingColumn()
|
173
|
+
) as progress:
|
174
|
+
|
175
|
+
download_task = progress.add_task("[green]Downloading FFmpeg", total=total_size)
|
176
|
+
for chunk in response.iter_content(chunk_size=8192):
|
177
|
+
size = file.write(chunk)
|
178
|
+
progress.update(download_task, advance=size)
|
179
|
+
return True
|
180
|
+
|
181
|
+
except Exception as e:
|
182
|
+
logging.error(f"Download error: {e}")
|
183
|
+
return False
|
184
|
+
|
185
|
+
def _extract_file(self, gz_path: str, final_path: str) -> bool:
|
186
|
+
"""
|
187
|
+
Extract a gzipped file and set proper permissions.
|
188
|
+
|
189
|
+
Parameters:
|
190
|
+
gz_path (str): Path to the gzipped file
|
191
|
+
final_path (str): Path where the extracted file should be saved
|
192
|
+
|
193
|
+
Returns:
|
194
|
+
bool: True if extraction was successful, False otherwise
|
195
|
+
"""
|
196
|
+
try:
|
197
|
+
logging.info(f"Attempting to extract {gz_path} to {final_path}")
|
198
|
+
|
199
|
+
# Check if source file exists and is readable
|
200
|
+
if not os.path.exists(gz_path):
|
201
|
+
logging.error(f"Source file {gz_path} does not exist")
|
202
|
+
return False
|
203
|
+
|
204
|
+
if not os.access(gz_path, os.R_OK):
|
205
|
+
logging.error(f"Source file {gz_path} is not readable")
|
206
|
+
return False
|
207
|
+
|
208
|
+
# Extract the file
|
209
|
+
with gzip.open(gz_path, 'rb') as f_in:
|
210
|
+
# Test if the gzip file is valid
|
211
|
+
try:
|
212
|
+
f_in.read(1)
|
213
|
+
f_in.seek(0)
|
214
|
+
except Exception as e:
|
215
|
+
logging.error(f"Invalid gzip file {gz_path}: {e}")
|
216
|
+
return False
|
217
|
+
|
218
|
+
# Extract the file
|
219
|
+
with open(final_path, 'wb') as f_out:
|
220
|
+
shutil.copyfileobj(f_in, f_out)
|
221
|
+
|
222
|
+
# Set executable permissions
|
223
|
+
os.chmod(final_path, 0o755)
|
224
|
+
logging.info(f"Successfully extracted {gz_path} to {final_path}")
|
225
|
+
|
226
|
+
# Remove the gzip file
|
227
|
+
os.remove(gz_path)
|
228
|
+
return True
|
229
|
+
|
230
|
+
except Exception as e:
|
231
|
+
logging.error(f"Extraction error for {gz_path}: {e}")
|
232
|
+
return False
|
233
|
+
|
234
|
+
def download(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
235
|
+
"""
|
236
|
+
Main method to download and set up FFmpeg executables.
|
237
|
+
|
238
|
+
Returns:
|
239
|
+
Tuple[Optional[str], Optional[str], Optional[str]]: Paths to ffmpeg, ffprobe, and ffplay executables.
|
240
|
+
"""
|
241
|
+
if self.os_name == 'linux':
|
242
|
+
try:
|
243
|
+
# Attempt to install FFmpeg using apt
|
244
|
+
console.print("[bold blue]Trying to install FFmpeg using 'sudo apt install ffmpeg'[/]")
|
245
|
+
result = subprocess.run(
|
246
|
+
['sudo', 'apt', 'install', '-y', 'ffmpeg'],
|
247
|
+
stdout=subprocess.PIPE,
|
248
|
+
stderr=subprocess.PIPE,
|
249
|
+
text=True
|
250
|
+
)
|
251
|
+
if result.returncode == 0:
|
252
|
+
ffmpeg_path = shutil.which('ffmpeg')
|
253
|
+
ffprobe_path = shutil.which('ffprobe')
|
254
|
+
|
255
|
+
if ffmpeg_path and ffprobe_path:
|
256
|
+
console.print("[bold green]FFmpeg successfully installed via apt[/]")
|
257
|
+
return ffmpeg_path, ffprobe_path, None
|
258
|
+
else:
|
259
|
+
console.print("[bold yellow]Failed to install FFmpeg via apt. Proceeding with static download.[/]")
|
260
|
+
|
261
|
+
except Exception as e:
|
262
|
+
logging.error(f"Error during 'sudo apt install ffmpeg': {e}")
|
263
|
+
console.print("[bold red]Error during 'sudo apt install ffmpeg'. Proceeding with static download.[/]")
|
264
|
+
|
265
|
+
# Proceed with static download if apt installation fails or is not applicable
|
266
|
+
config = FFMPEG_CONFIGURATION[self.os_name]
|
267
|
+
executables = [exe.format(arch=self.arch) for exe in config['executables']]
|
268
|
+
successful_extractions = []
|
269
|
+
|
270
|
+
for executable in executables:
|
271
|
+
try:
|
272
|
+
download_url = f"https://github.com/eugeneware/ffmpeg-static/releases/latest/download/{executable}.gz"
|
273
|
+
download_path = os.path.join(self.base_dir, f"{executable}.gz")
|
274
|
+
final_path = os.path.join(self.base_dir, executable)
|
275
|
+
|
276
|
+
# Log the current operation
|
277
|
+
logging.info(f"Processing {executable}")
|
278
|
+
console.print(f"[bold blue]Downloading {executable} from GitHub[/]")
|
279
|
+
|
280
|
+
# Download the file
|
281
|
+
if not self._download_file(download_url, download_path):
|
282
|
+
console.print(f"[bold red]Failed to download {executable}[/]")
|
283
|
+
continue
|
284
|
+
|
285
|
+
# Extract the file
|
286
|
+
if self._extract_file(download_path, final_path):
|
287
|
+
successful_extractions.append(final_path)
|
288
|
+
console.print(f"[bold green]Successfully installed {executable}[/]")
|
289
|
+
else:
|
290
|
+
console.print(f"[bold red]Failed to extract {executable}[/]")
|
291
|
+
|
292
|
+
except Exception as e:
|
293
|
+
logging.error(f"Error processing {executable}: {e}")
|
294
|
+
console.print(f"[bold red]Error processing {executable}: {str(e)}[/]")
|
295
|
+
continue
|
296
|
+
|
297
|
+
# Return the results based on successful extractions
|
298
|
+
return (
|
299
|
+
successful_extractions[0] if len(successful_extractions) > 0 else None,
|
300
|
+
successful_extractions[1] if len(successful_extractions) > 1 else None,
|
301
|
+
None # ffplay is not included in the current implementation
|
302
|
+
)
|
303
|
+
|
304
|
+
def check_ffmpeg() -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
305
|
+
"""
|
306
|
+
Check for FFmpeg executables in the system and download them if not found.
|
307
|
+
Enhanced detection for macOS systems.
|
308
|
+
|
309
|
+
Returns:
|
310
|
+
Tuple[Optional[str], Optional[str], Optional[str]]: Paths to ffmpeg, ffprobe, and ffplay executables.
|
311
|
+
"""
|
312
|
+
try:
|
313
|
+
system_platform = platform.system().lower()
|
314
|
+
|
315
|
+
# Special handling for macOS
|
316
|
+
if system_platform == 'darwin':
|
317
|
+
|
318
|
+
# Common installation paths on macOS
|
319
|
+
potential_paths = [
|
320
|
+
'/usr/local/bin', # Homebrew default
|
321
|
+
'/opt/homebrew/bin', # Apple Silicon Homebrew
|
322
|
+
'/usr/bin', # System default
|
323
|
+
os.path.expanduser('~/Applications/binary'), # Custom installation
|
324
|
+
'/Applications/binary' # Custom installation
|
325
|
+
]
|
326
|
+
|
327
|
+
for path in potential_paths:
|
328
|
+
ffmpeg_path = os.path.join(path, 'ffmpeg')
|
329
|
+
ffprobe_path = os.path.join(path, 'ffprobe')
|
330
|
+
ffplay_path = os.path.join(path, 'ffplay')
|
331
|
+
|
332
|
+
if (os.path.exists(ffmpeg_path) and os.path.exists(ffprobe_path) and
|
333
|
+
os.access(ffmpeg_path, os.X_OK) and os.access(ffprobe_path, os.X_OK)):
|
334
|
+
|
335
|
+
# Return found executables, with ffplay being optional
|
336
|
+
ffplay_path = ffplay_path if os.path.exists(ffplay_path) else None
|
337
|
+
return ffmpeg_path, ffprobe_path, ffplay_path
|
338
|
+
|
339
|
+
# Windows detection
|
340
|
+
elif system_platform == 'windows':
|
341
|
+
try:
|
342
|
+
ffmpeg_path = subprocess.check_output(
|
343
|
+
['where', 'ffmpeg'], stderr=subprocess.DEVNULL, text=True
|
344
|
+
).strip().split('\n')[0]
|
345
|
+
|
346
|
+
ffprobe_path = subprocess.check_output(
|
347
|
+
['where', 'ffprobe'], stderr=subprocess.DEVNULL, text=True
|
348
|
+
).strip().split('\n')[0]
|
349
|
+
|
350
|
+
ffplay_path = subprocess.check_output(
|
351
|
+
['where', 'ffplay'], stderr=subprocess.DEVNULL, text=True
|
352
|
+
).strip().split('\n')[0]
|
353
|
+
|
354
|
+
return ffmpeg_path, ffprobe_path, ffplay_path
|
355
|
+
|
356
|
+
except subprocess.CalledProcessError:
|
357
|
+
logging.warning("One or more FFmpeg binaries were not found with command where")
|
358
|
+
|
359
|
+
# Linux detection
|
360
|
+
else:
|
361
|
+
ffmpeg_path = shutil.which('ffmpeg')
|
362
|
+
ffprobe_path = shutil.which('ffprobe')
|
363
|
+
ffplay_path = shutil.which('ffplay')
|
364
|
+
|
365
|
+
if ffmpeg_path and ffprobe_path:
|
366
|
+
return ffmpeg_path, ffprobe_path, ffplay_path
|
367
|
+
|
368
|
+
# If executables were not found, attempt to download FFmpeg
|
369
|
+
downloader = FFMPEGDownloader()
|
370
|
+
return downloader.download()
|
371
|
+
|
372
|
+
except Exception as e:
|
373
|
+
logging.error(f"Error checking or downloading FFmpeg executables: {e}")
|
374
|
+
return None, None, None
|
SpotDown/utils/file_utils.py
CHANGED
@@ -1,15 +1,30 @@
|
|
1
1
|
# 05.04.2024
|
2
2
|
|
3
3
|
import re
|
4
|
+
import os
|
5
|
+
import sys
|
6
|
+
import glob
|
4
7
|
from pathlib import Path
|
5
8
|
from typing import Optional
|
9
|
+
import platform
|
6
10
|
|
7
11
|
|
8
12
|
# External imports
|
13
|
+
from rich.console import Console
|
9
14
|
from unidecode import unidecode
|
10
15
|
|
11
16
|
|
17
|
+
# Internal logic
|
18
|
+
from .ffmpeg_installer import check_ffmpeg
|
19
|
+
|
20
|
+
|
21
|
+
# Variable
|
22
|
+
console = Console()
|
23
|
+
|
24
|
+
|
12
25
|
class FileUtils:
|
26
|
+
ffmpeg_path = None
|
27
|
+
ffprobe_path = None
|
13
28
|
|
14
29
|
@staticmethod
|
15
30
|
def get_music_folder() -> Path:
|
@@ -126,4 +141,93 @@ class FileUtils:
|
|
126
141
|
return files[0] if files else None
|
127
142
|
|
128
143
|
except Exception:
|
129
|
-
return None
|
144
|
+
return None
|
145
|
+
|
146
|
+
@staticmethod
|
147
|
+
def get_binary_directory():
|
148
|
+
"""Get the binary directory based on OS."""
|
149
|
+
system = platform.system().lower()
|
150
|
+
home = os.path.expanduser('~')
|
151
|
+
|
152
|
+
if system == 'windows':
|
153
|
+
return os.path.join(os.path.splitdrive(home)[0] + os.path.sep, 'binary')
|
154
|
+
elif system == 'darwin':
|
155
|
+
return os.path.join(home, 'Applications', 'binary')
|
156
|
+
else: # linux
|
157
|
+
return os.path.join(home, '.local', 'bin', 'binary')
|
158
|
+
|
159
|
+
@staticmethod
|
160
|
+
def get_ffmpeg_path():
|
161
|
+
"""Returns the path of FFmpeg."""
|
162
|
+
return FileUtils.ffmpeg_path
|
163
|
+
|
164
|
+
@staticmethod
|
165
|
+
def get_ffprobe_path():
|
166
|
+
"""Returns the path of FFprobe."""
|
167
|
+
return FileUtils.ffprobe_path
|
168
|
+
|
169
|
+
@staticmethod
|
170
|
+
def check_python_version():
|
171
|
+
"""
|
172
|
+
Check if the installed Python is the official CPython distribution.
|
173
|
+
Exits with a message if not the official version.
|
174
|
+
"""
|
175
|
+
python_implementation = platform.python_implementation()
|
176
|
+
python_version = platform.python_version()
|
177
|
+
|
178
|
+
if python_implementation != "CPython":
|
179
|
+
console.print(f"[bold red]Warning: You are using a non-official Python distribution: {python_implementation}.[/bold red]")
|
180
|
+
console.print("Please install the official Python from [bold blue]https://www.python.org[/bold blue] and try again.", style="bold yellow")
|
181
|
+
sys.exit(0)
|
182
|
+
|
183
|
+
console.print(f"[cyan]Python version: [bold red]{python_version}[/bold red]")
|
184
|
+
|
185
|
+
@staticmethod
|
186
|
+
def get_system_summary():
|
187
|
+
FileUtils.check_python_version()
|
188
|
+
|
189
|
+
# FFmpeg detection
|
190
|
+
binary_dir = FileUtils.get_binary_directory()
|
191
|
+
system = platform.system().lower()
|
192
|
+
arch = platform.machine().lower()
|
193
|
+
|
194
|
+
# Map architecture names
|
195
|
+
arch_map = {
|
196
|
+
'amd64': 'x64',
|
197
|
+
'x86_64': 'x64',
|
198
|
+
'x64': 'x64',
|
199
|
+
'arm64': 'arm64',
|
200
|
+
'aarch64': 'arm64',
|
201
|
+
'armv7l': 'arm',
|
202
|
+
'i386': 'ia32',
|
203
|
+
'i686': 'ia32'
|
204
|
+
}
|
205
|
+
arch = arch_map.get(arch, arch)
|
206
|
+
|
207
|
+
# Check FFmpeg binaries
|
208
|
+
if os.path.exists(binary_dir):
|
209
|
+
ffmpeg_files = glob.glob(os.path.join(binary_dir, f'*ffmpeg*{arch}*'))
|
210
|
+
ffprobe_files = glob.glob(os.path.join(binary_dir, f'*ffprobe*{arch}*'))
|
211
|
+
|
212
|
+
if ffmpeg_files and ffprobe_files:
|
213
|
+
FileUtils.ffmpeg_path = ffmpeg_files[0]
|
214
|
+
FileUtils.ffprobe_path = ffprobe_files[0]
|
215
|
+
|
216
|
+
if system != 'windows':
|
217
|
+
os.chmod(FileUtils.ffmpeg_path, 0o755)
|
218
|
+
os.chmod(FileUtils.ffprobe_path, 0o755)
|
219
|
+
else:
|
220
|
+
FileUtils.ffmpeg_path, FileUtils.ffprobe_path, ffplay_path = check_ffmpeg()
|
221
|
+
else:
|
222
|
+
FileUtils.ffmpeg_path, FileUtils.ffprobe_path, ffplay_path = check_ffmpeg()
|
223
|
+
|
224
|
+
if not FileUtils.ffmpeg_path or not FileUtils.ffprobe_path:
|
225
|
+
console.log("[red]Can't locate ffmpeg or ffprobe")
|
226
|
+
sys.exit(0)
|
227
|
+
|
228
|
+
ffmpeg_str = f"'{FileUtils.ffmpeg_path}'" if FileUtils.ffmpeg_path else "None"
|
229
|
+
ffprobe_str = f"'{FileUtils.ffprobe_path}'" if FileUtils.ffprobe_path else "None"
|
230
|
+
console.print(f"[cyan]Path: [red]ffmpeg [bold yellow]{ffmpeg_str}[/bold yellow][white], [red]ffprobe [bold yellow]{ffprobe_str}[/bold yellow][white].")
|
231
|
+
|
232
|
+
|
233
|
+
file_utils = FileUtils()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: SpotDown
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.3.0
|
4
4
|
Summary: A command-line program to download music
|
5
5
|
Home-page: https://github.com/Arrowar/spotdown
|
6
6
|
Author: Arrowar
|
@@ -13,12 +13,12 @@ Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE
|
14
14
|
Requires-Dist: rich
|
15
15
|
Requires-Dist: httpx
|
16
|
-
Requires-Dist: playwright
|
17
16
|
Requires-Dist: unidecode
|
18
17
|
Requires-Dist: ua-generator
|
19
18
|
Requires-Dist: unidecode
|
20
19
|
Requires-Dist: yt-dlp
|
21
20
|
Requires-Dist: Pillow
|
21
|
+
Requires-Dist: spotipy
|
22
22
|
Dynamic: author
|
23
23
|
Dynamic: author-email
|
24
24
|
Dynamic: classifier
|
@@ -42,6 +42,13 @@ Dynamic: summary
|
|
42
42
|
|
43
43
|
[](https://www.paypal.com/donate/?hosted_button_id=UXTWMT8P6HE2C)
|
44
44
|
|
45
|
+
## 🚀 Download & Install
|
46
|
+
|
47
|
+
[](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_win.exe)
|
48
|
+
[](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_mac)
|
49
|
+
[](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_linux_latest)
|
50
|
+
[](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_linux_previous)
|
51
|
+
|
45
52
|
---
|
46
53
|
|
47
54
|
*⚡ **Quick Start:** `pip install spotdown && spotdown`*
|
@@ -52,9 +59,9 @@ Dynamic: summary
|
|
52
59
|
|
53
60
|
- [✨ Features](#features)
|
54
61
|
- [🛠️ Installation](#️installation)
|
62
|
+
- [⚙️ Setup](#setup)
|
55
63
|
- [⚙️ Configuration](#configuration)
|
56
64
|
- [💻 Usage](#usage)
|
57
|
-
- [⚠️ Disclaimer](#disclaimer)
|
58
65
|
|
59
66
|
## Features
|
60
67
|
|
@@ -99,6 +106,24 @@ After installation, run this one-time setup command:
|
|
99
106
|
playwright install chromium
|
100
107
|
```
|
101
108
|
|
109
|
+
## Setup
|
110
|
+
|
111
|
+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/)
|
112
|
+
2. Log in and create a new application
|
113
|
+
3. Copy your **Client ID** and **Client Secret**
|
114
|
+
4. Create a file named `.env` in the SpotDown directory with the following content:
|
115
|
+
|
116
|
+
```
|
117
|
+
SPOTIFY_CLIENT_ID=your_client_id_here
|
118
|
+
SPOTIPY_CLIENT_SECRET=your_client_secret_here
|
119
|
+
```
|
120
|
+
|
121
|
+
5. Save the file. SpotDown will automatically load these credentials.
|
122
|
+
|
123
|
+
### Error Handling
|
124
|
+
- If the credentials are missing, SpotDown will log an error and exit.
|
125
|
+
- If the credentials are invalid, SpotDown will log an error and exit. Please double-check your `.env` file and credentials.
|
126
|
+
|
102
127
|
## Configuration
|
103
128
|
|
104
129
|
SpotDown uses a JSON configuration file with the following structure:
|
@@ -112,10 +137,6 @@ SpotDown uses a JSON configuration file with the following structure:
|
|
112
137
|
"DOWNLOAD": {
|
113
138
|
"auto_first": false,
|
114
139
|
"quality": "320K"
|
115
|
-
},
|
116
|
-
"BROWSER": {
|
117
|
-
"headless": true,
|
118
|
-
"timeout": 8
|
119
140
|
}
|
120
141
|
}
|
121
142
|
```
|
@@ -130,10 +151,6 @@ SpotDown uses a JSON configuration file with the following structure:
|
|
130
151
|
- **`auto_first`**: Automatically select first search result
|
131
152
|
- **`quality`**: Audio quality (320K recommended for best quality)
|
132
153
|
|
133
|
-
#### BROWSER Settings
|
134
|
-
- **`headless`**: Run browser in background (recommended: true)
|
135
|
-
- **`timeout`**: Browser timeout in seconds
|
136
|
-
|
137
154
|
## Usage
|
138
155
|
|
139
156
|
### Starting SpotDown
|
@@ -0,0 +1,21 @@
|
|
1
|
+
SpotDown/__init__.py,sha256=ebKRxKh14_cO7UWvOgE4R1RqIFBdekg50xtzc1LU6DU,59
|
2
|
+
SpotDown/main.py,sha256=qTNHAyXdxe6BMwwTMssuFBdpfdxMEYwFM6LweGiBBYI,5084
|
3
|
+
SpotDown/downloader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
+
SpotDown/downloader/youtube_downloader.py,sha256=i4Iq7jFKD8dFxpWHPcxgE2_hykwHf6TQXZSal_FHUAY,5557
|
5
|
+
SpotDown/extractor/__init__.py,sha256=pQtkjMzB4D8AVEFQ3pnp_UQI-KbYaQhZbKuDnMImN0o,161
|
6
|
+
SpotDown/extractor/spotify_extractor.py,sha256=gI4iJvs_MYV7MO-zcuUXavqtQo5jDAuXNqXs2ScTjDE,7755
|
7
|
+
SpotDown/extractor/youtube_extractor.py,sha256=v1JDV_NHBpc56UU5kk4tfaRjPB0Jy23_F5r4LE2gOlU,10442
|
8
|
+
SpotDown/upload/version.py,sha256=uvgDGChIXb3JAYtO_GQPGc-au8t6sDfzX40RkqQex14,161
|
9
|
+
SpotDown/utils/__init__.py,sha256=ZhJJhNQkUd4XG6vkoVQSC3qvSlaArhsRjscn4bfC9WA,128
|
10
|
+
SpotDown/utils/config_json.py,sha256=q_9lQhGLmsaZfM1WU-kOMByf8y1Wt9QWnZT5g_IR4Lg,7447
|
11
|
+
SpotDown/utils/console_utils.py,sha256=oW7jD3UjrSlYoZ8R7VJAV8WPymD2NZGs1SS5BUUQ3Zk,6825
|
12
|
+
SpotDown/utils/ffmpeg_installer.py,sha256=kFcjr9HqQyxu01mpWumt8d_0D9aEkxMb5sSBJqSkR0w,14889
|
13
|
+
SpotDown/utils/file_utils.py,sha256=eNb0JT99FyeMGCoVzFkyhF-39FZE-yogwtUgKSbaTCs,7132
|
14
|
+
SpotDown/utils/headers.py,sha256=nSF2rEQEl7g2tIJMjz9c5mAQfLiugfWwdlJBhtMfBSo,312
|
15
|
+
SpotDown/utils/logger.py,sha256=Bzje-6nwGxL7TzMVhJfhPe7mv-9upYylFvx_zjWJX3M,3290
|
16
|
+
spotdown-1.3.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
17
|
+
spotdown-1.3.0.dist-info/METADATA,sha256=apC3RZd0_akzY0ujIZ_EdcdqWkBsx-9gxMM0CaUh9CA,6836
|
18
|
+
spotdown-1.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
19
|
+
spotdown-1.3.0.dist-info/entry_points.txt,sha256=qHq6M94aU-XQL0y9R4yFKoqnHjU2nsN53Bj4OuHp3vQ,47
|
20
|
+
spotdown-1.3.0.dist-info/top_level.txt,sha256=1E-ZZ8rsrXmBPAsseY_kEfHfNo9w9PtSCaS6pM63-tw,9
|
21
|
+
spotdown-1.3.0.dist-info/RECORD,,
|
spotdown-1.0.0.dist-info/RECORD
DELETED
@@ -1,20 +0,0 @@
|
|
1
|
-
SpotDown/__init__.py,sha256=ebKRxKh14_cO7UWvOgE4R1RqIFBdekg50xtzc1LU6DU,59
|
2
|
-
SpotDown/main.py,sha256=HWhik7Kr1OAnvJPNvaqpO_YqHWCbtyaUIVzaicL3jhg,4999
|
3
|
-
SpotDown/downloader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
-
SpotDown/downloader/youtube_downloader.py,sha256=7Qs1IhSKZqMCm5tuMSCoI-BItGO-28aRLmAdcchcq7w,5492
|
5
|
-
SpotDown/extractor/__init__.py,sha256=pQtkjMzB4D8AVEFQ3pnp_UQI-KbYaQhZbKuDnMImN0o,161
|
6
|
-
SpotDown/extractor/spotify_extractor.py,sha256=55jndORplGMNb2GiRzo2MkN_v_OrfpEqpKnGjzy6BQo,15130
|
7
|
-
SpotDown/extractor/youtube_extractor.py,sha256=v1JDV_NHBpc56UU5kk4tfaRjPB0Jy23_F5r4LE2gOlU,10442
|
8
|
-
SpotDown/upload/version.py,sha256=ff4spj6Qs71lt0V8utidpPgidmzTDRCnhGGafOJXvoU,161
|
9
|
-
SpotDown/utils/__init__.py,sha256=ZhJJhNQkUd4XG6vkoVQSC3qvSlaArhsRjscn4bfC9WA,128
|
10
|
-
SpotDown/utils/config_json.py,sha256=q_9lQhGLmsaZfM1WU-kOMByf8y1Wt9QWnZT5g_IR4Lg,7447
|
11
|
-
SpotDown/utils/console_utils.py,sha256=A1zNRyB9yG7BqWzlQgfVZ25cWUjMmYQooRKyeqVlcDw,6823
|
12
|
-
SpotDown/utils/file_utils.py,sha256=Lgq_Nu1r-bVIH37H7Mx73WgotN_nrgMUAT2STDyEYoY,3495
|
13
|
-
SpotDown/utils/headers.py,sha256=nSF2rEQEl7g2tIJMjz9c5mAQfLiugfWwdlJBhtMfBSo,312
|
14
|
-
SpotDown/utils/logger.py,sha256=Bzje-6nwGxL7TzMVhJfhPe7mv-9upYylFvx_zjWJX3M,3290
|
15
|
-
spotdown-1.0.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
16
|
-
spotdown-1.0.0.dist-info/METADATA,sha256=DTDziAY2-Bql3Vews8smKT78jQC6qQfXSjFuajbeL1A,5522
|
17
|
-
spotdown-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
18
|
-
spotdown-1.0.0.dist-info/entry_points.txt,sha256=qHq6M94aU-XQL0y9R4yFKoqnHjU2nsN53Bj4OuHp3vQ,47
|
19
|
-
spotdown-1.0.0.dist-info/top_level.txt,sha256=1E-ZZ8rsrXmBPAsseY_kEfHfNo9w9PtSCaS6pM63-tw,9
|
20
|
-
spotdown-1.0.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|