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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue