SpotDown 0.1.1__py3-none-any.whl → 1.0.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 +17 -4
- SpotDown/extractor/spotify_extractor.py +24 -0
- SpotDown/extractor/youtube_extractor.py +15 -1
- SpotDown/main.py +4 -8
- SpotDown/upload/version.py +2 -2
- SpotDown/utils/config_json.py +2 -2
- SpotDown/utils/logger.py +90 -0
- {spotdown-0.1.1.dist-info → spotdown-1.0.0.dist-info}/METADATA +45 -29
- spotdown-1.0.0.dist-info/RECORD +20 -0
- spotdown-0.1.1.dist-info/RECORD +0 -19
- {spotdown-0.1.1.dist-info → spotdown-1.0.0.dist-info}/WHEEL +0 -0
- {spotdown-0.1.1.dist-info → spotdown-1.0.0.dist-info}/entry_points.txt +0 -0
- {spotdown-0.1.1.dist-info → spotdown-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {spotdown-0.1.1.dist-info → spotdown-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,7 @@
|
|
1
1
|
# 05.04.2024
|
2
2
|
|
3
3
|
import io
|
4
|
+
import logging
|
4
5
|
import subprocess
|
5
6
|
from typing import Dict
|
6
7
|
|
@@ -44,6 +45,7 @@ class YouTubeDownloader:
|
|
44
45
|
spotify_info.get('title', video_info.get('title', 'Unknown Title'))
|
45
46
|
)
|
46
47
|
output_path = music_folder / f"{filename}.%(ext)s"
|
48
|
+
logging.info(f"Start download: {video_info.get('url')} as {output_path}")
|
47
49
|
|
48
50
|
# Download cover image if available
|
49
51
|
cover_path = None
|
@@ -65,12 +67,15 @@ class YouTubeDownloader:
|
|
65
67
|
img.save(cover_path, "JPEG")
|
66
68
|
|
67
69
|
self.console.print(f"[blue]Downloaded thumbnail: {cover_path}[/blue]")
|
70
|
+
logging.info(f"Downloaded thumbnail: {cover_path}")
|
68
71
|
|
69
72
|
else:
|
70
73
|
cover_path = None
|
74
|
+
logging.warning(f"Failed to download cover image, status code: {resp.status_code}")
|
71
75
|
|
72
76
|
except Exception as e:
|
73
77
|
self.console.print(f"[yellow]Unable to download cover: {e}[/yellow]")
|
78
|
+
logging.error(f"Unable to download cover: {e}")
|
74
79
|
cover_path = None
|
75
80
|
|
76
81
|
ytdlp_options = [
|
@@ -94,6 +99,7 @@ class YouTubeDownloader:
|
|
94
99
|
console=self.console
|
95
100
|
) as progress:
|
96
101
|
task = progress.add_task("Downloading...", total=None)
|
102
|
+
logging.info(f"Running yt-dlp with options: {ytdlp_options}")
|
97
103
|
process = subprocess.run(
|
98
104
|
ytdlp_options,
|
99
105
|
capture_output=True,
|
@@ -102,30 +108,37 @@ class YouTubeDownloader:
|
|
102
108
|
progress.remove_task(task)
|
103
109
|
|
104
110
|
if process.returncode == 0:
|
111
|
+
logging.info("yt-dlp finished successfully")
|
105
112
|
|
106
113
|
# Find the downloaded file
|
107
114
|
downloaded_files = list(music_folder.glob(f"{filename}.*"))
|
108
115
|
if downloaded_files:
|
109
116
|
self.console.print("[red]Download completed![/red]")
|
117
|
+
logging.info(f"Download completed: {downloaded_files[0]}")
|
110
118
|
|
111
119
|
# Remove cover file after embedding
|
112
120
|
if cover_path and cover_path.exists():
|
113
121
|
try:
|
114
122
|
cover_path.unlink()
|
115
|
-
|
116
|
-
|
117
|
-
|
123
|
+
logging.info(f"Removed temporary cover file: {cover_path}")
|
124
|
+
|
125
|
+
except Exception as ex:
|
126
|
+
logging.warning(f"Failed to remove cover file: {ex}")
|
127
|
+
|
118
128
|
return True
|
119
129
|
|
120
130
|
else:
|
121
131
|
self.console.print("[yellow]Download apparently succeeded but file not found[/yellow]")
|
132
|
+
logging.error("Download apparently succeeded but file not found")
|
122
133
|
return False
|
123
|
-
|
134
|
+
|
124
135
|
else:
|
125
136
|
self.console.print("[red]Download error:[/red]")
|
126
137
|
self.console.print(f"[red]{process.stderr}[/red]")
|
138
|
+
logging.error(f"yt-dlp error: {process.stderr}")
|
127
139
|
return False
|
128
140
|
|
129
141
|
except Exception as e:
|
130
142
|
self.console.print(f"[red]Error during download: {e}[/red]")
|
143
|
+
logging.error(f"Error during download: {e}")
|
131
144
|
return False
|
@@ -31,9 +31,11 @@ class SpotifyExtractor:
|
|
31
31
|
self.user_agent = get_userAgent()
|
32
32
|
self.total_songs = None
|
33
33
|
self.playlist_items = []
|
34
|
+
logging.info("SpotifyExtractor initialized")
|
34
35
|
|
35
36
|
def __enter__(self):
|
36
37
|
"""Context manager to automatically handle the browser"""
|
38
|
+
logging.info("Starting Playwright and launching browser")
|
37
39
|
self.playwright = sync_playwright().start()
|
38
40
|
self.browser = self.playwright.chromium.launch(headless=headless)
|
39
41
|
self.context = self.browser.new_context(
|
@@ -44,6 +46,7 @@ class SpotifyExtractor:
|
|
44
46
|
|
45
47
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
46
48
|
"""Automatically closes the browser"""
|
49
|
+
logging.info("Closing browser and stopping Playwright")
|
47
50
|
if self.browser:
|
48
51
|
self.browser.close()
|
49
52
|
if self.playwright:
|
@@ -61,18 +64,21 @@ class SpotifyExtractor:
|
|
61
64
|
Dict: Track information or None if an error occurs
|
62
65
|
"""
|
63
66
|
try:
|
67
|
+
logging.info(f"Analyzing Spotify URL: {spotify_url}")
|
64
68
|
console.print("[cyan]Analyzing Spotify URL ...")
|
65
69
|
|
66
70
|
# Extract Spotify data by intercepting API calls
|
67
71
|
spotify_data, raw_json = self._extract_spotify_data(spotify_url, return_raw=True)
|
68
72
|
|
69
73
|
if not spotify_data:
|
74
|
+
logging.info("Unable to extract data from Spotify")
|
70
75
|
console.print("[cyan]Unable to extract data from Spotify")
|
71
76
|
return None
|
72
77
|
|
73
78
|
# Save the JSON response if requested
|
74
79
|
if save_json and raw_json:
|
75
80
|
try:
|
81
|
+
logging.info("Saving Spotify API response JSON")
|
76
82
|
log_dir = os.path.join(os.getcwd(), "log")
|
77
83
|
os.makedirs(log_dir, exist_ok=True)
|
78
84
|
|
@@ -91,18 +97,22 @@ class SpotifyExtractor:
|
|
91
97
|
console.print(f"[green]Spotify API response saved to {filepath}")
|
92
98
|
|
93
99
|
except Exception as e:
|
100
|
+
logging.error(f"Could not save JSON file: {e}")
|
94
101
|
console.print(f"[yellow]Warning: Could not save JSON file: {e}")
|
95
102
|
|
103
|
+
logging.info(f"Found track: {spotify_data['artist']} - {spotify_data['title']}")
|
96
104
|
console.print(f"[cyan]Found: [red]{spotify_data['artist']} - {spotify_data['title']}[/red]")
|
97
105
|
return spotify_data
|
98
106
|
|
99
107
|
except Exception as e:
|
108
|
+
logging.error(f"Spotify extraction error: {e}")
|
100
109
|
console.print(f"[cyan]Spotify extraction error: {e}")
|
101
110
|
return None
|
102
111
|
|
103
112
|
def _extract_spotify_data(self, spotify_url: str, return_raw: bool = False) -> Optional[Dict]:
|
104
113
|
"""Extracts Spotify data by intercepting API calls"""
|
105
114
|
try:
|
115
|
+
logging.info(f"Intercepting API calls for URL: {spotify_url}")
|
106
116
|
api_responses = []
|
107
117
|
|
108
118
|
def handle_request(request):
|
@@ -130,20 +140,24 @@ class SpotifyExtractor:
|
|
130
140
|
# This avoids unnecessary waiting after a valid API response is received
|
131
141
|
for _ in range(timeout * 10): # 100 * 100ms = 10000ms (10 seconds max)
|
132
142
|
if api_responses:
|
143
|
+
logging.info("Valid API response found, stopping polling")
|
133
144
|
break
|
134
145
|
|
135
146
|
self.page.wait_for_timeout(timeout * 10)
|
136
147
|
|
137
148
|
if not api_responses:
|
149
|
+
logging.info("No valid API responses found")
|
138
150
|
console.print("[cyan]No valid API responses found")
|
139
151
|
return (None, None) if return_raw else None
|
140
152
|
|
141
153
|
# Selects the most complete response
|
142
154
|
best_response = max(api_responses, key=lambda x: len(json.dumps(x)))
|
143
155
|
parsed = self._parse_spotify_response(best_response)
|
156
|
+
logging.info("Returning parsed Spotify API response")
|
144
157
|
return (parsed, best_response) if return_raw else parsed
|
145
158
|
|
146
159
|
except Exception as e:
|
160
|
+
logging.error(f"Spotify data extraction error: {e}")
|
147
161
|
console.print(f"[cyan]❌ Spotify data extraction error: {e}")
|
148
162
|
return (None, None) if return_raw else None
|
149
163
|
|
@@ -154,6 +168,7 @@ class SpotifyExtractor:
|
|
154
168
|
return bool(track_union.get("name") and track_union.get("firstArtist", {}).get("items"))
|
155
169
|
|
156
170
|
except Exception:
|
171
|
+
logging.error("Error validating track data")
|
157
172
|
return False
|
158
173
|
|
159
174
|
def _parse_spotify_response(self, response: Dict) -> Dict:
|
@@ -202,6 +217,7 @@ class SpotifyExtractor:
|
|
202
217
|
}
|
203
218
|
|
204
219
|
except Exception as e:
|
220
|
+
logging.error(f"Error parsing Spotify response: {e}")
|
205
221
|
console.print(f"[cyan]Error parsing Spotify response: {e}")
|
206
222
|
return {}
|
207
223
|
|
@@ -220,6 +236,7 @@ class SpotifyExtractor:
|
|
220
236
|
|
221
237
|
def extract_playlist_tracks(self, playlist_url: str) -> List[Dict]:
|
222
238
|
"""Extracts all tracks from a Spotify playlist URL"""
|
239
|
+
logging.info(f"Extracting playlist tracks from: {playlist_url}")
|
223
240
|
self.total_songs = None
|
224
241
|
self.playlist_items = []
|
225
242
|
console.print("[cyan]Extracting playlist tracks...")
|
@@ -242,6 +259,7 @@ class SpotifyExtractor:
|
|
242
259
|
if parsed_item:
|
243
260
|
self.playlist_items.append(parsed_item)
|
244
261
|
except Exception as e:
|
262
|
+
logging.error(f"Error processing playlist request: {e}")
|
245
263
|
console.print(f"Error processing request: {e}")
|
246
264
|
|
247
265
|
self.page.on("response", handle_request)
|
@@ -249,14 +267,17 @@ class SpotifyExtractor:
|
|
249
267
|
self.page.wait_for_timeout(5000)
|
250
268
|
|
251
269
|
if self.total_songs is None:
|
270
|
+
logging.error("Could not extract the total number of songs")
|
252
271
|
console.print("Error: Could not extract the total number of songs.")
|
253
272
|
return []
|
254
273
|
|
274
|
+
logging.info(f"Playlist has {self.total_songs} tracks")
|
255
275
|
console.print(f"[cyan]The playlist has [green]{self.total_songs}[/green] tracks")
|
256
276
|
|
257
277
|
try:
|
258
278
|
self.page.wait_for_selector('div[data-testid="playlist-tracklist"]', timeout=15000)
|
259
279
|
except Exception:
|
280
|
+
logging.error("Playlist table did not load")
|
260
281
|
console.print("Error: Playlist table did not load")
|
261
282
|
return []
|
262
283
|
|
@@ -281,9 +302,11 @@ class SpotifyExtractor:
|
|
281
302
|
unique[key] = item
|
282
303
|
|
283
304
|
unique_tracks = list(unique.values())
|
305
|
+
logging.info(f"Extracted {len(unique_tracks)} unique tracks from playlist")
|
284
306
|
return unique_tracks
|
285
307
|
|
286
308
|
except Exception as e:
|
309
|
+
logging.error(f"Error extracting playlist: {e}")
|
287
310
|
console.print(f"Error extracting playlist: {e}")
|
288
311
|
return []
|
289
312
|
|
@@ -327,5 +350,6 @@ class SpotifyExtractor:
|
|
327
350
|
}
|
328
351
|
|
329
352
|
except Exception as e:
|
353
|
+
logging.error(f"Error parsing playlist item: {e}")
|
330
354
|
console.print(f"Error parsing playlist item: {e}")
|
331
355
|
return {}
|
@@ -3,6 +3,7 @@
|
|
3
3
|
import re
|
4
4
|
import json
|
5
5
|
import difflib
|
6
|
+
import logging
|
6
7
|
from urllib.parse import quote_plus
|
7
8
|
from typing import Dict, List, Optional
|
8
9
|
|
@@ -42,6 +43,7 @@ class YouTubeExtractor:
|
|
42
43
|
List[Dict]: List of found videos
|
43
44
|
"""
|
44
45
|
try:
|
46
|
+
logging.info(f"Starting YouTube search for query: {query}")
|
45
47
|
search_url = f"https://www.youtube.com/results?search_query={quote_plus(query)}"
|
46
48
|
console.print(f"\n[bold blue]Searching on YouTube:[/bold blue] {query}")
|
47
49
|
|
@@ -49,9 +51,12 @@ class YouTubeExtractor:
|
|
49
51
|
response = client.get(search_url, headers={"User-Agent": get_userAgent()})
|
50
52
|
html = response.text
|
51
53
|
|
52
|
-
|
54
|
+
results = self._extract_youtube_videos(html, max_results)
|
55
|
+
logging.info(f"Found {len(results)} results for query: {query}")
|
56
|
+
return results
|
53
57
|
|
54
58
|
except Exception as e:
|
59
|
+
logging.error(f"YouTube search error: {e}")
|
55
60
|
print(f"YouTube search error: {e}")
|
56
61
|
return []
|
57
62
|
|
@@ -63,6 +68,7 @@ class YouTubeExtractor:
|
|
63
68
|
youtube_results (List[Dict]): List of YouTube videos
|
64
69
|
target_duration (int): Target duration in seconds
|
65
70
|
"""
|
71
|
+
logging.info(f"Sorting {len(youtube_results)} results by duration similarity to {target_duration}s")
|
66
72
|
for result in youtube_results:
|
67
73
|
if result.get('duration_seconds') is not None:
|
68
74
|
result['duration_difference'] = abs(result['duration_seconds'] - target_duration)
|
@@ -80,6 +86,7 @@ class YouTubeExtractor:
|
|
80
86
|
youtube_results (List[Dict]): List of YouTube videos
|
81
87
|
spotify_info (Dict): Spotify track info
|
82
88
|
"""
|
89
|
+
logging.info(f"Sorting {len(youtube_results)} results by affinity and duration using Spotify info")
|
83
90
|
target_duration = spotify_info.get('duration_seconds')
|
84
91
|
target_title = spotify_info.get('title', '').lower()
|
85
92
|
target_artist = spotify_info.get('artist', '').lower()
|
@@ -123,7 +130,9 @@ class YouTubeExtractor:
|
|
123
130
|
"""Extract videos from YouTube HTML"""
|
124
131
|
try:
|
125
132
|
yt_match = re.search(r'var ytInitialData = ({.+?});', html, re.DOTALL)
|
133
|
+
|
126
134
|
if not yt_match:
|
135
|
+
logging.warning("ytInitialData not found in HTML")
|
127
136
|
return []
|
128
137
|
|
129
138
|
yt_data = json.loads(yt_match.group(1))
|
@@ -152,9 +161,11 @@ class YouTubeExtractor:
|
|
152
161
|
if len(results) >= max_results:
|
153
162
|
break
|
154
163
|
|
164
|
+
logging.info(f"Extracted {len(results)} video(s) from HTML")
|
155
165
|
return results
|
156
166
|
|
157
167
|
except Exception as e:
|
168
|
+
logging.error(f"Video extraction error: {e}")
|
158
169
|
print(f"Video extraction error: {e}")
|
159
170
|
return []
|
160
171
|
|
@@ -163,6 +174,7 @@ class YouTubeExtractor:
|
|
163
174
|
try:
|
164
175
|
video_id = video_data.get('videoId')
|
165
176
|
if not video_id:
|
177
|
+
logging.warning("videoId not found in video_data")
|
166
178
|
return None
|
167
179
|
|
168
180
|
# Title
|
@@ -187,6 +199,7 @@ class YouTubeExtractor:
|
|
187
199
|
# Published date
|
188
200
|
published = self._extract_text(video_data.get('publishedTimeText', {}))
|
189
201
|
|
202
|
+
logging.info(f"Parsed video: {title} (ID: {video_id})")
|
190
203
|
return {
|
191
204
|
'video_id': video_id,
|
192
205
|
'url': f'https://www.youtube.com/watch?v={video_id}',
|
@@ -200,6 +213,7 @@ class YouTubeExtractor:
|
|
200
213
|
}
|
201
214
|
|
202
215
|
except Exception as e:
|
216
|
+
logging.error(f"Video parsing error: {e}")
|
203
217
|
print(f"Video parsing error: {e}")
|
204
218
|
return None
|
205
219
|
|
SpotDown/main.py
CHANGED
@@ -1,12 +1,13 @@
|
|
1
1
|
# 05.04.2024
|
2
2
|
|
3
3
|
import time
|
4
|
-
import logging
|
5
4
|
from typing import Dict, List, Optional
|
6
5
|
|
7
6
|
|
8
7
|
# Internal utils
|
8
|
+
from SpotDown.utils.logger import Logger
|
9
9
|
from SpotDown.utils.console_utils import ConsoleUtils
|
10
|
+
from SpotDown.upload.update import update as git_update
|
10
11
|
from SpotDown.extractor.spotify_extractor import SpotifyExtractor
|
11
12
|
from SpotDown.extractor.youtube_extractor import YouTubeExtractor
|
12
13
|
from SpotDown.downloader.youtube_downloader import YouTubeDownloader
|
@@ -17,11 +18,6 @@ from SpotDown.downloader.youtube_downloader import YouTubeDownloader
|
|
17
18
|
console = ConsoleUtils()
|
18
19
|
|
19
20
|
|
20
|
-
def setup_logging():
|
21
|
-
"""Initialize basic logging configuration"""
|
22
|
-
logging.basicConfig(level=logging.ERROR)
|
23
|
-
|
24
|
-
|
25
21
|
def extract_spotify_data(spotify_url: str, max_retry: int = 3) -> Optional[Dict]:
|
26
22
|
"""Extract data from Spotify URL with retry mechanism"""
|
27
23
|
for attempt in range(1, max_retry + 1):
|
@@ -108,11 +104,11 @@ def handle_single_track_download(spotify_info: Dict, max_results: int):
|
|
108
104
|
|
109
105
|
def run():
|
110
106
|
"""Main execution function"""
|
111
|
-
|
107
|
+
Logger()
|
112
108
|
|
113
109
|
console = ConsoleUtils()
|
114
110
|
console.start_message()
|
115
|
-
|
111
|
+
git_update()
|
116
112
|
|
117
113
|
spotify_url = console.get_spotify_url()
|
118
114
|
max_results = 5
|
SpotDown/upload/version.py
CHANGED
SpotDown/utils/config_json.py
CHANGED
@@ -44,7 +44,7 @@ class ConfigManager:
|
|
44
44
|
|
45
45
|
def download_config(self) -> None:
|
46
46
|
"""Download config.json from the Arrowar/SpotDown GitHub repository."""
|
47
|
-
url = "https://raw.githubusercontent.com/Arrowar/SpotDown/main/config.json"
|
47
|
+
url = "https://raw.githubusercontent.com/Arrowar/SpotDown/refs/heads/main/config.json"
|
48
48
|
try:
|
49
49
|
with httpx.Client(timeout=10, headers=get_headers()) as client:
|
50
50
|
response = client.get(url)
|
@@ -220,4 +220,4 @@ class ConfigManager:
|
|
220
220
|
return section in config_source
|
221
221
|
|
222
222
|
|
223
|
-
config_manager = ConfigManager()
|
223
|
+
config_manager = ConfigManager()
|
SpotDown/utils/logger.py
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
# 05.04.2024
|
2
|
+
|
3
|
+
import os
|
4
|
+
import logging
|
5
|
+
from logging.handlers import RotatingFileHandler
|
6
|
+
|
7
|
+
|
8
|
+
# Internal utils
|
9
|
+
from SpotDown.utils.config_json import config_manager
|
10
|
+
|
11
|
+
|
12
|
+
|
13
|
+
class Logger:
|
14
|
+
_instance = None
|
15
|
+
|
16
|
+
def __new__(cls):
|
17
|
+
# Singleton pattern to avoid multiple logger instances
|
18
|
+
if cls._instance is None:
|
19
|
+
cls._instance = super(Logger, cls).__new__(cls)
|
20
|
+
cls._instance._initialized = False
|
21
|
+
return cls._instance
|
22
|
+
|
23
|
+
def __init__(self):
|
24
|
+
# Initialize only once
|
25
|
+
if getattr(self, '_initialized', False):
|
26
|
+
return
|
27
|
+
|
28
|
+
# Configure root logger
|
29
|
+
self.debug_mode = config_manager.get_bool('DEFAULT', "debug")
|
30
|
+
self.logger = logging.getLogger('')
|
31
|
+
|
32
|
+
# Remove any existing handlers to avoid duplication
|
33
|
+
for handler in self.logger.handlers[:]:
|
34
|
+
self.logger.removeHandler(handler)
|
35
|
+
|
36
|
+
# Reduce logging level for external libraries
|
37
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
38
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
39
|
+
|
40
|
+
# Set logging level based on debug_mode
|
41
|
+
if self.debug_mode:
|
42
|
+
self.logger.setLevel(logging.DEBUG)
|
43
|
+
# In debug mode: log SOLO nel file, non nel terminale
|
44
|
+
self._configure_console_log_file()
|
45
|
+
else:
|
46
|
+
self.logger.setLevel(logging.ERROR)
|
47
|
+
# In modalità normale: log solo nel terminale per gli errori
|
48
|
+
self._configure_console_logging()
|
49
|
+
|
50
|
+
self._initialized = True
|
51
|
+
|
52
|
+
def _configure_console_logging(self):
|
53
|
+
"""Configure console logging output to terminal."""
|
54
|
+
console_handler = logging.StreamHandler()
|
55
|
+
console_handler.setLevel(logging.ERROR) # Solo errori nel terminale
|
56
|
+
formatter = logging.Formatter('[%(filename)s:%(lineno)s - %(funcName)20s() ] %(asctime)s - %(levelname)s - %(message)s')
|
57
|
+
console_handler.setFormatter(formatter)
|
58
|
+
self.logger.addHandler(console_handler)
|
59
|
+
|
60
|
+
def _configure_console_log_file(self):
|
61
|
+
"""Create a console.log file only when debug mode is enabled."""
|
62
|
+
console_log_path = "console.log"
|
63
|
+
try:
|
64
|
+
# Remove existing file if present
|
65
|
+
if os.path.exists(console_log_path):
|
66
|
+
os.remove(console_log_path)
|
67
|
+
|
68
|
+
# Create handler for console.log
|
69
|
+
console_file_handler = RotatingFileHandler(
|
70
|
+
console_log_path,
|
71
|
+
maxBytes=5*1024*1024, # 5 MB
|
72
|
+
backupCount=3
|
73
|
+
)
|
74
|
+
console_file_handler.setLevel(logging.DEBUG)
|
75
|
+
formatter = logging.Formatter('[%(filename)s:%(lineno)s - %(funcName)20s() ] %(asctime)s - %(levelname)s - %(message)s')
|
76
|
+
console_file_handler.setFormatter(formatter)
|
77
|
+
self.logger.addHandler(console_file_handler)
|
78
|
+
|
79
|
+
except Exception as e:
|
80
|
+
print(f"Error creating console.log: {e}")
|
81
|
+
|
82
|
+
@staticmethod
|
83
|
+
def get_logger(name=None):
|
84
|
+
"""
|
85
|
+
Get a specific logger for a module/component.
|
86
|
+
If name is None, returns the root logger.
|
87
|
+
"""
|
88
|
+
# Ensure Logger instance is initialized
|
89
|
+
Logger()
|
90
|
+
return logging.getLogger(name)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: SpotDown
|
3
|
-
Version: 0.
|
3
|
+
Version: 1.0.0
|
4
4
|
Summary: A command-line program to download music
|
5
5
|
Home-page: https://github.com/Arrowar/spotdown
|
6
6
|
Author: Arrowar
|
@@ -41,16 +41,10 @@ Dynamic: summary
|
|
41
41
|
## 💝 Support the Project
|
42
42
|
|
43
43
|
[](https://www.paypal.com/donate/?hosted_button_id=UXTWMT8P6HE2C)
|
44
|
-
## 🚀 Download & Install
|
45
|
-
|
46
|
-
[](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_win.exe)
|
47
|
-
[](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_mac)
|
48
|
-
[](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_linux_latest)
|
49
|
-
[](https://github.com/Arrowar/spotdown/releases/latest/download/spotdown_linux_previous)
|
50
44
|
|
51
45
|
---
|
52
46
|
|
53
|
-
*⚡ **Quick Start:** `pip install spotdown
|
47
|
+
*⚡ **Quick Start:** `pip install spotdown && spotdown`*
|
54
48
|
|
55
49
|
</div>
|
56
50
|
|
@@ -68,36 +62,41 @@ Dynamic: summary
|
|
68
62
|
- 📋 **Download entire playlists** with ease
|
69
63
|
- 🔍 **No authentication required** - uses web scraping
|
70
64
|
- 🎨 **Automatic cover art embedding** (JPEG format)
|
65
|
+
- ⚡ **Simple command-line interface** - just run `spotdown`!
|
71
66
|
|
72
67
|
## Installation
|
73
68
|
|
74
|
-
###
|
75
|
-
|
76
|
-
- **Python 3.8+**
|
77
|
-
- **FFmpeg** (for audio processing)
|
78
|
-
- **yt-dlp** (for downloading)
|
79
|
-
|
80
|
-
### 1. Install Python Dependencies
|
69
|
+
### Method 1: PyPI (Recommended)
|
81
70
|
|
82
71
|
```bash
|
83
|
-
pip install
|
72
|
+
pip install spotdown
|
84
73
|
```
|
85
74
|
|
86
|
-
|
75
|
+
That's it! You can now run `spotdown` from anywhere in your terminal.
|
76
|
+
|
77
|
+
### Method 2: From Source
|
78
|
+
|
79
|
+
If you prefer to install from source:
|
87
80
|
|
88
81
|
```bash
|
89
|
-
|
82
|
+
git clone https://github.com/Arrowar/spotdown.git
|
83
|
+
cd spotdown
|
84
|
+
pip install -e .
|
90
85
|
```
|
91
86
|
|
92
|
-
###
|
87
|
+
### Prerequisites
|
93
88
|
|
94
|
-
|
89
|
+
The following dependencies will be automatically installed:
|
95
90
|
|
96
|
-
|
97
|
-
|
91
|
+
- **Python 3.8+**
|
92
|
+
- **FFmpeg** (for audio processing)
|
93
|
+
- **yt-dlp** (for downloading)
|
94
|
+
- **Playwright** (for web scraping)
|
95
|
+
|
96
|
+
After installation, run this one-time setup command:
|
98
97
|
|
99
|
-
|
100
|
-
|
98
|
+
```bash
|
99
|
+
playwright install chromium
|
101
100
|
```
|
102
101
|
|
103
102
|
## Configuration
|
@@ -116,7 +115,7 @@ SpotDown uses a JSON configuration file with the following structure:
|
|
116
115
|
},
|
117
116
|
"BROWSER": {
|
118
117
|
"headless": true,
|
119
|
-
"timeout":
|
118
|
+
"timeout": 8
|
120
119
|
}
|
121
120
|
}
|
122
121
|
```
|
@@ -137,15 +136,19 @@ SpotDown uses a JSON configuration file with the following structure:
|
|
137
136
|
|
138
137
|
## Usage
|
139
138
|
|
140
|
-
###
|
139
|
+
### Starting SpotDown
|
140
|
+
|
141
|
+
Simply run the following command in your terminal:
|
141
142
|
|
142
143
|
```bash
|
143
|
-
|
144
|
+
spotdown
|
144
145
|
```
|
145
146
|
|
147
|
+
The interactive interface will guide you through the download process.
|
148
|
+
|
146
149
|
### Download Individual Songs
|
147
150
|
|
148
|
-
1. Run
|
151
|
+
1. Run `spotdown`
|
149
152
|
2. Paste the Spotify song URL when prompted
|
150
153
|
3. The script will automatically:
|
151
154
|
- Extract song information
|
@@ -154,15 +157,28 @@ python run.py
|
|
154
157
|
|
155
158
|
### Download Playlists
|
156
159
|
|
157
|
-
1. Run
|
160
|
+
1. Run `spotdown`
|
158
161
|
2. Paste the Spotify playlist URL when prompted
|
159
162
|
3. All songs in the playlist will be downloaded automatically
|
160
163
|
|
164
|
+
### Example Usage
|
165
|
+
|
166
|
+
```bash
|
167
|
+
$ spotdown
|
168
|
+
🎵 Welcome to SpotDown!
|
169
|
+
Please paste your Spotify URL: https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh
|
170
|
+
🔍 Processing: Song Name - Artist Name
|
171
|
+
⬇️ Downloading...
|
172
|
+
✅ Download complete!
|
173
|
+
```
|
174
|
+
|
161
175
|
## To Do
|
162
176
|
|
163
177
|
- [ ] Implement batch download queue
|
164
178
|
- [ ] Add GUI interface option
|
165
179
|
- [ ] Support for additional music platforms
|
180
|
+
- [ ] Album art quality selection
|
181
|
+
- [ ] Custom output directory configuration
|
166
182
|
|
167
183
|
## Disclaimer
|
168
184
|
|
@@ -0,0 +1,20 @@
|
|
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,,
|
spotdown-0.1.1.dist-info/RECORD
DELETED
@@ -1,19 +0,0 @@
|
|
1
|
-
SpotDown/__init__.py,sha256=ebKRxKh14_cO7UWvOgE4R1RqIFBdekg50xtzc1LU6DU,59
|
2
|
-
SpotDown/main.py,sha256=TjxUMIe0ee8IMbnYhXekO2MoLRiY6pJ5CCPgz7pOpHg,5042
|
3
|
-
SpotDown/downloader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
-
SpotDown/downloader/youtube_downloader.py,sha256=zHl98F2Ax72wTg3n8pohmkBZgBIPff0NrD9QKhG-TBM,4589
|
5
|
-
SpotDown/extractor/__init__.py,sha256=pQtkjMzB4D8AVEFQ3pnp_UQI-KbYaQhZbKuDnMImN0o,161
|
6
|
-
SpotDown/extractor/spotify_extractor.py,sha256=abE48E4M8s3xf95a28IYD8L4K2-QFDaYoZC5V0Jqu-4,13486
|
7
|
-
SpotDown/extractor/youtube_extractor.py,sha256=XIPNWEBb5oUkGP1DrQ4h4XTJvHlBlbVIkAFNdpFE-Rc,9586
|
8
|
-
SpotDown/upload/version.py,sha256=hjqk3lk5Ic6SKn0Dk_-gfU6u75JmZLl5bcDSWIKdOAE,162
|
9
|
-
SpotDown/utils/__init__.py,sha256=ZhJJhNQkUd4XG6vkoVQSC3qvSlaArhsRjscn4bfC9WA,128
|
10
|
-
SpotDown/utils/config_json.py,sha256=Z_fuM2mYQVPc51sQ_-LXrX-ArTPL-lRDcQ3lLM_hmck,7435
|
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-0.1.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
15
|
-
spotdown-0.1.1.dist-info/METADATA,sha256=C3V6JqDDU14RFwGsniGuO-uEHOzvVvOezNPZ7Haqi_I,5744
|
16
|
-
spotdown-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
17
|
-
spotdown-0.1.1.dist-info/entry_points.txt,sha256=qHq6M94aU-XQL0y9R4yFKoqnHjU2nsN53Bj4OuHp3vQ,47
|
18
|
-
spotdown-0.1.1.dist-info/top_level.txt,sha256=1E-ZZ8rsrXmBPAsseY_kEfHfNo9w9PtSCaS6pM63-tw,9
|
19
|
-
spotdown-0.1.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|