548 lines
18 KiB
Python
548 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Raskidator-3000 — сортировка изображений по EXIF дате.
|
|
|
|
Сканирует папку, читает дату съёмки (EXIF DateTimeOriginal)
|
|
и перемещает файлы в подпапки Год/Месяц/День.
|
|
|
|
Поддерживаемые форматы:
|
|
JPEG, PNG, HEIC, TIFF, WebP, BMP, CR2 (Canon Raw), AVI
|
|
|
|
Использование:
|
|
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 shutil
|
|
import sys
|
|
import time
|
|
from collections import Counter
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# ── Optional imports ────────────────────────────────────────────────────────
|
|
|
|
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 _c(text: str, colour: str, *, no_color: bool = False) -> str:
|
|
"""Colourise *text* unless *no_color* is True."""
|
|
if no_color:
|
|
return text
|
|
return f'{colour}{text}{_RESET}'
|
|
|
|
|
|
# ── 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:
|
|
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 _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 _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
|
|
|
|
|
|
# ── 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_count = 0
|
|
exif_count = 0
|
|
fs_count = 0
|
|
year_counts: Counter[int] = Counter()
|
|
move_log: list[dict[str, str]] = []
|
|
|
|
start_time = time.time()
|
|
|
|
for filepath in iterator:
|
|
dt: datetime | None = None
|
|
from_exif = False
|
|
date_source_label: str | None = None
|
|
|
|
ext = filepath.suffix.lower()
|
|
|
|
# ── 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_created_date(filepath)
|
|
if dt:
|
|
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
|
|
continue
|
|
|
|
# ── Track stats ────────────────────────────────────────────────
|
|
if from_exif:
|
|
exif_count += 1
|
|
else:
|
|
fs_count += 1
|
|
year_counts[dt.year] += 1
|
|
|
|
# ── 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_file = dest / filepath.name
|
|
|
|
# Handle filename collisions
|
|
if dest_file.exists():
|
|
stem = filepath.stem
|
|
suffix = filepath.suffix
|
|
n = 1
|
|
while True:
|
|
dest_file = dest / f'{stem}_{n}{suffix}'
|
|
if not dest_file.exists():
|
|
break
|
|
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)
|
|
|
|
moved += 1
|
|
|
|
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(_c('=== Year Summary ===', _BOLD, no_color=no_color))
|
|
if year_counts:
|
|
for year in sorted(year_counts):
|
|
print(f' {year}: {year_counts[year]} files')
|
|
else:
|
|
print(' (no entries with a determinable year)')
|
|
print()
|
|
|
|
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 — sort images by EXIF date.',
|
|
add_help=True,
|
|
)
|
|
parser.add_argument(
|
|
'path',
|
|
nargs='?',
|
|
default=None,
|
|
help='Folder to scan (default: current directory)',
|
|
)
|
|
parser.add_argument(
|
|
'--no-color',
|
|
action='store_true',
|
|
default=False,
|
|
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()
|
|
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()
|
|
|
|
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__':
|
|
main()
|