From 75fe04907fde9729aa983193a017bab9547c9b32 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Sun, 3 May 2026 12:54:21 -0700
Subject: [PATCH 1/3] Wire SoundCloud as a first-class download source
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 `
+
@@ -4412,8 +4413,8 @@
-
-
+
+
@@ -4675,6 +4676,28 @@
+
+
+
+
+ SoundCloud downloads run anonymously — no account required. Use this for DJ mixes,
+ remixes, and tracks that aren't on Spotify/Tidal/Deezer.
+
+ Quality: 128 kbps MP3 / AAC depending on the upload (anonymous tier).
+ SoundCloud doesn't expose lossless audio to anyone.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 9e38fc6d..24a5efd8 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -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: (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' },
diff --git a/webui/static/settings.js b/webui/static/settings.js
index 79013d1a..274b2561 100644
--- a/webui/static/settings.js
+++ b/webui/static/settings.js
@@ -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;
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
index c9635f12..976f27a6 100644
--- a/webui/static/shared-helpers.js
+++ b/webui/static/shared-helpers.js
@@ -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;
From 8de4a186b77b228bf08c937a7b2f581b7046577b Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Sun, 3 May 2026 13:09:02 -0700
Subject: [PATCH 2/3] Fix three SoundCloud integration gaps surfaced by smoke
testing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User report: switched download source to SoundCloud and noticed:
1. Download progress % stays at 0 until "suddenly done" — no live progress
2. Sidebar status indicator next to "SoundCloud" label is red
3. Dashboard service status card still shows "Soulseek" as the source name
Fix 1 — Live progress for HLS-segmented SoundCloud downloads
(`core/soundcloud_client.py`):
- yt-dlp's `total_bytes` / `total_bytes_estimate` for HLS describes the
CURRENT FRAGMENT, not the whole download. So the byte-based
percentage stayed near 0 the entire time — until 'finished' fired.
- Added `_update_download_progress_fragmented` which uses
`fragment_index` / `fragment_count` (which yt-dlp DOES populate
accurately for HLS) to compute a meaningful percentage. Total size
is extrapolated from per-fragment average for the bytes/remaining
display. Time-remaining estimate uses elapsed/index seconds-per-
fragment.
- The progress hook prefers fragment progress when both fragment_index
and fragment_count are present; falls back to byte-based for
non-fragmented (progressive MP3) downloads. Five new unit tests pin
the fragment-progress math, the 99.9% cap, and the defensive
zero-index / unknown-id paths.
Fix 2 — Sidebar status indicator stays green for SoundCloud mode
(`web_server.py`):
- The `/api/status` route's `serverless_sources` tuple decides whether
to even probe slskd. SoundCloud (and Lidarr) were missing — so when
the active source was SoundCloud, the route fell through to "test
slskd, mark not-relevant", which set `connected: False` and turned
the sidebar dot red even though SoundCloud was working.
- Added `'soundcloud'` and `'lidarr'` to the tuple. Both are
serverless from slskd's perspective, so the dot now stays green
whenever they're the active source.
Fix 3 — Dashboard service card title shows the active source
(`webui/static/shared-helpers.js`):
- The dashboard's "Download Source" card has its own
`sourceNames` map at line 3351 (separate from the sidebar map I
already updated at 3396). Missed it during the integration PR.
- Added `'lidarr'` and `'soundcloud'` so the card title now reads
"SoundCloud" / "Lidarr" instead of falling back to "Soulseek".
Bonus — Dashboard "Test Connection" button works for SoundCloud
(`core/connection_test.py`):
- The dashboard's Test Connection button on the download-source card
sends `service` based on the active source — so for SoundCloud it
was sending `service='soundcloud'`. `run_service_test` had no
branch for it, so it fell through to "Unknown service." and the
button always failed.
- Added a `soundcloud` branch that mirrors `/api/soundcloud/status`
behavior: confirms yt-dlp is installed, runs a real cheap probe,
returns a meaningful pass/fail. (HiFi has the same gap but no
user reported it; out of scope for this fix.)
Verified:
- 41 unit tests pass (5 new fragment-progress tests added)
- Full suite 1732 passed
- Ruff clean
---
core/connection_test.py | 18 +++++++++
core/soundcloud_client.py | 63 +++++++++++++++++++++++++++++-
tests/test_soundcloud_client.py | 68 +++++++++++++++++++++++++++++++++
web_server.py | 7 +++-
webui/static/shared-helpers.js | 2 +-
5 files changed, 154 insertions(+), 4 deletions(-)
diff --git a/core/connection_test.py b/core/connection_test.py
index 9401d1aa..8b905264 100644
--- a/core/connection_test.py
+++ b/core/connection_test.py
@@ -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
diff --git a/core/soundcloud_client.py b/core/soundcloud_client.py
index a4c50bd4..87e9e075 100644
--- a/core/soundcloud_client.py
+++ b/core/soundcloud_client.py
@@ -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.
diff --git a/tests/test_soundcloud_client.py b/tests/test_soundcloud_client.py
index 5320e19b..2a421651 100644
--- a/tests/test_soundcloud_client.py
+++ b/tests/test_soundcloud_client.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/web_server.py b/web_server.py
index 3f5fa7e9..ccee40ad 100644
--- a/web_server.py
+++ b/web_server.py
@@ -3486,8 +3486,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)))
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
index 976f27a6..661f72d0 100644
--- a/webui/static/shared-helpers.js
+++ b/webui/static/shared-helpers.js
@@ -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;
From c9dbf421dca2c8c271d91d99bdfbf47e804ed38e Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Sun, 3 May 2026 13:22:35 -0700
Subject: [PATCH 3/3] SoundCloud progress UI fix: include SoundCloud in cached
transfer lookup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User: SoundCloud downloads finish correctly but the modal stays at
"Downloading... 0%" until "Processing..." flips on. Live percentage
never updates.
Root cause: my live-progress fix in 8de4a18 made the SoundCloud client
compute progress correctly via fragment_index/fragment_count — but the
percent never reached the modal because `get_cached_transfer_data` in
web_server.py iterates `[youtube, tidal, qobuz, hifi, deezer_dl,
lidarr]` to build the lookup that drives `task.progress`. SoundCloud
was missing from that loop, so `live_transfers_lookup` had no entry
for SoundCloud downloads, so `live_info` lookup at
`core/downloads/status.py:135` always missed, so `task_status['progress']`
defaulted to 0 the entire time.
Frontend was reading `task.progress` (rendered as
"Downloading... ${task.progress}%" in `webui/static/downloads.js:3142`),
which stayed at 0. The percentComplete field that the
`/api/downloads/status` endpoint includes for SoundCloud was correct;
this only affected the cached lookup used by the V2 task tracker.
Fix: include SoundCloud in the iteration. Used `getattr` fallback to
match the same pattern I used in `core/downloads/monitor.py` so older
soulseek_client snapshots without the attribute don't AttributeError.
Bonus: also wired the SoundCloud client's `set_shutdown_check` callback
in the startup block right after HiFi's. Previously the cooperative-
cancellation hook in `_progress_hook` would never fire on shutdown
because `self.shutdown_check` was None.
Verified: full suite 1732 passed, ruff clean. yt-dlp probe confirms
fragment_index / fragment_count are populated correctly during HLS
download (164 hook calls for a 19-fragment track), so the now-
exposed progress will increment smoothly from 0 to 99.9 and then
flip to Completed.
---
web_server.py | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/web_server.py b/web_server.py
index ccee40ad..4275e39a 100644
--- a/web_server.py
+++ b/web_server.py
@@ -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()))