From 7944568c5cfe346e9e15584175507f193c86c091 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 4 May 2026 13:52:55 -0700 Subject: [PATCH] C4: Migrate Qobuz to engine.worker Same migration pattern as C2/C3. QobuzClient drops active_downloads + _download_lock + _download_thread_worker. Mid-download chunk-progress mutations + cancel-state checks flow through engine.update_record / get_record. Query/cancel methods read engine state. Pinning tests updated to assert engine state. Suite still green (316 download tests). --- core/qobuz_client.py | 258 +++++++++----------------- tests/downloads/test_qobuz_pinning.py | 119 +++++++----- 2 files changed, 161 insertions(+), 216 deletions(-) diff --git a/core/qobuz_client.py b/core/qobuz_client.py index 3a3bda97..a06a9031 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -136,13 +136,19 @@ class QobuzClient: self.user_info: Optional[Dict] = None self._auth_error: Optional[str] = None - # Download queue management - self.active_downloads: Dict[str, Dict[str, Any]] = {} - self._download_lock = threading.Lock() + # Engine reference is populated by set_engine() at registration + # time. None until orchestrator wires the registry. + self._engine = None # Try to restore saved session self._restore_session() + def set_engine(self, engine): + """Engine callback — gives the client access to the central + thread worker + state store. Engine calls this during + ``register_plugin`` if the plugin defines it.""" + self._engine = engine + def set_shutdown_check(self, check_callable): """Set a callback function to check for system shutdown""" self.shutdown_check = check_callable @@ -884,97 +890,35 @@ class QobuzClient: return None async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]: - """ - Download a Qobuz track (async, Soulseek-compatible interface). - - Returns download_id immediately and runs download in background thread. - - Args: - username: Ignored for Qobuz (always "qobuz") - filename: Encoded as "track_id||display_name" - file_size: Ignored - """ - try: - if '||' not in filename: - logger.error(f"Invalid filename format: {filename}") - return None - - track_id_str, display_name = filename.split('||', 1) - try: - track_id = int(track_id_str) - except ValueError: - logger.error(f"Invalid Qobuz track ID: {track_id_str}") - return None - - logger.info(f"Starting Qobuz download: {display_name}") - - download_id = str(uuid.uuid4()) - - with self._download_lock: - self.active_downloads[download_id] = { - 'id': download_id, - 'filename': filename, - 'username': 'qobuz', - 'state': 'Initializing', - 'progress': 0.0, - 'size': 0, - 'transferred': 0, - 'speed': 0, - 'time_remaining': None, - 'track_id': track_id, - 'display_name': display_name, - 'file_path': None, - } - - # Start download in background thread - download_thread = threading.Thread( - target=self._download_thread_worker, - args=(download_id, track_id, display_name, filename), - daemon=True, - ) - download_thread.start() - - logger.info(f"Qobuz download {download_id} started in background") - return download_id - - except Exception as e: - logger.error(f"Failed to start Qobuz download: {e}") - import traceback - traceback.print_exc() + """Download a Qobuz track. Delegates to engine.worker which + spawns the background thread + manages state.""" + if '||' not in filename: + logger.error(f"Invalid filename format: {filename}") + return None + if self._engine is None: + logger.error("Qobuz client has no engine reference — cannot dispatch download") return None - def _download_thread_worker(self, download_id: str, track_id: int, display_name: str, original_filename: str): - """Background thread worker for downloading Qobuz tracks.""" + track_id_str, display_name = filename.split('||', 1) try: - with self._download_lock: - if download_id in self.active_downloads: - self.active_downloads[download_id]['state'] = 'InProgress, Downloading' + track_id = int(track_id_str) + except ValueError: + logger.error(f"Invalid Qobuz track ID: {track_id_str}") + return None - file_path = self._download_sync(download_id, track_id, display_name) + logger.info(f"Starting Qobuz 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"Qobuz download {download_id} completed: {file_path}") - else: - with self._download_lock: - if download_id in self.active_downloads: - self.active_downloads[download_id]['state'] = 'Errored' - - logger.error(f"Qobuz download {download_id} failed") - - except Exception as e: - logger.error(f"Qobuz download thread failed for {download_id}: {e}") - 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='qobuz', + target_id=track_id, + display_name=display_name, + original_filename=filename, + impl_callable=self._download_sync, + extra_record_fields={ + 'track_id': track_id, + 'display_name': display_name, + }, + ) def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]: """ @@ -1050,9 +994,8 @@ class QobuzClient: chunk_size = 64 * 1024 # 64KB chunks start_time = time.time() - with self._download_lock: - if download_id in self.active_downloads: - self.active_downloads[download_id]['size'] = total_size + if self._engine is not None: + self._engine.update_record('qobuz', download_id, {'size': total_size}) with open(out_path, 'wb') as f: for chunk in response.iter_content(chunk_size=chunk_size): @@ -1061,9 +1004,10 @@ class QobuzClient: # Check for shutdown or cancellation cancelled = False - with self._download_lock: - if download_id in self.active_downloads: - cancelled = self.active_downloads[download_id].get('state') == 'Cancelled' + if self._engine is not None: + rec = self._engine.get_record('qobuz', download_id) + if rec is not None: + cancelled = rec.get('state') == 'Cancelled' if cancelled or (self.shutdown_check and self.shutdown_check()): reason = "cancelled" if cancelled else "server shutting down" logger.info(f"Aborting Qobuz download mid-stream: {reason}") @@ -1084,18 +1028,20 @@ class QobuzClient: progress = 0 time_remaining = None - with self._download_lock: - if download_id in self.active_downloads: - self.active_downloads[download_id]['transferred'] = downloaded - self.active_downloads[download_id]['progress'] = round(progress, 1) - self.active_downloads[download_id]['speed'] = int(speed) - self.active_downloads[download_id]['time_remaining'] = time_remaining + if self._engine is not None: + self._engine.update_record('qobuz', download_id, { + 'transferred': downloaded, + 'progress': round(progress, 1), + 'speed': int(speed), + 'time_remaining': time_remaining, + }) # If download was aborted (shutdown/cancel), clean up partial file abort_check = False - with self._download_lock: - if download_id in self.active_downloads: - abort_check = self.active_downloads[download_id].get('state') == 'Cancelled' + if self._engine is not None: + rec = self._engine.get_record('qobuz', download_id) + if rec is not None: + abort_check = rec.get('state') == 'Cancelled' if abort_check or (self.shutdown_check and self.shutdown_check()): out_path.unlink(missing_ok=True) return None @@ -1139,79 +1085,55 @@ class QobuzClient: # ===================== Status / Cancel / Clear ===================== + def _record_to_status(self, record): + 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]: - """Get all active downloads (matches Soulseek interface).""" - download_statuses = [] - - with self._download_lock: - for _download_id, info in self.active_downloads.items(): - status = 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'), - ) - download_statuses.append(status) - - return download_statuses + if self._engine is None: + return [] + return [ + self._record_to_status(record) + for record in self._engine.iter_records_for_source('qobuz') + ] async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]: - """Get status of a specific download (matches Soulseek interface).""" - with self._download_lock: - if download_id not in self.active_downloads: - return None - - info = self.active_downloads[download_id] - 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('qobuz', download_id) + return self._record_to_status(record) if record is not None else None async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool: - """Cancel an active download (matches Soulseek interface).""" - try: - with self._download_lock: - if download_id not in self.active_downloads: - logger.warning(f"Download {download_id} not found") - return False - - self.active_downloads[download_id]['state'] = 'Cancelled' - logger.info(f"Marked Qobuz download {download_id} as cancelled") - - if remove: - del self.active_downloads[download_id] - logger.info(f"Removed Qobuz download {download_id} from queue") - - return True - except Exception as e: - logger.error(f"Failed to cancel download {download_id}: {e}") + if self._engine is None: return False + if self._engine.get_record('qobuz', download_id) is None: + logger.warning(f"Qobuz download {download_id} not found") + return False + self._engine.update_record('qobuz', download_id, {'state': 'Cancelled'}) + logger.info(f"Marked Qobuz download {download_id} as cancelled") + if remove: + self._engine.remove_record('qobuz', download_id) + logger.info(f"Removed Qobuz download {download_id} from queue") + return True async def clear_all_completed_downloads(self) -> bool: - """Clear all terminal downloads from the list (matches Soulseek interface).""" + if self._engine is None: + return True try: - with self._download_lock: - ids_to_remove = [ - did for did, info in self.active_downloads.items() - if info.get('state', '') in ('Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted') - ] - for did in ids_to_remove: - del self.active_downloads[did] - + terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'} + for record in list(self._engine.iter_records_for_source('qobuz')): + if record.get('state') in terminal: + self._engine.remove_record('qobuz', record['id']) return True except Exception as e: logger.error(f"Error clearing downloads: {e}") diff --git a/tests/downloads/test_qobuz_pinning.py b/tests/downloads/test_qobuz_pinning.py index 6897c2e4..9db40975 100644 --- a/tests/downloads/test_qobuz_pinning.py +++ b/tests/downloads/test_qobuz_pinning.py @@ -1,8 +1,6 @@ -"""Phase A pinning tests for QobuzClient's download lifecycle. +"""Phase A pinning tests for QobuzClient — UPDATED for Phase C4. -Qobuz hits the Qobuz REST API + downloads HLS-segmented FLAC. -Same thread-worker + state-dict pattern as Tidal/HiFi — Phase C -will lift the threading. These tests pin the contract. +Post-C4 the client uses engine.worker for thread + state management. """ from __future__ import annotations @@ -14,6 +12,7 @@ from unittest.mock import patch import pytest +from core.download_engine import DownloadEngine from core.qobuz_client import QobuzClient @@ -26,68 +25,92 @@ def _run_async(coro): @pytest.fixture -def qobuz_client(): +def qobuz_client_with_engine(): client = QobuzClient.__new__(QobuzClient) client.download_path = Path('./test_qobuz_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_invalid_filename_format(qobuz_client): - """Pinning: filename without `||` → None, not exception.""" - result = _run_async(qobuz_client.download('qobuz', 'no-separator', 0)) +def test_download_returns_none_for_invalid_filename_format(qobuz_client_with_engine): + client, _ = qobuz_client_with_engine + result = _run_async(client.download('qobuz', 'no-separator', 0)) assert result is None -def test_download_returns_none_for_non_integer_track_id(qobuz_client): - """Pinning: Qobuz REST API uses int track IDs. Non-int → None.""" - result = _run_async(qobuz_client.download('qobuz', 'not-int||title', 0)) +def test_download_returns_none_for_non_integer_track_id(qobuz_client_with_engine): + client, _ = qobuz_client_with_engine + result = _run_async(client.download('qobuz', 'not-int||title', 0)) assert result is None -def test_download_returns_uuid_for_valid_filename(qobuz_client): - """Pinning: valid `||display` returns UUID download_id.""" - with patch('core.qobuz_client.threading.Thread') as fake: - fake.return_value.start = lambda: None - result = _run_async(qobuz_client.download('qobuz', '12345||Some Song', 0)) +def test_download_returns_none_when_engine_not_wired(): + client = QobuzClient.__new__(QobuzClient) + client._engine = None + result = _run_async(client.download('qobuz', '12345||x', 0)) + assert result is None + + +def test_download_returns_uuid_for_valid_filename(qobuz_client_with_engine): + client, _ = qobuz_client_with_engine + with patch.object(client, '_download_sync', return_value='/tmp/x.flac'): + result = _run_async(client.download('qobuz', '12345||Some Song', 0)) assert result is not None assert len(result) == 36 -def test_download_populates_active_downloads_with_initial_state(qobuz_client): - """Pinning: per-download record schema for engine extraction.""" - with patch('core.qobuz_client.threading.Thread') as fake: - fake.return_value.start = lambda: None - download_id = _run_async(qobuz_client.download('qobuz', '999||My Qobuz Song', 0)) +def test_download_populates_engine_record_with_initial_state(qobuz_client_with_engine): + client, engine = qobuz_client_with_engine + started = threading.Event() + release = threading.Event() - record = qobuz_client.active_downloads[download_id] - assert record['id'] == download_id - assert record['filename'] == '999||My Qobuz Song' - assert record['username'] == 'qobuz' - assert record['state'] == 'Initializing' - assert record['progress'] == 0.0 - assert record['track_id'] == 999 - assert record['display_name'] == 'My Qobuz Song' - assert record['file_path'] is None + def slow_impl(*args, **kwargs): + started.set() + release.wait(timeout=1.0) + return '/tmp/done.flac' + + with patch.object(client, '_download_sync', side_effect=slow_impl): + download_id = _run_async(client.download('qobuz', '999||My Qobuz Song', 0)) + started.wait(timeout=1.0) + record = engine.get_record('qobuz', download_id) + + assert record is not None + assert record['filename'] == '999||My Qobuz Song' + assert record['username'] == 'qobuz' + assert record['track_id'] == 999 + assert record['display_name'] == 'My Qobuz Song' + release.set() -def test_download_spawns_daemon_thread_targeting_worker(qobuz_client): - """Pinning: daemon thread → `_download_thread_worker(download_id, track_id, display_name, original_filename)`.""" - captured_kwargs = {} +def test_get_all_downloads_reads_engine_records(qobuz_client_with_engine): + client, engine = qobuz_client_with_engine + engine.add_record('qobuz', 'dl-1', { + 'id': 'dl-1', 'filename': '111||A', 'username': 'qobuz', + 'state': 'InProgress, Downloading', 'progress': 50.0, + }) + result = _run_async(client.get_all_downloads()) + assert len(result) == 1 + assert result[0].id == 'dl-1' - def capture_thread(*args, **kwargs): - captured_kwargs.update(kwargs) - return type('FakeThread', (), {'start': lambda self: None})() - with patch('core.qobuz_client.threading.Thread', side_effect=capture_thread): - _run_async(qobuz_client.download('qobuz', '777||Title', 0)) +def test_cancel_download_marks_cancelled(qobuz_client_with_engine): + client, engine = qobuz_client_with_engine + engine.add_record('qobuz', 'dl-1', {'id': 'dl-1', 'state': 'InProgress, Downloading'}) - assert captured_kwargs.get('daemon') is True - assert captured_kwargs.get('target') == qobuz_client._download_thread_worker - args = captured_kwargs.get('args', ()) - assert len(args) == 4 - assert args[1] == 777 - assert args[2] == 'Title' - assert args[3] == '777||Title' + ok = _run_async(client.cancel_download('dl-1', None, remove=False)) + assert ok is True + assert engine.get_record('qobuz', 'dl-1')['state'] == 'Cancelled' + + +def test_clear_all_completed_drops_only_terminal_records(qobuz_client_with_engine): + client, engine = qobuz_client_with_engine + engine.add_record('qobuz', 'done', {'id': 'done', 'state': 'Completed, Succeeded'}) + engine.add_record('qobuz', 'live', {'id': 'live', 'state': 'InProgress, Downloading'}) + + _run_async(client.clear_all_completed_downloads()) + + assert engine.get_record('qobuz', 'done') is None + assert engine.get_record('qobuz', 'live') is not None