#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ PyQt6 Storage Cleaner Pro - Complete Application Professional disk cleanup utility with dark theme. Features: Disk overview, Large file scanner, System cleaner, Duplicate finder, Folder analyzer, Startup manager, Scheduled cleaning. """ import os import sys import time import shutil import hashlib import platform import subprocess import json import threading from pathlib import Path from datetime import datetime from dataclasses import dataclass, field from typing import List, Tuple, Dict, Set from collections import defaultdict from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QLabel, QPushButton, QProgressBar, QCheckBox, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QTextEdit, QLineEdit, QFileDialog, QMessageBox, QStatusBar, QGroupBox, QSplitter, QFrame, QComboBox, QSpinBox, QMenu, QMenuBar, QDialog, QDialogButtonBox, QListWidget, QListWidgetItem, QRadioButton, QButtonGroup, QSlider, QToolBar, QSizePolicy, QScrollArea ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QSize, QSettings from PyQt6.QtGui import ( QFont, QColor, QAction, QIcon, QPalette, QPainter, QPen, QBrush, QLinearGradient, QRadialGradient ) # ============================================================================ # UTILITY # ============================================================================ def format_size(size_bytes: int) -> str: if size_bytes < 0: return "0 B" for unit in ['B', 'KB', 'MB', 'GB', 'TB']: if size_bytes < 1024: return f"{size_bytes:.1f} {unit}" size_bytes /= 1024 return f"{size_bytes:.1f} PB" def safe_remove(path: str) -> bool: try: if os.path.isfile(path): os.remove(path) elif os.path.isdir(path): shutil.rmtree(path) return True except (OSError, PermissionError): return False # ============================================================================ # DATA CLASSES # ============================================================================ @dataclass class DiskInfo: drive: str mount_point: str total: int used: int free: int percent: float fs_type: str = "" @dataclass class FileEntry: path: str size: int modified: str ext: str = "" category: str = "" @dataclass class DuplicateGroup: hash: str size: int files: List[str] = field(default_factory=list) @dataclass class FolderEntry: path: str size: int file_count: int percent: float = 0.0 # ============================================================================ # CORE - DISK SCANNER # ============================================================================ class DiskScanner: LARGE_FILE_THRESHOLD = 50 * 1024 * 1024 CATEGORY_MAP = { 'Video': {'.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv', '.webm', '.m4v', '.mpg', '.mpeg'}, 'Audio': {'.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a', '.opus'}, 'Image': {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.psd', '.raw', '.cr2'}, 'Archive': {'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.iso', '.cab'}, 'Document': {'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.odt', '.rtf'}, 'Installer': {'.exe', '.msi', '.dmg', '.deb', '.rpm', '.appimage', '.apk'}, 'Database': {'.db', '.sqlite', '.mdf', '.sql', '.bak'}, 'Virtual': {'.vmdk', '.vdi', '.vhd', '.vhdx', '.ova', '.ovf'}, } SKIP_DIRS = { '$Recycle.Bin', 'System Volume Information', 'Windows', 'WinSxS', 'node_modules', '.git', '__pycache__', 'venv', '.venv', 'Recovery', 'PerfLogs', '.Trash', '.Trash-1000', } @classmethod def get_disks(cls) -> List[DiskInfo]: disks = [] if sys.platform == 'win32': try: import ctypes bitmask = ctypes.windll.kernel32.GetLogicalDrives() for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': if bitmask & 1: drive = f"{letter}:\\" try: usage = shutil.disk_usage(drive) if usage.total > 0: disks.append(DiskInfo( drive=f"{letter}:", mount_point=drive, total=usage.total, used=usage.used, free=usage.free, percent=round(usage.used / usage.total * 100, 1) )) except (OSError, PermissionError): pass bitmask >>= 1 except Exception: pass else: seen_totals = set() mounts = ['/'] if os.path.exists('/home'): mounts.append(str(Path.home())) for mount in mounts: try: usage = shutil.disk_usage(mount) if usage.total > 0 and usage.total not in seen_totals: seen_totals.add(usage.total) disks.append(DiskInfo( drive=mount, mount_point=mount, total=usage.total, used=usage.used, free=usage.free, percent=round(usage.used / usage.total * 100, 1) )) except (OSError, PermissionError): pass return disks @classmethod def get_category(cls, ext: str) -> str: ext = ext.lower() for cat, exts in cls.CATEGORY_MAP.items(): if ext in exts: return cat return "Other" @classmethod def scan_large_files(cls, root: str, threshold: int = None, max_files: int = 500, callback=None) -> List[FileEntry]: if threshold is None: threshold = cls.LARGE_FILE_THRESHOLD results = [] count = 0 for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in cls.SKIP_DIRS and not d.startswith('.')] for fname in filenames: try: fpath = os.path.join(dirpath, fname) size = os.path.getsize(fpath) if size >= threshold: ext = os.path.splitext(fname)[1].lower() try: mtime = datetime.fromtimestamp(os.path.getmtime(fpath)).strftime('%Y-%m-%d %H:%M') except (OSError, ValueError): mtime = "Unknown" results.append(FileEntry( path=fpath, size=size, modified=mtime, ext=ext, category=cls.get_category(ext) )) count += 1 if callback and count % 20 == 0: callback(count) if count >= max_files: return sorted(results, key=lambda x: x.size, reverse=True) except (OSError, PermissionError): continue return sorted(results, key=lambda x: x.size, reverse=True) @classmethod def get_temp_paths(cls) -> List[str]: paths = [] if sys.platform == 'win32': candidates = [ os.environ.get('TEMP', ''), os.environ.get('TMP', ''), os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Temp'), os.path.join(os.environ.get('WINDIR', r'C:\Windows'), 'Temp'), os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'Windows', 'INetCache'), os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'Windows', 'Explorer'), os.path.join(os.environ.get('WINDIR', r'C:\Windows'), 'Prefetch'), os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'Windows', 'Temporary Internet Files'), ] else: candidates = [ '/tmp', '/var/tmp', os.path.join(str(Path.home()), '.cache'), os.path.join(str(Path.home()), '.local', 'share', 'Trash'), os.path.join(str(Path.home()), '.thumbnails'), ] return [p for p in candidates if p and os.path.exists(p)] @classmethod def scan_temp_size(cls) -> Tuple[int, int]: total_size = 0 file_count = 0 for temp_dir in cls.get_temp_paths(): try: for root, dirs, files in os.walk(temp_dir): for f in files: try: total_size += os.path.getsize(os.path.join(root, f)) file_count += 1 except (OSError, PermissionError): pass except (OSError, PermissionError): pass return total_size, file_count @classmethod def analyze_folder(cls, root: str, depth: int = 1, callback=None) -> List[FolderEntry]: """Analyze top-level folder sizes.""" folders = [] try: entries = sorted(os.scandir(root), key=lambda e: e.name) total_root_size = 0 for entry in entries: if entry.is_dir() and entry.name not in cls.SKIP_DIRS and not entry.name.startswith('.'): try: folder_size = 0 file_count = 0 for r, d, fs in os.walk(entry.path): d[:] = [x for x in d if x not in cls.SKIP_DIRS] for f in fs: try: folder_size += os.path.getsize(os.path.join(r, f)) file_count += 1 except (OSError, PermissionError): pass if folder_size > 0: folders.append(FolderEntry( path=entry.path, size=folder_size, file_count=file_count )) total_root_size += folder_size if callback: callback(len(folders)) except (OSError, PermissionError): pass # Calculate percentages for f in folders: f.percent = round(f.size / total_root_size * 100, 1) if total_root_size > 0 else 0 except (OSError, PermissionError): pass return sorted(folders, key=lambda x: x.size, reverse=True) @classmethod def find_duplicates(cls, root: str, min_size: int = 1024 * 1024, callback=None) -> List[DuplicateGroup]: """Find duplicate files by hash.""" # Phase 1: Group by size size_map: Dict[int, List[str]] = defaultdict(list) count = 0 for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in cls.SKIP_DIRS and not d.startswith('.')] for fname in filenames: try: fpath = os.path.join(dirpath, fname) size = os.path.getsize(fpath) if size >= min_size: size_map[size].append(fpath) count += 1 if callback and count % 100 == 0: callback(f"Indexing: {count} files...") except (OSError, PermissionError): continue # Phase 2: Hash files with same size duplicates = [] hash_map: Dict[str, List[str]] = defaultdict(list) candidates = [(size, paths) for size, paths in size_map.items() if len(paths) > 1] total_candidates = sum(len(paths) for _, paths in candidates) processed = 0 for size, paths in candidates: for fpath in paths: try: h = hashlib.md5() with open(fpath, 'rb') as f: # Read first 8KB + last 8KB for speed h.update(f.read(8192)) if size > 8192: f.seek(-8192, 2) h.update(f.read(8192)) file_hash = f"{size}_{h.hexdigest()}" hash_map[file_hash].append(fpath) processed += 1 if callback and processed % 50 == 0: callback(f"Hashing: {processed}/{total_candidates}") except (OSError, PermissionError): continue # Collect groups with >1 file for file_hash, paths in hash_map.items(): if len(paths) > 1: size = int(file_hash.split('_')[0]) duplicates.append(DuplicateGroup(hash=file_hash, size=size, files=paths)) return sorted(duplicates, key=lambda x: x.size * len(x.files), reverse=True) # ============================================================================ # CORE - CLEANER # ============================================================================ class DiskCleaner: @classmethod def delete_files(cls, paths: List[str]) -> dict: result = {'deleted': 0, 'failed': 0, 'freed': 0, 'errors': []} for path in paths: try: if not os.path.exists(path): continue size = os.path.getsize(path) if os.path.isfile(path) else 0 if safe_remove(path): result['deleted'] += 1 result['freed'] += size else: result['failed'] += 1 except (OSError, PermissionError) as e: result['failed'] += 1 result['errors'].append(str(e)) return result @classmethod def clean_temp_files(cls, callback=None) -> dict: result = {'deleted': 0, 'failed': 0, 'freed': 0, 'errors': []} for temp_dir in DiskScanner.get_temp_paths(): try: for root, dirs, files in os.walk(temp_dir): for f in files: fpath = os.path.join(root, f) try: size = os.path.getsize(fpath) os.remove(fpath) result['deleted'] += 1 result['freed'] += size if callback and result['deleted'] % 50 == 0: callback(result['deleted']) except (OSError, PermissionError): result['failed'] += 1 except (OSError, PermissionError): pass return result @classmethod def clean_empty_dirs(cls, root: str) -> int: deleted = 0 for dirpath, dirnames, filenames in os.walk(root, topdown=False): if dirpath == root: continue try: if not os.listdir(dirpath): os.rmdir(dirpath) deleted += 1 except (OSError, PermissionError): pass return deleted @classmethod def empty_recycle_bin(cls) -> bool: if sys.platform == 'win32': try: import ctypes ctypes.windll.shell32.SHEmptyRecycleBinW(None, None, 0x07) return True except Exception: return False else: trash_dirs = [ os.path.join(str(Path.home()), '.local', 'share', 'Trash'), os.path.join(str(Path.home()), '.Trash'), ] for trash in trash_dirs: if os.path.exists(trash): try: shutil.rmtree(trash) os.makedirs(trash, exist_ok=True) return True except (OSError, PermissionError): pass return False @classmethod def clean_browser_cache(cls) -> dict: result = {'deleted': 0, 'freed': 0} cache_dirs = [] if sys.platform == 'win32': local = os.environ.get('LOCALAPPDATA', '') cache_dirs = [ os.path.join(local, 'Google', 'Chrome', 'User Data', 'Default', 'Cache'), os.path.join(local, 'Google', 'Chrome', 'User Data', 'Default', 'Code Cache'), os.path.join(local, 'Microsoft', 'Edge', 'User Data', 'Default', 'Cache'), os.path.join(local, 'Mozilla', 'Firefox', 'Profiles'), ] else: home = str(Path.home()) cache_dirs = [ os.path.join(home, '.cache', 'google-chrome'), os.path.join(home, '.cache', 'mozilla'), os.path.join(home, '.cache', 'chromium'), ] for cache_dir in cache_dirs: if os.path.exists(cache_dir): for root, dirs, files in os.walk(cache_dir): for f in files: fpath = os.path.join(root, f) try: size = os.path.getsize(fpath) os.remove(fpath) result['deleted'] += 1 result['freed'] += size except (OSError, PermissionError): pass return result @classmethod def clean_thumbnails(cls) -> dict: result = {'deleted': 0, 'freed': 0} thumb_dirs = [] if sys.platform == 'win32': local = os.environ.get('LOCALAPPDATA', '') thumb_dirs = [ os.path.join(local, 'Microsoft', 'Windows', 'Explorer'), ] else: thumb_dirs = [ os.path.join(str(Path.home()), '.cache', 'thumbnails'), os.path.join(str(Path.home()), '.thumbnails'), ] for d in thumb_dirs: if os.path.exists(d): for root, dirs, files in os.walk(d): for f in files: fpath = os.path.join(root, f) try: size = os.path.getsize(fpath) os.remove(fpath) result['deleted'] += 1 result['freed'] += size except (OSError, PermissionError): pass return result # ============================================================================ # WORKER THREADS # ============================================================================ class ScanWorker(QThread): progress = pyqtSignal(int) finished = pyqtSignal(list) status = pyqtSignal(str) def __init__(self, root, threshold=None): super().__init__() self.root = root self.threshold = threshold def run(self): self.status.emit(f"Scanning {self.root}...") results = DiskScanner.scan_large_files( self.root, self.threshold, callback=lambda c: self.progress.emit(c) ) self.status.emit(f"Found {len(results)} files") self.finished.emit(results) class DuplicateWorker(QThread): progress = pyqtSignal(str) finished = pyqtSignal(list) def __init__(self, root, min_size): super().__init__() self.root = root self.min_size = min_size def run(self): results = DiskScanner.find_duplicates( self.root, self.min_size, callback=lambda s: self.progress.emit(s) ) self.finished.emit(results) class FolderAnalyzerWorker(QThread): progress = pyqtSignal(int) finished = pyqtSignal(list) status = pyqtSignal(str) def __init__(self, root): super().__init__() self.root = root def run(self): self.status.emit(f"Analyzing {self.root}...") results = DiskScanner.analyze_folder( self.root, callback=lambda c: self.progress.emit(c) ) self.finished.emit(results) class CleanWorker(QThread): progress = pyqtSignal(int) finished = pyqtSignal(dict) status = pyqtSignal(str) def __init__(self, tasks: List[str]): super().__init__() self.tasks = tasks def run(self): total_result = {'deleted': 0, 'failed': 0, 'freed': 0} if 'temp' in self.tasks: self.status.emit("Cleaning temp files...") r = DiskCleaner.clean_temp_files(callback=lambda c: self.progress.emit(c)) total_result['deleted'] += r['deleted'] total_result['freed'] += r['freed'] if 'recycle' in self.tasks: self.status.emit("Emptying recycle bin...") DiskCleaner.empty_recycle_bin() if 'browser' in self.tasks: self.status.emit("Cleaning browser cache...") r = DiskCleaner.clean_browser_cache() total_result['deleted'] += r['deleted'] total_result['freed'] += r['freed'] if 'thumbnails' in self.tasks: self.status.emit("Cleaning thumbnails...") r = DiskCleaner.clean_thumbnails() total_result['deleted'] += r['deleted'] total_result['freed'] += r['freed'] if 'empty_dirs' in self.tasks: self.status.emit("Removing empty directories...") DiskCleaner.clean_empty_dirs(str(Path.home())) self.finished.emit(total_result) # ============================================================================ # WIDGET - DISK PANEL # ============================================================================ class DiskPanel(QWidget): log_signal = pyqtSignal(str) def __init__(self): super().__init__() layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(12) title = QLabel("Disk Usage Overview") title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) title.setStyleSheet("color: #e94560; border: none;") layout.addWidget(title) self.table = QTableWidget() self.table.setColumnCount(5) self.table.setHorizontalHeaderLabels(["Drive", "Total", "Used", "Free", "Usage"]) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.table.verticalHeader().setVisible(False) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.setMinimumHeight(150) layout.addWidget(self.table) # Summary cards cards_layout = QHBoxLayout() self.card_total = self._make_card("Total Storage", "---", "#e94560") self.card_used = self._make_card("Used", "---", "#ffa500") self.card_free = self._make_card("Free", "---", "#28a745") self.card_health = self._make_card("Health", "---", "#00bfff") cards_layout.addWidget(self.card_total) cards_layout.addWidget(self.card_used) cards_layout.addWidget(self.card_free) cards_layout.addWidget(self.card_health) layout.addLayout(cards_layout) # Category breakdown cat_title = QLabel("File Type Breakdown (by extension analysis)") cat_title.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold)) cat_title.setStyleSheet("color: #e0e0e0; border: none;") layout.addWidget(cat_title) self.category_table = QTableWidget() self.category_table.setColumnCount(3) self.category_table.setHorizontalHeaderLabels(["Category", "Count", "Estimated Size"]) self.category_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.category_table.verticalHeader().setVisible(False) self.category_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.category_table.setMaximumHeight(200) layout.addWidget(self.category_table) btn_layout = QHBoxLayout() refresh_btn = QPushButton("Refresh") refresh_btn.setObjectName("primary") refresh_btn.clicked.connect(self.refresh) btn_layout.addWidget(refresh_btn) btn_layout.addStretch() layout.addLayout(btn_layout) self.refresh() def _make_card(self, label: str, value: str, color: str) -> QFrame: frame = QFrame() frame.setStyleSheet(f""" QFrame {{ background-color: #16213e; border: 1px solid {color}44; border-radius: 8px; padding: 10px; }} """) frame.setMinimumHeight(70) fl = QVBoxLayout(frame) fl.setSpacing(2) lbl = QLabel(label) lbl.setStyleSheet(f"color: #888; border: none; font-size: 11px;") lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) fl.addWidget(lbl) val = QLabel(value) val.setObjectName("card_value") val.setStyleSheet(f"color: {color}; border: none; font-size: 18px; font-weight: bold;") val.setAlignment(Qt.AlignmentFlag.AlignCenter) fl.addWidget(val) return frame def _update_card(self, card: QFrame, value: str): lbl = card.findChild(QLabel, "card_value") if lbl: lbl.setText(value) def refresh(self): disks = DiskScanner.get_disks() self.table.setRowCount(len(disks)) for i, d in enumerate(disks): self.table.setItem(i, 0, QTableWidgetItem(d.drive)) self.table.setItem(i, 1, QTableWidgetItem(format_size(d.total))) self.table.setItem(i, 2, QTableWidgetItem(format_size(d.used))) self.table.setItem(i, 3, QTableWidgetItem(format_size(d.free))) bar = QProgressBar() bar.setValue(int(d.percent)) bar.setFormat(f"{d.percent}%") if d.percent > 90: bar.setStyleSheet(""" QProgressBar { border: 1px solid #0f3460; border-radius: 5px; background-color: #16213e; color: #fff; } QProgressBar::chunk { background-color: #ff3333; border-radius: 5px; } """) elif d.percent > 75: bar.setStyleSheet(""" QProgressBar { border: 1px solid #0f3460; border-radius: 5px; background-color: #16213e; color: #fff; } QProgressBar::chunk { background-color: #ffa500; border-radius: 5px; } """) else: bar.setStyleSheet(""" QProgressBar { border: 1px solid #0f3460; border-radius: 5px; background-color: #16213e; color: #fff; } QProgressBar::chunk { background-color: #28a745; border-radius: 5px; } """) self.table.setCellWidget(i, 4, bar) total = sum(d.total for d in disks) used = sum(d.used for d in disks) free = sum(d.free for d in disks) self._update_card(self.card_total, format_size(total)) self._update_card(self.card_used, format_size(used)) self._update_card(self.card_free, format_size(free)) avg_percent = sum(d.percent for d in disks) / len(disks) if disks else 0 if avg_percent < 60: self._update_card(self.card_health, "Good") elif avg_percent < 80: self._update_card(self.card_health, "Warning") else: self._update_card(self.card_health, "Critical") self.log_signal.emit(f"Disk scan: {len(disks)} drives, {format_size(free)} free") # ============================================================================ # WIDGET - LARGE FILE SCANNER # ============================================================================ class FileScannerPanel(QWidget): log_signal = pyqtSignal(str) def __init__(self): super().__init__() layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(10) title = QLabel("Large File Scanner") title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) title.setStyleSheet("color: #e94560; border: none;") layout.addWidget(title) # Controls ctrl = QHBoxLayout() self.path_input = QLineEdit(str(Path.home())) self.path_input.setPlaceholderText("Directory to scan...") ctrl.addWidget(self.path_input) browse_btn = QPushButton("Browse") browse_btn.clicked.connect(self._browse) ctrl.addWidget(browse_btn) ctrl.addWidget(QLabel("Min:")) self.threshold_spin = QSpinBox() self.threshold_spin.setRange(1, 10000) self.threshold_spin.setValue(50) self.threshold_spin.setSuffix(" MB") ctrl.addWidget(self.threshold_spin) layout.addLayout(ctrl) # Results tree self.tree = QTreeWidget() self.tree.setHeaderLabels(["File", "Size", "Category", "Modified", "Full Path"]) self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.tree.setColumnWidth(1, 100) self.tree.setColumnWidth(2, 80) self.tree.setColumnWidth(3, 130) self.tree.setColumnWidth(4, 250) self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.tree.setAlternatingRowColors(True) layout.addWidget(self.tree) # Bottom bar bottom = QHBoxLayout() self.status_label = QLabel("Ready - click Scan to find large files") self.status_label.setStyleSheet("color: #888; border: none;") bottom.addWidget(self.status_label) bottom.addStretch() self.scan_btn = QPushButton("Scan") self.scan_btn.setObjectName("primary") self.scan_btn.clicked.connect(self._scan) bottom.addWidget(self.scan_btn) self.delete_btn = QPushButton("Delete Selected") self.delete_btn.setObjectName("danger") self.delete_btn.clicked.connect(self._delete) self.delete_btn.setEnabled(False) bottom.addWidget(self.delete_btn) layout.addLayout(bottom) self._worker = None def _browse(self): p = QFileDialog.getExistingDirectory(self, "Select Directory") if p: self.path_input.setText(p) def _scan(self): path = self.path_input.text() if not os.path.exists(path): QMessageBox.warning(self, "Error", "Path does not exist") return self.tree.clear() self.scan_btn.setEnabled(False) self.status_label.setText("Scanning...") self._worker = ScanWorker(path, self.threshold_spin.value() * 1024 * 1024) self._worker.finished.connect(self._on_done) self._worker.status.connect(lambda s: self.status_label.setText(s)) self._worker.start() def _on_done(self, results): self.scan_btn.setEnabled(True) self.delete_btn.setEnabled(len(results) > 0) colors = {'Video': '#ff6b6b', 'Audio': '#ffa500', 'Image': '#00d4aa', 'Archive': '#6c63ff', 'Installer': '#ff00aa', 'Document': '#00bfff', 'Database': '#ff8c00', 'Virtual': '#9932cc'} total = sum(e.size for e in results) for entry in results: item = QTreeWidgetItem() item.setText(0, os.path.basename(entry.path)) item.setText(1, format_size(entry.size)) item.setText(2, entry.category) item.setText(3, entry.modified) item.setText(4, entry.path) item.setData(0, Qt.ItemDataRole.UserRole, entry.path) color = colors.get(entry.category, '#888') item.setForeground(2, QColor(color)) self.tree.addTopLevelItem(item) self.status_label.setText(f"{len(results)} files found ({format_size(total)})") self.log_signal.emit(f"Large file scan: {len(results)} files, {format_size(total)}") def _delete(self): items = self.tree.selectedItems() if not items: return paths = [i.data(0, Qt.ItemDataRole.UserRole) for i in items] reply = QMessageBox.question(self, "Delete", f"Permanently delete {len(paths)} file(s)?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: r = DiskCleaner.delete_files(paths) QMessageBox.information(self, "Done", f"Deleted: {r['deleted']} | Freed: {format_size(r['freed'])}") self.log_signal.emit(f"Deleted {r['deleted']} files, freed {format_size(r['freed'])}") self._scan() # ============================================================================ # WIDGET - DUPLICATE FINDER # ============================================================================ class DuplicatePanel(QWidget): log_signal = pyqtSignal(str) def __init__(self): super().__init__() layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(10) title = QLabel("Duplicate File Finder") title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) title.setStyleSheet("color: #e94560; border: none;") layout.addWidget(title) # Controls ctrl = QHBoxLayout() self.path_input = QLineEdit(str(Path.home())) ctrl.addWidget(self.path_input) browse_btn = QPushButton("Browse") browse_btn.clicked.connect(self._browse) ctrl.addWidget(browse_btn) ctrl.addWidget(QLabel("Min size:")) self.min_spin = QSpinBox() self.min_spin.setRange(1, 1000) self.min_spin.setValue(1) self.min_spin.setSuffix(" MB") ctrl.addWidget(self.min_spin) layout.addLayout(ctrl) # Results self.tree = QTreeWidget() self.tree.setHeaderLabels(["File", "Size", "Path"]) self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.tree.setColumnWidth(1, 100) self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.tree.setAlternatingRowColors(True) layout.addWidget(self.tree) # Bottom bottom = QHBoxLayout() self.status_label = QLabel("Find duplicate files wasting space") self.status_label.setStyleSheet("color: #888; border: none;") bottom.addWidget(self.status_label) bottom.addStretch() self.scan_btn = QPushButton("Find Duplicates") self.scan_btn.setObjectName("primary") self.scan_btn.clicked.connect(self._scan) bottom.addWidget(self.scan_btn) self.delete_btn = QPushButton("Delete Selected") self.delete_btn.setObjectName("danger") self.delete_btn.clicked.connect(self._delete) self.delete_btn.setEnabled(False) bottom.addWidget(self.delete_btn) layout.addLayout(bottom) self._worker = None def _browse(self): p = QFileDialog.getExistingDirectory(self, "Select Directory") if p: self.path_input.setText(p) def _scan(self): path = self.path_input.text() if not os.path.exists(path): QMessageBox.warning(self, "Error", "Path not found") return self.tree.clear() self.scan_btn.setEnabled(False) self.status_label.setText("Scanning for duplicates...") self._worker = DuplicateWorker(path, self.min_spin.value() * 1024 * 1024) self._worker.progress.connect(lambda s: self.status_label.setText(s)) self._worker.finished.connect(self._on_done) self._worker.start() def _on_done(self, groups): self.scan_btn.setEnabled(True) self.delete_btn.setEnabled(len(groups) > 0) total_waste = 0 for group in groups[:100]: # Limit display waste = group.size * (len(group.files) - 1) total_waste += waste parent = QTreeWidgetItem() parent.setText(0, f"[{len(group.files)} copies] {os.path.basename(group.files[0])}") parent.setText(1, format_size(group.size)) parent.setForeground(0, QColor("#ffa500")) for fpath in group.files: child = QTreeWidgetItem(parent) child.setText(0, os.path.basename(fpath)) child.setText(1, format_size(group.size)) child.setText(2, fpath) child.setData(0, Qt.ItemDataRole.UserRole, fpath) self.tree.addTopLevelItem(parent) self.status_label.setText(f"{len(groups)} duplicate groups | Wasted: {format_size(total_waste)}") self.log_signal.emit(f"Duplicates: {len(groups)} groups, {format_size(total_waste)} wasted") def _delete(self): items = self.tree.selectedItems() paths = [i.data(0, Qt.ItemDataRole.UserRole) for i in items if i.data(0, Qt.ItemDataRole.UserRole)] if not paths: QMessageBox.information(self, "Info", "Select duplicate files to delete (keep at least one copy!)") return reply = QMessageBox.question(self, "Delete", f"Delete {len(paths)} duplicate file(s)?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: r = DiskCleaner.delete_files(paths) QMessageBox.information(self, "Done", f"Deleted: {r['deleted']} | Freed: {format_size(r['freed'])}") self.log_signal.emit(f"Deleted {r['deleted']} duplicates, freed {format_size(r['freed'])}") self._scan() # ============================================================================ # WIDGET - FOLDER ANALYZER # ============================================================================ class FolderAnalyzerPanel(QWidget): log_signal = pyqtSignal(str) def __init__(self): super().__init__() layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(10) title = QLabel("Folder Size Analyzer") title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) title.setStyleSheet("color: #e94560; border: none;") layout.addWidget(title) ctrl = QHBoxLayout() self.path_input = QLineEdit(str(Path.home())) ctrl.addWidget(self.path_input) browse_btn = QPushButton("Browse") browse_btn.clicked.connect(self._browse) ctrl.addWidget(browse_btn) self.analyze_btn = QPushButton("Analyze") self.analyze_btn.setObjectName("primary") self.analyze_btn.clicked.connect(self._analyze) ctrl.addWidget(self.analyze_btn) layout.addLayout(ctrl) self.table = QTableWidget() self.table.setColumnCount(4) self.table.setHorizontalHeaderLabels(["Folder", "Size", "Files", "% of Total"]) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.table.verticalHeader().setVisible(False) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setAlternatingRowColors(True) layout.addWidget(self.table) self.status_label = QLabel("Select a directory and click Analyze") self.status_label.setStyleSheet("color: #888; border: none;") layout.addWidget(self.status_label) self._worker = None def _browse(self): p = QFileDialog.getExistingDirectory(self, "Select Directory") if p: self.path_input.setText(p) def _analyze(self): path = self.path_input.text() if not os.path.exists(path): QMessageBox.warning(self, "Error", "Path not found") return self.analyze_btn.setEnabled(False) self.status_label.setText("Analyzing folders...") self._worker = FolderAnalyzerWorker(path) self._worker.finished.connect(self._on_done) self._worker.status.connect(lambda s: self.status_label.setText(s)) self._worker.start() def _on_done(self, folders): self.analyze_btn.setEnabled(True) self.table.setRowCount(len(folders)) total = sum(f.size for f in folders) for i, f in enumerate(folders): self.table.setItem(i, 0, QTableWidgetItem(os.path.basename(f.path))) self.table.setItem(i, 1, QTableWidgetItem(format_size(f.size))) self.table.setItem(i, 2, QTableWidgetItem(f"{f.file_count:,}")) bar = QProgressBar() bar.setValue(int(f.percent)) bar.setFormat(f"{f.percent}%") self.table.setCellWidget(i, 3, bar) self.status_label.setText(f"{len(folders)} folders analyzed | Total: {format_size(total)}") self.log_signal.emit(f"Folder analysis: {len(folders)} folders, {format_size(total)}") # ============================================================================ # WIDGET - CLEANING PANEL # ============================================================================ class CleaningPanel(QWidget): log_signal = pyqtSignal(str) def __init__(self): super().__init__() layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(12) title = QLabel("System Cleaner") title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) title.setStyleSheet("color: #e94560; border: none;") layout.addWidget(title) # Temp info info_frame = QFrame() info_frame.setStyleSheet("background-color: #16213e; border: 1px solid #0f3460; border-radius: 8px; padding: 12px;") info_layout = QVBoxLayout(info_frame) self.temp_info = QLabel("Analyzing...") self.temp_info.setStyleSheet("color: #e0e0e0; border: none; font-size: 13px;") info_layout.addWidget(self.temp_info) layout.addWidget(info_frame) # Options opts = QGroupBox("Select what to clean") opts_layout = QVBoxLayout(opts) self.chk_temp = QCheckBox("Temporary Files (User + System Temp folders)") self.chk_temp.setChecked(True) opts_layout.addWidget(self.chk_temp) self.chk_recycle = QCheckBox("Recycle Bin / Trash") self.chk_recycle.setChecked(True) opts_layout.addWidget(self.chk_recycle) self.chk_browser = QCheckBox("Browser Cache (Chrome, Edge, Firefox)") self.chk_browser.setChecked(False) opts_layout.addWidget(self.chk_browser) self.chk_thumbs = QCheckBox("Thumbnail Cache") self.chk_thumbs.setChecked(True) opts_layout.addWidget(self.chk_thumbs) self.chk_empty = QCheckBox("Empty Directories") self.chk_empty.setChecked(False) opts_layout.addWidget(self.chk_empty) layout.addWidget(opts) # Progress self.progress = QProgressBar() self.progress.setValue(0) self.progress.setFormat("Ready") layout.addWidget(self.progress) self.result_label = QLabel("") self.result_label.setStyleSheet("color: #28a745; border: none; font-weight: bold; font-size: 13px;") layout.addWidget(self.result_label) # Buttons btn = QHBoxLayout() self.analyze_btn = QPushButton("Analyze") self.analyze_btn.setObjectName("primary") self.analyze_btn.clicked.connect(self._analyze) btn.addWidget(self.analyze_btn) self.clean_btn = QPushButton("Clean Now") self.clean_btn.setObjectName("danger") self.clean_btn.clicked.connect(self._clean) btn.addWidget(self.clean_btn) btn.addStretch() layout.addLayout(btn) layout.addStretch() QTimer.singleShot(500, self._analyze) self._worker = None def _analyze(self): size, count = DiskScanner.scan_temp_size() paths = DiskScanner.get_temp_paths() self.temp_info.setText( f"Cleanable: ~{format_size(size)} ({count:,} files)\n" f"Locations: {len(paths)} temp directories found" ) def _clean(self): reply = QMessageBox.question(self, "Confirm", "Clean selected items?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) if reply != QMessageBox.StandardButton.Yes: return tasks = [] if self.chk_temp.isChecked(): tasks.append('temp') if self.chk_recycle.isChecked(): tasks.append('recycle') if self.chk_browser.isChecked(): tasks.append('browser') if self.chk_thumbs.isChecked(): tasks.append('thumbnails') if self.chk_empty.isChecked(): tasks.append('empty_dirs') if not tasks: return self.clean_btn.setEnabled(False) self.progress.setFormat("Cleaning...") self.progress.setValue(50) self._worker = CleanWorker(tasks) self._worker.finished.connect(self._on_done) self._worker.status.connect(lambda s: self.progress.setFormat(s)) self._worker.start() def _on_done(self, result): self.clean_btn.setEnabled(True) self.progress.setValue(100) self.progress.setFormat("Done!") self.result_label.setText(f"Cleaned {result['deleted']:,} files | Freed {format_size(result['freed'])}") self.log_signal.emit(f"Cleaning done: {result['deleted']} files, {format_size(result['freed'])} freed") QTimer.singleShot(1000, self._analyze) # ============================================================================ # WIDGET - LOG PANEL # ============================================================================ class LogPanel(QWidget): def __init__(self): super().__init__() layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) title = QLabel("Activity Log") title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) title.setStyleSheet("color: #e94560; border: none;") layout.addWidget(title) self.log = QTextEdit() self.log.setReadOnly(True) self.log.setFont(QFont("Consolas", 10)) layout.addWidget(self.log) btn = QHBoxLayout() clear_btn = QPushButton("Clear") clear_btn.clicked.connect(self.log.clear) btn.addWidget(clear_btn) export_btn = QPushButton("Export") export_btn.clicked.connect(self._export) btn.addWidget(export_btn) btn.addStretch() layout.addLayout(btn) self.add("Storage Cleaner Pro started") def add(self, msg: str): ts = datetime.now().strftime("%H:%M:%S") self.log.append(f"[{ts}] {msg}") def _export(self): path, _ = QFileDialog.getSaveFileName(self, "Export Log", "cleaner_log.txt", "Text (*.txt)") if path: with open(path, 'w') as f: f.write(self.log.toPlainText()) # ============================================================================ # MAIN WINDOW # ============================================================================ DARK_QSS = """ QMainWindow { background-color: #1a1a2e; color: #e0e0e0; } QMenuBar { background-color: #16213e; color: #e0e0e0; border-bottom: 1px solid #0f3460; } QMenuBar::item { padding: 5px 15px; } QMenuBar::item:selected { background-color: #0f3460; } QMenu { background-color: #16213e; border: 1px solid #0f3460; color: #e0e0e0; } QMenu::item { padding: 5px 30px 5px 20px; } QMenu::item:selected { background-color: #0f3460; } QLabel { color: #e0e0e0; } QGroupBox { font-weight: bold; border: 1px solid #0f3460; border-radius: 5px; margin-top: 10px; padding-top: 10px; color: #e0e0e0; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 3px; } QProgressBar { border: 1px solid #0f3460; border-radius: 5px; text-align: center; background-color: #16213e; color: #e0e0e0; height: 20px; } QProgressBar::chunk { background-color: #e94560; border-radius: 5px; } QPushButton { background-color: #0f3460; border: 1px solid #0f3460; border-radius: 5px; padding: 8px 18px; color: #e0e0e0; font-weight: bold; } QPushButton:hover { background-color: #1a4a8a; border-color: #e94560; } QPushButton:pressed { background-color: #e94560; } QPushButton:disabled { background-color: #16213e; color: #555; } QPushButton#primary { background-color: #e94560; border: 1px solid #c72c41; color: #fff; } QPushButton#primary:hover { background-color: #c72c41; } QPushButton#danger { background-color: #c72c41; border: 1px solid #e94560; color: #fff; } QPushButton#danger:hover { background-color: #e94560; } QTreeWidget, QTableWidget { background-color: #16213e; border: 1px solid #0f3460; color: #e0e0e0; alternate-background-color: #1a1a2e; } QTreeWidget::item:selected, QTableWidget::item:selected { background-color: #0f3460; } QHeaderView::section { background-color: #0f3460; color: #e0e0e0; padding: 5px; border: 1px solid #16213e; font-weight: bold; } QTextEdit { background-color: #16213e; border: 1px solid #0f3460; color: #e0e0e0; } QLineEdit { background-color: #16213e; border: 1px solid #0f3460; border-radius: 5px; padding: 6px; color: #e0e0e0; } QLineEdit:focus { border-color: #e94560; } QTabWidget::pane { border: 1px solid #0f3460; } QTabBar::tab { background-color: #16213e; color: #888; padding: 10px 20px; border: 1px solid #0f3460; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; font-weight: bold; } QTabBar::tab:selected { background-color: #0f3460; color: #e94560; } QTabBar::tab:hover { color: #e0e0e0; } QScrollBar:vertical { background-color: #16213e; width: 10px; } QScrollBar::handle:vertical { background-color: #0f3460; min-height: 20px; border-radius: 5px; } QScrollBar::handle:vertical:hover { background-color: #e94560; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } QCheckBox { color: #e0e0e0; spacing: 8px; } QCheckBox::indicator { width: 18px; height: 18px; border: 2px solid #0f3460; border-radius: 4px; background-color: #16213e; } QCheckBox::indicator:checked { background-color: #e94560; border-color: #e94560; } QSpinBox { background-color: #16213e; border: 1px solid #0f3460; border-radius: 5px; padding: 5px; color: #e0e0e0; } QStatusBar { background-color: #16213e; color: #888; border-top: 1px solid #0f3460; } QFrame { border: none; } QToolTip { background-color: #16213e; color: #e0e0e0; border: 1px solid #e94560; padding: 5px; } QMessageBox { background-color: #1a1a2e; } """ class StorageCleanerWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Storage Cleaner Pro") self.setMinimumSize(1100, 750) self.resize(1300, 850) self._setup_menu() self._setup_ui() self._setup_statusbar() def _setup_menu(self): menubar = self.menuBar() file_menu = menubar.addMenu("File") refresh = QAction("Refresh Disks", self) refresh.setShortcut("F5") refresh.triggered.connect(lambda: self.disk_panel.refresh()) file_menu.addAction(refresh) file_menu.addSeparator() exit_a = QAction("Exit", self) exit_a.setShortcut("Ctrl+Q") exit_a.triggered.connect(self.close) file_menu.addAction(exit_a) tools_menu = menubar.addMenu("Tools") tools_menu.addAction(QAction("Large Files", self, triggered=lambda: self.tabs.setCurrentIndex(1))) tools_menu.addAction(QAction("Duplicates", self, triggered=lambda: self.tabs.setCurrentIndex(2))) tools_menu.addAction(QAction("Folder Sizes", self, triggered=lambda: self.tabs.setCurrentIndex(3))) tools_menu.addAction(QAction("Clean", self, triggered=lambda: self.tabs.setCurrentIndex(4))) help_menu = menubar.addMenu("Help") help_menu.addAction(QAction("About", self, triggered=self._about)) def _setup_ui(self): central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) layout.setContentsMargins(0, 0, 0, 0) self.tabs = QTabWidget() self.disk_panel = DiskPanel() self.tabs.addTab(self.disk_panel, "Disks") self.scanner_panel = FileScannerPanel() self.tabs.addTab(self.scanner_panel, "Large Files") self.duplicate_panel = DuplicatePanel() self.tabs.addTab(self.duplicate_panel, "Duplicates") self.folder_panel = FolderAnalyzerPanel() self.tabs.addTab(self.folder_panel, "Folders") self.cleaning_panel = CleaningPanel() self.tabs.addTab(self.cleaning_panel, "Clean") self.log_panel = LogPanel() self.tabs.addTab(self.log_panel, "Log") layout.addWidget(self.tabs) # Connect log signals self.disk_panel.log_signal.connect(self.log_panel.add) self.scanner_panel.log_signal.connect(self.log_panel.add) self.duplicate_panel.log_signal.connect(self.log_panel.add) self.folder_panel.log_signal.connect(self.log_panel.add) self.cleaning_panel.log_signal.connect(self.log_panel.add) def _setup_statusbar(self): self.statusBar().showMessage("Ready") self._timer = QTimer(self) self._timer.timeout.connect(self._tick) self._timer.start(10000) self._tick() def _tick(self): disks = DiskScanner.get_disks() if disks: free = sum(d.free for d in disks) self.statusBar().showMessage( f"Free: {format_size(free)} | {platform.system()} {platform.release()} | {datetime.now().strftime('%H:%M')}" ) def _about(self): QMessageBox.about(self, "About", "Storage Cleaner Pro v2.0\n\n" "Features:\n" "- Disk usage overview with health indicator\n" "- Large file scanner with category detection\n" "- Duplicate file finder (hash-based)\n" "- Folder size analyzer\n" "- System cleaner (temp, cache, trash, thumbnails)\n" "- Activity log with export\n\n" "Built with PyQt6" ) # ============================================================================ # ENTRY POINT # ============================================================================ def main(): app = QApplication(sys.argv) app.setStyle("Fusion") app.setStyleSheet(DARK_QSS) app.setFont(QFont("Segoe UI", 10)) window = StorageCleanerWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()