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 +78 -0
- sfotipy-1.0.0/README.md +58 -0
- sfotipy-1.0.0/pyproject.toml +37 -0
- sfotipy-1.0.0/setup.cfg +4 -0
- sfotipy-1.0.0/sfotipy/__init__.py +4 -0
- sfotipy-1.0.0/sfotipy/__main__.py +6 -0
- sfotipy-1.0.0/sfotipy/cli.py +335 -0
- sfotipy-1.0.0/sfotipy/config.py +69 -0
- sfotipy-1.0.0/sfotipy/downloader.py +495 -0
- sfotipy-1.0.0/sfotipy/lyrics.py +32 -0
- sfotipy-1.0.0/sfotipy/metadata.py +166 -0
- sfotipy-1.0.0/sfotipy/source.py +56 -0
- sfotipy-1.0.0/sfotipy/spotify.py +173 -0
- sfotipy-1.0.0/sfotipy/utils.py +463 -0
- sfotipy-1.0.0/sfotipy.egg-info/PKG-INFO +78 -0
- sfotipy-1.0.0/sfotipy.egg-info/SOURCES.txt +18 -0
- sfotipy-1.0.0/sfotipy.egg-info/dependency_links.txt +1 -0
- sfotipy-1.0.0/sfotipy.egg-info/entry_points.txt +2 -0
- sfotipy-1.0.0/sfotipy.egg-info/requires.txt +5 -0
- sfotipy-1.0.0/sfotipy.egg-info/top_level.txt +1 -0
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
|
sfotipy-1.0.0/README.md
ADDED
|
@@ -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 = []
|
sfotipy-1.0.0/setup.cfg
ADDED
|
@@ -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
|