clean up orphaned downloads before they gunk up the download folder.
This commit is contained in:
parent
e66ed32463
commit
a5c4783da6
2 changed files with 105 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue