Wire SoundCloud as a first-class download source

Plug the previously-built SoundcloudClient (PR #478, the build-and-verify
phase) into every place a download source needs to appear. Follows the
same wiring contract as Tidal/Qobuz/HiFi/Deezer/Lidarr — orchestrator
routing, hybrid-mode picker, search dispatch, queue/cancel/clear,
provenance + library history, sidebar source label, settings UI all
work plug-and-play.

Backend wiring:
- `core/download_orchestrator.py` — import SoundcloudClient, _safe_init
  it at startup, add to _client() lookup, get_source_status(),
  check_connection's sources_to_check default, search source_names map,
  search_and_download_best _streaming_sources tuple, download
  source_map + source_names, and every iteration loop in
  reload_settings download-path-update / get_all_downloads /
  get_download_status / cancel_download (route + iterate) /
  clear_all_completed_downloads / cancel_all_downloads.
- `core/downloads/monitor.py` — added SoundCloud to the per-client
  loop that fetches active downloads outside the orchestrator (uses
  getattr fallback for older soulseek_client snapshots).
- `core/downloads/task_worker.py` — added SoundCloud (and Lidarr,
  which was missing too — bonus fix) to source_clients dict for hybrid
  fallback dispatch.
- `core/downloads/validation.py` — added 'soundcloud' to
  _streaming_sources so SoundCloud results go through the matching
  engine validation path instead of the Soulseek quality-filter path.
- `core/imports/side_effects.py` — three call sites: source_map for
  download_source label written to library_history, streaming-source
  guard for the `||`-encoded stream_id parsing, and source_service
  map for provenance recording. All three now include 'soundcloud'.
- `web_server.py` — five streaming-source detection tuples updated.
  New `/api/soundcloud/status` endpoint returns
  {available, configured, reachable} mirroring the Deezer/HiFi
  status-endpoint pattern; reachability runs a real cheap yt-dlp
  search so the settings Test Connection button gives a meaningful
  pass/fail signal.
- `config/settings.py` — added empty `soundcloud_download` defaults
  block so future tier-2 OAuth (SoundCloud Go+ session) doesn't have
  to migrate existing configs.

Frontend:
- `webui/index.html` — new `<option value="soundcloud">` in the
  download-source-mode dropdown, SoundCloud added to both hidden
  legacy hybrid-source selects, new settings container with info
  text + Test Connection button.
- `webui/static/settings.js` — HYBRID_SOURCES entry (with the
  SoundCloud cloud SVG icon), _hybridSourceEnabled default,
  updateDownloadSourceUI container display, allSources for legacy
  hybrid picker, testSoundcloudConnection function (hits the new
  status endpoint, color-codes the result), saveSettings
  soundcloud_download empty block.
- `webui/static/shared-helpers.js` — sidebar source-name map
  includes SoundCloud + Lidarr (Lidarr was also missing, bonus fix).
- `webui/static/helper.js` — WHATS_NEW entry under '2.4.2' dev cycle
  describing the user-visible change in the chill terse voice.

Tests:
- `tests/test_download_orchestrator_soundcloud.py` — 14 integration
  tests verifying the wiring: client constructed at startup, _client
  lookup resolves 'soundcloud', get_source_status includes it,
  download dispatcher routes username='soundcloud' to the SoundCloud
  client (and unknown usernames still fall back to Soulseek), hybrid
  search iterates SoundCloud when in order and skips it cleanly when
  unconfigured, get_all_downloads / get_download_status / cancel /
  clear walk SoundCloud, soundcloud-only mode dispatches only to
  SoundCloud, _streaming_sources tuple in validation includes
  'soundcloud'.
- `tests/downloads/test_download_orchestrator.py` — added
  `soundcloud` to the test fixture's _build_orchestrator helper so
  the new orchestrator attribute doesn't AttributeError in pre-
  existing tests that bypass __init__.

Verified:
- Full suite green (1728 passed, 2 deselected for soundcloud_live)
- Ruff clean
- Live SoundCloud-only mode search returns 25 SoundCloud tracks for
  "kendrick lamar luther" in <2s, returning properly-shaped
  TrackResult objects with username='soundcloud' and dispatch-key
  filename ready for the download path.

Out of scope (intentional deferrals):
- SoundCloud Go+ OAuth tier (256 kbps AAC) — anonymous-only for now.
  Adding auth later is a settings-page extension, no orchestrator
  changes needed.
- Album/playlist support — SoundCloud has playlists but they don't
  map to the album model the rest of SoulSync expects. Singles only.
This commit is contained in:
Broque Thomas 2026-05-03 12:54:21 -07:00
parent e1d6e4d51f
commit 75fe04907f
13 changed files with 396 additions and 28 deletions

View file

@ -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": "",

View file

@ -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()

View file

@ -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()))

View file

@ -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.

View file

@ -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 []

View file

@ -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 {}

View file

@ -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

View file

@ -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"

View file

@ -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."""

View file

@ -4365,6 +4365,7 @@
<option value="hifi">HiFi Only (Free Lossless)</option>
<option value="deezer_dl">Deezer Only</option>
<option value="lidarr">Lidarr Only</option>
<option value="soundcloud">SoundCloud Only</option>
<option value="hybrid">Hybrid (Primary + Fallback)</option>
</select>
<div class="setting-help-text">
@ -4412,8 +4413,8 @@
<!-- Populated by JS -->
</div>
<!-- Hidden selects for backward compatibility with saveSettings -->
<select id="hybrid-primary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option><option value="deezer_dl">Deezer</option></select>
<select id="hybrid-secondary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option><option value="deezer_dl">Deezer</option></select>
<select id="hybrid-primary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option><option value="deezer_dl">Deezer</option><option value="soundcloud">SoundCloud</option></select>
<select id="hybrid-secondary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option><option value="deezer_dl">Deezer</option><option value="soundcloud">SoundCloud</option></select>
</div>
<!-- Soulseek Settings (shown when soulseek is active source) -->
@ -4675,6 +4676,28 @@
</div>
</div>
<!-- SoundCloud Download Settings -->
<div id="soundcloud-download-settings-container" style="display: none;">
<div class="form-group">
<div class="setting-help-text">
SoundCloud downloads run anonymously — no account required. Use this for DJ mixes,
remixes, and tracks that aren't on Spotify/Tidal/Deezer.
<br><br>
<strong>Quality:</strong> 128 kbps MP3 / AAC depending on the upload (anonymous tier).
SoundCloud doesn't expose lossless audio to anyone.
</div>
</div>
<div class="form-group">
<label>SoundCloud Status:</label>
<div class="form-actions" style="margin-top: 4px;">
<button class="test-button" id="soundcloud-test-btn" onclick="testSoundcloudConnection()">
Test Connection
</button>
<span id="soundcloud-connection-status" class="setting-help-text" style="margin-left: 8px;"></span>
</div>
</div>
</div>
<!-- Lidarr Download Settings -->
<div id="lidarr-download-settings-container" style="display: none;">
<div class="form-group">

View file

@ -3444,6 +3444,7 @@ const WHATS_NEW = {
'2.4.2': [
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.4.2 dev cycle' },
{ title: 'SoundCloud as a Download Source', desc: 'discord request (toasti): some tracks (DJ mixes, sets, removed-from-spotify exclusives) only live on soundcloud. soundcloud now plugs into the existing download-source picker on settings → downloads — pick "SoundCloud Only" or include it in the hybrid order alongside soulseek / youtube / tidal / qobuz / hifi / deezer / lidarr. anonymous-only (no account needed); quality is whatever soundcloud serves anonymously, typically 128 kbps mp3 or aac depending on the upload. soundcloud doesn\'t expose lossless to anyone, so don\'t expect flac. follows the exact same wiring contract as every other download source — search dispatch, hybrid fallback, queue / cancel / clear, sidebar source label, provenance + library history all work plug-and-play.', page: 'settings' },
{ title: 'Fix Qobuz Connection Not Sticking After Login', desc: 'logging in via the qobuz connect button on settings showed "connected: <username> (active)" but underneath an error said "qobuz not authenticated...", and the dashboard indicator stayed yellow. cause: two separate qobuz client instances run side by side (one for the auth flow, one for the enrichment worker) and login only updated the first one. now the worker\'s client gets synced from config the moment login / token / logout completes, so the dashboard indicator goes green and connection-test stops yelling.', page: 'settings' },
{ title: 'Fix Lossy Copy Not Deleting Original FLAC', desc: 'with lossy copy enabled and "delete original" turned on (you wanted an mp3-only library), every download still left both the flac and the converted mp3 sitting in the same folder. the setting was being read but never acted on during the conversion step. now the original gets removed right after a successful conversion, with a same-path safety check + graceful handling if the original is already gone or locked.', page: 'settings' },
{ title: 'Watchlist Stops Re-Downloading Tracks That Already Exist', desc: 'a track that was already on disk got re-downloaded by the watchlist on every scan because the library had stale album metadata for it (file tagged on the wrong album by an old import) and the album fuzzy comparison declared the track missing. now the watchlist also matches by stable external IDs (spotify / itunes / deezer / tidal / qobuz / musicbrainz / audiodb / hydrabase / isrc) before falling through to the fuzzy block — so any track whose tags or DB row carry a matching ID is recognized as already present regardless of album drift. provider-neutral, falls through to existing fuzzy logic for older imports without IDs.', page: 'watchlist' },

View file

@ -596,10 +596,11 @@ const HYBRID_SOURCES = [
{ id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' },
{ id: 'deezer_dl', name: 'Deezer', icon: 'https://www.svgrepo.com/show/519734/deezer.svg', emoji: '🎧' },
{ id: 'lidarr', name: 'Lidarr', icon: null, emoji: '📦' },
{ id: 'soundcloud', name: 'SoundCloud', icon: 'https://www.svgrepo.com/show/452219/soundcloud.svg', emoji: '☁️' },
];
let _hybridSourceOrder = ['soulseek', 'youtube'];
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, lidarr: false };
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, lidarr: false, soundcloud: false };
let _hybridVisualOrder = null; // Full visual order including disabled sources
function buildHybridSourceList() {
@ -1475,6 +1476,7 @@ function updateDownloadSourceUI() {
const hifiContainer = document.getElementById('hifi-download-settings-container');
const deezerDlContainer = document.getElementById('deezer-download-settings-container');
const lidarrContainer = document.getElementById('lidarr-download-settings-container');
const soundcloudContainer = document.getElementById('soundcloud-download-settings-container');
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
@ -1496,6 +1498,7 @@ function updateDownloadSourceUI() {
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none';
if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none';
if (soundcloudContainer) soundcloudContainer.style.display = activeSources.has('soundcloud') ? 'block' : 'none';
// Quality profile is Soulseek-only and downloads-tab-only
const qualityProfileSection = document.getElementById('quality-profile-section');
@ -1514,6 +1517,9 @@ function updateDownloadSourceUI() {
if (activeSources.has('hifi')) {
testHiFiConnection();
}
if (activeSources.has('soundcloud')) {
testSoundcloudConnection();
}
}
function updateHybridSecondaryOptions() {
@ -1526,6 +1532,9 @@ function updateHybridSecondaryOptions() {
{ value: 'tidal', label: 'Tidal' },
{ value: 'qobuz', label: 'Qobuz' },
{ value: 'hifi', label: 'HiFi' },
{ value: 'deezer_dl', label: 'Deezer' },
{ value: 'lidarr', label: 'Lidarr' },
{ value: 'soundcloud', label: 'SoundCloud' },
];
secondary.innerHTML = '';
@ -2667,6 +2676,11 @@ async function saveSettings(quiet = false) {
url: document.getElementById('lidarr-url').value || '',
api_key: document.getElementById('lidarr-api-key').value || '',
},
soundcloud_download: {
// No knobs yet — anonymous-only. Keeping the key present so
// future tier-2 OAuth wiring (Go+ session token) doesn't have
// to migrate existing configs.
},
qobuz: {
quality: document.getElementById('qobuz-quality').value || 'lossless',
embed_tags: document.getElementById('embed-qobuz').checked,
@ -3421,6 +3435,32 @@ async function testHiFiConnection() {
}
}
async function testSoundcloudConnection() {
const statusEl = document.getElementById('soundcloud-connection-status');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
const resp = await fetch('/api/soundcloud/status');
const data = await resp.json();
if (data.available && data.reachable) {
statusEl.textContent = 'Connected (anonymous)';
statusEl.style.color = '#4caf50';
} else if (data.available) {
// Client up but the live probe failed — likely a SoundCloud
// outage or a transient yt-dlp parse error. Surface plainly.
statusEl.textContent = 'Reachable check failed — try again';
statusEl.style.color = '#ff9800';
} else {
statusEl.textContent = data.error || 'Unavailable';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function testLidarrConnection() {
const statusEl = document.getElementById('lidarr-connection-status');
if (!statusEl) return;

View file

@ -3393,7 +3393,7 @@ function updateSidebarServiceStatus(service, statusData, spotifyStatus = null) {
// Update download source name based on configured mode
if (service === 'soulseek' && statusData.source) {
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', lidarr: 'Lidarr', soundcloud: 'SoundCloud', hybrid: 'Hybrid' };
const displayName = sourceNames[statusData.source] || 'Soulseek';
const sidebarName = document.getElementById('download-source-name');
if (sidebarName) sidebarName.textContent = displayName;