C6: Migrate Deezer to engine.worker
Same migration pattern as C2-C5. Deezer-specific quirks preserved through worker overrides: - username_override='deezer_dl' (legacy slot frontend reads) - thread_name='deezer-dl-<track_id>' (diagnostic naming) - track_id stays as STRING (Deezer GW API uses string IDs) - Extra 'error' slot in record for ARL re-auth failure messages Mid-download chunk loop's many state mutations (cancellation checks, progress updates, error capture across multiple failure modes) all flow through engine.update_record / get_record now. Added _set_error and _is_cancelled helpers to keep call sites readable. Pinning tests updated. Suite still green (319 download tests).
This commit is contained in:
parent
27a97f8af6
commit
cf438ba2d6
2 changed files with 194 additions and 215 deletions
|
|
@ -91,9 +91,9 @@ class DeezerDownloadClient:
|
||||||
self.download_path = Path(download_path)
|
self.download_path = Path(download_path)
|
||||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Download tracking (same pattern as Tidal/Qobuz/HiFi)
|
# Engine reference is populated by set_engine() at registration
|
||||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
# time. None until orchestrator wires the registry.
|
||||||
self._download_lock = threading.Lock()
|
self._engine = None
|
||||||
|
|
||||||
# Shutdown check callback (set by web_server)
|
# Shutdown check callback (set by web_server)
|
||||||
self.shutdown_check = None
|
self.shutdown_check = None
|
||||||
|
|
@ -125,6 +125,10 @@ class DeezerDownloadClient:
|
||||||
|
|
||||||
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
|
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
|
||||||
|
|
||||||
|
def set_engine(self, engine):
|
||||||
|
"""Engine callback — wires the central thread worker + state store."""
|
||||||
|
self._engine = engine
|
||||||
|
|
||||||
# ─── Authentication ──────────────────────────────────────────
|
# ─── Authentication ──────────────────────────────────────────
|
||||||
|
|
||||||
def _authenticate(self, arl: str) -> bool:
|
def _authenticate(self, arl: str) -> bool:
|
||||||
|
|
@ -605,87 +609,64 @@ class DeezerDownloadClient:
|
||||||
if not self._authenticated:
|
if not self._authenticated:
|
||||||
logger.error("Deezer not authenticated — cannot download")
|
logger.error("Deezer not authenticated — cannot download")
|
||||||
return None
|
return None
|
||||||
|
if self._engine is None:
|
||||||
|
logger.error("Deezer client has no engine reference — cannot dispatch download")
|
||||||
|
return None
|
||||||
|
|
||||||
# Parse filename: "track_id||display_name"
|
# Parse filename: "track_id||display_name"
|
||||||
parts = filename.split('||', 1)
|
parts = filename.split('||', 1)
|
||||||
track_id = parts[0]
|
track_id = parts[0]
|
||||||
display_name = parts[1] if len(parts) > 1 else f"Track {track_id}"
|
display_name = parts[1] if len(parts) > 1 else f"Track {track_id}"
|
||||||
|
|
||||||
download_id = str(uuid.uuid4())
|
return self._engine.worker.dispatch(
|
||||||
|
source_name='deezer',
|
||||||
with self._download_lock:
|
target_id=track_id,
|
||||||
self.active_downloads[download_id] = {
|
display_name=display_name,
|
||||||
'id': download_id,
|
original_filename=filename,
|
||||||
|
impl_callable=self._download_sync,
|
||||||
|
extra_record_fields={
|
||||||
'track_id': track_id,
|
'track_id': track_id,
|
||||||
'display_name': display_name,
|
'display_name': display_name,
|
||||||
'filename': filename,
|
|
||||||
'username': 'deezer_dl',
|
|
||||||
'state': 'Initializing',
|
|
||||||
'progress': 0.0,
|
|
||||||
'size': file_size,
|
'size': file_size,
|
||||||
'transferred': 0,
|
|
||||||
'speed': 0,
|
|
||||||
'file_path': None,
|
|
||||||
'error': None,
|
'error': None,
|
||||||
}
|
},
|
||||||
|
# Legacy username slot — frontend status indicators key off
|
||||||
thread = threading.Thread(
|
# ``deezer_dl``, not the canonical ``deezer``.
|
||||||
target=self._download_thread_worker,
|
username_override='deezer_dl',
|
||||||
args=(download_id, track_id, display_name),
|
# Diagnostic thread name for multi-thread debugging.
|
||||||
daemon=True,
|
thread_name=f'deezer-dl-{track_id}',
|
||||||
name=f'deezer-dl-{track_id}'
|
|
||||||
)
|
)
|
||||||
thread.start()
|
|
||||||
|
|
||||||
logger.info(f"Started Deezer download {download_id}: {display_name}")
|
def _set_error(self, download_id: str, message: str) -> None:
|
||||||
return download_id
|
"""Helper: set the engine record's `error` slot. No-op if
|
||||||
|
engine isn't wired or record was already removed."""
|
||||||
|
if self._engine is None:
|
||||||
|
return
|
||||||
|
self._engine.update_record('deezer', download_id, {'error': message})
|
||||||
|
|
||||||
def _download_thread_worker(self, download_id: str, track_id: str, display_name: str):
|
def _is_cancelled(self, download_id: str) -> bool:
|
||||||
"""Background worker for a single download."""
|
if self._engine is None:
|
||||||
try:
|
return False
|
||||||
result_path = self._download_sync(download_id, track_id, display_name)
|
record = self._engine.get_record('deezer', download_id)
|
||||||
with self._download_lock:
|
return record is not None and record.get('state') == 'Cancelled'
|
||||||
if download_id in self.active_downloads:
|
|
||||||
dl = self.active_downloads[download_id]
|
|
||||||
if dl['state'] == 'Cancelled':
|
|
||||||
return
|
|
||||||
if result_path:
|
|
||||||
dl['state'] = 'Completed, Succeeded'
|
|
||||||
dl['progress'] = 100.0
|
|
||||||
dl['file_path'] = result_path
|
|
||||||
logger.info(f"Deezer download {download_id} completed: {result_path}")
|
|
||||||
else:
|
|
||||||
dl['state'] = 'Errored'
|
|
||||||
logger.error(f"Deezer download {download_id} failed: {dl.get('error', 'unknown')}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Deezer download thread error: {e}")
|
|
||||||
with self._download_lock:
|
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['state'] = 'Errored'
|
|
||||||
self.active_downloads[download_id]['error'] = str(e)
|
|
||||||
|
|
||||||
def _download_sync(self, download_id: str, track_id: str, display_name: str) -> Optional[str]:
|
def _download_sync(self, download_id: str, track_id: str, display_name: str) -> Optional[str]:
|
||||||
"""Synchronous download: get URL, download, decrypt, save."""
|
"""Synchronous download: get URL, download, decrypt, save."""
|
||||||
# Check for shutdown
|
# Check for shutdown
|
||||||
if self.shutdown_check and self.shutdown_check():
|
if self.shutdown_check and self.shutdown_check():
|
||||||
with self._download_lock:
|
if self._engine is not None:
|
||||||
if download_id in self.active_downloads:
|
self._engine.update_record('deezer', download_id, {'state': 'Aborted'})
|
||||||
self.active_downloads[download_id]['state'] = 'Aborted'
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get track data from private API
|
# Get track data from private API
|
||||||
track_data = self._get_track_data(track_id)
|
track_data = self._get_track_data(track_id)
|
||||||
if not track_data:
|
if not track_data:
|
||||||
with self._download_lock:
|
self._set_error(download_id, 'Failed to get track data')
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['error'] = 'Failed to get track data'
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
track_token = track_data.get('TRACK_TOKEN', '')
|
track_token = track_data.get('TRACK_TOKEN', '')
|
||||||
if not track_token:
|
if not track_token:
|
||||||
with self._download_lock:
|
self._set_error(download_id, 'No track token available')
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['error'] = 'No track token available'
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Determine quality and get media URL with fallback
|
# Determine quality and get media URL with fallback
|
||||||
|
|
@ -695,7 +676,6 @@ class DeezerDownloadClient:
|
||||||
|
|
||||||
if allow_fallback:
|
if allow_fallback:
|
||||||
quality_order = _QUALITY_ORDER.copy()
|
quality_order = _QUALITY_ORDER.copy()
|
||||||
# Start from user's preferred quality
|
|
||||||
try:
|
try:
|
||||||
pref_idx = quality_order.index(self._quality)
|
pref_idx = quality_order.index(self._quality)
|
||||||
quality_order = quality_order[pref_idx:] + quality_order[:pref_idx]
|
quality_order = quality_order[pref_idx:] + quality_order[:pref_idx]
|
||||||
|
|
@ -712,27 +692,16 @@ class DeezerDownloadClient:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not media_url:
|
if not media_url:
|
||||||
with self._download_lock:
|
self._set_error(download_id, 'No media URL available (may require higher subscription tier)')
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['error'] = 'No media URL available (may require higher subscription tier)'
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if actual_quality != self._quality:
|
if actual_quality != self._quality:
|
||||||
logger.info(f"Quality fallback: {self._quality} → {actual_quality} for {display_name}")
|
logger.info(f"Quality fallback: {self._quality} → {actual_quality} for {display_name}")
|
||||||
|
|
||||||
# Determine file extension
|
|
||||||
ext = '.flac' if actual_quality == 'flac' else '.mp3'
|
ext = '.flac' if actual_quality == 'flac' else '.mp3'
|
||||||
|
|
||||||
# Sanitize filename
|
|
||||||
safe_name = self._sanitize_filename(display_name)
|
safe_name = self._sanitize_filename(display_name)
|
||||||
out_path = str(self.download_path / f"{safe_name}{ext}")
|
out_path = str(self.download_path / f"{safe_name}{ext}")
|
||||||
|
|
||||||
# Update state
|
|
||||||
with self._download_lock:
|
|
||||||
if download_id in self.active_downloads:
|
|
||||||
dl = self.active_downloads[download_id]
|
|
||||||
dl['state'] = 'InProgress, Downloading'
|
|
||||||
|
|
||||||
# Download and decrypt
|
# Download and decrypt
|
||||||
try:
|
try:
|
||||||
bf_key = _get_blowfish_key(track_id)
|
bf_key = _get_blowfish_key(track_id)
|
||||||
|
|
@ -740,9 +709,8 @@ class DeezerDownloadClient:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
total_size = int(resp.headers.get('content-length', 0))
|
total_size = int(resp.headers.get('content-length', 0))
|
||||||
with self._download_lock:
|
if self._engine is not None:
|
||||||
if download_id in self.active_downloads:
|
self._engine.update_record('deezer', download_id, {'size': total_size})
|
||||||
self.active_downloads[download_id]['size'] = total_size
|
|
||||||
|
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
chunk_index = 0
|
chunk_index = 0
|
||||||
|
|
@ -755,23 +723,20 @@ class DeezerDownloadClient:
|
||||||
|
|
||||||
# Check for cancellation/shutdown
|
# Check for cancellation/shutdown
|
||||||
if self.shutdown_check and self.shutdown_check():
|
if self.shutdown_check and self.shutdown_check():
|
||||||
with self._download_lock:
|
if self._engine is not None:
|
||||||
if download_id in self.active_downloads:
|
self._engine.update_record('deezer', download_id, {'state': 'Aborted'})
|
||||||
self.active_downloads[download_id]['state'] = 'Aborted'
|
|
||||||
try:
|
try:
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with self._download_lock:
|
if self._is_cancelled(download_id):
|
||||||
if download_id in self.active_downloads:
|
try:
|
||||||
if self.active_downloads[download_id]['state'] == 'Cancelled':
|
os.remove(out_path)
|
||||||
try:
|
except OSError:
|
||||||
os.remove(out_path)
|
pass
|
||||||
except OSError:
|
return None
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Decrypt every 3rd chunk (Deezer's encryption pattern)
|
# Decrypt every 3rd chunk (Deezer's encryption pattern)
|
||||||
if chunk_index % 3 == 0 and len(raw_chunk) == _CHUNK_SIZE:
|
if chunk_index % 3 == 0 and len(raw_chunk) == _CHUNK_SIZE:
|
||||||
|
|
@ -788,12 +753,12 @@ class DeezerDownloadClient:
|
||||||
speed = int(downloaded / elapsed) if elapsed > 0 else 0
|
speed = int(downloaded / elapsed) if elapsed > 0 else 0
|
||||||
progress = (downloaded / total_size * 100) if total_size > 0 else 0
|
progress = (downloaded / total_size * 100) if total_size > 0 else 0
|
||||||
|
|
||||||
with self._download_lock:
|
if self._engine is not None:
|
||||||
if download_id in self.active_downloads:
|
self._engine.update_record('deezer', download_id, {
|
||||||
dl = self.active_downloads[download_id]
|
'transferred': downloaded,
|
||||||
dl['transferred'] = downloaded
|
'progress': min(progress, 99.9),
|
||||||
dl['progress'] = min(progress, 99.9)
|
'speed': speed,
|
||||||
dl['speed'] = speed
|
})
|
||||||
|
|
||||||
# Validate file size
|
# Validate file size
|
||||||
file_size = os.path.getsize(out_path)
|
file_size = os.path.getsize(out_path)
|
||||||
|
|
@ -803,9 +768,7 @@ class DeezerDownloadClient:
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
with self._download_lock:
|
self._set_error(download_id, f'File too small ({file_size} bytes)')
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['error'] = f'File too small ({file_size} bytes)'
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(f"Deezer download complete: {out_path} ({file_size / 1048576:.1f} MB, {actual_quality})")
|
logger.info(f"Deezer download complete: {out_path} ({file_size / 1048576:.1f} MB, {actual_quality})")
|
||||||
|
|
@ -817,59 +780,58 @@ class DeezerDownloadClient:
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
with self._download_lock:
|
self._set_error(download_id, str(e))
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['error'] = str(e)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ─── Download Status ─────────────────────────────────────────
|
# ─── Download Status ─────────────────────────────────────────
|
||||||
|
|
||||||
|
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),
|
||||||
|
file_path=record.get('file_path'),
|
||||||
|
)
|
||||||
|
|
||||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||||
"""Return all active downloads."""
|
if self._engine is None:
|
||||||
with self._download_lock:
|
return []
|
||||||
return [self._to_status(dl) for dl in self.active_downloads.values()]
|
return [
|
||||||
|
self._record_to_status(record)
|
||||||
|
for record in self._engine.iter_records_for_source('deezer')
|
||||||
|
]
|
||||||
|
|
||||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||||
"""Get status of a specific download."""
|
if self._engine is None:
|
||||||
with self._download_lock:
|
return None
|
||||||
dl = self.active_downloads.get(download_id)
|
record = self._engine.get_record('deezer', download_id)
|
||||||
return self._to_status(dl) if dl else None
|
return self._record_to_status(record) if record is not None else None
|
||||||
|
|
||||||
async def cancel_download(self, download_id: str, username: str = None,
|
async def cancel_download(self, download_id: str, username: str = None,
|
||||||
remove: bool = False) -> bool:
|
remove: bool = False) -> bool:
|
||||||
"""Cancel a download."""
|
if self._engine is None:
|
||||||
with self._download_lock:
|
return False
|
||||||
dl = self.active_downloads.get(download_id)
|
if self._engine.get_record('deezer', download_id) is None:
|
||||||
if not dl:
|
return False
|
||||||
return False
|
self._engine.update_record('deezer', download_id, {'state': 'Cancelled'})
|
||||||
dl['state'] = 'Cancelled'
|
if remove:
|
||||||
if remove:
|
self._engine.remove_record('deezer', download_id)
|
||||||
del self.active_downloads[download_id]
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def clear_all_completed_downloads(self) -> bool:
|
async def clear_all_completed_downloads(self) -> bool:
|
||||||
"""Remove all terminal downloads."""
|
if self._engine is None:
|
||||||
terminal_states = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
return True
|
||||||
with self._download_lock:
|
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||||
to_remove = [k for k, v in self.active_downloads.items() if v['state'] in terminal_states]
|
for record in list(self._engine.iter_records_for_source('deezer')):
|
||||||
for k in to_remove:
|
if record.get('state') in terminal:
|
||||||
del self.active_downloads[k]
|
self._engine.remove_record('deezer', record['id'])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _to_status(self, dl: dict) -> DownloadStatus:
|
|
||||||
"""Convert internal dict to DownloadStatus."""
|
|
||||||
return DownloadStatus(
|
|
||||||
id=dl['id'],
|
|
||||||
filename=dl['filename'],
|
|
||||||
username=dl['username'],
|
|
||||||
state=dl['state'],
|
|
||||||
progress=dl['progress'],
|
|
||||||
size=dl['size'],
|
|
||||||
transferred=dl['transferred'],
|
|
||||||
speed=dl['speed'],
|
|
||||||
file_path=dl.get('file_path'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ─── Utilities ───────────────────────────────────────────────
|
# ─── Utilities ───────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
"""Phase A pinning tests for DeezerDownloadClient's download lifecycle.
|
"""Phase A pinning tests for DeezerDownloadClient — UPDATED for Phase C6.
|
||||||
|
|
||||||
Deezer auths via ARL token, fetches Blowfish-encrypted FLAC chunks
|
Deezer has the same engine-driven dispatch as the other streaming
|
||||||
from the Deezer GW API, decrypts client-side. Different from
|
sources, with three Deezer-specific quirks preserved:
|
||||||
Tidal/Qobuz/HiFi:
|
- track_id stays as STRING (Deezer GW API uses string IDs).
|
||||||
|
- Engine record's `username` slot is the legacy `'deezer_dl'`
|
||||||
- track_id is STRING (not int).
|
via worker username_override.
|
||||||
- username is the legacy ``'deezer_dl'`` (not ``'deezer'``).
|
- Worker thread is named `deezer-dl-<track_id>` for diagnostics.
|
||||||
- Auth gate at the top of `download()` short-circuits when not
|
|
||||||
authenticated (returns None without spawning a thread).
|
|
||||||
- Thread is named ``deezer-dl-<track_id>`` for diagnostics.
|
|
||||||
|
|
||||||
Engine refactor must preserve all of these.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -22,6 +17,7 @@ from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from core.download_engine import DownloadEngine
|
||||||
from core.deezer_download_client import DeezerDownloadClient
|
from core.deezer_download_client import DeezerDownloadClient
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,98 +30,119 @@ def _run_async(coro):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def deezer_client():
|
def deezer_client_with_engine():
|
||||||
client = DeezerDownloadClient.__new__(DeezerDownloadClient)
|
client = DeezerDownloadClient.__new__(DeezerDownloadClient)
|
||||||
client.download_path = Path('./test_deezer_downloads')
|
client.download_path = Path('./test_deezer_downloads')
|
||||||
client.shutdown_check = None
|
client.shutdown_check = None
|
||||||
client.active_downloads = {}
|
|
||||||
client._download_lock = threading.Lock()
|
|
||||||
client._authenticated = True
|
client._authenticated = True
|
||||||
return client
|
client._engine = None
|
||||||
|
engine = DownloadEngine()
|
||||||
|
client.set_engine(engine)
|
||||||
|
return client, engine
|
||||||
|
|
||||||
|
|
||||||
def test_download_returns_none_when_not_authenticated(deezer_client):
|
def test_download_returns_none_when_not_authenticated(deezer_client_with_engine):
|
||||||
"""Pinning: unauthenticated client refuses BEFORE any thread is
|
client, _ = deezer_client_with_engine
|
||||||
spawned. The orchestrator's hybrid fallback depends on this
|
client._authenticated = False
|
||||||
early return — if the auth gate moves into the thread, fallback
|
result = _run_async(client.download('deezer_dl', '12345||x', 0))
|
||||||
behavior changes."""
|
|
||||||
deezer_client._authenticated = False
|
|
||||||
result = _run_async(deezer_client.download('deezer_dl', '12345||Some Song', 0))
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
def test_download_accepts_string_track_id(deezer_client):
|
def test_download_returns_none_when_engine_not_wired():
|
||||||
"""Pinning: Deezer track_id stays as string — the GW API uses
|
client = DeezerDownloadClient.__new__(DeezerDownloadClient)
|
||||||
string IDs. Engine refactor cannot int-coerce on the way through."""
|
client._engine = None
|
||||||
with patch('core.deezer_download_client.threading.Thread') as fake:
|
client._authenticated = True
|
||||||
fake.return_value.start = lambda: None
|
result = _run_async(client.download('deezer_dl', '12345||x', 0))
|
||||||
download_id = _run_async(
|
assert result is None
|
||||||
deezer_client.download('deezer_dl', '999||My Deezer Song', 5000)
|
|
||||||
)
|
|
||||||
|
|
||||||
record = deezer_client.active_downloads[download_id]
|
|
||||||
assert record['track_id'] == '999' # STRING, not int
|
|
||||||
assert isinstance(record['track_id'], str)
|
|
||||||
|
|
||||||
|
|
||||||
def test_download_username_field_is_legacy_deezer_dl(deezer_client):
|
def test_download_track_id_stays_as_string(deezer_client_with_engine):
|
||||||
"""Pinning: the `username` slot in the state dict is ``'deezer_dl'``,
|
"""Pinning: Deezer GW API uses string IDs — engine record must
|
||||||
not ``'deezer'``. Frontend status indicators + per-source
|
keep track_id as str."""
|
||||||
dispatch strings depend on the legacy form."""
|
client, engine = deezer_client_with_engine
|
||||||
with patch('core.deezer_download_client.threading.Thread') as fake:
|
started = threading.Event()
|
||||||
fake.return_value.start = lambda: None
|
release = threading.Event()
|
||||||
download_id = _run_async(
|
|
||||||
deezer_client.download('deezer_dl', '999||x', 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert deezer_client.active_downloads[download_id]['username'] == 'deezer_dl'
|
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('deezer_dl', '999||X', 0))
|
||||||
|
started.wait(timeout=1.0)
|
||||||
|
record = engine.get_record('deezer', download_id)
|
||||||
|
assert record['track_id'] == '999'
|
||||||
|
assert isinstance(record['track_id'], str)
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
|
||||||
def test_download_handles_missing_display_name_with_fallback(deezer_client):
|
def test_download_username_slot_is_legacy_deezer_dl(deezer_client_with_engine):
|
||||||
"""Pinning: filename without `||` produces a synthetic display
|
"""Pinning: frontend status indicators key off `'deezer_dl'`,
|
||||||
name `Track <track_id>`. Other clients return None for missing
|
not the canonical `'deezer'`."""
|
||||||
`||` — Deezer is more lenient. Engine refactor must NOT change
|
client, engine = deezer_client_with_engine
|
||||||
this defensive fallback."""
|
started = threading.Event()
|
||||||
with patch('core.deezer_download_client.threading.Thread') as fake:
|
release = threading.Event()
|
||||||
fake.return_value.start = lambda: None
|
|
||||||
download_id = _run_async(deezer_client.download('deezer_dl', '12345', 0))
|
|
||||||
|
|
||||||
assert download_id is not None
|
def slow_impl(*args, **kwargs):
|
||||||
record = deezer_client.active_downloads[download_id]
|
started.set()
|
||||||
assert record['display_name'] == 'Track 12345'
|
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('deezer_dl', '999||x', 0))
|
||||||
|
started.wait(timeout=1.0)
|
||||||
|
assert engine.get_record('deezer', download_id)['username'] == 'deezer_dl'
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
|
||||||
def test_download_populates_active_downloads_with_initial_state(deezer_client):
|
def test_download_handles_missing_display_name_with_fallback(deezer_client_with_engine):
|
||||||
"""Pinning: per-download record schema. NOTE the extra `error`
|
"""Pinning: filename without `||` synthesizes display name `Track <id>`."""
|
||||||
slot — Deezer-specific, used for ARL re-auth failure messages."""
|
client, engine = deezer_client_with_engine
|
||||||
with patch('core.deezer_download_client.threading.Thread') as fake:
|
started = threading.Event()
|
||||||
fake.return_value.start = lambda: None
|
release = threading.Event()
|
||||||
download_id = _run_async(
|
|
||||||
deezer_client.download('deezer_dl', '999||My Deezer Song', 1024)
|
|
||||||
)
|
|
||||||
|
|
||||||
record = deezer_client.active_downloads[download_id]
|
def slow_impl(*args, **kwargs):
|
||||||
assert record['id'] == download_id
|
started.set()
|
||||||
assert record['filename'] == '999||My Deezer Song'
|
release.wait(timeout=1.0)
|
||||||
assert record['username'] == 'deezer_dl'
|
return '/tmp/x.flac'
|
||||||
assert record['state'] == 'Initializing'
|
|
||||||
assert record['size'] == 1024 # Deezer respects the file_size hint
|
with patch.object(client, '_download_sync', side_effect=slow_impl):
|
||||||
assert record['file_path'] is None
|
download_id = _run_async(client.download('deezer_dl', '12345', 0))
|
||||||
assert record['error'] is None # Deezer-specific slot
|
started.wait(timeout=1.0)
|
||||||
|
assert engine.get_record('deezer', download_id)['display_name'] == 'Track 12345'
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
|
||||||
def test_download_thread_is_named_for_diagnostics(deezer_client):
|
def test_download_engine_record_carries_error_slot(deezer_client_with_engine):
|
||||||
"""Pinning: thread is named `deezer-dl-<track_id>` so multi-thread
|
"""Pinning: Deezer-specific `error` slot for ARL re-auth failure
|
||||||
debugging shows which download a stuck thread belongs to. Engine
|
messages must be present on init."""
|
||||||
refactor's BackgroundDownloadWorker must preserve diagnostic naming."""
|
client, engine = deezer_client_with_engine
|
||||||
captured_kwargs = {}
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
|
||||||
def capture_thread(*args, **kwargs):
|
def slow_impl(*args, **kwargs):
|
||||||
captured_kwargs.update(kwargs)
|
started.set()
|
||||||
return type('FakeThread', (), {'start': lambda self: None})()
|
release.wait(timeout=1.0)
|
||||||
|
return '/tmp/x.flac'
|
||||||
|
|
||||||
with patch('core.deezer_download_client.threading.Thread', side_effect=capture_thread):
|
with patch.object(client, '_download_sync', side_effect=slow_impl):
|
||||||
_run_async(deezer_client.download('deezer_dl', '777||Title', 0))
|
download_id = _run_async(client.download('deezer_dl', '999||X', 1024))
|
||||||
|
started.wait(timeout=1.0)
|
||||||
|
record = engine.get_record('deezer', download_id)
|
||||||
|
assert 'error' in record
|
||||||
|
assert record['error'] is None
|
||||||
|
assert record['size'] == 1024
|
||||||
|
release.set()
|
||||||
|
|
||||||
assert captured_kwargs.get('daemon') is True
|
|
||||||
assert captured_kwargs.get('name') == 'deezer-dl-777'
|
def test_get_all_downloads_reads_engine_records(deezer_client_with_engine):
|
||||||
|
client, engine = deezer_client_with_engine
|
||||||
|
engine.add_record('deezer', 'dl-1', {
|
||||||
|
'id': 'dl-1', 'filename': '111||A', 'username': 'deezer_dl',
|
||||||
|
'state': 'InProgress, Downloading', 'progress': 50.0,
|
||||||
|
})
|
||||||
|
result = _run_async(client.get_all_downloads())
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == 'dl-1'
|
||||||
|
assert result[0].username == 'deezer_dl'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue