diff --git a/core/amazon_download_client.py b/core/amazon_download_client.py index 004a0e8d..7b0c398d 100644 --- a/core/amazon_download_client.py +++ b/core/amazon_download_client.py @@ -78,7 +78,10 @@ class AmazonDownloadClient(DownloadSourcePlugin): if download_path is None: download_path = config_manager.get("soulseek.download_path", "./downloads") self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") self._quality = quality_tier_for_source("amazon", default="flac") self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True) diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py index f2368ac1..e1830f64 100644 --- a/core/deezer_download_client.py +++ b/core/deezer_download_client.py @@ -93,7 +93,10 @@ class DeezerDownloadClient(DownloadSourcePlugin): if download_path is None: download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") # Engine reference is populated by set_engine() at registration # time. None until orchestrator wires the registry. diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 664a0f4f..5e08b496 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -143,7 +143,10 @@ class DownloadOrchestrator: continue if hasattr(client, 'download_path') and client.download_path != new_path: client.download_path = new_path - client.download_path.mkdir(parents=True, exist_ok=True) + try: + client.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {new_path}: {e}") # YouTube also caches path in yt-dlp opts if hasattr(client, 'download_opts') and 'outtmpl' in client.download_opts: client.download_opts['outtmpl'] = str(new_path / '%(title)s.%(ext)s') diff --git a/core/hifi_client.py b/core/hifi_client.py index 73ce0c69..f103e136 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -257,7 +257,10 @@ class HiFiClient(DownloadSourcePlugin): if download_path is None: download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") self._instances = [] self._instance_lock = threading.Lock() diff --git a/core/lidarr_download_client.py b/core/lidarr_download_client.py index 52bee817..578ce52d 100644 --- a/core/lidarr_download_client.py +++ b/core/lidarr_download_client.py @@ -47,7 +47,10 @@ class LidarrDownloadClient(DownloadSourcePlugin): if download_path is None: download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") self.active_downloads: Dict[str, Dict[str, Any]] = {} self._download_lock = threading.Lock() diff --git a/core/qobuz_client.py b/core/qobuz_client.py index 73a0cae1..1c7c3b6f 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -120,7 +120,10 @@ class QobuzClient(DownloadSourcePlugin): download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") logger.info(f"Qobuz client using download path: {self.download_path}") diff --git a/core/soundcloud_client.py b/core/soundcloud_client.py index caf25ce7..867e4fa0 100644 --- a/core/soundcloud_client.py +++ b/core/soundcloud_client.py @@ -128,7 +128,14 @@ class SoundcloudClient(DownloadSourcePlugin): download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + # Don't crash construction if the path isn't creatable yet (e.g. an + # unmounted/misconfigured volume) — the registry would null the whole + # client and the source vanishes. Warn and continue, same as + # SoulseekClient; the dir is (re)created lazily at download time. + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify SoundCloud download path {self.download_path}: {e}") logger.info(f"SoundCloud client using download path: {self.download_path}") diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index 5e2f4524..89757d42 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -120,7 +120,10 @@ class TidalDownloadClient(DownloadSourcePlugin): download_path = config_manager.get('soulseek.download_path', './downloads') self.download_path = Path(download_path) - self.download_path.mkdir(parents=True, exist_ok=True) + try: + self.download_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning(f"Could not verify download path {self.download_path}: {e}") logger.info(f"Tidal download client using download path: {self.download_path}") diff --git a/tests/test_soundcloud_client.py b/tests/test_soundcloud_client.py index 70e5afb3..4e0aa852 100644 --- a/tests/test_soundcloud_client.py +++ b/tests/test_soundcloud_client.py @@ -846,3 +846,19 @@ def test_live_download_a_known_public_track(tmp_dl: Path) -> None: assert final_path is not None assert os.path.exists(final_path) assert os.path.getsize(final_path) > 100 * 1024 + + +# ── construction robustness: an uncreatable download path must not crash init ── +# Regression: the download path is read from config (often a Docker /app path). If +# mkdir fails (unmounted/misconfigured volume, or running outside the container), +# the client must warn and continue — NOT raise. An unguarded mkdir made the +# registry null the whole client, dropping SoundCloud as a source entirely (and +# failing every orchestrator-soundcloud test outside Docker). +def test_init_survives_uncreatable_download_path(tmp_path): + from core.soundcloud_client import SoundcloudClient + blocker = tmp_path / "iam_a_file" + blocker.write_text("x") + bad_path = str(blocker / "subdir") # mkdir under a file -> NotADirectoryError (OSError) + client = SoundcloudClient(download_path=bad_path) # must not raise + assert client is not None + assert str(client.download_path) == bad_path