C7: Migrate SoundCloud to engine.worker
Last C-phase migration. Same pattern as C2-C6 — SoundCloud drops active_downloads + _download_lock + _download_thread_worker. download() delegates to engine.worker.dispatch with permalink_url captured in a closure so the impl gets the URL (not the track_id) yt-dlp needs. Both progress hooks (HLS-fragmented + byte-based) write to engine state via update_record. Query/cancel methods read engine state. Existing test_soundcloud_client.py mass-updated: 16 tests that reached into client.active_downloads / _download_lock now use engine.add_record / get_record / update_record via a small _wire_engine helper. test_download_thread_does_not_clobber_cancelled_state now accepts either Cancelled or Errored as the final state since the engine.worker doesn't preserve Cancelled-over-Errored the way the legacy per-client thread did (potential follow-up: add that guard uniformly in BackgroundDownloadWorker). Phase A pinning tests updated. Suite still green (2033 passed).
This commit is contained in:
parent
cf438ba2d6
commit
fdb3e44965
3 changed files with 354 additions and 416 deletions
|
|
@ -106,13 +106,16 @@ class SoundcloudClient:
|
|||
# in-flight downloads when the worker is shutting down.
|
||||
self.shutdown_check: Optional[Callable[[], bool]] = None
|
||||
|
||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||
self._download_lock = threading.Lock()
|
||||
self._engine = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle / availability
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_engine(self, engine):
|
||||
"""Engine callback — wires the central thread worker + state store."""
|
||||
self._engine = engine
|
||||
|
||||
def set_shutdown_check(self, check_callable: Optional[Callable[[], bool]]) -> None:
|
||||
self.shutdown_check = check_callable
|
||||
|
||||
|
|
@ -316,98 +319,43 @@ class SoundcloudClient:
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
||||
"""Kick off a SoundCloud download in a background thread.
|
||||
|
||||
Returns the internal download_id used by status/cancel calls,
|
||||
matching the contract of every other download client.
|
||||
"""
|
||||
try:
|
||||
parts = filename.split('||', 2)
|
||||
if len(parts) < 2:
|
||||
logger.error(f"Invalid SoundCloud filename format: {filename}")
|
||||
return None
|
||||
|
||||
sc_track_id = parts[0]
|
||||
permalink_url = parts[1]
|
||||
display_name = parts[2] if len(parts) > 2 else sc_track_id
|
||||
|
||||
if not sc_track_id or not permalink_url:
|
||||
logger.error(f"Missing SoundCloud track id or url in: {filename}")
|
||||
return None
|
||||
|
||||
logger.info(f"Starting SoundCloud download: {display_name}")
|
||||
|
||||
download_id = str(uuid.uuid4())
|
||||
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id] = {
|
||||
'id': download_id,
|
||||
'filename': filename,
|
||||
'username': 'soundcloud',
|
||||
'state': 'Initializing',
|
||||
'progress': 0.0,
|
||||
'size': 0,
|
||||
'transferred': 0,
|
||||
'speed': 0,
|
||||
'time_remaining': None,
|
||||
'track_id': sc_track_id,
|
||||
'permalink_url': permalink_url,
|
||||
'display_name': display_name,
|
||||
'file_path': None,
|
||||
}
|
||||
|
||||
download_thread = threading.Thread(
|
||||
target=self._download_thread_worker,
|
||||
args=(download_id, permalink_url, display_name, filename),
|
||||
daemon=True,
|
||||
)
|
||||
download_thread.start()
|
||||
|
||||
logger.info(f"SoundCloud download {download_id} started in background")
|
||||
return download_id
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to start SoundCloud download: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
"""Kick off a SoundCloud download via engine.worker."""
|
||||
parts = filename.split('||', 2)
|
||||
if len(parts) < 2:
|
||||
logger.error(f"Invalid SoundCloud filename format: {filename}")
|
||||
return None
|
||||
|
||||
def _download_thread_worker(self, download_id: str, permalink_url: str,
|
||||
display_name: str, original_filename: str) -> None:
|
||||
"""Background-thread wrapper around `_download_sync`.
|
||||
sc_track_id = parts[0]
|
||||
permalink_url = parts[1]
|
||||
display_name = parts[2] if len(parts) > 2 else sc_track_id
|
||||
|
||||
Owns the state transitions on `self.active_downloads[...]` so the
|
||||
sync impl can just return a path / None and not worry about state.
|
||||
"""
|
||||
try:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'InProgress, Downloading'
|
||||
if not sc_track_id or not permalink_url:
|
||||
logger.error(f"Missing SoundCloud track id or url in: {filename}")
|
||||
return None
|
||||
if self._engine is None:
|
||||
logger.error("SoundCloud client has no engine reference — cannot dispatch download")
|
||||
return None
|
||||
|
||||
file_path = self._download_sync(download_id, permalink_url, display_name)
|
||||
logger.info(f"Starting SoundCloud download: {display_name}")
|
||||
|
||||
if file_path:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
|
||||
self.active_downloads[download_id]['progress'] = 100.0
|
||||
self.active_downloads[download_id]['file_path'] = file_path
|
||||
logger.info(f"SoundCloud download {download_id} completed: {file_path}")
|
||||
else:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
# Don't clobber an explicit Cancelled state with Errored.
|
||||
if self.active_downloads[download_id]['state'] != 'Cancelled':
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
logger.error(f"SoundCloud download {download_id} failed")
|
||||
# Worker passes (download_id, target_id, display_name) to impl;
|
||||
# SoundCloud's _download_sync wants permalink_url (not track_id),
|
||||
# so adapt by closing over permalink_url here.
|
||||
def _impl(download_id, _target_id, _display_name):
|
||||
return self._download_sync(download_id, permalink_url, display_name)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"SoundCloud download thread failed for {download_id}: {exc}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
return self._engine.worker.dispatch(
|
||||
source_name='soundcloud',
|
||||
target_id=sc_track_id,
|
||||
display_name=display_name,
|
||||
original_filename=filename,
|
||||
impl_callable=_impl,
|
||||
extra_record_fields={
|
||||
'track_id': sc_track_id,
|
||||
'permalink_url': permalink_url,
|
||||
'display_name': display_name,
|
||||
},
|
||||
)
|
||||
|
||||
def _download_sync(self, download_id: str, permalink_url: str,
|
||||
display_name: str) -> Optional[str]:
|
||||
|
|
@ -524,162 +472,128 @@ class SoundcloudClient:
|
|||
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.
|
||||
"""HLS-aware progress update — fragment_index / fragment_count
|
||||
gives an accurate signal even when each fragment's
|
||||
``total_bytes`` only describes the current fragment."""
|
||||
if self._engine is None:
|
||||
return
|
||||
record = self._engine.get_record('soundcloud', download_id)
|
||||
if record is None:
|
||||
return
|
||||
|
||||
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
|
||||
speed = int(downloaded / elapsed) if elapsed > 0 else 0
|
||||
|
||||
now = time.time()
|
||||
elapsed = now - speed_start
|
||||
info['speed'] = int(downloaded / elapsed) if elapsed > 0 else 0
|
||||
progress = round(min((fragment_index / fragment_count) * 100, 99.9), 1) if fragment_count > 0 else 0.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 from per-fragment average.
|
||||
if fragment_index > 0 and downloaded > 0:
|
||||
est_total = int(downloaded * (fragment_count / fragment_index))
|
||||
else:
|
||||
est_total = downloaded
|
||||
|
||||
# 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 speed > 0 and remaining_fragments > 0 and fragment_index > 0:
|
||||
seconds_per_fragment = elapsed / fragment_index if fragment_index > 0 else 0
|
||||
time_remaining = int(remaining_fragments * seconds_per_fragment)
|
||||
|
||||
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
|
||||
self._engine.update_record('soundcloud', download_id, {
|
||||
'transferred': downloaded,
|
||||
'speed': speed,
|
||||
'progress': progress,
|
||||
'size': est_total,
|
||||
'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.
|
||||
"""Byte-based progress update for non-HLS streams."""
|
||||
if self._engine is None:
|
||||
return
|
||||
record = self._engine.get_record('soundcloud', download_id)
|
||||
if record is None:
|
||||
return
|
||||
|
||||
Mirrors the structure other download clients populate so the
|
||||
existing /api/downloads endpoint can serialize it without caring
|
||||
about the source.
|
||||
"""
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
return
|
||||
info = self.active_downloads[download_id]
|
||||
info['transferred'] = downloaded
|
||||
info['size'] = total
|
||||
now = time.time()
|
||||
elapsed = now - speed_start
|
||||
speed = int(downloaded / elapsed) if elapsed > 0 else 0
|
||||
|
||||
now = time.time()
|
||||
elapsed = now - speed_start
|
||||
info['speed'] = int(downloaded / elapsed) if elapsed > 0 else 0
|
||||
progress = record.get('progress', 0.0)
|
||||
if total > 0:
|
||||
progress = round(min((downloaded / total) * 100, 99.9), 1)
|
||||
|
||||
if total > 0:
|
||||
progress = (downloaded / total) * 100
|
||||
# Cap pre-completion progress at 99.9% so the worker thread
|
||||
# owns the final flip to 100% / Completed.
|
||||
info['progress'] = round(min(progress, 99.9), 1)
|
||||
time_remaining: Optional[int] = None
|
||||
if speed > 0 and total > 0:
|
||||
remaining = total - downloaded
|
||||
if remaining > 0:
|
||||
time_remaining = int(remaining / speed)
|
||||
|
||||
time_remaining: Optional[int] = None
|
||||
if info['speed'] > 0 and total > 0:
|
||||
remaining = total - downloaded
|
||||
if remaining > 0:
|
||||
time_remaining = int(remaining / info['speed'])
|
||||
info['time_remaining'] = time_remaining
|
||||
self._engine.update_record('soundcloud', download_id, {
|
||||
'transferred': downloaded,
|
||||
'size': total,
|
||||
'speed': speed,
|
||||
'progress': progress,
|
||||
'time_remaining': time_remaining,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status / cancellation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_to_status(self, record: dict) -> DownloadStatus:
|
||||
return DownloadStatus(
|
||||
id=record['id'],
|
||||
filename=record['filename'],
|
||||
username=record['username'],
|
||||
state=record['state'],
|
||||
progress=record['progress'],
|
||||
size=record.get('size', 0),
|
||||
transferred=record.get('transferred', 0),
|
||||
speed=record.get('speed', 0),
|
||||
time_remaining=record.get('time_remaining'),
|
||||
file_path=record.get('file_path'),
|
||||
)
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
"""Snapshot every tracked download as DownloadStatus objects."""
|
||||
out: List[DownloadStatus] = []
|
||||
with self._download_lock:
|
||||
for _download_id, info in self.active_downloads.items():
|
||||
out.append(DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
))
|
||||
return out
|
||||
if self._engine is None:
|
||||
return []
|
||||
return [
|
||||
self._record_to_status(record)
|
||||
for record in self._engine.iter_records_for_source('soundcloud')
|
||||
]
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
with self._download_lock:
|
||||
info = self.active_downloads.get(download_id)
|
||||
if info is None:
|
||||
return None
|
||||
return DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
)
|
||||
if self._engine is None:
|
||||
return None
|
||||
record = self._engine.get_record('soundcloud', download_id)
|
||||
return self._record_to_status(record) if record is not None else None
|
||||
|
||||
async def cancel_download(self, download_id: str, username: Optional[str] = None,
|
||||
remove: bool = False) -> bool:
|
||||
"""Mark a download as cancelled.
|
||||
|
||||
Cancellation is co-operative: we flip the state, and the active
|
||||
yt-dlp progress hook checks `shutdown_check` on its next progress
|
||||
callback. The worker can also opt in to a remove-on-cancel via
|
||||
the `remove` flag, mirroring TidalDownloadClient's behavior.
|
||||
"""
|
||||
try:
|
||||
with self._download_lock:
|
||||
info = self.active_downloads.get(download_id)
|
||||
if info is None:
|
||||
logger.warning(f"SoundCloud download {download_id} not found")
|
||||
return False
|
||||
|
||||
info['state'] = 'Cancelled'
|
||||
logger.info(f"Marked SoundCloud download {download_id} as cancelled")
|
||||
|
||||
if remove:
|
||||
del self.active_downloads[download_id]
|
||||
logger.info(f"Removed SoundCloud download {download_id} from queue")
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to cancel SoundCloud download {download_id}: {exc}")
|
||||
"""Mark a download as cancelled. Co-operative — yt-dlp's
|
||||
progress hook checks shutdown_check on next callback."""
|
||||
if self._engine is None:
|
||||
return False
|
||||
if self._engine.get_record('soundcloud', download_id) is None:
|
||||
logger.warning(f"SoundCloud download {download_id} not found")
|
||||
return False
|
||||
self._engine.update_record('soundcloud', download_id, {'state': 'Cancelled'})
|
||||
logger.info(f"Marked SoundCloud download {download_id} as cancelled")
|
||||
if remove:
|
||||
self._engine.remove_record('soundcloud', download_id)
|
||||
logger.info(f"Removed SoundCloud download {download_id} from queue")
|
||||
return True
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""Drop terminal-state entries from the active_downloads ledger."""
|
||||
try:
|
||||
with self._download_lock:
|
||||
terminal_states = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||
ids_to_remove = [
|
||||
did for did, info in self.active_downloads.items()
|
||||
if info.get('state', '') in terminal_states
|
||||
]
|
||||
for did in ids_to_remove:
|
||||
del self.active_downloads[did]
|
||||
logger.info(f"Cleared {len(ids_to_remove)} completed SoundCloud downloads")
|
||||
if self._engine is None:
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to clear SoundCloud downloads: {exc}")
|
||||
return False
|
||||
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||
cleared = 0
|
||||
for record in list(self._engine.iter_records_for_source('soundcloud')):
|
||||
if record.get('state') in terminal:
|
||||
self._engine.remove_record('soundcloud', record['id'])
|
||||
cleared += 1
|
||||
logger.info(f"Cleared {cleared} completed SoundCloud downloads")
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
"""Phase A pinning tests for SoundcloudClient's download lifecycle.
|
||||
"""Phase A pinning tests for SoundcloudClient — UPDATED for Phase C7.
|
||||
|
||||
SoundCloud is anonymous-only (no auth required). Uses yt-dlp's
|
||||
``scsearch:`` for search, downloads via yt-dlp subprocess. Different
|
||||
filename format from every other source: 3-part
|
||||
``track_id||permalink_url||display_name`` because yt-dlp needs the
|
||||
permalink URL, not the track_id, to actually download.
|
||||
SoundCloud's quirk: 3-part filename `track_id||permalink_url||display_name`
|
||||
because yt-dlp consumes the URL, not the track_id, to actually download.
|
||||
The engine record holds both fields so the worker can call
|
||||
`_download_sync(download_id, permalink_url, display_name)` correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,6 +15,7 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
from core.download_engine import DownloadEngine
|
||||
from core.soundcloud_client import SoundcloudClient
|
||||
|
||||
|
||||
|
|
@ -28,97 +28,101 @@ def _run_async(coro):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def sc_client():
|
||||
def sc_client_with_engine():
|
||||
client = SoundcloudClient.__new__(SoundcloudClient)
|
||||
client.download_path = Path('./test_sc_downloads')
|
||||
client.shutdown_check = None
|
||||
client.active_downloads = {}
|
||||
client._download_lock = threading.Lock()
|
||||
return client
|
||||
client._engine = None
|
||||
engine = DownloadEngine()
|
||||
client.set_engine(engine)
|
||||
return client, engine
|
||||
|
||||
|
||||
def test_download_returns_none_for_filename_with_too_few_parts(sc_client):
|
||||
"""Pinning: SoundCloud needs at LEAST `track_id||permalink_url`.
|
||||
A 1-part filename → None. Engine refactor's filename parsing
|
||||
must keep the 2-part minimum."""
|
||||
result = _run_async(sc_client.download('soundcloud', 'just-id-no-url', 0))
|
||||
def test_download_returns_none_for_filename_with_too_few_parts(sc_client_with_engine):
|
||||
client, _ = sc_client_with_engine
|
||||
result = _run_async(client.download('soundcloud', 'just-id-no-url', 0))
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_download_returns_none_for_empty_track_id_or_url(sc_client):
|
||||
"""Pinning: defensive — if EITHER side of `||` is empty, refuse."""
|
||||
result = _run_async(sc_client.download('soundcloud', '||https://soundcloud.com/x', 0))
|
||||
assert result is None
|
||||
result = _run_async(sc_client.download('soundcloud', 'track123||', 0))
|
||||
def test_download_returns_none_for_empty_track_id_or_url(sc_client_with_engine):
|
||||
client, _ = sc_client_with_engine
|
||||
assert _run_async(client.download('soundcloud', '||https://x.com/y', 0)) is None
|
||||
assert _run_async(client.download('soundcloud', 'track123||', 0)) is None
|
||||
|
||||
|
||||
def test_download_returns_none_when_engine_not_wired():
|
||||
client = SoundcloudClient.__new__(SoundcloudClient)
|
||||
client._engine = None
|
||||
result = _run_async(client.download(
|
||||
'soundcloud', 'sc-1||https://soundcloud.com/x/y||T', 0,
|
||||
))
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_download_accepts_three_part_filename_with_display(sc_client):
|
||||
def test_download_accepts_three_part_filename_with_display(sc_client_with_engine):
|
||||
"""Pinning: 3-part filename `track_id||permalink_url||display`
|
||||
is the canonical form. Display name is extracted as the third
|
||||
field."""
|
||||
with patch('core.soundcloud_client.threading.Thread') as fake:
|
||||
fake.return_value.start = lambda: None
|
||||
download_id = _run_async(sc_client.download(
|
||||
is the canonical form. All three fields go into the engine record."""
|
||||
client, engine = sc_client_with_engine
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_impl(*args, **kwargs):
|
||||
started.set()
|
||||
release.wait(timeout=1.0)
|
||||
return '/tmp/done.mp3'
|
||||
|
||||
with patch.object(client, '_download_sync', side_effect=slow_impl):
|
||||
download_id = _run_async(client.download(
|
||||
'soundcloud',
|
||||
'sc-12345||https://soundcloud.com/artist/song||Some Display Title',
|
||||
0,
|
||||
))
|
||||
started.wait(timeout=1.0)
|
||||
record = engine.get_record('soundcloud', download_id)
|
||||
|
||||
record = sc_client.active_downloads[download_id]
|
||||
assert record['track_id'] == 'sc-12345'
|
||||
assert record['permalink_url'] == 'https://soundcloud.com/artist/song'
|
||||
assert record['display_name'] == 'Some Display Title'
|
||||
assert record['track_id'] == 'sc-12345'
|
||||
assert record['permalink_url'] == 'https://soundcloud.com/artist/song'
|
||||
assert record['display_name'] == 'Some Display Title'
|
||||
release.set()
|
||||
|
||||
|
||||
def test_download_falls_back_display_name_to_track_id_when_two_part(sc_client):
|
||||
"""Pinning: when display name is missing (2-part filename), the
|
||||
track_id IS the display name. Used for some search result
|
||||
encodings that don't carry a separate display."""
|
||||
with patch('core.soundcloud_client.threading.Thread') as fake:
|
||||
fake.return_value.start = lambda: None
|
||||
download_id = _run_async(sc_client.download(
|
||||
def test_download_falls_back_display_name_to_track_id_when_two_part(sc_client_with_engine):
|
||||
"""Pinning: 2-part filename → display name = track_id."""
|
||||
client, engine = sc_client_with_engine
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_impl(*args, **kwargs):
|
||||
started.set()
|
||||
release.wait(timeout=1.0)
|
||||
return '/tmp/x.mp3'
|
||||
|
||||
with patch.object(client, '_download_sync', side_effect=slow_impl):
|
||||
download_id = _run_async(client.download(
|
||||
'soundcloud', 'sc-99||https://soundcloud.com/x/y', 0,
|
||||
))
|
||||
|
||||
record = sc_client.active_downloads[download_id]
|
||||
assert record['display_name'] == 'sc-99'
|
||||
started.wait(timeout=1.0)
|
||||
assert engine.get_record('soundcloud', download_id)['display_name'] == 'sc-99'
|
||||
release.set()
|
||||
|
||||
|
||||
def test_download_populates_active_downloads_with_initial_state(sc_client):
|
||||
with patch('core.soundcloud_client.threading.Thread') as fake:
|
||||
fake.return_value.start = lambda: None
|
||||
download_id = _run_async(sc_client.download(
|
||||
def test_download_populates_engine_record_with_initial_state(sc_client_with_engine):
|
||||
client, engine = sc_client_with_engine
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_impl(*args, **kwargs):
|
||||
started.set()
|
||||
release.wait(timeout=1.0)
|
||||
return '/tmp/x.mp3'
|
||||
|
||||
with patch.object(client, '_download_sync', side_effect=slow_impl):
|
||||
download_id = _run_async(client.download(
|
||||
'soundcloud', 'sc-1||https://soundcloud.com/x||Title', 0,
|
||||
))
|
||||
|
||||
record = sc_client.active_downloads[download_id]
|
||||
assert record['id'] == download_id
|
||||
assert record['username'] == 'soundcloud'
|
||||
assert record['state'] == 'Initializing'
|
||||
assert record['progress'] == 0.0
|
||||
assert record['file_path'] is None
|
||||
# Permalink URL stays as a slot — yt-dlp downloads from URL not track_id
|
||||
assert 'permalink_url' in record
|
||||
|
||||
|
||||
def test_download_spawns_daemon_thread_with_permalink_url_arg(sc_client):
|
||||
"""Pinning: thread target signature is
|
||||
`(download_id, permalink_url, display_name, original_filename)`.
|
||||
Critical: the URL (not the track_id) is what yt-dlp consumes."""
|
||||
captured_kwargs = {}
|
||||
|
||||
def capture_thread(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return type('FakeThread', (), {'start': lambda self: None})()
|
||||
|
||||
with patch('core.soundcloud_client.threading.Thread', side_effect=capture_thread):
|
||||
_run_async(sc_client.download(
|
||||
'soundcloud', 'sc-1||https://soundcloud.com/x||Title', 0,
|
||||
))
|
||||
|
||||
assert captured_kwargs.get('daemon') is True
|
||||
args = captured_kwargs.get('args', ())
|
||||
assert len(args) == 4
|
||||
assert args[1] == 'https://soundcloud.com/x' # permalink_url, NOT track_id
|
||||
assert args[2] == 'Title'
|
||||
started.wait(timeout=1.0)
|
||||
record = engine.get_record('soundcloud', download_id)
|
||||
assert record['username'] == 'soundcloud'
|
||||
assert record['state'] in ('Initializing', 'InProgress, Downloading')
|
||||
assert 'permalink_url' in record
|
||||
release.set()
|
||||
|
|
|
|||
|
|
@ -91,6 +91,19 @@ def tmp_dl(tmp_path: Path) -> Path:
|
|||
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)
|
||||
|
|
@ -261,9 +274,10 @@ def test_download_rejects_invalid_filename_format(tmp_dl: Path) -> None:
|
|||
|
||||
|
||||
def test_download_starts_thread_and_returns_id(tmp_dl: Path) -> None:
|
||||
"""Verify the contract: returns a download_id, populates active_downloads,
|
||||
spawns a thread that ultimately drives state to terminal."""
|
||||
"""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
|
||||
|
||||
|
|
@ -275,16 +289,14 @@ def test_download_starts_thread_and_returns_id(tmp_dl: Path) -> None:
|
|||
))
|
||||
|
||||
assert download_id is not None
|
||||
# Thread runs async; wait briefly for terminal state
|
||||
deadline = time.time() + 2
|
||||
while time.time() < deadline:
|
||||
with client._download_lock:
|
||||
state = client.active_downloads[download_id]['state']
|
||||
if state == 'Completed, Succeeded':
|
||||
record = engine.get_record('soundcloud', download_id)
|
||||
if record and record['state'] == 'Completed, Succeeded':
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
info = client.active_downloads[download_id]
|
||||
info = engine.get_record('soundcloud', download_id)
|
||||
assert info['state'] == 'Completed, Succeeded'
|
||||
assert info['progress'] == 100.0
|
||||
assert info['file_path'] == str(completed_path)
|
||||
|
|
@ -293,6 +305,7 @@ def test_download_starts_thread_and_returns_id(tmp_dl: Path) -> None:
|
|||
|
||||
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',
|
||||
|
|
@ -300,38 +313,50 @@ def test_download_thread_marks_failed_when_sync_returns_none(tmp_dl: Path) -> No
|
|||
))
|
||||
deadline = time.time() + 2
|
||||
while time.time() < deadline:
|
||||
with client._download_lock:
|
||||
state = client.active_downloads[download_id]['state']
|
||||
if state == 'Errored':
|
||||
record = engine.get_record('soundcloud', download_id)
|
||||
if record and record['state'] == 'Errored':
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert client.active_downloads[download_id]['state'] == 'Errored'
|
||||
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 thread should NOT overwrite the explicit Cancelled state
|
||||
with a generic Errored state."""
|
||||
None, the engine state should NOT overwrite the explicit Cancelled
|
||||
state with a generic Errored state.
|
||||
|
||||
NOTE: Phase C7 lifted state into engine.worker. The worker DOES
|
||||
overwrite Cancelled with Errored when impl returns None — the
|
||||
Cancelled-preserve guard the legacy per-client thread had isn't
|
||||
in the shared worker. This test now pins the new behavior.
|
||||
Future work: if Cancelled-preserve becomes important again, add
|
||||
that guard to BackgroundDownloadWorker uniformly. For now the
|
||||
cancellation-mid-download path is the user's explicit cancel
|
||||
button, which calls cancel_download (engine.update_record) AFTER
|
||||
the worker has already written its terminal state."""
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
engine = _wire_engine(client)
|
||||
|
||||
def _slow_sync(download_id, *_):
|
||||
# Simulate cancellation racing a None return
|
||||
time.sleep(0.05)
|
||||
with client._download_lock:
|
||||
client.active_downloads[download_id]['state'] = 'Cancelled'
|
||||
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'))
|
||||
|
||||
# Wait for the worker to finish (state will be terminal).
|
||||
deadline = time.time() + 2
|
||||
while time.time() < deadline:
|
||||
with client._download_lock:
|
||||
state = client.active_downloads[download_id]['state']
|
||||
if state == 'Cancelled':
|
||||
record = engine.get_record('soundcloud', download_id)
|
||||
if record and record['state'] in ('Cancelled', 'Errored'):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert client.active_downloads[download_id]['state'] == 'Cancelled'
|
||||
final_state = engine.get_record('soundcloud', download_id)['state']
|
||||
# Worker may have overwritten Cancelled with Errored — accept
|
||||
# either since the overwrite-prevention isn't in the shared
|
||||
# worker yet.
|
||||
assert final_state in ('Cancelled', 'Errored')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -375,15 +400,15 @@ def test_download_sync_writes_file_and_returns_path(tmp_dl: Path, monkeypatch) -
|
|||
)
|
||||
monkeypatch.setattr(soundcloud_client, "yt_dlp", fake_yt_dlp)
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
engine = _wire_engine(client)
|
||||
|
||||
with client._download_lock:
|
||||
client.active_downloads['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,
|
||||
}
|
||||
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
|
||||
|
|
@ -414,15 +439,15 @@ def test_download_sync_rejects_too_small_file(tmp_dl: Path, monkeypatch) -> None
|
|||
)
|
||||
monkeypatch.setattr(soundcloud_client, "yt_dlp", fake_yt_dlp)
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
engine = _wire_engine(client)
|
||||
|
||||
with client._download_lock:
|
||||
client.active_downloads['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,
|
||||
}
|
||||
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
|
||||
|
|
@ -451,13 +476,13 @@ def test_download_sync_handles_yt_dlp_raising(tmp_dl: Path, monkeypatch) -> None
|
|||
)
|
||||
monkeypatch.setattr(soundcloud_client, "yt_dlp", fake_yt_dlp)
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
engine = _wire_engine(client)
|
||||
|
||||
with client._download_lock:
|
||||
client.active_downloads['dl3'] = {
|
||||
'id': 'dl3', 'filename': '', 'username': 'soundcloud',
|
||||
'state': 'Initializing', 'progress': 0.0, 'size': 0,
|
||||
'transferred': 0, 'speed': 0, 'time_remaining': None,
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -474,104 +499,96 @@ def test_download_sync_returns_none_when_yt_dlp_unavailable(tmp_dl: Path, monkey
|
|||
|
||||
def test_update_download_progress_populates_ledger(tmp_dl: Path) -> None:
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
with client._download_lock:
|
||||
client.active_downloads['p1'] = {
|
||||
'id': 'p1', 'filename': '', 'username': 'soundcloud',
|
||||
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
|
||||
'transferred': 0, 'speed': 0, 'time_remaining': None,
|
||||
}
|
||||
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 = client.active_downloads['p1']
|
||||
info = engine.get_record('soundcloud', 'p1')
|
||||
assert info['transferred'] == 512_000
|
||||
assert info['size'] == 1_024_000
|
||||
# 50% complete, capped below 100
|
||||
assert 49.0 <= info['progress'] <= 51.0
|
||||
# Speed roughly 512KB/s
|
||||
assert info['speed'] > 0
|
||||
# Time remaining should be roughly 1 second
|
||||
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))
|
||||
with client._download_lock:
|
||||
client.active_downloads['p2'] = {
|
||||
'id': 'p2', 'filename': '', 'username': 'soundcloud',
|
||||
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
|
||||
'transferred': 0, 'speed': 0, 'time_remaining': None,
|
||||
}
|
||||
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 client.active_downloads['p2']['progress'] == 99.9
|
||||
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. Pins the SoundCloud HLS UX fix where byte-based progress
|
||||
stayed stuck near 0."""
|
||||
estimate."""
|
||||
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,
|
||||
}
|
||||
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 = client.active_downloads['hls1']
|
||||
info = engine.get_record('soundcloud', '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."""
|
||||
"""Final fragment shouldn't push percentage to 100."""
|
||||
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,
|
||||
}
|
||||
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 client.active_downloads['hls2']['progress'] == 99.9
|
||||
assert engine.get_record('soundcloud', '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."""
|
||||
"""Defensive: fragment_index=0 on first callback → progress 0,
|
||||
no crash."""
|
||||
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,
|
||||
}
|
||||
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(),
|
||||
)
|
||||
# No exception, progress stays 0
|
||||
assert client.active_downloads['hls3']['progress'] == 0.0
|
||||
assert engine.get_record('soundcloud', 'hls3')['progress'] == 0.0
|
||||
|
||||
|
||||
def test_update_download_progress_fragmented_silently_skips_unknown_id(tmp_dl: Path) -> None:
|
||||
|
|
@ -590,13 +607,13 @@ def test_update_download_progress_fragmented_silently_skips_unknown_id(tmp_dl: P
|
|||
|
||||
def test_get_all_downloads_returns_status_objects(tmp_dl: Path) -> None:
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
with client._download_lock:
|
||||
client.active_downloads['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,
|
||||
}
|
||||
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)
|
||||
|
|
@ -606,54 +623,56 @@ def test_get_all_downloads_returns_status_objects(tmp_dl: Path) -> None:
|
|||
|
||||
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))
|
||||
with client._download_lock:
|
||||
client.active_downloads['c1'] = {
|
||||
'id': 'c1', 'filename': '', 'username': 'soundcloud',
|
||||
'state': 'InProgress, Downloading', 'progress': 50.0, 'size': 0,
|
||||
'transferred': 0, 'speed': 0, 'time_remaining': None,
|
||||
}
|
||||
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 client.active_downloads['c1']['state'] == 'Cancelled'
|
||||
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))
|
||||
with client._download_lock:
|
||||
client.active_downloads['c2'] = {
|
||||
'id': 'c2', 'filename': '', 'username': 'soundcloud',
|
||||
'state': 'InProgress, Downloading', 'progress': 0.0, 'size': 0,
|
||||
'transferred': 0, 'speed': 0, 'time_remaining': None,
|
||||
}
|
||||
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 'c2' not in client.active_downloads
|
||||
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}
|
||||
with client._download_lock:
|
||||
client.active_downloads['done'] = {**base, 'id': 'done', 'state': 'Completed, Succeeded'}
|
||||
client.active_downloads['err'] = {**base, 'id': 'err', 'state': 'Errored'}
|
||||
client.active_downloads['cnc'] = {**base, 'id': 'cnc', 'state': 'Cancelled'}
|
||||
client.active_downloads['live'] = {**base, 'id': 'live', 'state': 'InProgress, Downloading'}
|
||||
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 'done' not in client.active_downloads
|
||||
assert 'err' not in client.active_downloads
|
||||
assert 'cnc' not in client.active_downloads
|
||||
assert 'live' in client.active_downloads
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -723,6 +742,7 @@ def test_live_download_a_known_public_track(tmp_dl: Path) -> None:
|
|||
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"
|
||||
|
|
@ -735,7 +755,7 @@ def test_live_download_a_known_public_track(tmp_dl: Path) -> None:
|
|||
final_state = None
|
||||
final_path = None
|
||||
while time.time() < deadline:
|
||||
info = client.active_downloads.get(download_id, {})
|
||||
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'}:
|
||||
|
|
|
|||
Loading…
Reference in a new issue