Merge pull request #482 from Nezreka/feat/soundcloud-integration

Feat/soundcloud integration
This commit is contained in:
BoulderBadgeDad 2026-05-03 13:26:39 -07:00 committed by GitHub
commit c8618ba0d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 559 additions and 34 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

@ -379,6 +379,24 @@ def run_service_test(service, test_config):
return False, "Hydrabase not connected. Configure URL + API key and click Connect."
except Exception as e:
return False, f"Hydrabase connection error: {str(e)}"
elif service == "soundcloud":
# Anonymous SoundCloud has no auth, so "test" really means
# "is yt-dlp installed and can it reach SoundCloud right now."
# This mirrors the /api/soundcloud/status check.
try:
from core.soundcloud_client import SoundcloudClient
sc = SoundcloudClient()
if not sc.is_available():
return False, "SoundCloud unavailable — yt-dlp not installed."
# Run a tiny live probe via asyncio so the dashboard test
# gives a meaningful pass/fail.
import asyncio
reachable = asyncio.new_event_loop().run_until_complete(sc.check_connection())
if reachable:
return True, "SoundCloud reachable (anonymous)"
return False, "SoundCloud unreachable — search probe failed. Try again."
except Exception as e:
return False, f"SoundCloud connection error: {str(e)}"
return False, "Unknown service."
except AttributeError as e:
# This specifically catches the error you reported for Jellyfin

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

@ -438,7 +438,22 @@ class SoundcloudClient:
if status == 'downloading':
downloaded = int(progress.get('downloaded_bytes') or 0)
total = int(progress.get('total_bytes') or progress.get('total_bytes_estimate') or 0)
self._update_download_progress(download_id, downloaded, total, speed_start)
# SoundCloud serves HLS-segmented audio. yt-dlp doesn't know
# the final byte total upfront — `total_bytes` and
# `total_bytes_estimate` reflect the CURRENT FRAGMENT size,
# not the whole download, so a byte-based percentage stays
# near 0 until the very end. Fall back to fragment progress
# which yt-dlp DOES populate accurately for HLS.
fragment_index = progress.get('fragment_index')
fragment_count = progress.get('fragment_count')
if (fragment_index is not None and fragment_count
and fragment_count > 0):
self._update_download_progress_fragmented(
download_id, downloaded, fragment_index, fragment_count, speed_start,
)
else:
self._update_download_progress(download_id, downloaded, total, speed_start)
elif status == 'finished':
# yt-dlp signals 'finished' once the bytes are on disk; the
# final size is authoritative. Mark progress at 99% — the
@ -506,6 +521,52 @@ class SoundcloudClient:
)
return resolved_path
def _update_download_progress_fragmented(self, download_id: str, downloaded: int,
fragment_index: int, fragment_count: int,
speed_start: float) -> None:
"""HLS-aware progress update.
SoundCloud's HLS streams arrive as N small fragments. yt-dlp can't
compute a true byte-based percentage because each fragment's
``total_bytes`` only describes the current fragment, not the
whole download. fragment_index / fragment_count gives an accurate
progress signal N fragments downloaded out of M known fragments
is the real ratio.
"""
with self._download_lock:
if download_id not in self.active_downloads:
return
info = self.active_downloads[download_id]
info['transferred'] = downloaded
now = time.time()
elapsed = now - speed_start
info['speed'] = int(downloaded / elapsed) if elapsed > 0 else 0
# `fragment_index` is 1-based when yt-dlp finishes a fragment;
# use it directly. Cap below 100% so the worker thread owns the
# final flip.
progress = (fragment_index / fragment_count) * 100
info['progress'] = round(min(progress, 99.9), 1)
# Estimate total size so the UI's bytes-remaining reads match
# roughly what users expect — extrapolate from per-fragment
# average. Defensive against fragment_index being 0 on the
# very first callback.
if fragment_index > 0 and downloaded > 0:
est_total = int(downloaded * (fragment_count / fragment_index))
info['size'] = est_total
else:
info['size'] = downloaded
time_remaining: Optional[int] = None
remaining_fragments = max(0, fragment_count - fragment_index)
if info['speed'] > 0 and remaining_fragments > 0 and fragment_index > 0:
# Extrapolate seconds from per-fragment average download time.
seconds_per_fragment = elapsed / fragment_index if fragment_index > 0 else 0
time_remaining = int(remaining_fragments * seconds_per_fragment)
info['time_remaining'] = time_remaining
def _update_download_progress(self, download_id: str, downloaded: int,
total: int, speed_start: float) -> None:
"""Push a progress update into the active_downloads ledger.

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

@ -515,6 +515,74 @@ def test_update_download_progress_silently_skips_unknown_id(tmp_dl: Path) -> Non
client._update_download_progress('does_not_exist', 100, 1000, time.time())
def test_update_download_progress_fragmented_uses_fragment_count(tmp_dl: Path) -> None:
"""HLS-aware progress: fragment 5 of 10 → ~50%, regardless of byte
estimate. Pins the SoundCloud HLS UX fix where byte-based progress
stayed stuck near 0."""
client = SoundcloudClient(download_path=str(tmp_dl))
with client._download_lock:
client.active_downloads['hls1'] = {
'id': 'hls1', 'filename': '', 'username': 'soundcloud',
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
}
client._update_download_progress_fragmented(
'hls1', downloaded=512_000, fragment_index=5, fragment_count=10,
speed_start=time.time() - 1.0,
)
info = client.active_downloads['hls1']
assert 49.0 <= info['progress'] <= 51.0
# Total size estimate extrapolates from per-fragment average
assert info['size'] == 1_024_000 # 512000 * (10/5)
# Time remaining computed
assert info['time_remaining'] is not None and info['time_remaining'] > 0
def test_update_download_progress_fragmented_caps_at_99_9(tmp_dl: Path) -> None:
"""Final fragment shouldn't push percentage to 100 — outer thread
owns the final flip. Mirrors byte-based behavior."""
client = SoundcloudClient(download_path=str(tmp_dl))
with client._download_lock:
client.active_downloads['hls2'] = {
'id': 'hls2', 'filename': '', 'username': 'soundcloud',
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
}
client._update_download_progress_fragmented(
'hls2', downloaded=10_000_000, fragment_index=10, fragment_count=10,
speed_start=time.time() - 5.0,
)
assert client.active_downloads['hls2']['progress'] == 99.9
def test_update_download_progress_fragmented_handles_zero_index(tmp_dl: Path) -> None:
"""Defensive: yt-dlp can call the progress hook with fragment_index=0
on the very first callback (HLS init segment). Progress is 0% and
nothing crashes."""
client = SoundcloudClient(download_path=str(tmp_dl))
with client._download_lock:
client.active_downloads['hls3'] = {
'id': 'hls3', 'filename': '', 'username': 'soundcloud',
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
}
client._update_download_progress_fragmented(
'hls3', downloaded=0, fragment_index=0, fragment_count=10,
speed_start=time.time(),
)
# No exception, progress stays 0
assert client.active_downloads['hls3']['progress'] == 0.0
def test_update_download_progress_fragmented_silently_skips_unknown_id(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
# Must not raise
client._update_download_progress_fragmented(
'unknown', downloaded=100, fragment_index=1, fragment_count=10,
speed_start=time.time(),
)
# ---------------------------------------------------------------------------
# Status / cancel / clear
# ---------------------------------------------------------------------------

View file

@ -641,6 +641,9 @@ if soulseek_client:
if hasattr(soulseek_client, 'hifi'):
soulseek_client.hifi.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
logger.info(" Configured HiFi client shutdown callback")
if hasattr(soulseek_client, 'soundcloud') and soulseek_client.soundcloud:
soulseek_client.soundcloud.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
logger.info(" Configured SoundCloud client shutdown callback")
# Initialize web scan manager for automatic post-download scanning
try:
@ -2472,11 +2475,15 @@ def get_cached_transfer_data():
key = _make_context_key(transfer.get('username'), transfer.get('filename', ''))
live_transfers_lookup[key] = transfer
# Also add non-Soulseek downloads (avoid redundant slskd call through orchestrator)
# Also add non-Soulseek downloads (avoid redundant slskd call through orchestrator).
# Every streaming source must appear here — task progress for in-flight
# downloads comes from this lookup. Missing a source = task.progress
# stays at 0 even when the underlying client knows the real percent.
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()))
@ -3486,8 +3493,11 @@ def get_status():
soulseek_relevant = (download_mode == 'soulseek' or
(download_mode == 'hybrid' and 'soulseek' in hybrid_order))
# Serverless sources (YouTube, HiFi, Qobuz) are always available
serverless_sources = ('youtube', 'hifi', 'qobuz', 'tidal', 'deezer_dl')
# Serverless sources (YouTube, HiFi, Qobuz, Tidal, Deezer, Lidarr, SoundCloud)
# don't depend on slskd being reachable — when one of these is the
# active source, surface "connected" without probing slskd so the
# dashboard / sidebar indicator stays green.
serverless_sources = ('youtube', 'hifi', 'qobuz', 'tidal', 'deezer_dl', 'lidarr', 'soundcloud')
is_serverless = (download_mode in serverless_sources or
(download_mode == 'hybrid' and
hybrid_order and any(s in serverless_sources for s in hybrid_order)))
@ -7144,7 +7154,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 +7536,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 +11725,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 +17028,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 +17130,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 +19669,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

@ -3348,7 +3348,7 @@ function updateServiceStatus(service, statusData, spotifyStatus = null) {
// Update download source title on dashboard card
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 titleEl = document.getElementById('download-source-title');
if (titleEl) titleEl.textContent = displayName;
@ -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;