soulsync/tests/test_download_orchestrator_soundcloud.py
Broque Thomas 75fe04907f 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.
2026-05-03 12:54:21 -07:00

247 lines
9.9 KiB
Python

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