Merge pull request #482 from Nezreka/feat/soundcloud-integration
Feat/soundcloud integration
This commit is contained in:
commit
c8618ba0d4
16 changed files with 559 additions and 34 deletions
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
247
tests/test_download_orchestrator_soundcloud.py
Normal file
247
tests/test_download_orchestrator_soundcloud.py
Normal 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"
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue