diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 1c872324..2ff5817d 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -982,7 +982,7 @@ class SoulseekClient: async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool: if not self.base_url: return False - + # If username is not provided, try to extract it from stored transfer data if not username: logger.debug(f"No username provided for download_id {download_id}, attempting to find it") @@ -993,27 +993,31 @@ class SoulseekClient: username = download.username logger.debug(f"Found username {username} for download_id {download_id}") break - + if not username: logger.error(f"Could not find username for download_id {download_id}") return False except Exception as e: logger.error(f"Error finding username for download: {e}") return False - + try: + from urllib.parse import quote + # URL-encode download_id to handle backslashes and special characters + encoded_id = quote(download_id, safe='') + # Try multiple API formats as slskd API may vary between versions endpoints_to_try = [ # Format 1: With username and remove parameter (original format) - f'transfers/downloads/{username}/{download_id}?remove={str(remove).lower()}', + f'transfers/downloads/{username}/{encoded_id}?remove={str(remove).lower()}', # Format 2: Simple format with just download_id (used in sync.py) - f'transfers/downloads/{download_id}', + f'transfers/downloads/{encoded_id}', # Format 3: Alternative format without remove parameter - f'transfers/downloads/{username}/{download_id}' + f'transfers/downloads/{username}/{encoded_id}' ] - + action = "Removing" if remove else "Cancelling" - + for i, endpoint in enumerate(endpoints_to_try): logger.debug(f"{action} download (attempt {i+1}/3) with endpoint: {endpoint}") response = await self._make_request('DELETE', endpoint) @@ -1022,10 +1026,30 @@ class SoulseekClient: return True else: logger.debug(f"โŒ Endpoint format {i+1} failed: {endpoint}") - + + # Fallback: if download_id looks like a filename (contains path separators), + # list all transfers, find by filename, and cancel with the real transfer ID + if '\\' in download_id or '/' in download_id: + logger.debug(f"Download ID looks like a filename, trying filename-based lookup fallback") + try: + downloads = await self.get_all_downloads() + target_basename = os.path.basename(download_id.replace('\\', '/')) + for download in downloads: + dl_basename = os.path.basename(download.filename.replace('\\', '/')) + if dl_basename == target_basename and download.username == username: + real_id = quote(str(download.id), safe='') + fallback_endpoint = f'transfers/downloads/{username}/{real_id}?remove={str(remove).lower()}' + logger.debug(f"Found matching transfer with real ID, trying: {fallback_endpoint}") + response = await self._make_request('DELETE', fallback_endpoint) + if response is not None: + logger.info(f"โœ… Successfully cancelled download via filename fallback") + return True + except Exception as fallback_error: + logger.debug(f"Filename fallback failed: {fallback_error}") + logger.error(f"โŒ All cancel endpoint formats failed for download_id: {download_id}") return False - + except Exception as e: logger.error(f"Error cancelling download: {e}") return False diff --git a/web_server.py b/web_server.py index 662aa7ee..e46b3385 100644 --- a/web_server.py +++ b/web_server.py @@ -254,6 +254,7 @@ db_update_lock = threading.Lock() # Key: slskd download ID, Value: dict containing Spotify artist/album data matched_downloads_context = {} matched_context_lock = threading.Lock() +_orphaned_download_keys = set() # Context keys of downloads abandoned during retry # --- File-Level Metadata Write Locking --- # Prevents concurrent threads from writing metadata to the same file simultaneously @@ -696,12 +697,20 @@ class WebUIDownloadMonitor: used_sources.add(source_key) task['used_sources'] = used_sources print(f"๐Ÿšซ Marked errored source as used: {source_key}") - + + # Mark old download as orphaned so we can clean it up if it completes later + if username and filename: + old_context_key = f"{username}::{extract_filename(filename)}" + _orphaned_download_keys.add(old_context_key) + with matched_context_lock: + matched_downloads_context.pop(old_context_key, None) + print(f"๐Ÿงน Marked orphaned download for cleanup: {old_context_key}") + # Clear download info since we cancelled it task.pop('download_id', None) - task.pop('username', None) + task.pop('username', None) task.pop('filename', None) - + # Reset task state for immediate retry task['status'] = 'searching' task.pop('queued_start_time', None) @@ -785,12 +794,20 @@ class WebUIDownloadMonitor: used_sources.add(source_key) task['used_sources'] = used_sources print(f"๐Ÿšซ Marked timeout source as used: {source_key}") - + + # Mark old download as orphaned so we can clean it up if it completes later + if username and filename: + old_context_key = f"{username}::{extract_filename(filename)}" + _orphaned_download_keys.add(old_context_key) + with matched_context_lock: + matched_downloads_context.pop(old_context_key, None) + print(f"๐Ÿงน Marked orphaned download for cleanup: {old_context_key}") + # Clear download info since we cancelled it task.pop('download_id', None) - task.pop('username', None) + task.pop('username', None) task.pop('filename', None) - + # Reset task state for immediate retry (like error retry) task['status'] = 'searching' task.pop('queued_start_time', None) @@ -874,12 +891,20 @@ class WebUIDownloadMonitor: used_sources.add(source_key) task['used_sources'] = used_sources print(f"๐Ÿšซ Marked 0% progress source as used: {source_key}") - + + # Mark old download as orphaned so we can clean it up if it completes later + if username and filename: + old_context_key = f"{username}::{extract_filename(filename)}" + _orphaned_download_keys.add(old_context_key) + with matched_context_lock: + matched_downloads_context.pop(old_context_key, None) + print(f"๐Ÿงน Marked orphaned download for cleanup: {old_context_key}") + # Clear download info since we cancelled it task.pop('download_id', None) - task.pop('username', None) + task.pop('username', None) task.pop('filename', None) - + # Reset task state for immediate retry (like error retry) task['status'] = 'searching' task.pop('queued_start_time', None) @@ -4125,6 +4150,43 @@ def get_download_status(): # Check if this completed download has a matched context # CRITICAL FIX: Use extract_filename() to match storage key format context_key = f"{username}::{extract_filename(filename_from_api)}" + + # Check if this is an orphaned download that completed after retry + if context_key in _orphaned_download_keys: + # Safety check: if a new context exists for this key, the retry + # re-claimed the same source โ€” treat it as active, not orphaned + with matched_context_lock: + has_active_context = context_key in matched_downloads_context + if has_active_context: + print(f"๐Ÿ”„ Orphaned key {context_key} has active context โ€” retry re-used same source, treating as active") + _orphaned_download_keys.discard(context_key) + # Fall through to normal processing below + else: + download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) + found_result = _find_completed_file_robust(download_dir, filename_from_api) + found_path = found_result[0] if found_result and found_result[0] else None + orphan_cleaned = False + if found_path: + try: + os.remove(found_path) + print(f"๐Ÿงน Deleted orphaned download: {os.path.basename(found_path)}") + orphan_cleaned = True + except Exception as e: + print(f"โš ๏ธ Failed to delete orphaned file (will retry next poll): {e}") + else: + # File not on disk (already gone or never written) โ€” nothing to clean + orphan_cleaned = True + if orphan_cleaned: + # Remove transfer from slskd + transfer_id = file_info.get('id') + if transfer_id: + try: + asyncio.run(soulseek_client.cancel_download(str(transfer_id), username, remove=True)) + except Exception: + pass + _orphaned_download_keys.discard(context_key) + continue # Skip normal post-processing either way + with matched_context_lock: context = matched_downloads_context.get(context_key) if context: