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.
@@ -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 FileUtils
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 = FileUtils()
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 imports
12
+ # External library
13
+ import spotipy
14
+ from spotipy.oauth2 import SpotifyClientCredentials
10
15
  from rich.console import Console
11
- from playwright.sync_api import sync_playwright
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
- headless = config_manager.get("BROWSER", "headless")
22
- timeout = config_manager.get("BROWSER", "timeout")
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
- self.playwright = None
28
- self.browser = None
29
- self.context = None
30
- self.page = None
31
- self.user_agent = get_userAgent()
32
- self.total_songs = None
33
- self.playlist_items = []
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
- """Automatically closes the browser"""
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
- Extracts track information from a Spotify URL
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
- logging.info(f"Analyzing Spotify URL: {spotify_url}")
68
- console.print("[cyan]Analyzing Spotify URL ...")
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
- except Exception as e:
108
- logging.error(f"Spotify extraction error: {e}")
109
- console.print(f"[cyan]Spotify extraction error: {e}")
110
- return None
79
+ # Extract album info
80
+ album = track['album']
111
81
 
112
- def _extract_spotify_data(self, spotify_url: str, return_raw: bool = False) -> Optional[Dict]:
113
- """Extracts Spotify data by intercepting API calls"""
114
- try:
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
- except Exception as e:
160
- logging.error(f"Spotify data extraction error: {e}")
161
- console.print(f"[cyan]❌ Spotify data extraction error: {e}")
162
- return (None, None) if return_raw else None
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
- def _is_valid_track_data(self, data: Dict) -> bool:
165
- """Checks if the data contains valid track information"""
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
- def _parse_spotify_response(self, response: Dict) -> Dict:
175
- """Parses the Spotify API response"""
176
- try:
177
- # Extract title
178
- track_data = response.get("data", {}).get("trackUnion", {})
179
- title = track_data.get("name", "").strip()
180
-
181
- # Extract artist
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
- logging.error(f"Error parsing Spotify response: {e}")
221
- console.print(f"[cyan]Error parsing Spotify response: {e}")
222
- return {}
223
-
224
- def _format_seconds(self, seconds: int) -> str:
225
- """Formats seconds into mm:ss or hh:mm:ss"""
226
- if seconds < 3600:
227
- minutes = seconds // 60
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
- """Extracts all tracks from a Spotify playlist URL"""
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
- def handle_request(response):
246
- try:
247
- if "pathfinder/v2/query" in response.url and response.request.method == "POST":
248
- json_data = response.json()
249
- if (
250
- "data" in json_data and
251
- "playlistV2" in json_data["data"] and
252
- "content" in json_data["data"]["playlistV2"]
253
- ):
254
- if self.total_songs is None:
255
- self.total_songs = json_data["data"]["playlistV2"]["content"].get("totalCount", 0)
256
- items = json_data["data"]["playlistV2"]["content"].get("items", [])
257
- for item in items:
258
- parsed_item = self._parse_spotify_playlist_item(item)
259
- if parsed_item:
260
- self.playlist_items.append(parsed_item)
261
- except Exception as e:
262
- logging.error(f"Error processing playlist request: {e}")
263
- console.print(f"Error processing request: {e}")
264
-
265
- self.page.on("response", handle_request)
266
- self.page.goto(playlist_url)
267
- self.page.wait_for_timeout(5000)
268
-
269
- if self.total_songs is None:
270
- logging.error("Could not extract the total number of songs")
271
- console.print("Error: Could not extract the total number of songs.")
272
- return []
273
-
274
- logging.info(f"Playlist has {self.total_songs} tracks")
275
- console.print(f"[cyan]The playlist has [green]{self.total_songs}[/green] tracks")
276
-
277
- try:
278
- self.page.wait_for_selector('div[data-testid="playlist-tracklist"]', timeout=15000)
279
- except Exception:
280
- logging.error("Playlist table did not load")
281
- console.print("Error: Playlist table did not load")
282
- return []
283
-
284
- last_item_count = len(self.playlist_items)
285
- with console.status("[cyan]Loading tracks...") as status:
286
- while len(self.playlist_items) < self.total_songs:
287
- status.update(f"[cyan]Progress: {len(self.playlist_items)}/{self.total_songs} tracks loaded")
288
- rows = self.page.locator('div[role="row"]')
289
- row_count = rows.count()
290
- last_row = rows.nth(row_count - 1)
291
- last_row.scroll_into_view_if_needed()
292
- current_items = len(self.playlist_items)
293
- if current_items > last_item_count:
294
- last_item_count = current_items
295
- self.page.wait_for_timeout(300)
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 self.playlist_items:
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
- logging.info(f"Extracted {len(unique_tracks)} unique tracks from playlist")
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 parsing playlist item: {e}")
354
- console.print(f"Error parsing playlist item: {e}")
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
@@ -1,5 +1,5 @@
1
1
  __title__ = 'SpotDown'
2
- __version__ = '1.0.0'
2
+ __version__ = '1.3.0'
3
3
  __author__ = 'Arrowar'
4
4
  __description__ = 'A command-line program to download music'
5
5
  __copyright__ = 'Copyright 2025'
@@ -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
@@ -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.0.0
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
  [![Donate PayPal](https://img.shields.io/badge/💳_Donate-PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white&labelColor=2d3748)](https://www.paypal.com/donate/?hosted_button_id=UXTWMT8P6HE2C)
44
44
 
45
+ ## 🚀 Download & Install
46
+
47
+ [![Windows](https://img.shields.io/badge/🪟_Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white&labelColor=2d3748)](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_win.exe)
48
+ [![macOS](https://img.shields.io/badge/🍎_macOS-000000?style=for-the-badge&logo=apple&logoColor=white&labelColor=2d3748)](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_mac)
49
+ [![Linux latest](https://img.shields.io/badge/🐧_Linux_latest-FCC624?style=for-the-badge&logo=linux&logoColor=black&labelColor=2d3748)](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_linux_latest)
50
+ [![Linux 22.04](https://img.shields.io/badge/🐧_Linux_22.04-FCC624?style=for-the-badge&logo=linux&logoColor=black&labelColor=2d3748)](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,,
@@ -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,,