"""Поиск крупных файлов и дубликатов.""" from __future__ import annotations import hashlib import os from collections import defaultdict from typing import Callable, Iterable from .model import Category, Finding, Risk from .rules import SKIP_DIRS from .util import Cancel, human # Расширения, которые почти всегда — «тяжёлый мусор, о котором забыли» JUNKY_EXT = { ".iso": "образ диска", ".img": "образ диска", ".vhd": "виртуальный диск", ".vhdx": "виртуальный диск", ".vmdk": "виртуальный диск", ".zip": "архив", ".rar": "архив", ".7z": "архив", ".tar": "архив", ".gz": "архив", ".msi": "установщик", ".exe": "установщик/программа", ".dmg": "образ", ".bak": "резервная копия", ".old": "старая версия", ".tmp": "временный файл", ".dmp": "дамп памяти", ".log": "журнал", ".pkg": "пакет", } MEDIA_EXT = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".psd", ".ai", ".raw", ".cr2", ".nef", ".wav", ".flac", ".prproj", ".aep"} def scan_big_files( roots: Iterable[str], min_size: int = 100 * 1024 * 1024, limit: int = 400, cancel: Cancel | None = None, progress: Callable[[str], None] | None = None, max_depth: int = 12, ) -> list[Finding]: """Найти файлы крупнее ``min_size``. Ничего не предлагает удалять автоматически.""" out: list[Finding] = [] scanned = 0 for root in roots: root = os.path.abspath(root) if not os.path.isdir(root): continue stack = [(root, 0)] while stack: if cancel is not None and cancel.cancelled: return _trim(out, limit) cur, depth = stack.pop() if depth > max_depth: continue try: entries = list(os.scandir(cur)) except (OSError, ValueError): continue scanned += 1 if progress is not None and scanned % 300 == 0: progress(cur) for e in entries: try: if e.is_dir(follow_symlinks=False): if e.name.lower() not in SKIP_DIRS: stack.append((e.path, depth + 1)) continue if not e.is_file(follow_symlinks=False): continue st = e.stat(follow_symlinks=False) except OSError: continue if st.st_size < min_size: continue ext = os.path.splitext(e.name)[1].lower() kind = JUNKY_EXT.get(ext) risk = Risk.MEDIUM if kind else Risk.HIGH note = f"Тип: {kind}. " if kind else "" if ext in MEDIA_EXT: note = "Медиафайл или исходник проекта. " out.append(Finding( path=e.path, title=e.name, category=Category.BIG_FILES, risk=risk, size=st.st_size, files=1, mtime=st.st_mtime, detail=(f"{note}{human(st.st_size)}\nПапка: {cur}\n" "Решение об удалении принимаете вы — приложение ничего " "не отмечает здесь автоматически."), group=kind or "файл", is_dir=False, )) return _trim(out, limit) def _trim(items: list[Finding], limit: int) -> list[Finding]: items.sort(key=lambda f: -f.size) return items[:limit] # ------------------------------------------------------------- дубликаты ---- def _quick_hash(path: str, size: int, chunk: int = 65536) -> str | None: """Хеш по началу, середине и концу файла — быстрый предварительный фильтр.""" try: h = hashlib.blake2b(digest_size=16) with open(path, "rb") as fh: h.update(fh.read(chunk)) if size > chunk * 3: fh.seek(size // 2) h.update(fh.read(chunk)) fh.seek(max(0, size - chunk)) h.update(fh.read(chunk)) h.update(str(size).encode()) return h.hexdigest() except OSError: return None def _full_hash(path: str) -> str | None: try: h = hashlib.blake2b(digest_size=16) with open(path, "rb") as fh: for block in iter(lambda: fh.read(1 << 20), b""): h.update(block) return h.hexdigest() except OSError: return None def scan_duplicates( roots: Iterable[str], min_size: int = 5 * 1024 * 1024, cancel: Cancel | None = None, progress: Callable[[str], None] | None = None, max_files: int = 200_000, verify_full: bool = True, max_depth: int = 12, ) -> list[Finding]: """Найти одинаковые файлы: группировка по размеру → быстрый хеш → полный хеш.""" by_size: dict[int, list[str]] = defaultdict(list) count = 0 for root in roots: root = os.path.abspath(root) if not os.path.isdir(root): continue stack = [(root, 0)] while stack: if cancel is not None and cancel.cancelled: return [] cur, depth = stack.pop() if depth > max_depth: continue try: entries = list(os.scandir(cur)) except (OSError, ValueError): continue for e in entries: try: if e.is_dir(follow_symlinks=False): if e.name.lower() not in SKIP_DIRS: stack.append((e.path, depth + 1)) continue if not e.is_file(follow_symlinks=False): continue st = e.stat(follow_symlinks=False) except OSError: continue if st.st_size < min_size: continue by_size[st.st_size].append(e.path) count += 1 if count >= max_files: stack.clear() break if progress is not None and count % 500 < 2: progress(cur) groups: list[Finding] = [] for size, paths in by_size.items(): if len(paths) < 2: continue if cancel is not None and cancel.cancelled: break buckets: dict[str, list[str]] = defaultdict(list) for p in paths: qh = _quick_hash(p, size) if qh: buckets[qh].append(p) for qh, group in buckets.items(): if len(group) < 2: continue if verify_full and size <= 512 * 1024 * 1024: confirmed: dict[str, list[str]] = defaultdict(list) for p in group: fh = _full_hash(p) if fh: confirmed[fh].append(p) final_groups = [g for g in confirmed.values() if len(g) > 1] else: final_groups = [group] for g in final_groups: g.sort(key=lambda p: (len(p), p)) wasted = size * (len(g) - 1) try: mtime = os.stat(g[0]).st_mtime except OSError: mtime = 0.0 groups.append(Finding( path=g[1] if len(g) > 1 else g[0], # по умолчанию «лишняя» копия title=f"{os.path.basename(g[0])} × {len(g)}", category=Category.DUPLICATES, risk=Risk.MEDIUM, size=wasted, files=len(g) - 1, mtime=mtime, detail=("Одинаковые по содержимому файлы ({} копий по {}).\n" "Оригинал (оставить): {}\nЛишние копии:\n {}".format( len(g), human(size), g[0], "\n ".join(g[1:]))), group="дубликаты", is_dir=False, members=g[1:], extra={"original": g[0], "all": g, "unit": size}, )) groups.sort(key=lambda f: -f.size) return groups[:300]