sfotipy 1.0.0__tar.gz

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.
sfotipy-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.1
2
+ Name: sfotipy
3
+ Version: 1.0.0
4
+ Summary: Spotify Music Downloader - Download music from Spotify
5
+ Author: Ren
6
+ Project-URL: Homepage, https://github.com/Minu181/Sfotipy
7
+ Project-URL: Repository, https://github.com/Minu181/Sfotipy
8
+ Project-URL: Issues, https://github.com/Minu181/Sfotipy/issues
9
+ Keywords: spotify,music,downloader,mp3,yt-dlp
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Multimedia :: Sound/Audio
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: spotifyscraper>=3.9.0
16
+ Requires-Dist: rich>=13.0.0
17
+ Requires-Dist: yt-dlp>=2024.1.0
18
+ Requires-Dist: mutagen>=1.47.0
19
+ Requires-Dist: syncedlyrics>=1.0.0
20
+
21
+ # sfotipy
22
+
23
+ Spotify Music Downloader - Download music from Spotify playlists, albums, and tracks as MP3 with metadata, cover art, and watermark.
24
+
25
+ **made by Ren - Discord: wtf_renn**
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install sfotipy
31
+ ```
32
+
33
+ ### Requirements
34
+
35
+ - Python 3.8+
36
+ - [FFmpeg](https://ffmpeg.org/download.html) installed and in your PATH
37
+
38
+ ## Usage
39
+
40
+ ### Interactive Mode
41
+
42
+ ```bash
43
+ sfotipy
44
+ ```
45
+
46
+ Opens a menu to configure settings and download music.
47
+
48
+ ### Direct Mode
49
+
50
+ ```bash
51
+ sfotipy https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh
52
+ sfotipy https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
53
+ sfotipy https://open.spotify.com/album/1DFixLWuPmc35TjJb6Wqeq
54
+ ```
55
+
56
+ ### Options
57
+
58
+ | Flag | Description |
59
+ |------|-------------|
60
+ | `-p`, `--path` | Download path |
61
+ | `-q`, `--quality` | Audio quality: 128, 192, 256, 320 kbps |
62
+ | `-s`, `--source` | Audio source: soundcloud, youtube-music, audius, beatbump |
63
+ | `-l`, `--lyrics` | Download lyrics |
64
+ | `-v`, `--version` | Show version |
65
+
66
+ ## Features
67
+
68
+ - Download tracks, playlists, and albums from Spotify
69
+ - Automatic metadata (title, artist, album, cover art)
70
+ - `[Sfotipy]` watermark in filename
71
+ - Multiple audio sources with fallback
72
+ - Real-time progress display
73
+ - ESC to pause with continue/exit option
74
+ - Lyrics download support
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,58 @@
1
+ # sfotipy
2
+
3
+ Spotify Music Downloader - Download music from Spotify playlists, albums, and tracks as MP3 with metadata, cover art, and watermark.
4
+
5
+ **made by Ren - Discord: wtf_renn**
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install sfotipy
11
+ ```
12
+
13
+ ### Requirements
14
+
15
+ - Python 3.8+
16
+ - [FFmpeg](https://ffmpeg.org/download.html) installed and in your PATH
17
+
18
+ ## Usage
19
+
20
+ ### Interactive Mode
21
+
22
+ ```bash
23
+ sfotipy
24
+ ```
25
+
26
+ Opens a menu to configure settings and download music.
27
+
28
+ ### Direct Mode
29
+
30
+ ```bash
31
+ sfotipy https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh
32
+ sfotipy https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
33
+ sfotipy https://open.spotify.com/album/1DFixLWuPmc35TjJb6Wqeq
34
+ ```
35
+
36
+ ### Options
37
+
38
+ | Flag | Description |
39
+ |------|-------------|
40
+ | `-p`, `--path` | Download path |
41
+ | `-q`, `--quality` | Audio quality: 128, 192, 256, 320 kbps |
42
+ | `-s`, `--source` | Audio source: soundcloud, youtube-music, audius, beatbump |
43
+ | `-l`, `--lyrics` | Download lyrics |
44
+ | `-v`, `--version` | Show version |
45
+
46
+ ## Features
47
+
48
+ - Download tracks, playlists, and albums from Spotify
49
+ - Automatic metadata (title, artist, album, cover art)
50
+ - `[Sfotipy]` watermark in filename
51
+ - Multiple audio sources with fallback
52
+ - Real-time progress display
53
+ - ESC to pause with continue/exit option
54
+ - Lyrics download support
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64,<75", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sfotipy"
7
+ version = "1.0.0"
8
+ description = "Spotify Music Downloader - Download music from Spotify"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ authors = [
12
+ { name = "Ren" },
13
+ ]
14
+ keywords = ["spotify", "music", "downloader", "mp3", "yt-dlp"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Multimedia :: Sound/Audio",
19
+ ]
20
+ dependencies = [
21
+ "spotifyscraper>=3.9.0",
22
+ "rich>=13.0.0",
23
+ "yt-dlp>=2024.1.0",
24
+ "mutagen>=1.47.0",
25
+ "syncedlyrics>=1.0.0",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/Minu181/Sfotipy"
30
+ Repository = "https://github.com/Minu181/Sfotipy"
31
+ Issues = "https://github.com/Minu181/Sfotipy/issues"
32
+
33
+ [project.scripts]
34
+ sfotipy = "sfotipy.cli:main"
35
+
36
+ [tool.setuptools]
37
+ license-files = []
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """sfotipy - Spotify Music Downloader"""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "sfotipy"
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m sfotipy"""
2
+
3
+ from sfotipy.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,335 @@
1
+ import sys
2
+ import argparse
3
+ from pathlib import Path
4
+
5
+ from sfotipy import __version__
6
+ from sfotipy.config import load_config, save_config, ensure_download_dir
7
+ from sfotipy.downloader import parse_url, get_track_metadata, get_playlist_name, download_all
8
+ from sfotipy.source import get_source_name, SOURCE_OPTIONS
9
+ from sfotipy.utils import (
10
+ console,
11
+ show_banner,
12
+ show_error,
13
+ show_warning,
14
+ show_success,
15
+ show_info,
16
+ ask_url,
17
+ ask_download_path,
18
+ ask_quality,
19
+ show_tracks,
20
+ ask_download_action,
21
+ ask_track_selection,
22
+ ask_continue_or_exit,
23
+ show_summary,
24
+ confirm_continue,
25
+ show_source_table,
26
+ DownloadProgress,
27
+ )
28
+
29
+
30
+ def interactive_mode():
31
+ show_banner()
32
+
33
+ config = load_config()
34
+ current_path = config["download_path"]
35
+ current_quality = config["quality"]
36
+ current_source = config["source"]
37
+ current_lyrics = config.get("download_lyrics", False)
38
+
39
+ while True:
40
+ console.print()
41
+ console.print("[bold cyan]═══ Settings ═══[/bold cyan]")
42
+ console.print(f" Download path: [green]{current_path}[/green]")
43
+ console.print(f" Quality: [green]{current_quality}kbps[/green]")
44
+ console.print(f" Source: [green]{get_source_name(current_source)}[/green]")
45
+ console.print(f" Lyrics: [green]{'ON' if current_lyrics else 'OFF'}[/green]")
46
+ console.print()
47
+
48
+ console.print("[bold cyan]═══ Options ═══[/bold cyan]")
49
+ console.print(" 1. Change settings")
50
+ console.print(" 2. Download music")
51
+ console.print(" 3. Exit")
52
+ console.print()
53
+
54
+ choice = input("Select option (1-3): ").strip()
55
+
56
+ if choice == "1":
57
+ current_path, current_quality, current_source, current_lyrics = change_settings(
58
+ current_path, current_quality, current_source, current_lyrics
59
+ )
60
+ elif choice == "2":
61
+ download_flow(current_path, current_quality, current_source, current_lyrics)
62
+ elif choice == "3":
63
+ console.print("\n[bold cyan]Goodbye![/bold cyan]\n")
64
+ break
65
+ else:
66
+ show_warning("Invalid option. Please try again.")
67
+
68
+
69
+ def change_settings(path: str, quality: int, source: str, lyrics: bool) -> tuple:
70
+ console.print()
71
+ console.print("[bold cyan]═══ Change Settings ═══[/bold cyan]")
72
+ console.print(" 1. Change download path")
73
+ console.print(" 2. Change quality")
74
+ console.print(" 3. Change audio source")
75
+ console.print(" 4. Toggle lyrics download")
76
+ console.print(" 5. Back to main menu")
77
+ console.print()
78
+
79
+ choice = input("Select option (1-5): ").strip()
80
+
81
+ if choice == "1":
82
+ path = ask_download_path(path)
83
+ show_success(f"Download path set to: {path}")
84
+ elif choice == "2":
85
+ quality = ask_quality(quality)
86
+ show_success(f"Quality set to: {quality}kbps")
87
+ elif choice == "3":
88
+ source = ask_source(source)
89
+ show_success(f"Source set to: {get_source_name(source)}")
90
+ elif choice == "4":
91
+ lyrics = not lyrics
92
+ show_success(f"Lyrics download: {'ON' if lyrics else 'OFF'}")
93
+ elif choice == "5":
94
+ return path, quality, source, lyrics
95
+ else:
96
+ show_warning("Invalid option.")
97
+
98
+ save_config({
99
+ "download_path": path,
100
+ "quality": quality,
101
+ "source": source,
102
+ "output_format": "mp3",
103
+ "download_lyrics": lyrics,
104
+ })
105
+
106
+ return path, quality, source, lyrics
107
+
108
+
109
+ def ask_source(current: str) -> str:
110
+ from rich.prompt import Prompt
111
+
112
+ console.print()
113
+ source_map = show_source_table(current)
114
+
115
+ reverse_map = {v: k for k, v in source_map.items()}
116
+ default = reverse_map.get(current, "1")
117
+ choices = list(source_map.keys())
118
+
119
+ choice = Prompt.ask(
120
+ "[bold cyan]Select source[/bold cyan]",
121
+ choices=choices,
122
+ default=default,
123
+ )
124
+
125
+ return source_map[choice]
126
+
127
+
128
+ def download_flow(path: str, quality: int, source: str, lyrics: bool):
129
+ console.print()
130
+ url = ask_url()
131
+
132
+ if not url:
133
+ show_warning("No URL provided.")
134
+ return
135
+
136
+ url_info = parse_url(url)
137
+
138
+ if url_info["type"] == "unknown":
139
+ show_error("Invalid Spotify URL. Please provide a valid track, playlist, or album URL.")
140
+ return
141
+
142
+ console.print(f"[dim]Detected: {url_info['type'].capitalize()}[/dim]")
143
+
144
+ tracks, track_dicts = get_track_metadata(url)
145
+
146
+ if not track_dicts:
147
+ show_error("No tracks found. Please check the URL and try again.")
148
+ return
149
+
150
+ show_tracks(track_dicts)
151
+
152
+ if url_info["type"] in ("playlist", "album"):
153
+ folder_name = get_playlist_name(url)
154
+ else:
155
+ folder_name = sanitize_folder_name(f"{track_dicts[0]['artist']} - {track_dicts[0]['title']}")
156
+
157
+ output_dir = ensure_download_dir(folder_name)
158
+ show_info(f"Download folder: {output_dir}")
159
+
160
+ action = ask_download_action()
161
+
162
+ if action == "1":
163
+ selected_tracks = tracks
164
+ selected_count = len(tracks)
165
+ elif action == "2":
166
+ selected_indices = ask_track_selection(len(track_dicts))
167
+ if not selected_indices:
168
+ show_warning("No tracks selected.")
169
+ return
170
+ selected_tracks = [tracks[i - 1] for i in selected_indices]
171
+ selected_count = len(selected_tracks)
172
+ show_info(f"Selected {selected_count} track(s)")
173
+ else:
174
+ console.print("[dim]Cancelled.[/dim]")
175
+ return
176
+
177
+ console.print()
178
+ show_info(f"Starting download: {selected_count} track(s) to {output_dir}")
179
+ show_info(f"Quality: {quality}kbps | Source: {get_source_name(source)}")
180
+ if lyrics:
181
+ show_info("Lyrics: ON")
182
+ console.print()
183
+
184
+ if not confirm_continue("Start downloading?"):
185
+ console.print("[dim]Cancelled.[/dim]")
186
+ return
187
+
188
+ progress = DownloadProgress()
189
+ progress.start(len(selected_tracks))
190
+
191
+ downloaded, skipped, failed, errors = download_all(
192
+ selected_tracks, output_dir, quality, source, progress
193
+ )
194
+
195
+ if progress.force_stop:
196
+ progress.stop()
197
+ remaining = len(selected_tracks) - downloaded - failed - skipped
198
+ if not ask_continue_or_exit(downloaded, failed, skipped, remaining):
199
+ show_summary(downloaded, skipped, failed, errors)
200
+ return
201
+ else:
202
+ remaining_tracks = selected_tracks[downloaded + failed + skipped:]
203
+ if remaining_tracks:
204
+ progress = DownloadProgress()
205
+ progress.start(len(remaining_tracks))
206
+ d, s, f, e = download_all(
207
+ remaining_tracks, output_dir, quality, source, progress
208
+ )
209
+ downloaded += d
210
+ skipped += s
211
+ failed += f
212
+ errors.extend(e)
213
+ else:
214
+ progress.stop()
215
+
216
+ show_summary(downloaded, skipped, failed, errors)
217
+
218
+
219
+ def direct_mode(url: str, path: str = None, quality: int = None, source: str = None):
220
+ show_banner()
221
+
222
+ config = load_config()
223
+ path = path or config["download_path"]
224
+ quality = quality or config["quality"]
225
+ source = source or config["source"]
226
+
227
+ console.print(f"[dim]Using: {path} | {quality}kbps | {get_source_name(source)}[/dim]")
228
+
229
+ url_info = parse_url(url)
230
+
231
+ if url_info["type"] == "unknown":
232
+ show_error("Invalid Spotify URL.")
233
+ return
234
+
235
+ tracks, track_dicts = get_track_metadata(url)
236
+
237
+ if not track_dicts:
238
+ show_error("No tracks found.")
239
+ return
240
+
241
+ show_tracks(track_dicts)
242
+
243
+ if url_info["type"] in ("playlist", "album"):
244
+ folder_name = get_playlist_name(url)
245
+ else:
246
+ folder_name = sanitize_folder_name(f"{track_dicts[0]['artist']} - {track_dicts[0]['title']}")
247
+
248
+ output_dir = ensure_download_dir(folder_name)
249
+
250
+ if len(tracks) > 1:
251
+ if not confirm_continue("Download all tracks?"):
252
+ return
253
+ progress = DownloadProgress()
254
+ progress.start(len(tracks))
255
+ downloaded, skipped, failed, errors = download_all(
256
+ tracks, output_dir, quality, source, progress
257
+ )
258
+
259
+ if progress.force_stop:
260
+ progress.stop()
261
+ remaining = len(tracks) - downloaded - failed - skipped
262
+ if not ask_continue_or_exit(downloaded, failed, skipped, remaining):
263
+ show_summary(downloaded, skipped, failed, errors)
264
+ return
265
+ else:
266
+ remaining_tracks = tracks[downloaded + failed + skipped:]
267
+ if remaining_tracks:
268
+ progress = DownloadProgress()
269
+ progress.start(len(remaining_tracks))
270
+ d, s, f, e = download_all(
271
+ remaining_tracks, output_dir, quality, source, progress
272
+ )
273
+ downloaded += d
274
+ skipped += s
275
+ failed += f
276
+ errors.extend(e)
277
+ else:
278
+ progress.stop()
279
+
280
+ show_summary(downloaded, skipped, failed, errors)
281
+
282
+
283
+ def sanitize_folder_name(name: str) -> str:
284
+ invalid_chars = '<>:"/\\|?*'
285
+ for char in invalid_chars:
286
+ name = name.replace(char, "")
287
+ return name.strip()[:100] or "Spotify_Download"
288
+
289
+
290
+ def main():
291
+ parser = argparse.ArgumentParser(
292
+ prog="sfotipy",
293
+ description="Spotify Music Downloader - Download music from Spotify",
294
+ )
295
+ parser.add_argument(
296
+ "url",
297
+ nargs="?",
298
+ help="Spotify URL (track, playlist, or album)",
299
+ )
300
+ parser.add_argument(
301
+ "--path", "-p",
302
+ help="Download path",
303
+ )
304
+ parser.add_argument(
305
+ "--quality", "-q",
306
+ type=int,
307
+ choices=[128, 192, 256, 320],
308
+ help="Audio quality in kbps",
309
+ )
310
+ parser.add_argument(
311
+ "--source", "-s",
312
+ choices=SOURCE_OPTIONS + ["auto"],
313
+ help="Audio source",
314
+ )
315
+ parser.add_argument(
316
+ "--lyrics", "-l",
317
+ action="store_true",
318
+ help="Download lyrics",
319
+ )
320
+ parser.add_argument(
321
+ "--version", "-v",
322
+ action="version",
323
+ version=f"sfotipy {__version__}",
324
+ )
325
+
326
+ args = parser.parse_args()
327
+
328
+ if args.url:
329
+ direct_mode(args.url, args.path, args.quality, args.source)
330
+ else:
331
+ interactive_mode()
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()
@@ -0,0 +1,69 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ CONFIG_DIR = Path.home() / ".sfotipy"
5
+ CONFIG_FILE = CONFIG_DIR / "config.json"
6
+
7
+ DEFAULT_CONFIG = {
8
+ "download_path": str(Path.home() / "Music" / "sfotipy"),
9
+ "quality": 320,
10
+ "source": "auto",
11
+ "output_format": "mp3",
12
+ "download_lyrics": False,
13
+ }
14
+
15
+
16
+ def get_config_dir() -> Path:
17
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
18
+ return CONFIG_DIR
19
+
20
+
21
+ def load_config() -> dict:
22
+ get_config_dir()
23
+ if CONFIG_FILE.exists():
24
+ try:
25
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
26
+ saved = json.load(f)
27
+ config = {**DEFAULT_CONFIG, **saved}
28
+ return config
29
+ except (json.JSONDecodeError, IOError):
30
+ pass
31
+ return DEFAULT_CONFIG.copy()
32
+
33
+
34
+ def save_config(config: dict) -> None:
35
+ get_config_dir()
36
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
37
+ json.dump(config, f, indent=4)
38
+
39
+
40
+ def update_config(key: str, value) -> None:
41
+ config = load_config()
42
+ config[key] = value
43
+ save_config(config)
44
+
45
+
46
+ def get_download_path() -> Path:
47
+ config = load_config()
48
+ path = Path(config["download_path"])
49
+ path.mkdir(parents=True, exist_ok=True)
50
+ return path
51
+
52
+
53
+ def get_quality() -> int:
54
+ return load_config().get("quality", 320)
55
+
56
+
57
+ def get_source() -> str:
58
+ return load_config().get("source", "auto")
59
+
60
+
61
+ def get_lyrics_setting() -> bool:
62
+ return load_config().get("download_lyrics", False)
63
+
64
+
65
+ def ensure_download_dir(name: str) -> Path:
66
+ base = get_download_path()
67
+ folder = base / name
68
+ folder.mkdir(parents=True, exist_ok=True)
69
+ return folder