clean up orphaned downloads before they gunk up the download folder.

This commit is contained in:
Broque Thomas 2026-02-15 10:03:28 -08:00
parent e66ed32463
commit a5c4783da6
2 changed files with 105 additions and 19 deletions

View file

@ -1002,14 +1002,18 @@ class SoulseekClient:
return False return False
try: 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 # Try multiple API formats as slskd API may vary between versions
endpoints_to_try = [ endpoints_to_try = [
# Format 1: With username and remove parameter (original format) # 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) # 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 # 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" action = "Removing" if remove else "Cancelling"
@ -1023,6 +1027,26 @@ class SoulseekClient:
else: else:
logger.debug(f"❌ Endpoint format {i+1} failed: {endpoint}") 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}") logger.error(f"❌ All cancel endpoint formats failed for download_id: {download_id}")
return False return False

View file

@ -254,6 +254,7 @@ db_update_lock = threading.Lock()
# Key: slskd download ID, Value: dict containing Spotify artist/album data # Key: slskd download ID, Value: dict containing Spotify artist/album data
matched_downloads_context = {} matched_downloads_context = {}
matched_context_lock = threading.Lock() matched_context_lock = threading.Lock()
_orphaned_download_keys = set() # Context keys of downloads abandoned during retry
# --- File-Level Metadata Write Locking --- # --- File-Level Metadata Write Locking ---
# Prevents concurrent threads from writing metadata to the same file simultaneously # Prevents concurrent threads from writing metadata to the same file simultaneously
@ -697,6 +698,14 @@ class WebUIDownloadMonitor:
task['used_sources'] = used_sources task['used_sources'] = used_sources
print(f"🚫 Marked errored source as used: {source_key}") 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 # Clear download info since we cancelled it
task.pop('download_id', None) task.pop('download_id', None)
task.pop('username', None) task.pop('username', None)
@ -786,6 +795,14 @@ class WebUIDownloadMonitor:
task['used_sources'] = used_sources task['used_sources'] = used_sources
print(f"🚫 Marked timeout source as used: {source_key}") 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 # Clear download info since we cancelled it
task.pop('download_id', None) task.pop('download_id', None)
task.pop('username', None) task.pop('username', None)
@ -875,6 +892,14 @@ class WebUIDownloadMonitor:
task['used_sources'] = used_sources task['used_sources'] = used_sources
print(f"🚫 Marked 0% progress source as used: {source_key}") 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 # Clear download info since we cancelled it
task.pop('download_id', None) task.pop('download_id', None)
task.pop('username', None) task.pop('username', None)
@ -4125,6 +4150,43 @@ def get_download_status():
# Check if this completed download has a matched context # Check if this completed download has a matched context
# CRITICAL FIX: Use extract_filename() to match storage key format # CRITICAL FIX: Use extract_filename() to match storage key format
context_key = f"{username}::{extract_filename(filename_from_api)}" 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: with matched_context_lock:
context = matched_downloads_context.get(context_key) context = matched_downloads_context.get(context_key)
if context: if context: