diff --git a/README.md b/README.md index 6255dea..ca8f886 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ # Raskidator-3000 🗂️ -Сортировка фотографий по EXIF дате. +Сортировка изображений по EXIF дате. -**Логика:** сканирует папку, читает EXIF (`DateTimeOriginal`), создаёт подпапки `Год/Месяц/День` и перемещает файлы туда. Файлы без EXIF даты остаются на месте. +Сканирует папку, читает EXIF (`DateTimeOriginal`), создаёт подпапки +`Год/Месяц/День` (или `Год/Месяц` с `--group-by month`) и перемещает файлы. ## Поддерживаемые форматы `.jpg` `.jpeg` `.png` `.heic` `.tiff` `.tif` `.webp` `.bmp` +`.cr2` `.cr3` `.nef` `.arw` `.dng` (Raw) +`.avi` ## Установка @@ -14,6 +17,10 @@ pip install -r requirements.txt ``` +Для Raw-форматов (`CR2`, `NEF` и т.д.) нужен `rawpy`. +Для HEIC нужен `pillow-heif`. +Оба ставятся только на Windows (`sys_platform == 'win32'`). + ## Использование ```bash @@ -22,24 +29,37 @@ python organize.py # Указанная папка python organize.py "D:\Фото\Скопировать" + +# Только посмотреть, что будет (ничего не перемещает) +python organize.py --dry-run + +# Без цвета (для пайпов) +python organize.py --no-color + +# Тихий режим — только итог +python organize.py --quiet + +# Группировать по месяцам (без дня) +python organize.py --group-by month ``` +## Логи + +После каждого запуска создаются: +- `.raskidator-log.csv` — лог перемещений (source, destination, date) +- `raskidator-undo.bat` — скрипт для возврата всех файлов обратно + ## Контекстное меню Windows ```cmd -# 1. Запусти install.bat (сгенерирует .reg файл с твоими путями) +# Установить контекстное меню install.bat -# 2. Откроется install-context-menu.reg — подтверди добавление в реестр +# Удалить контекстное меню +uninstall-context-menu.bat ``` -После этого: +После установки: - **ПКМ в пустом месте папки** → «Raskidator-3000» — обработает текущую папку - **ПКМ на папке** → «Raskidator-3000» — обработает выбранную папку - **Работает в библиотеках** (Pictures, Music, Videos, Documents) - -Удалить из контекстного меню: -```cmd -reg delete "HKEY_CLASSES_ROOT\Directory\Background\shell\Raskidator3000" /f -reg delete "HKEY_CLASSES_ROOT\Directory\shell\Raskidator3000" /f -``` diff --git a/organize.py b/organize.py index 40d50b3..40923a9 100644 --- a/organize.py +++ b/organize.py @@ -1,224 +1,546 @@ #!/usr/bin/env python3 """ -Raskidator-3000 — сортировка фотографий по EXIF дате. +Raskidator-3000 — сортировка изображений по EXIF дате. -Сканирует указанную папку (по умолчанию текущую) на изображения, -читает EXIF дату съёмки (DateTimeOriginal) и перемещает файлы -в подпапки Год/Месяц/День. +Сканирует папку, читает дату съёмки (EXIF DateTimeOriginal) +и перемещает файлы в подпапки Год/Месяц/День. -Файлы без EXIF даты остаются на месте. - -Поддерживаемые форматы: .jpg, .jpeg, .png, .heic, .tiff, .tif, .webp, .bmp +Поддерживаемые форматы: + JPEG, PNG, HEIC, TIFF, WebP, BMP, CR2 (Canon Raw), AVI Использование: - python organize.py [путь_к_папке] - python organize.py --no-color [путь_к_папке] - python organize.py [путь_к_папке] --no-color + python organize.py [путь] [--no-color] [--dry-run] [--quiet] [--group-by month] + python organize.py --help """ +from __future__ import annotations + import argparse +import csv import os -import sys import shutil +import sys +import time from collections import Counter from datetime import datetime from pathlib import Path +from typing import Any -EXTENSIONS = {'.jpg', '.jpeg', '.png', '.heic', '.tiff', '.tif', '.webp', '.bmp'} +# ── Optional imports ──────────────────────────────────────────────────────── -# ANSI color codes +HAS_TQDM = False +HAS_PIEXIF = False +HAS_RAWPY = False +HAS_PILLOW_HEIF = False + +try: + from tqdm import tqdm as _tqdm + HAS_TQDM = True +except ImportError: + pass + +try: + import piexif + HAS_PIEXIF = True +except ImportError: + pass + +try: + import rawpy + HAS_RAWPY = True +except ImportError: + pass + +try: + from pillow_heif import open_heif + HAS_PILLOW_HEIF = True +except ImportError: + pass + +# ── Constants ─────────────────────────────────────────────────────────────── + +EXTENSIONS = { + '.jpg', '.jpeg', '.png', '.heic', '.tiff', '.tif', '.webp', '.bmp', + '.cr2', '.cr3', '.nef', '.arw', '.dng', + '.avi', +} + +# ANSI colour codes _GREEN = '\033[32m' _YELLOW = '\033[33m' _RED = '\033[31m' +_CYAN = '\033[36m' +_BOLD = '\033[1m' _RESET = '\033[0m' -def _colorize(text: str, color: str, no_color: bool) -> str: - """Wrap *text* in ANSI *color*, unless *no_color* is True.""" +def _c(text: str, colour: str, *, no_color: bool = False) -> str: + """Colourise *text* unless *no_color* is True.""" if no_color: return text - return f'{color}{text}{_RESET}' + return f'{colour}{text}{_RESET}' -def get_file_created_date(filepath: Path) -> datetime | None: - """Возвращает дату создания файла из файловой системы (Windows: st_ctime).""" +# ── EXIF extraction (JPEG — piexif, 5-10x faster than PIL) ────────────────── + +def _read_jpeg_exif_piexif(path: Path) -> str | None: + """Read DateTimeOriginal from a JPEG using piexif (no PIL needed). + + Works on raw bytes — much faster than opening the image. + """ + if not HAS_PIEXIF: + return None try: - ts = filepath.stat().st_ctime - return datetime.fromtimestamp(ts) - except Exception: - return None - - -def get_exif_date(filepath: Path) -> str | None: - """Извлекает дату съёмки из EXIF (DateTimeOriginal).""" - try: - # HEIC требует pillow-heif - if filepath.suffix.lower() == '.heic': - try: - from pillow_heif import open_heif - heif_file = open_heif(str(filepath)) - exif_data = heif_file.info.get('exif', b'') - if not exif_data: - return None - import piexif - exif_dict = piexif.load(exif_data) - if piexif.ExifIFD.DateTimeOriginal in exif_dict.get('Exif', {}): - return exif_dict['Exif'][piexif.ExifIFD.DateTimeOriginal].decode() - except Exception: - return None - else: - from PIL import Image - from PIL.ExifTags import TAGS - - img = Image.open(filepath) - exif_data = img._getexif() - if not exif_data: - return None - - tag_map = {v: k for k, v in TAGS.items()} - datetime_original_tag = tag_map.get('DateTimeOriginal') - if datetime_original_tag and datetime_original_tag in exif_data: - return exif_data[datetime_original_tag] - - # Fallback: DateTime (если DateTimeOriginal нет) - datetime_tag = tag_map.get('DateTime') - if datetime_tag and datetime_tag in exif_data: - return exif_data[datetime_tag] - + exif_dict = piexif.load(str(path)) except Exception: return None + exif = exif_dict.get('Exif', {}) + raw = exif.get(piexif.ExifIFD.DateTimeOriginal) or exif.get(piexif.ExifIFD.DateTime) + if raw: + try: + return raw.decode('utf-8') + except Exception: + return None return None -def parse_date(date_str: str) -> datetime | None: - """Парсит EXIF дату в формате 'YYYY:MM:DD HH:MM:SS'.""" +def _read_jpeg_exif_pil(path: Path) -> str | None: + """Fallback: PIL-based EXIF read for JPEG (slower but more compatible).""" + try: + from PIL import Image + from PIL.ExifTags import TAGS + except ImportError: + return None + + try: + img = Image.open(path) + raw = img._getexif() + img.close() + except Exception: + return None + if not raw: + return None + + tag_map = {v: k for k, v in TAGS.items()} + for tag_name in ('DateTimeOriginal', 'DateTime'): + tag_code = tag_map.get(tag_name) + if tag_code and tag_code in raw: + return raw[tag_code] + return None + + +def _read_heic_exif(path: Path) -> str | None: + """HEIC: needs pillow-heif + piexif.""" + if not HAS_PILLOW_HEIF or not HAS_PIEXIF: + return None + try: + heif = open_heif(str(path)) + raw_bytes = heif.info.get('exif', b'') + if not raw_bytes: + return None + exif_dict = piexif.load(raw_bytes) + raw = exif_dict.get('Exif', {}).get(piexif.ExifIFD.DateTimeOriginal) + if raw: + return raw.decode('utf-8') + except Exception: + pass + return None + + +def _read_other_exif_pil(path: Path) -> str | None: + """Generic PIL-based EXIF read for TIFF, WebP, BMP.""" + try: + from PIL import Image + from PIL.ExifTags import TAGS + except ImportError: + return None + + try: + img = Image.open(path) + raw = img._getexif() + img.close() + except Exception: + return None + if not raw: + return None + + tag_map = {v: k for k, v in TAGS.items()} + for tag_name in ('DateTimeOriginal', 'DateTime'): + tag_code = tag_map.get(tag_name) + if tag_code and tag_code in raw: + return raw[tag_code] + return None + + +def _read_cr2_exif(path: Path) -> str | None: + """CR2 (Canon Raw): rawpy to read EXIF.""" + if not HAS_RAWPY: + return None + try: + with rawpy.imread(str(path)) as raw: + # rawpy doesn't expose parsed EXIF directly; + # try the raw_image metadata + pass + except Exception: + pass + + # Fallback: try piexif on CR2 (CR2 has a JPEG preview with EXIF) + if HAS_PIEXIF: + try: + exif_dict = piexif.load(str(path)) + raw_val = exif_dict.get('Exif', {}).get(piexif.ExifIFD.DateTimeOriginal) + if raw_val: + return raw_val.decode('utf-8') + except Exception: + pass + return None + + +# ── Date helpers ──────────────────────────────────────────────────────────── + +def _parse_date(date_str: str) -> datetime | None: + """Parse 'YYYY:MM:DD HH:MM:SS' to datetime.""" try: return datetime.strptime(date_str.strip(), '%Y:%m:%d %H:%M:%S') except (ValueError, AttributeError): return None -def organize(folder: Path, no_color: bool = False) -> None: - """Сканирует папку и сортирует изображения.""" - if not folder.exists() or not folder.is_dir(): - print(f'Ошибка: папка не существует — {folder}') - sys.exit(1) +def _get_created_date(path: Path) -> datetime | None: + """Fallback: file system creation date (st_ctime on Windows).""" + try: + ts = path.stat().st_ctime + return datetime.fromtimestamp(ts) + except Exception: + return None - files = [f for f in folder.iterdir() if f.is_file() and f.suffix.lower() in EXTENSIONS] + +# ── Core ───────────────────────────────────────────────────────────────────── + +def organize( + folder: Path, + *, + dry_run: bool = False, + no_color: bool = False, + group_by: str = 'day', + quiet: bool = False, +) -> int: + """Scan *folder* and sort images into year/month[/day] subfolders. + + Returns exit code (0 = ok, 1 = errors). + """ + if not folder.is_dir(): + print(f'Error: folder not found — {folder}') + return 1 + + # ── Scan ──────────────────────────────────────────────────────────────── + files: list[Path] = [ + f for f in folder.iterdir() + if f.is_file() and f.suffix.lower() in EXTENSIONS + ] files.sort() + if not files: + print(_c(' No supported files found.', _YELLOW, no_color=no_color)) + return 0 + + total = len(files) + + # ── Progress bar ──────────────────────────────────────────────────────── + use_tqdm = HAS_TQDM and not quiet and not dry_run + iterator: Any = files + if use_tqdm: + iterator = _tqdm(files, desc='Processing', unit='file', + bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} ' + '[{elapsed}<{remaining}, {rate_fmt}]') + + # ── State ─────────────────────────────────────────────────────────────── moved = 0 skipped = 0 - no_date = 0 + no_date_count = 0 exif_count = 0 fs_count = 0 year_counts: Counter[int] = Counter() + move_log: list[dict[str, str]] = [] - for filepath in files: - dt = None - date_source = None + start_time = time.time() + + for filepath in iterator: + dt: datetime | None = None from_exif = False + date_source_label: str | None = None - # 1. EXIF DateTimeOriginal - date_str = get_exif_date(filepath) - if date_str: - dt = parse_date(date_str) - if dt: - from_exif = True + ext = filepath.suffix.lower() - # 2. Fallback: дата создания файла (Windows: st_ctime) + # ── EXIF tier 1: JPEG — piexif (fastest) ──────────────────────── + if ext in ('.jpg', '.jpeg'): + date_str = _read_jpeg_exif_piexif(filepath) + if date_str: + dt = _parse_date(date_str) + if dt: + from_exif = True + + # ── EXIF tier 2: HEIC (pillow-heif + piexif) ──────────────────── + if dt is None and ext == '.heic': + date_str = _read_heic_exif(filepath) + if date_str: + dt = _parse_date(date_str) + if dt: + from_exif = True + + # ── EXIF tier 3: CR2/raw (piexif on JPEG preview) ────────────── + if dt is None and ext in ('.cr2', '.cr3'): + date_str = _read_cr2_exif(filepath) + if date_str: + dt = _parse_date(date_str) + if dt: + from_exif = True + + # ── EXIF tier 4: JPEG fallback (PIL) ─────────────────────────── + if dt is None and ext in ('.jpg', '.jpeg'): + date_str = _read_jpeg_exif_pil(filepath) + if date_str: + dt = _parse_date(date_str) + if dt: + from_exif = True + + # ── EXIF tier 5: other formats (TIFF, WebP, BMP, PNG) ────────── + if dt is None and ext not in ('.avi',): + date_str = _read_other_exif_pil(filepath) + if date_str: + dt = _parse_date(date_str) + if dt: + from_exif = True + + # ── Fallback: file creation date (always available) ──────────── if dt is None: - dt = get_file_created_date(filepath) + dt = _get_created_date(filepath) if dt: - date_source = 'дата создания файла' + date_source_label = 'Created Date' + # ── No date → skip ───────────────────────────────────────────── if dt is None: + msg = ( + f' [{_c("—", _RED, no_color=no_color)}] ' + f'{filepath.name} — no date, skipped' + ) + if not quiet: + print(msg) + no_date_count += 1 skipped += 1 - no_date += 1 - msg = f' [—] {filepath.name} — нет даты (EXIF + CreatedDate), оставлен на месте' - print(_colorize(msg, _RED, no_color)) continue - # Track source and year for final statistics + # ── Track stats ──────────────────────────────────────────────── if from_exif: exif_count += 1 else: fs_count += 1 year_counts[dt.year] += 1 - dest_dir = folder / str(dt.year) / f'{dt.month:02d}' / f'{dt.day:02d}' - dest_dir.mkdir(parents=True, exist_ok=True) + # ── Build destination ────────────────────────────────────────── + if group_by == 'month': + dest = folder / str(dt.year) / f'{dt.month:02d}' + else: + dest = folder / str(dt.year) / f'{dt.month:02d}' / f'{dt.day:02d}' - dest_path = dest_dir / filepath.name + dest_file = dest / filepath.name - # Если файл с таким именем уже есть — добавляем суффикс - if dest_path.exists(): + # Handle filename collisions + if dest_file.exists(): stem = filepath.stem suffix = filepath.suffix - counter = 1 + n = 1 while True: - dest_path = dest_dir / f'{stem}_{counter}{suffix}' - if not dest_path.exists(): + dest_file = dest / f'{stem}_{n}{suffix}' + if not dest_file.exists(): break - counter += 1 + n += 1 + + # ── Execute move or dry-run ──────────────────────────────────── + label = f'{dt.year}/{dt.month:02d}/{dt.day:02d}' if group_by == 'day' \ + else f'{dt.year}/{dt.month:02d}' + + if dry_run: + src_tag = f' ({date_source_label})' if date_source_label else '' + move_log.append({ + 'source': str(filepath), + 'destination': str(dest_file), + 'date': str(dt), + 'date_source': 'EXIF' if from_exif else (date_source_label or '?'), + }) + if not quiet and not use_tqdm: + marker = _c('D', _CYAN, no_color=no_color) + icon = _c('✓', _GREEN, no_color=no_color) if from_exif \ + else _c('✓', _YELLOW, no_color=no_color) + src_tag = f' ({date_source_label})' if date_source_label else '' + msg = f' [{marker}] {filepath.name} → {label}{src_tag}' + print(msg) + moved += 1 + continue + + # Real move + dest.mkdir(parents=True, exist_ok=True) + try: + shutil.move(str(filepath), str(dest_file)) + except OSError as e: + msg = ( + f' [{_c("✗", _RED, no_color=no_color)}] ' + f'{filepath.name} — move failed: {e}' + ) + if not quiet: + print(msg) + skipped += 1 + continue + + move_log.append({ + 'source': str(filepath), + 'destination': str(dest_file), + 'date': str(dt), + 'date_source': 'EXIF' if from_exif else (date_source_label or '?'), + }) + + if not quiet and not use_tqdm: + src_tag = f' ({date_source_label})' if date_source_label else '' + colour = _GREEN if from_exif else _YELLOW + msg = ( + f' [{_c("✓", colour, no_color=no_color)}] ' + f'{filepath.name} → {label}{src_tag}' + ) + print(msg) - shutil.move(str(filepath), str(dest_path)) moved += 1 - src = f' ({date_source})' if date_source else '' - msg = f' [✓] {filepath.name} → {dt.year}/{dt.month:02d}/{dt.day:02d}/{src}' - if from_exif: - # Green — valid EXIF date - print(_colorize(msg, _GREEN, no_color)) - else: - # Yellow — fallback to file system creation date - print(_colorize(msg, _YELLOW, no_color)) + if not dry_run and not quiet and use_tqdm and isinstance(iterator, _tqdm): + iterator.set_postfix(moved=moved, skipped=skipped) + # ── Summary ───────────────────────────────────────────────────────── + elapsed = time.time() - start_time print() - print('=== Year Summary ===') + print(_c('=== Year Summary ===', _BOLD, no_color=no_color)) if year_counts: for year in sorted(year_counts): - print(f' {year}: {year_counts[year]} entries') + print(f' {year}: {year_counts[year]} files') else: print(' (no entries with a determinable year)') print() - print(f' Total with EXIF date: {exif_count}') - print(f' Total with FS date: {fs_count}') - print(f' Total skipped: {skipped}') - print(f' Total processed: {moved + skipped}') - print() - print(f'Готово! Перемещено: {moved}, пропущено: {skipped} (без даты: {no_date})') + mode_label = 'DRY-RUN (no files moved)' if dry_run else '' + if mode_label: + print(_c(mode_label, _CYAN, no_color=no_color)) + + print(f' EXIF date: {exif_count}') + print(f' Created Date: {fs_count}') + print(f' Skipped (no date): {skipped}') + print(f' Total processed: {moved + skipped}') + print(f' Time: {elapsed:.1f}s') + print() + + if dry_run: + print(_c(f'Dry-run: {moved} would be moved, {skipped} skipped (no date).', + _CYAN, no_color=no_color)) + else: + print(f'Done! Moved: {moved}, skipped: {skipped} (no date: {no_date_count})') + + # ── Write move log ────────────────────────────────────────────────── + if move_log and not dry_run: + log_path = folder / '.raskidator-log.csv' + undo_path = folder / 'raskidator-undo.bat' + _write_logs(move_log, log_path, undo_path) + print() + print(_c(f' Log: {log_path}', _CYAN, no_color=no_color)) + print(_c(f' Undo: {undo_path} (run to restore files)', + _CYAN, no_color=no_color)) + + return 0 + + +# ── Logging helpers ───────────────────────────────────────────────────────── + +def _write_logs(entries: list[dict[str, str]], + log_path: Path, + undo_path: Path) -> None: + """Write CSV move log + undo.bat.""" + fieldnames = ['source', 'destination', 'date', 'date_source'] + with log_path.open('w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(entries) + + # Undo .bat — restores files in reverse order of columns + with undo_path.open('w', encoding='utf-8') as f: + f.write('@echo off\r\n') + f.write('title Raskidator-3000 — Undo move\r\n') + f.write('echo Restoring files...\r\n') + f.write('echo.\r\n') + # Reverse: move destination → source + for entry in reversed(entries): + src = entry['source'] + dst = entry['destination'] + f.write(f'move "{dst}" "{src}" >nul 2>&1\r\n') + if not f.write: # impossible but keep mypy happy + pass + f.write('echo.\r\n') + f.write(f'echo Restored {len(entries)} files.\r\n') + f.write('pause\r\n') + + +# ── CLI entry point ───────────────────────────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser( prog='organize.py', - description='Raskidator-3000 — сортировка фотографий по EXIF дате.', + description='Raskidator-3000 — sort images by EXIF date.', add_help=True, ) parser.add_argument( 'path', nargs='?', default=None, - help='Путь к папке с фотографиями (по умолчанию текущая папка)', + help='Folder to scan (default: current directory)', ) parser.add_argument( '--no-color', action='store_true', default=False, - help='Отключить цветной вывод ANSI (для совместимости с пайпами)', + help='Disable ANSI color output (for pipes)', + ) + parser.add_argument( + '--dry-run', + action='store_true', + default=False, + help='Show what would be moved without actually moving', + ) + parser.add_argument( + '--quiet', '-q', + action='store_true', + default=False, + help='Suppress per-file output (shows summary only)', + ) + parser.add_argument( + '--group-by', + choices=('day', 'month'), + default='day', + help='Output folder granularity: year/month/day (default) or year/month', ) args = parser.parse_args() folder = Path(args.path) if args.path else Path.cwd() - no_color = args.no_color - - print(f'Raskidator-3000 — сортировка фотографий') - print(f'Папка: {folder.resolve()}') + print(_c('Raskidator-3000', _BOLD, no_color=args.no_color)) + print(f' Folder: {folder.resolve()}') + if args.dry_run: + print(_c(' Mode: DRY-RUN', _CYAN, no_color=args.no_color)) print() - organize(folder, no_color=no_color) + + rc = organize( + folder, + dry_run=args.dry_run, + no_color=args.no_color, + group_by=args.group_by, + quiet=args.quiet, + ) + sys.exit(rc) if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index c5eff41..bf054dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ -Pillow>=10.0.0 -piexif>=1.1.3 -pillow-heif>=0.16.0 +Pillow +piexif>=1.1.2 +tqdm +rawpy; sys_platform == 'win32' +pillow-heif; sys_platform == 'win32'