diff --git a/config/settings.py b/config/settings.py index d94b9f53..18c42124 100644 --- a/config/settings.py +++ b/config/settings.py @@ -512,6 +512,13 @@ class ConfigManager: "quality_profile": "Any", "cleanup_after_import": True, }, + "soundcloud_download": { + # Anonymous-only for now — SoundCloud Go+ OAuth tier could be + # added later, with credentials living under a "session" subkey + # alongside Tidal/Qobuz. No quality knob: anonymous SoundCloud + # caps at the upload's transcoding (typically 128 kbps MP3 or + # AAC). yt-dlp resolves bestaudio at download time. + }, "listenbrainz": { "base_url": "", "token": "", diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 41abee9b..9a56c721 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -1,14 +1,15 @@ """ Download Orchestrator -Routes downloads between Soulseek, YouTube, Tidal, Qobuz, HiFi, and Deezer based on configuration. +Routes downloads between Soulseek, YouTube, Tidal, Qobuz, HiFi, Deezer, and SoundCloud based on configuration. -Supports seven modes: +Supports eight modes: - Soulseek Only: Traditional behavior - YouTube Only: YouTube-exclusive downloads - Tidal Only: Tidal-exclusive downloads - Qobuz Only: Qobuz-exclusive downloads - HiFi Only: Free lossless downloads via public hifi-api instances - Deezer Only: Deezer downloads via ARL authentication +- SoundCloud Only: Anonymous SoundCloud downloads (DJ mixes, removed/exclusive tracks) - Hybrid: Try primary source first, fallback to others """ @@ -25,6 +26,7 @@ from core.qobuz_client import QobuzClient from core.hifi_client import HiFiClient from core.deezer_download_client import DeezerDownloadClient from core.lidarr_download_client import LidarrDownloadClient +from core.soundcloud_client import SoundcloudClient logger = get_logger("download_orchestrator") @@ -49,6 +51,7 @@ class DownloadOrchestrator: self.hifi = self._safe_init('HiFi', HiFiClient) self.deezer_dl = self._safe_init('Deezer', DeezerDownloadClient) self.lidarr = self._safe_init('Lidarr', LidarrDownloadClient) + self.soundcloud = self._safe_init('SoundCloud', SoundcloudClient) if self._init_failures: logger.warning(f"Download clients failed to initialize: {', '.join(self._init_failures)}") @@ -97,7 +100,7 @@ class DownloadOrchestrator: # Reload download path for all clients that cache it new_path = Path(config_manager.get('soulseek.download_path', './downloads')) - for client in [self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl]: + for client in [self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.soundcloud]: if client and hasattr(client, 'download_path') and client.download_path != new_path: client.download_path = new_path client.download_path.mkdir(parents=True, exist_ok=True) @@ -112,7 +115,7 @@ class DownloadOrchestrator: """Get a client by name, returning None if not initialized.""" return {'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz, 'hifi': self.hifi, 'deezer_dl': self.deezer_dl, - 'lidarr': self.lidarr}.get(name) + 'lidarr': self.lidarr, 'soundcloud': self.soundcloud}.get(name) def is_configured(self) -> bool: """ @@ -134,7 +137,7 @@ class DownloadOrchestrator: for name, c in [('soulseek', self.soulseek), ('youtube', self.youtube), ('tidal', self.tidal), ('qobuz', self.qobuz), ('hifi', self.hifi), ('deezer_dl', self.deezer_dl), - ('lidarr', self.lidarr)]} + ('lidarr', self.lidarr), ('soundcloud', self.soundcloud)]} async def check_connection(self) -> bool: """ @@ -146,7 +149,7 @@ class DownloadOrchestrator: if client and self.mode != 'hybrid': return await client.check_connection() elif self.mode == 'hybrid': - sources_to_check = self.hybrid_order if self.hybrid_order else ['soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'] + sources_to_check = self.hybrid_order if self.hybrid_order else ['soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'] results = {} for source in sources_to_check: client = self._client(source) @@ -178,7 +181,8 @@ class DownloadOrchestrator: Tuple of (track_results, album_results) """ source_names = {'soulseek': 'Soulseek', 'youtube': 'YouTube', 'tidal': 'Tidal', - 'qobuz': 'Qobuz', 'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr'} + 'qobuz': 'Qobuz', 'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr', + 'soundcloud': 'SoundCloud'} if self.mode != 'hybrid': client = self._client(self.mode) @@ -262,7 +266,7 @@ class DownloadOrchestrator: return None # 2. Filter and validate results - _streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr') + _streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud') is_streaming = tracks[0].username in _streaming_sources if tracks else False if is_streaming and expected_track: @@ -344,9 +348,11 @@ class DownloadOrchestrator: """ # Detect which client to use based on username source_map = {'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz, - 'hifi': self.hifi, 'deezer_dl': self.deezer_dl, 'lidarr': self.lidarr} + 'hifi': self.hifi, 'deezer_dl': self.deezer_dl, 'lidarr': self.lidarr, + 'soundcloud': self.soundcloud} source_names = {'youtube': 'YouTube', 'tidal': 'Tidal', 'qobuz': 'Qobuz', - 'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr'} + 'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr', + 'soundcloud': 'SoundCloud'} if username in source_map: client = source_map[username] @@ -369,7 +375,7 @@ class DownloadOrchestrator: """ # Get downloads from all available sources all_downloads = [] - for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]: + for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr, self.soundcloud]: if client: try: all_downloads.extend(await client.get_all_downloads()) @@ -388,7 +394,7 @@ class DownloadOrchestrator: DownloadStatus object or None if not found """ # Try each source until we find the download - for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]: + for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr, self.soundcloud]: if not client: continue try: @@ -414,7 +420,8 @@ class DownloadOrchestrator: """ # If username is provided, route directly to that source source_map = {'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz, - 'hifi': self.hifi, 'deezer_dl': self.deezer_dl, 'lidarr': self.lidarr} + 'hifi': self.hifi, 'deezer_dl': self.deezer_dl, 'lidarr': self.lidarr, + 'soundcloud': self.soundcloud} if username in source_map: client = source_map[username] return await client.cancel_download(download_id, username, remove) if client else False @@ -422,7 +429,7 @@ class DownloadOrchestrator: return await self.soulseek.cancel_download(download_id, username, remove) if self.soulseek else False # Otherwise, try all available sources - for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]: + for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr, self.soundcloud]: if not client: continue try: @@ -465,6 +472,7 @@ class DownloadOrchestrator: ("hifi", self.hifi), ("deezer_dl", self.deezer_dl), ("lidarr", self.lidarr), + ("soundcloud", self.soundcloud), ]: if not client: continue @@ -541,7 +549,7 @@ class DownloadOrchestrator: async def cancel_all_downloads(self) -> bool: """Cancel and remove all downloads from all sources.""" ok = True - for client in [self.soulseek, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]: + for client in [self.soulseek, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr, self.soundcloud]: if client: try: await client.cancel_all_downloads() if hasattr(client, 'cancel_all_downloads') else await client.clear_all_completed_downloads() diff --git a/core/downloads/monitor.py b/core/downloads/monitor.py index c879257a..8f2bd06c 100644 --- a/core/downloads/monitor.py +++ b/core/downloads/monitor.py @@ -271,7 +271,8 @@ class WebUIDownloadMonitor: try: all_downloads = [] for _dl_client in [soulseek_client.youtube, soulseek_client.tidal, soulseek_client.qobuz, - soulseek_client.hifi, soulseek_client.deezer_dl, soulseek_client.lidarr]: + soulseek_client.hifi, soulseek_client.deezer_dl, soulseek_client.lidarr, + getattr(soulseek_client, 'soundcloud', None)]: if _dl_client: try: all_downloads.extend(run_async(_dl_client.get_all_downloads())) diff --git a/core/downloads/task_worker.py b/core/downloads/task_worker.py index d7708154..4bd77e3d 100644 --- a/core/downloads/task_worker.py +++ b/core/downloads/task_worker.py @@ -288,6 +288,8 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke 'qobuz': getattr(orch, 'qobuz', None), 'hifi': getattr(orch, 'hifi', None), 'deezer_dl': getattr(orch, 'deezer_dl', None), + 'lidarr': getattr(orch, 'lidarr', None), + 'soundcloud': getattr(orch, 'soundcloud', None), } # The orchestrator tried sources in order but stopped at the first with results. diff --git a/core/downloads/validation.py b/core/downloads/validation.py index fff78401..2b046739 100644 --- a/core/downloads/validation.py +++ b/core/downloads/validation.py @@ -33,9 +33,9 @@ def get_valid_candidates(results, spotify_track, query): if not results: return [] - # Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer) return structured API results + # Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer, SoundCloud) return structured API results # with proper artist/title metadata — score using the same matching engine as Soulseek - _streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl") + _streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl", "soundcloud") if results[0].username in _streaming_sources: source_label = results[0].username.replace('_dl', '').title() expected_artists = spotify_track.artists if spotify_track else [] diff --git a/core/imports/side_effects.py b/core/imports/side_effects.py index bfa6855b..589166e4 100644 --- a/core/imports/side_effects.py +++ b/core/imports/side_effects.py @@ -88,6 +88,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None: "hifi": "HiFi", "deezer_dl": "Deezer", "lidarr": "Lidarr", + "soundcloud": "SoundCloud", } download_source = source_map.get(username, "Soulseek") @@ -119,7 +120,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None: source_track_id = search_result.get("track_id", "") or search_result.get("id", "") or ti.get("id", "") source_track_title = search_result.get("title", "") or search_result.get("name", "") source_artist = search_result.get("artist", "") - if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr"): + if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud"): stream_id = source_filename.split("||")[0] if stream_id and not source_track_id: source_track_id = stream_id @@ -159,6 +160,7 @@ def record_download_provenance(context: Dict[str, Any]) -> None: "hifi": "hifi", "deezer_dl": "deezer", "lidarr": "lidarr", + "soundcloud": "soundcloud", }.get(username, "soulseek") ti = context.get("track_info") or context.get("search_result") or {} diff --git a/tests/downloads/test_download_orchestrator.py b/tests/downloads/test_download_orchestrator.py index 5e06376c..5b26a790 100644 --- a/tests/downloads/test_download_orchestrator.py +++ b/tests/downloads/test_download_orchestrator.py @@ -24,6 +24,7 @@ def _build_orchestrator(**clients): orch.hifi = clients.get("hifi") orch.deezer_dl = clients.get("deezer_dl") orch.lidarr = clients.get("lidarr") + orch.soundcloud = clients.get("soundcloud") return orch diff --git a/tests/test_download_orchestrator_soundcloud.py b/tests/test_download_orchestrator_soundcloud.py new file mode 100644 index 00000000..43d240df --- /dev/null +++ b/tests/test_download_orchestrator_soundcloud.py @@ -0,0 +1,247 @@ +"""Integration tests for SoundCloud wiring inside DownloadOrchestrator. + +The standalone SoundcloudClient is exhaustively unit-tested in +``tests/test_soundcloud_client.py``. These tests verify the *plumbing*: +the orchestrator constructs a SoundCloud client at startup, exposes it +via the same lookup APIs every other source uses, dispatches downloads +to it when the username matches, and includes it in the hybrid-mode +fan-out / status / cancel / clear paths. + +The intent is plug-and-play parity: any code that walks the +orchestrator's source list (UI, status endpoints, batch tracker) +picks up SoundCloud automatically without per-source special cases. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import patch, MagicMock + +import pytest + +from core.download_orchestrator import DownloadOrchestrator +from core.soundcloud_client import SoundcloudClient + + +def _run(coro): + return asyncio.run(coro) + + +@pytest.fixture +def orchestrator() -> DownloadOrchestrator: + """Real orchestrator with real (but mostly idle) clients.""" + return DownloadOrchestrator() + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +def test_orchestrator_constructs_soundcloud_client(orchestrator: DownloadOrchestrator) -> None: + assert orchestrator.soundcloud is not None + assert isinstance(orchestrator.soundcloud, SoundcloudClient) + + +def test_client_lookup_resolves_soundcloud(orchestrator: DownloadOrchestrator) -> None: + """Verify the dict-based name → client lookup includes SoundCloud.""" + assert orchestrator._client('soundcloud') is orchestrator.soundcloud + + +def test_client_lookup_returns_none_for_unknown(orchestrator: DownloadOrchestrator) -> None: + """Sanity: unknown sources don't somehow resolve to SoundCloud.""" + assert orchestrator._client('made_up') is None + + +# --------------------------------------------------------------------------- +# Status surface +# --------------------------------------------------------------------------- + + +def test_get_source_status_includes_soundcloud(orchestrator: DownloadOrchestrator) -> None: + """Every other source has a key here; SoundCloud should too. The UI + walks this dict to render configured-status badges.""" + status = orchestrator.get_source_status() + assert 'soundcloud' in status + # yt-dlp is in requirements.txt → SoundCloud is configured by default + assert status['soundcloud'] is True + + +# --------------------------------------------------------------------------- +# Download dispatch +# --------------------------------------------------------------------------- + + +def test_download_routes_soundcloud_username_to_client(orchestrator: DownloadOrchestrator) -> None: + """The dispatcher must route ``username='soundcloud'`` to the SoundCloud + client, not to Soulseek (the default fallback path).""" + sentinel = 'sc-download-id-xyz' + + async def _fake_download(username, filename, file_size=0): + return sentinel + + with patch.object(orchestrator.soundcloud, 'download', side_effect=_fake_download) as mock_dl: + result = _run(orchestrator.download( + 'soundcloud', + '999||https://soundcloud.com/x/y||Display', + file_size=0, + )) + assert result == sentinel + mock_dl.assert_called_once() + + +def test_download_unknown_username_still_falls_to_soulseek(orchestrator: DownloadOrchestrator) -> None: + """Adding SoundCloud must not change the legacy Soulseek-fallback + behavior for unrecognized usernames.""" + if orchestrator.soulseek is None: + pytest.skip("Soulseek client unavailable in this environment") + + async def _fake_soulseek_download(username, filename, file_size=0): + return 'soulseek-id' + + with patch.object(orchestrator.soulseek, 'download', side_effect=_fake_soulseek_download) as mock_dl: + result = _run(orchestrator.download('some_random_user', 'file.mp3', 0)) + assert result == 'soulseek-id' + mock_dl.assert_called_once() + + +# --------------------------------------------------------------------------- +# Hybrid mode +# --------------------------------------------------------------------------- + + +def test_hybrid_search_iterates_soundcloud_when_in_order(orchestrator: DownloadOrchestrator) -> None: + """When SoundCloud appears in the hybrid_order list, the orchestrator + must walk through its search results just like any other source.""" + orchestrator.mode = 'hybrid' + orchestrator.hybrid_order = ['soundcloud'] + + fake_track = MagicMock() + fake_track.username = 'soundcloud' + + async def _fake_search(query, timeout=None, progress_callback=None): + return ([fake_track], []) + + with patch.object(orchestrator.soundcloud, 'search', side_effect=_fake_search), \ + patch.object(orchestrator.soundcloud, 'is_configured', return_value=True): + tracks, albums = _run(orchestrator.search("any query")) + + assert tracks == [fake_track] + assert albums == [] + + +def test_hybrid_search_skips_unconfigured_soundcloud(orchestrator: DownloadOrchestrator) -> None: + """Defensive: if SoundCloud is unconfigured (yt-dlp missing), the + hybrid loop must skip it cleanly and continue to the next source.""" + orchestrator.mode = 'hybrid' + orchestrator.hybrid_order = ['soundcloud', 'soulseek'] + + if orchestrator.soulseek is None: + pytest.skip("Soulseek client unavailable in this environment") + + soulseek_track = MagicMock() + soulseek_track.username = 'unrelated_user' + + async def _fake_soulseek_search(query, timeout=None, progress_callback=None): + return ([soulseek_track], []) + + with patch.object(orchestrator.soundcloud, 'is_configured', return_value=False), \ + patch.object(orchestrator.soulseek, 'is_configured', return_value=True), \ + patch.object(orchestrator.soulseek, 'search', side_effect=_fake_soulseek_search): + tracks, _ = _run(orchestrator.search("any")) + + assert tracks == [soulseek_track] + + +# --------------------------------------------------------------------------- +# Aggregate operations +# --------------------------------------------------------------------------- + + +def test_get_all_downloads_walks_soundcloud(orchestrator: DownloadOrchestrator) -> None: + """Active-downloads endpoint pulls from every client; SoundCloud's + queue must show up in the aggregate.""" + fake_status = MagicMock(id='sc-1', filename='x', state='InProgress, Downloading') + + async def _fake_get_all(): + return [fake_status] + + with patch.object(orchestrator.soundcloud, 'get_all_downloads', side_effect=_fake_get_all): + all_dl = _run(orchestrator.get_all_downloads()) + + assert any(d is fake_status for d in all_dl) + + +def test_get_download_status_finds_soundcloud_id(orchestrator: DownloadOrchestrator) -> None: + """Status lookup must check SoundCloud — orchestrator iterates every + client until one finds the id.""" + fake_status = MagicMock(id='sc-2') + + async def _fake_get_status(download_id): + return fake_status if download_id == 'sc-2' else None + + with patch.object(orchestrator.soundcloud, 'get_download_status', side_effect=_fake_get_status): + result = _run(orchestrator.get_download_status('sc-2')) + + assert result is fake_status + + +def test_cancel_routes_soundcloud_username(orchestrator: DownloadOrchestrator) -> None: + """Username-routed cancel must dispatch to the SoundCloud client when + username='soundcloud' is provided.""" + async def _fake_cancel(download_id, username=None, remove=False): + return True + + with patch.object(orchestrator.soundcloud, 'cancel_download', side_effect=_fake_cancel) as mock_cancel: + ok = _run(orchestrator.cancel_download('sc-3', username='soundcloud')) + assert ok is True + mock_cancel.assert_called_once() + + +def test_clear_completed_walks_soundcloud(orchestrator: DownloadOrchestrator) -> None: + """Bulk clear-all-completed must call SoundCloud's clear method. + + We assert SoundCloud got called — not that the overall result is + True, since other sibling clients in the same orchestrator may + return False for unrelated reasons (e.g. an unrelated client + throwing). The contract this test pins is "SoundCloud is included + in the iteration", which is what plug-and-play parity requires. + """ + async def _fake_clear(): + return True + + with patch.object(orchestrator.soundcloud, 'clear_all_completed_downloads', side_effect=_fake_clear) as mock_clear: + _run(orchestrator.clear_all_completed_downloads()) + mock_clear.assert_called_once() + + +# --------------------------------------------------------------------------- +# Mode-only routing +# --------------------------------------------------------------------------- + + +def test_soundcloud_only_mode_uses_soundcloud(orchestrator: DownloadOrchestrator) -> None: + """When mode='soundcloud', search must be dispatched only to the + SoundCloud client — not soulseek or any other source.""" + orchestrator.mode = 'soundcloud' + + async def _fake_search(query, timeout=None, progress_callback=None): + return ([MagicMock(username='soundcloud')], []) + + with patch.object(orchestrator.soundcloud, 'search', side_effect=_fake_search) as mock_sc, \ + patch.object(orchestrator.soulseek, 'search', side_effect=AssertionError("soulseek must not be searched")): + tracks, _ = _run(orchestrator.search("any")) + + assert len(tracks) == 1 + mock_sc.assert_called_once() + + +def test_streaming_sources_tuple_includes_soundcloud() -> None: + """The validation/streaming-source tuples used to pick scoring + behavior must include SoundCloud — otherwise SoundCloud results + would skip the matching-engine validation in + search_and_download_best.""" + from core.downloads import validation + from inspect import getsource + src = getsource(validation.filter_streaming_results) if hasattr(validation, 'filter_streaming_results') else getsource(validation) + assert 'soundcloud' in src, "core.downloads.validation must include 'soundcloud' in _streaming_sources" diff --git a/web_server.py b/web_server.py index eb72aac6..3f5fa7e9 100644 --- a/web_server.py +++ b/web_server.py @@ -7144,7 +7144,7 @@ def start_download(): if download_id: # Register download for post-processing (simple transfer to /Transfer) context_key = _make_context_key(username, filename) - is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr') + is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud') with matched_context_lock: matched_downloads_context[context_key] = { 'search_result': { @@ -7526,7 +7526,7 @@ def get_download_status(): all_streaming_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_streaming_downloads: - if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): + if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): source_label = download.username.title() # Convert DownloadStatus to transfer format that frontend expects streaming_transfer = { @@ -11715,7 +11715,7 @@ def redownload_search_sources(track_id): quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or '' svc = source_name if source_name != 'default' else 'hybrid' uname = candidate.username - if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): + if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): svc = uname source_candidates.append({ 'username': uname, @@ -17018,7 +17018,7 @@ def _try_source_reuse(task_id, batch_id, track): if not source_tracks or not last_source: _sr.info("Skipped — no source_tracks or no last_source") return False - if last_source.get('username') in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): + if last_source.get('username') in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): _sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)") return False @@ -17120,7 +17120,7 @@ def _store_batch_source(batch_id, username, filename): """Browse the successful download's folder and store results on the batch for reuse.""" _sr = source_reuse_logger _sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}") - if not batch_id or username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr'): + if not batch_id or username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): _sr.info(f"Skipped — no batch_id or streaming source ({username})") return @@ -19659,6 +19659,42 @@ def hifi_status(): return jsonify({"available": False, "error": str(e)}) +@app.route('/api/soundcloud/status', methods=['GET']) +def soundcloud_status(): + """Report SoundCloud client availability + a quick reachability probe. + + SoundCloud anonymous mode needs no credentials, so "configured" is + really "yt-dlp is installed and SoundCloud responds to a search." + The check fans out a real (cheap) yt-dlp call so the settings page's + Test Connection button gives a meaningful pass/fail signal instead + of just verifying the import succeeded. + """ + try: + sc = None + if soulseek_client and hasattr(soulseek_client, 'soundcloud'): + sc = soulseek_client.soundcloud + if not sc: + return jsonify({ + "available": False, + "configured": False, + "error": "SoundCloud client not initialized — check yt-dlp install", + }) + if not sc.is_available(): + return jsonify({ + "available": False, + "configured": False, + "error": "yt-dlp not installed", + }) + reachable = run_async(sc.check_connection()) + return jsonify({ + "available": True, + "configured": True, + "reachable": bool(reachable), + }) + except Exception as exc: + return jsonify({"available": False, "configured": False, "error": str(exc)}) + + @app.route('/api/hifi/instances', methods=['GET']) def hifi_instances(): """Check availability of all HiFi API instances.""" diff --git a/webui/index.html b/webui/index.html index 57ef243f..48b2f2f4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4365,6 +4365,7 @@ +
@@ -4412,8 +4413,8 @@
- - + + @@ -4675,6 +4676,28 @@ + + +