diff --git a/web_server.py b/web_server.py index 319bbd40..32c9d696 100644 --- a/web_server.py +++ b/web_server.py @@ -4939,6 +4939,8 @@ def clear_finished_downloads(): # This single client call handles clearing everything that is no longer active success = run_async(soulseek_client.clear_all_completed_downloads()) if success: + # Also sweep empty directories left behind by completed downloads + _sweep_empty_download_directories() return jsonify({"success": True, "message": "Finished downloads cleared."}) else: return jsonify({"success": False, "error": "Backend failed to clear downloads."}), 500 @@ -4946,6 +4948,36 @@ def clear_finished_downloads(): print(f"Error clearing finished downloads: {e}") return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/quarantine/clear', methods=['POST']) +def clear_quarantine(): + """Delete all files and folders inside the ss_quarantine directory.""" + import shutil + try: + download_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) + quarantine_path = os.path.join(download_path, 'ss_quarantine') + + if not os.path.isdir(quarantine_path): + return jsonify({"success": True, "message": "Quarantine folder is already empty."}) + + removed_files = 0 + for entry in os.listdir(quarantine_path): + entry_path = os.path.join(quarantine_path, entry) + try: + if os.path.isfile(entry_path): + os.remove(entry_path) + removed_files += 1 + elif os.path.isdir(entry_path): + shutil.rmtree(entry_path) + removed_files += 1 + except Exception as e: + print(f"⚠️ [Quarantine] Failed to remove {entry}: {e}") + + print(f"🧹 [Quarantine] Cleared {removed_files} item(s) from quarantine folder") + return jsonify({"success": True, "message": f"Quarantine cleared ({removed_files} item{'s' if removed_files != 1 else ''} removed)."}) + except Exception as e: + print(f"❌ [Quarantine] Error clearing quarantine: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/scan/request', methods=['POST']) def request_media_scan(): """ @@ -7844,6 +7876,52 @@ def _cleanup_empty_directories(download_path, moved_file_path): print(f"Warning: An error occurred during directory cleanup: {e}") +def _sweep_empty_download_directories(): + """ + Walk the download directory bottom-up and remove ALL empty directories. + Called periodically when no downloads or post-processing are active. + Handles the edge case where per-file cleanup misses folders that become + empty only after all sibling downloads in a batch have been processed. + """ + import os + try: + download_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) + if not os.path.isdir(download_path): + return 0 + + removed = 0 + # os.walk bottom-up: deepest directories first so parents become empty after children removed + for dirpath, _dirnames, _filenames in os.walk(download_path, topdown=False): + # Never remove the root download directory itself + if os.path.normpath(dirpath) == os.path.normpath(download_path): + continue + # Re-read actual contents — os.walk's lists are stale after child removal + try: + entries = os.listdir(dirpath) + except OSError: + continue + visible = [e for e in entries if not e.startswith('.')] + if not visible: + try: + # Remove any leftover hidden files (e.g. .DS_Store) before rmdir + for hidden in entries: + try: + os.remove(os.path.join(dirpath, hidden)) + except Exception: + pass + os.rmdir(dirpath) + removed += 1 + except OSError: + pass # Directory not actually empty or locked — skip silently + + if removed > 0: + print(f"🧹 [Folder Cleanup] Removed {removed} empty director{'y' if removed == 1 else 'ies'} from downloads folder") + return removed + except Exception as e: + print(f"⚠️ [Folder Cleanup] Error sweeping empty directories: {e}") + return 0 + + # =================================================================== # ALBUM GROUPING SYSTEM (Ported from GUI downloads.py) # =================================================================== @@ -11404,14 +11482,24 @@ def _simple_monitor_task(): try: # Only clear if no batches are actively downloading has_active_batches = False + has_post_processing = False with tasks_lock: for batch_data in download_batches.values(): if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]: has_active_batches = True break + # Also check for any tasks still in post_processing + if not has_active_batches: + for task_data in download_tasks.values(): + if task_data.get('status') == 'post_processing': + has_post_processing = True + break if not has_active_batches: run_async(soulseek_client.clear_all_completed_downloads()) + # Sweep empty directories left behind by completed downloads + if not has_post_processing: + _sweep_empty_download_directories() print("✅ [Auto Cleanup] Periodic download cleanup completed") last_download_cleanup = current_time @@ -14142,6 +14230,12 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): except Exception as cleanup_error: logger.warning(f"⚠️ [Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}") + # Sweep empty directories left behind by this batch's downloads + try: + _sweep_empty_download_directories() + except Exception as sweep_error: + logger.warning(f"⚠️ [Auto-Cleanup] Failed to sweep empty directories: {sweep_error}") + return completion_summary except Exception as e: diff --git a/webui/index.html b/webui/index.html index 52788f0f..24080114 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2888,6 +2888,7 @@ Failed verifications move files to Quarantine folder. + diff --git a/webui/static/script.js b/webui/static/script.js index 4de18d1d..daf8bfef 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -2601,6 +2601,25 @@ async function testConnection(service) { } } +async function clearQuarantine() { + if (!confirm('Delete all files in the quarantine folder? This cannot be undone.')) return; + try { + showLoadingOverlay('Clearing quarantine folder...'); + const response = await fetch('/api/quarantine/clear', { method: 'POST' }); + const result = await response.json(); + if (result.success) { + showToast(result.message || 'Quarantine cleared', 'success'); + } else { + showToast(`Failed to clear quarantine: ${result.error}`, 'error'); + } + } catch (error) { + console.error('Error clearing quarantine:', error); + showToast('Failed to clear quarantine', 'error'); + } finally { + hideLoadingOverlay(); + } +} + // Dashboard-specific test functions that create activity items async function testDashboardConnection(service) { try {