- Green for entries with valid EXIF date - Yellow for entries with file system creation fallback - Red for skipped entries (no date found) - --no-color flag disables all ANSI escape sequences - Flag works before or after the path argument - Switched from sys.argv to argparse for proper flag parsing
226 lines
7.4 KiB
Python
226 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Raskidator-3000 — сортировка фотографий по EXIF дате.
|
|
|
|
Сканирует указанную папку (по умолчанию текущую) на изображения,
|
|
читает EXIF дату съёмки (DateTimeOriginal) и перемещает файлы
|
|
в подпапки Год/Месяц/День.
|
|
|
|
Файлы без EXIF даты остаются на месте.
|
|
|
|
Поддерживаемые форматы: .jpg, .jpeg, .png, .heic, .tiff, .tif, .webp, .bmp
|
|
|
|
Использование:
|
|
python organize.py [путь_к_папке]
|
|
python organize.py --no-color [путь_к_папке]
|
|
python organize.py [путь_к_папке] --no-color
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import shutil
|
|
from collections import Counter
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
EXTENSIONS = {'.jpg', '.jpeg', '.png', '.heic', '.tiff', '.tif', '.webp', '.bmp'}
|
|
|
|
# ANSI color codes
|
|
_GREEN = '\033[32m'
|
|
_YELLOW = '\033[33m'
|
|
_RED = '\033[31m'
|
|
_RESET = '\033[0m'
|
|
|
|
|
|
def _colorize(text: str, color: str, no_color: bool) -> str:
|
|
"""Wrap *text* in ANSI *color*, unless *no_color* is True."""
|
|
if no_color:
|
|
return text
|
|
return f'{color}{text}{_RESET}'
|
|
|
|
|
|
def get_file_created_date(filepath: Path) -> datetime | None:
|
|
"""Возвращает дату создания файла из файловой системы (Windows: st_ctime)."""
|
|
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]
|
|
|
|
except Exception:
|
|
return None
|
|
|
|
return None
|
|
|
|
|
|
def parse_date(date_str: str) -> datetime | None:
|
|
"""Парсит EXIF дату в формате 'YYYY:MM:DD HH:MM:SS'."""
|
|
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)
|
|
|
|
files = [f for f in folder.iterdir() if f.is_file() and f.suffix.lower() in EXTENSIONS]
|
|
files.sort()
|
|
|
|
moved = 0
|
|
skipped = 0
|
|
no_date = 0
|
|
exif_count = 0
|
|
fs_count = 0
|
|
year_counts: Counter[int] = Counter()
|
|
|
|
for filepath in files:
|
|
dt = None
|
|
date_source = None
|
|
from_exif = False
|
|
|
|
# 1. EXIF DateTimeOriginal
|
|
date_str = get_exif_date(filepath)
|
|
if date_str:
|
|
dt = parse_date(date_str)
|
|
if dt:
|
|
from_exif = True
|
|
|
|
# 2. Fallback: дата создания файла (Windows: st_ctime)
|
|
if dt is None:
|
|
dt = get_file_created_date(filepath)
|
|
if dt:
|
|
date_source = 'дата создания файла'
|
|
|
|
if dt is None:
|
|
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
|
|
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)
|
|
|
|
dest_path = dest_dir / filepath.name
|
|
|
|
# Если файл с таким именем уже есть — добавляем суффикс
|
|
if dest_path.exists():
|
|
stem = filepath.stem
|
|
suffix = filepath.suffix
|
|
counter = 1
|
|
while True:
|
|
dest_path = dest_dir / f'{stem}_{counter}{suffix}'
|
|
if not dest_path.exists():
|
|
break
|
|
counter += 1
|
|
|
|
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))
|
|
|
|
print()
|
|
print('=== Year Summary ===')
|
|
if year_counts:
|
|
for year in sorted(year_counts):
|
|
print(f' {year}: {year_counts[year]} entries')
|
|
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})')
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
prog='organize.py',
|
|
description='Raskidator-3000 — сортировка фотографий по EXIF дате.',
|
|
add_help=True,
|
|
)
|
|
parser.add_argument(
|
|
'path',
|
|
nargs='?',
|
|
default=None,
|
|
help='Путь к папке с фотографиями (по умолчанию текущая папка)',
|
|
)
|
|
parser.add_argument(
|
|
'--no-color',
|
|
action='store_true',
|
|
default=False,
|
|
help='Отключить цветной вывод ANSI (для совместимости с пайпами)',
|
|
)
|
|
|
|
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()
|
|
organize(folder, no_color=no_color)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|