diff --git a/.gitignore b/.gitignore index d26670e..47afa68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ soundclouddownloader/output/* **/*.mp3 +# Claude Code +.claude/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md index d19d185..42a1d55 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ poetry install ## Usage +### Interactive Mode + 1. Run the script: ```python @@ -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 --output [--proxy ] [--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 diff --git a/soundclouddownloader/cli_entry.py b/soundclouddownloader/cli_entry.py index ac80af9..c1544fa 100644 --- a/soundclouddownloader/cli_entry.py +++ b/soundclouddownloader/cli_entry.py @@ -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 ) @@ -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) diff --git a/soundclouddownloader/main.py b/soundclouddownloader/main.py index 7325cbf..4831a32 100644 --- a/soundclouddownloader/main.py +++ b/soundclouddownloader/main.py @@ -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", @@ -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]: """ @@ -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: """ @@ -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 @@ -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" @@ -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