You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/test_soundcloud_client.py

756 lines
30 KiB

"""Unit + integration tests for ``core/soundcloud_client.py``.
The unit tests stub out ``yt_dlp`` so they run fast, deterministically,
and offline. They cover: search shape correctness, the artist/title
heuristic, the dispatch-key (``filename``) round trip, the download
state machine (success / failure / shutdown), the progress emitter, and
the cancel/clear ledger operations.
The integration tests are gated behind ``-m soundcloud_live`` so they
don't run in CI by default. Run them locally to verify against real
SoundCloud:
python -m pytest tests/test_soundcloud_client.py -m soundcloud_live -v -s
They hit the public SoundCloud surface, so they require network access
and a working yt-dlp install.
"""
from __future__ import annotations
import asyncio
import os
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from core import soundcloud_client
from core.soundcloud_client import SoundcloudClient, _sanitize_filename
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------
def test_sanitize_filename_strips_reserved_chars() -> None:
# Reserved chars become underscores; trailing punctuation gets stripped.
assert _sanitize_filename('Track / Name : "Bad" ?') == 'Track _ Name _ _Bad'
# Repeated underscores collapse, leading/trailing underscores trimmed.
assert _sanitize_filename('////track////') == 'track'
# Empty input still returns a usable filename, never an empty string.
assert _sanitize_filename('') == 'soundcloud_track'
def test_split_artist_from_title_uses_dash_separator() -> None:
artist, title = SoundcloudClient._split_artist_from_title(
"Daft Punk - Get Lucky", "officialdaftpunk"
)
assert artist == "Daft Punk"
assert title == "Get Lucky"
def test_split_artist_from_title_falls_back_to_uploader_when_no_dash() -> None:
artist, title = SoundcloudClient._split_artist_from_title(
"Some Mix Title", "uploader_handle"
)
assert artist == "uploader_handle"
assert title == "Some Mix Title"
def test_split_artist_from_title_rejects_too_short_artist_part() -> None:
"""Things like "DJ - Mix" shouldn't get parsed as artist='DJ' / title='Mix'
when a 2-char artist is plausibly noise — but our threshold is >=2, so
"DJ" actually qualifies. This pins the boundary."""
artist, title = SoundcloudClient._split_artist_from_title("a - hello", "uploader")
# 'a' is 1 char → fall through to uploader
assert artist == "uploader"
assert title == "a - hello"
def test_split_artist_from_title_handles_empty_input() -> None:
artist, title = SoundcloudClient._split_artist_from_title("", "fallback")
assert artist == "fallback"
assert title == ""
# ---------------------------------------------------------------------------
# Construction / availability gates
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_dl(tmp_path: Path) -> Path:
p = tmp_path / "downloads"
p.mkdir()
return p
def _wire_engine(client: SoundcloudClient) -> 'DownloadEngine':
"""Phase C7: SoundCloud no longer owns its own active_downloads
dict — state lives on engine.DownloadEngine. Tests that
construct a bare client must wire an engine so download() can
dispatch and the client's query/cancel methods read from
somewhere. Returns the engine for tests that want to inspect
state directly."""
from core.download_engine import DownloadEngine
engine = DownloadEngine()
client.set_engine(engine)
return engine
def test_is_available_when_yt_dlp_installed(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
# In our test env yt-dlp is installed (it's a hard dep)
assert client.is_available() is True
assert client.is_configured() is True
def test_is_available_false_when_yt_dlp_missing(tmp_dl: Path, monkeypatch) -> None:
monkeypatch.setattr(soundcloud_client, "yt_dlp", None)
client = SoundcloudClient(download_path=str(tmp_dl))
assert client.is_available() is False
assert client.is_configured() is False
def test_is_authenticated_always_false_until_oauth_ships(tmp_dl: Path) -> None:
"""Anonymous-only client. Pin the contract so a future OAuth tier
has to explicitly flip this."""
client = SoundcloudClient(download_path=str(tmp_dl))
assert client.is_authenticated() is False
def test_download_path_created_on_construction(tmp_path: Path) -> None:
target = tmp_path / "nested" / "deeper" / "downloads"
assert not target.exists()
SoundcloudClient(download_path=str(target))
assert target.exists() and target.is_dir()
def test_set_shutdown_check_assigns_callable(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
sentinel = lambda: True # noqa: E731
client.set_shutdown_check(sentinel)
assert client.shutdown_check is sentinel
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
def _run(coro):
"""Tiny helper — we have async methods to exercise but no async test runner."""
return asyncio.run(coro)
def test_search_returns_empty_when_unavailable(tmp_dl: Path, monkeypatch) -> None:
monkeypatch.setattr(soundcloud_client, "yt_dlp", None)
client = SoundcloudClient(download_path=str(tmp_dl))
tracks, albums = _run(client.search("anything"))
assert tracks == []
assert albums == []
def test_search_returns_empty_for_empty_or_invalid_query(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
assert _run(client.search("")) == ([], [])
assert _run(client.search(None)) == ([], []) # type: ignore[arg-type]
assert _run(client.search(42)) == ([], []) # type: ignore[arg-type]
def test_search_converts_yt_dlp_entries_to_track_results(tmp_dl: Path) -> None:
"""Happy-path search: yt-dlp returns a list of entries, the client
converts each into a TrackResult, and the album list stays empty."""
fake_entries = [
{
'id': '12345',
'title': 'Daft Punk - Around the World',
'uploader': 'daftpunkofficial',
'url': 'https://soundcloud.com/daftpunk/around-the-world',
'duration': 425.0,
},
{
'id': '67890',
'title': 'Some DJ Mix Set',
'uploader': 'somedj',
'url': 'https://soundcloud.com/somedj/some-mix',
'duration': 3600.0,
},
]
client = SoundcloudClient(download_path=str(tmp_dl))
with patch.object(client, '_extract_search_entries', return_value=fake_entries):
tracks, albums = _run(client.search("daft punk"))
assert albums == []
assert len(tracks) == 2
# First entry: "Artist - Title" parsing kicked in
t1 = tracks[0]
assert isinstance(t1, TrackResult)
assert t1.username == 'soundcloud'
assert t1.artist == 'Daft Punk'
assert t1.title == 'Around the World'
assert t1.bitrate == 128
assert t1.quality == 'mp3'
assert t1.duration == 425000 # ms
# Filename carries id + URL + display name for downstream dispatch
parts = t1.filename.split('||')
assert parts[0] == '12345'
assert parts[1] == 'https://soundcloud.com/daftpunk/around-the-world'
assert 'Daft Punk' in parts[2]
# Source metadata roundtrips
assert t1._source_metadata['source'] == 'soundcloud'
assert t1._source_metadata['track_id'] == '12345'
assert t1._source_metadata['permalink_url'] == 'https://soundcloud.com/daftpunk/around-the-world'
# Second entry: no " - " in title, fall back to uploader as artist
t2 = tracks[1]
assert t2.artist == 'somedj'
assert t2.title == 'Some DJ Mix Set'
assert t2.duration == 3_600_000
def test_search_skips_entries_without_url(tmp_dl: Path) -> None:
"""No URL → can't download later → drop from results."""
fake_entries = [
{'id': '1', 'title': 'has url', 'url': 'https://soundcloud.com/x/y'},
{'id': '2', 'title': 'no url'}, # gets skipped
{'id': '', 'title': 'empty id', 'url': 'https://soundcloud.com/x/z'}, # also skipped
]
client = SoundcloudClient(download_path=str(tmp_dl))
with patch.object(client, '_extract_search_entries', return_value=fake_entries):
tracks, _ = _run(client.search("any"))
assert len(tracks) == 1
assert tracks[0]._source_metadata['track_id'] == '1'
def test_search_handles_yt_dlp_exception(tmp_dl: Path) -> None:
"""yt-dlp can raise on rate limit / network blip — caller still gets
a clean empty list, never a raised exception."""
client = SoundcloudClient(download_path=str(tmp_dl))
with patch.object(client, '_extract_search_entries',
side_effect=RuntimeError("network down")):
tracks, albums = _run(client.search("anything"))
assert tracks == []
assert albums == []
def test_search_handles_empty_entries(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
with patch.object(client, '_extract_search_entries', return_value=[]):
tracks, _ = _run(client.search("nothing"))
assert tracks == []
def test_search_handles_malformed_entries_individually(tmp_dl: Path) -> None:
"""One bad entry shouldn't poison the entire result set."""
fake_entries = [
{'id': '1', 'title': 'good', 'url': 'https://x/1'},
# Missing all required fields → conversion returns None → skipped
{'something': 'weird'},
{'id': '2', 'title': 'also good', 'url': 'https://x/2'},
]
client = SoundcloudClient(download_path=str(tmp_dl))
with patch.object(client, '_extract_search_entries', return_value=fake_entries):
tracks, _ = _run(client.search("any"))
assert len(tracks) == 2
# ---------------------------------------------------------------------------
# Download orchestration
# ---------------------------------------------------------------------------
def test_download_rejects_invalid_filename_format(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
# No || separator
assert _run(client.download('soundcloud', 'broken')) is None
def test_download_starts_thread_and_returns_id(tmp_dl: Path) -> None:
"""Verify the contract: returns a download_id, engine record is
populated, the worker drives state to terminal."""
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
completed_path = tmp_dl / "track.mp3"
completed_path.write_bytes(b"x" * (200 * 1024)) # > MIN_AUDIO_SIZE
with patch.object(client, '_download_sync', return_value=str(completed_path)):
download_id = _run(client.download(
'soundcloud',
'999||https://soundcloud.com/x/y||Display Name',
file_size=0,
))
assert download_id is not None
deadline = time.time() + 2
while time.time() < deadline:
record = engine.get_record('soundcloud', download_id)
if record and record['state'] == 'Completed, Succeeded':
break
time.sleep(0.05)
info = engine.get_record('soundcloud', download_id)
assert info['state'] == 'Completed, Succeeded'
assert info['progress'] == 100.0
assert info['file_path'] == str(completed_path)
assert info['username'] == 'soundcloud'
def test_download_thread_marks_failed_when_sync_returns_none(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
with patch.object(client, '_download_sync', return_value=None):
download_id = _run(client.download(
'soundcloud',
'1||https://soundcloud.com/x/y||name',
))
deadline = time.time() + 2
while time.time() < deadline:
record = engine.get_record('soundcloud', download_id)
if record and record['state'] == 'Errored':
break
time.sleep(0.05)
assert engine.get_record('soundcloud', download_id)['state'] == 'Errored'
def test_download_thread_does_not_clobber_cancelled_state(tmp_dl: Path) -> None:
"""If a user cancels mid-download and the sync function then returns
None, the worker must NOT overwrite the explicit Cancelled state
with a generic Errored state. The legacy per-client thread had
this guard; engine.worker._mark_terminal preserves it for every
source via a single check."""
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
def _slow_sync(download_id, *_):
time.sleep(0.05)
engine.update_record('soundcloud', download_id, {'state': 'Cancelled'})
return None
with patch.object(client, '_download_sync', side_effect=_slow_sync):
download_id = _run(client.download('soundcloud', '1||u||n'))
deadline = time.time() + 2
while time.time() < deadline:
record = engine.get_record('soundcloud', download_id)
if record and record['state'] in ('Cancelled', 'Errored'):
break
time.sleep(0.05)
assert engine.get_record('soundcloud', download_id)['state'] == 'Cancelled'
# ---------------------------------------------------------------------------
# yt-dlp interaction (download_sync)
# ---------------------------------------------------------------------------
class _FakeYDL:
"""Minimal stand-in for yt_dlp.YoutubeDL used to exercise download_sync."""
def __init__(self, opts):
self.opts = opts
self.last_url = None
self.fake_info = {'id': 'abc', 'title': 'fake', 'ext': 'mp3'}
def __enter__(self):
return self
def __exit__(self, *args):
return False
def extract_info(self, url, download=False):
self.last_url = url
if download:
# Write a fake audio file to the resolved path
resolved = self.prepare_filename(self.fake_info)
Path(resolved).parent.mkdir(parents=True, exist_ok=True)
Path(resolved).write_bytes(b"y" * (200 * 1024))
return self.fake_info
def prepare_filename(self, info):
# Simulate yt-dlp's outtmpl substitution
template = self.opts['outtmpl']
return template.replace('%(ext)s', info.get('ext', 'mp3'))
def test_download_sync_writes_file_and_returns_path(tmp_dl: Path, monkeypatch) -> None:
fake_yt_dlp = SimpleNamespace(
YoutubeDL=_FakeYDL,
utils=SimpleNamespace(DownloadError=Exception),
)
monkeypatch.setattr(soundcloud_client, "yt_dlp", fake_yt_dlp)
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', 'dl1', {
'id': 'dl1', 'filename': '', 'username': 'soundcloud',
'state': 'Initializing', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
'track_id': 'abc', 'permalink_url': 'u', 'display_name': 'My Track',
'file_path': None,
})
result = client._download_sync('dl1', 'https://soundcloud.com/x/y', 'My Track')
assert result is not None
assert os.path.exists(result)
assert os.path.getsize(result) > 100 * 1024
def test_download_sync_rejects_too_small_file(tmp_dl: Path, monkeypatch) -> None:
"""Files under MIN_AUDIO_SIZE_BYTES indicate yt-dlp got a preview
snippet or junk response; reject and clean up."""
class _TinyYDL(_FakeYDL):
def __init__(self, opts):
super().__init__(opts)
self.fake_info = {'id': 'tiny', 'title': 'tiny', 'ext': 'mp3'}
def extract_info(self, url, download=False):
self.last_url = url
if download:
resolved = self.prepare_filename(self.fake_info)
Path(resolved).parent.mkdir(parents=True, exist_ok=True)
Path(resolved).write_bytes(b"y" * 500) # Too small
return self.fake_info
fake_yt_dlp = SimpleNamespace(
YoutubeDL=_TinyYDL,
utils=SimpleNamespace(DownloadError=Exception),
)
monkeypatch.setattr(soundcloud_client, "yt_dlp", fake_yt_dlp)
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', 'dl2', {
'id': 'dl2', 'filename': '', 'username': 'soundcloud',
'state': 'Initializing', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
'track_id': 'tiny', 'permalink_url': 'u', 'display_name': 'Tiny',
'file_path': None,
})
result = client._download_sync('dl2', 'https://soundcloud.com/x/y', 'Tiny')
assert result is None
# File got cleaned up after rejection
target = tmp_dl / "Tiny.mp3"
assert not target.exists()
def test_download_sync_handles_yt_dlp_raising(tmp_dl: Path, monkeypatch) -> None:
"""yt-dlp can raise DownloadError or any other exception. download_sync
should surface a clean None instead of propagating."""
class _BoomYDL:
def __init__(self, opts):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def extract_info(self, *a, **kw):
raise RuntimeError("boom")
def prepare_filename(self, info):
return ""
fake_yt_dlp = SimpleNamespace(
YoutubeDL=_BoomYDL,
utils=SimpleNamespace(DownloadError=Exception),
)
monkeypatch.setattr(soundcloud_client, "yt_dlp", fake_yt_dlp)
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', 'dl3', {
'id': 'dl3', 'filename': '', 'username': 'soundcloud',
'state': 'Initializing', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
})
assert client._download_sync('dl3', 'https://soundcloud.com/x/y', 'Boom') is None
def test_download_sync_returns_none_when_yt_dlp_unavailable(tmp_dl: Path, monkeypatch) -> None:
monkeypatch.setattr(soundcloud_client, "yt_dlp", None)
client = SoundcloudClient(download_path=str(tmp_dl))
assert client._download_sync('any', 'u', 'name') is None
# ---------------------------------------------------------------------------
# Progress emitter
# ---------------------------------------------------------------------------
def test_update_download_progress_populates_ledger(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', 'p1', {
'id': 'p1', 'filename': '', 'username': 'soundcloud',
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
})
speed_start = time.time() - 1.0 # 1 second ago
client._update_download_progress('p1', downloaded=512_000, total=1_024_000,
speed_start=speed_start)
info = engine.get_record('soundcloud', 'p1')
assert info['transferred'] == 512_000
assert info['size'] == 1_024_000
assert 49.0 <= info['progress'] <= 51.0
assert info['speed'] > 0
assert info['time_remaining'] is not None
assert 0 < info['time_remaining'] < 5
def test_update_download_progress_caps_at_99_9(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', 'p2', {
'id': 'p2', 'filename': '', 'username': 'soundcloud',
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
})
client._update_download_progress('p2', downloaded=1_000_000,
total=1_000_000, speed_start=time.time() - 1)
assert engine.get_record('soundcloud', 'p2')['progress'] == 99.9
def test_update_download_progress_silently_skips_unknown_id(tmp_dl: Path) -> None:
"""No-op if the download id isn't tracked — defensive against late hooks."""
client = SoundcloudClient(download_path=str(tmp_dl))
_wire_engine(client)
# Should not raise
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."""
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', '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 = engine.get_record('soundcloud', 'hls1')
assert 49.0 <= info['progress'] <= 51.0
assert info['size'] == 1_024_000 # 512000 * (10/5)
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."""
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', '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 engine.get_record('soundcloud', 'hls2')['progress'] == 99.9
def test_update_download_progress_fragmented_handles_zero_index(tmp_dl: Path) -> None:
"""Defensive: fragment_index=0 on first callback → progress 0,
no crash."""
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', '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(),
)
assert engine.get_record('soundcloud', '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
# ---------------------------------------------------------------------------
def test_get_all_downloads_returns_status_objects(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', 's1', {
'id': 's1', 'filename': 'f', 'username': 'soundcloud',
'state': 'InProgress, Downloading', 'progress': 33.3, 'size': 1000,
'transferred': 333, 'speed': 100, 'time_remaining': 7,
'file_path': None,
})
out = _run(client.get_all_downloads())
assert len(out) == 1
assert isinstance(out[0], DownloadStatus)
assert out[0].id == 's1'
assert out[0].progress == 33.3
def test_get_download_status_returns_none_for_unknown(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
_wire_engine(client)
assert _run(client.get_download_status('nope')) is None
def test_cancel_download_marks_state(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', 'c1', {
'id': 'c1', 'filename': '', 'username': 'soundcloud',
'state': 'InProgress, Downloading', 'progress': 50.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
})
assert _run(client.cancel_download('c1')) is True
assert engine.get_record('soundcloud', 'c1')['state'] == 'Cancelled'
def test_cancel_download_with_remove_drops_entry(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
engine.add_record('soundcloud', 'c2', {
'id': 'c2', 'filename': '', 'username': 'soundcloud',
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
'transferred': 0, 'speed': 0, 'time_remaining': None,
})
assert _run(client.cancel_download('c2', remove=True)) is True
assert engine.get_record('soundcloud', 'c2') is None
def test_cancel_download_returns_false_for_unknown(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
_wire_engine(client)
assert _run(client.cancel_download('not_real')) is False
def test_clear_completed_drops_terminal_entries_only(tmp_dl: Path) -> None:
"""Terminal states get cleared; in-flight downloads survive."""
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
base = {'filename': '', 'username': 'soundcloud', 'progress': 0.0,
'size': 0, 'transferred': 0, 'speed': 0, 'time_remaining': None}
engine.add_record('soundcloud', 'done', {**base, 'id': 'done', 'state': 'Completed, Succeeded'})
engine.add_record('soundcloud', 'err', {**base, 'id': 'err', 'state': 'Errored'})
engine.add_record('soundcloud', 'cnc', {**base, 'id': 'cnc', 'state': 'Cancelled'})
engine.add_record('soundcloud', 'live', {**base, 'id': 'live', 'state': 'InProgress, Downloading'})
assert _run(client.clear_all_completed_downloads()) is True
assert engine.get_record('soundcloud', 'done') is None
assert engine.get_record('soundcloud', 'err') is None
assert engine.get_record('soundcloud', 'cnc') is None
assert engine.get_record('soundcloud', 'live') is not None
# ---------------------------------------------------------------------------
# Connection check
# ---------------------------------------------------------------------------
def test_check_connection_returns_false_when_unavailable(tmp_dl: Path, monkeypatch) -> None:
monkeypatch.setattr(soundcloud_client, "yt_dlp", None)
client = SoundcloudClient(download_path=str(tmp_dl))
assert _run(client.check_connection()) is False
def test_check_connection_returns_true_on_successful_search(tmp_dl: Path) -> None:
client = SoundcloudClient(download_path=str(tmp_dl))
async def _fake_search(*_a, **_kw):
return ([MagicMock()], [])
with patch.object(client, 'search', side_effect=_fake_search):
assert _run(client.check_connection()) is True
def test_check_connection_returns_false_when_search_raises(tmp_dl: Path) -> None:
"""Connection check shouldn't propagate the underlying exception."""
client = SoundcloudClient(download_path=str(tmp_dl))
async def _boom(*_a, **_kw):
raise RuntimeError("network down")
with patch.object(client, 'search', side_effect=_boom):
assert _run(client.check_connection()) is False
# ---------------------------------------------------------------------------
# Live integration tests (gated)
# ---------------------------------------------------------------------------
# Run with: python -m pytest tests/test_soundcloud_client.py -m soundcloud_live -v -s
# These hit real SoundCloud — network required, slow, and skip in default CI.
pytestmark_live = pytest.mark.soundcloud_live
@pytestmark_live
def test_live_search_returns_real_results(tmp_dl: Path) -> None:
"""Real query against SoundCloud's public search."""
client = SoundcloudClient(download_path=str(tmp_dl))
tracks, albums = _run(client.search("daft punk around the world"))
assert albums == []
assert len(tracks) > 0
# First result should at least have a title and a usable filename
t = tracks[0]
assert t.title or t.artist
assert '||' in t.filename
parts = t.filename.split('||')
assert parts[0] # track id
assert parts[1].startswith('https://')
@pytestmark_live
def test_live_download_a_known_public_track(tmp_dl: Path) -> None:
"""Download a real public SoundCloud track end-to-end. This is the
headline smoke test — if this passes, the client genuinely works.
We use a SoundCloud-Provided promotional track to avoid hammering
any specific creator's stats. If this URL ever 404s, swap it for
another reliably-public free track.
"""
client = SoundcloudClient(download_path=str(tmp_dl))
engine = _wire_engine(client)
# Search-then-download flow: pick the first hit for a popular query
tracks, _ = _run(client.search("creative commons electronic music"))
assert tracks, "Live search returned no results"
first = tracks[0]
download_id = _run(client.download(first.username, first.filename))
assert download_id is not None
# Wait up to 60s for completion
deadline = time.time() + 60
final_state = None
final_path = None
while time.time() < deadline:
info = engine.get_record('soundcloud', download_id) or {}
final_state = info.get('state')
final_path = info.get('file_path')
if final_state in {'Completed, Succeeded', 'Errored', 'Cancelled'}:
break
time.sleep(0.5)
assert final_state == 'Completed, Succeeded', f"Live download didn't complete: {final_state}"
assert final_path is not None
assert os.path.exists(final_path)
assert os.path.getsize(final_path) > 100 * 1024