#!/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()