Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
soundclouddownloader/output/*
**/*.mp3

# Claude Code
.claude/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ poetry install

## Usage

### Interactive Mode

1. Run the script:

```python
Expand All @@ -53,9 +55,40 @@ poetry run python main.py

3. Enter Y/n if you want the script to create a zip file of all the tracks.

4. Enter the output directory where you want the files to be saved (or press Enter to use the `output` directory).
4. (Optional) Enter a proxy URL if needed to bypass geo-restrictions, or press Enter to skip.

5. Enter the output directory where you want the files to be saved (or press Enter to use the `output` directory).

6. The script will download all tracks in the playlist, convert them to MP3, and optionally create a zip file containing all the tracks.

### CLI Mode (for GitHub Actions or automation)

```bash
poetry run python -m soundclouddownloader.cli_entry --url <PLAYLIST_URL> --output <OUTPUT_DIR> [--proxy <PROXY_URL>] [--zip]
```

**Options:**
- `--url`: SoundCloud playlist URL (required)
- `--output`: Output directory (default: "output")
- `--proxy`: Proxy URL for bypassing geo-restrictions (e.g., `http://proxy.example.com:8080`)
- `--zip`: Create a zip file of downloaded tracks

**Example:**
```bash
poetry run python -m soundclouddownloader.cli_entry --url "https://soundcloud.com/user/sets/playlist" --output downloads --proxy http://proxy.example.com:8080 --zip
```

### Handling Geo-Restricted Tracks

The downloader gracefully handles geo-restricted tracks by:
- Logging a warning when a track is skipped due to geo-restrictions
- Continuing to download other available tracks in the playlist
- Providing a summary of successful downloads and skipped tracks

5. The script will download all tracks in the playlist, convert them to MP3, and optionally create a zip file containing all the tracks.
If you encounter geo-restrictions, you can:
1. Use the `--proxy` option to route downloads through a proxy server
2. Run the downloader from a different geographic location
3. Accept that some tracks may be unavailable and download the rest

## Running tests
```bash
Expand Down
14 changes: 11 additions & 3 deletions soundclouddownloader/cli_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
from soundclouddownloader.main import SoundCloudDownloader


def run(playlist_url: str, output_dir: Path, should_zip: bool = False):
def run(playlist_url: str, output_dir: Path, should_zip: bool = False, proxy: str = None):
"""
Run the download using non-interactive input (for CLI or GitHub Actions).

Args:
playlist_url (str): The URL of the SoundCloud playlist
output_dir (Path): The directory to save downloads
should_zip (bool): Whether to zip the downloaded files
proxy (str): Optional proxy URL to use for downloads
"""
downloader = SoundCloudDownloader()
downloader = SoundCloudDownloader(proxy=proxy)
result = downloader.download_playlist(
playlist_url, output_dir, max_workers=3, should_zip=should_zip
)
Expand All @@ -18,9 +24,11 @@ def run(playlist_url: str, output_dir: Path, should_zip: bool = False):
parser = argparse.ArgumentParser(description="CLI for SoundCloud Downloader")
parser.add_argument("--url", required=True, help="SoundCloud playlist URL")
parser.add_argument("--output", default="output", help="Output directory")
parser.add_argument("--proxy", help="Proxy URL (e.g., http://proxy.example.com:8080)")
parser.add_argument("--zip", action="store_true", help="Zip the downloaded files")
args = parser.parse_args()

output_path = Path(args.output).resolve()
output_path.mkdir(parents=True, exist_ok=True)

run(args.url, output_path, False)
run(args.url, output_path, args.zip, args.proxy)
107 changes: 67 additions & 40 deletions soundclouddownloader/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ class SoundCloudDownloader:
from SoundCloud, using yt-dlp as the backend.
"""

def __init__(self):
def __init__(self, proxy: Optional[str] = None):
"""
Initialize the SoundCloudDownloader with default yt-dlp options.

Args:
proxy (Optional[str]): Proxy URL to use for downloads (e.g., 'http://proxy.example.com:8080')
"""
self.ydl_opts = {
"format": "bestaudio/best",
Expand All @@ -37,6 +40,9 @@ def __init__(self):
"quiet": True,
"no_warnings": True,
}
if proxy:
self.ydl_opts["proxy"] = proxy
logger.info(f"Using proxy: {proxy}")

def download_track(self, track: Track, output_dir: Path) -> Optional[Path]:
"""
Expand All @@ -49,46 +55,53 @@ def download_track(self, track: Track, output_dir: Path) -> Optional[Path]:
Returns:
Optional[Path]: The path to the downloaded file, or None if download failed.
"""
with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
info = ydl.extract_info(track.url, download=False)
filename = ydl.prepare_filename(info)
clean_name = clean_filename(filename)
filepath_without_ext = Path(output_dir) / clean_name

ydl_opts = dict(self.ydl_opts)
ydl_opts["outtmpl"] = str(filepath_without_ext)

with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([track.url])

time.sleep(0.5) # Small delay to ensure file system update
if filepath_without_ext.with_suffix(".mp3").exists():
filepath = filepath_without_ext.with_suffix(".mp3")
elif filepath_without_ext.exists():
filepath = filepath_without_ext
else:
filepath = None

if not filepath:
dir_contents = list(Path(output_dir).iterdir())
try:
with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
info = ydl.extract_info(track.url, download=False)
filename = ydl.prepare_filename(info)
clean_name = clean_filename(filename)
filepath_without_ext = Path(output_dir) / clean_name

ydl_opts = dict(self.ydl_opts)
ydl_opts["outtmpl"] = str(filepath_without_ext)

with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([track.url])

time.sleep(0.5) # Small delay to ensure file system update
if filepath_without_ext.with_suffix(".mp3").exists():
filepath = filepath_without_ext.with_suffix(".mp3")
elif filepath_without_ext.exists():
filepath = filepath_without_ext
else:
filepath = None

# Try to find a file with a similar name
similar_files = [
f for f in dir_contents if f.stem.startswith(clean_name)
]
if similar_files:
filepath = similar_files[0]
if not filepath:
logger.info(
f"File not found after download: {filepath_without_ext}"
)
logger.debug(
f"Directory contents: {[str(f) for f in dir_contents]}"
)
return None

logger.info(f"Successfully downloaded: {filepath}")
return filepath
dir_contents = list(Path(output_dir).iterdir())

# Try to find a file with a similar name
similar_files = [
f for f in dir_contents if f.stem.startswith(clean_name)
]
if similar_files:
filepath = similar_files[0]
if not filepath:
logger.info(
f"File not found after download: {filepath_without_ext}"
)
logger.debug(
f"Directory contents: {[str(f) for f in dir_contents]}"
)
return None

logger.info(f"Successfully downloaded: {filepath}")
return filepath
except yt_dlp.utils.GeoRestrictedError:
logger.warning(f"Skipping geo-restricted track: {track.title} ({track.url})")
return None
except Exception as e:
logger.error(f"Failed to download track '{track.title}': {str(e)}")
return None

def get_playlist_info(self, playlist_url: str) -> Playlist:
"""
Expand Down Expand Up @@ -149,6 +162,9 @@ def download_playlist(
playlist_dir.mkdir(exist_ok=True)

downloaded_files: List[Path] = []
failed_tracks: List[str] = []
total_tracks = len(playlist.tracks)

with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_track = {
executor.submit(self.download_track, track, playlist_dir): track
Expand All @@ -159,10 +175,19 @@ def download_playlist(
filepath = future.result()
if filepath:
downloaded_files.append(filepath)
else:
failed_tracks.append(track.title)

delay = random.uniform(min_delay, max_delay)
time.sleep(delay)

# Log summary
logger.info(f"Download complete: {len(downloaded_files)}/{total_tracks} tracks downloaded")
if failed_tracks:
logger.warning(f"Skipped {len(failed_tracks)} track(s): {', '.join(failed_tracks[:5])}")
if len(failed_tracks) > 5:
logger.warning(f"... and {len(failed_tracks) - 5} more")

if downloaded_files:
if should_zip:
zip_filename = output_dir / f"{playlist_name}.zip"
Expand Down Expand Up @@ -207,7 +232,9 @@ def main() -> None:
break
logger.warning("Invalid input. Please enter 'y' for yes or 'n' for no.")

downloader = SoundCloudDownloader()
proxy = input("Enter proxy URL (optional, press Enter to skip): ").strip() or None

downloader = SoundCloudDownloader(proxy=proxy)
logger.info("Downloading now please wait...")
download = downloader.download_playlist(
playlist_url, output_dir, max_workers=3, should_zip=should_zip
Expand Down