Сейчас приводил в порядок (да и в процессе еще…) свой архив и “забытые” места хранения фото/видео и в помощь навайбкодил это, сильно помогло (пути на свои поменяйте ну или придётся создать как у меня
)
В связи с ограничением форума
Вот файл organize_photos_v9.py
import os
import shutil
import subprocess
import hashlib
import json
import re
from datetime import datetime, timedelta
from pathlib import Path
from PIL import Image
# ================= НАСТРОЙКИ =================
SOURCE = r"Z:\ФОТО_РАБОЧАЯ_КОПИЯ\Все файлы\РАБОЧАЯ_ОБРАБОТКА\SOURCE"
TARGET = r"Z:\ФОТО_РАБОЧАЯ_КОПИЯ\Все файлы\РАБОЧАЯ_ОБРАБОТКА\SORTED"
EXIFTOOL = r"C:\Users\PC\Pictures\exiftool-13.59_64\exiftool.exe"
FFPROBE = r"C:\Users\PC\Pictures\exiftool-13.59_64\ffmpeg-2026-07-23-git-80eb9e99b9-full_build\bin\ffprobe.exe"
# Временная ASCII-папка для записи метаданных exiftool-ом. Кириллица в пути
# (у нас Z:\ФОТО_РАБОЧАЯ_КОПИЯ\...) иногда мешает exiftool.exe на Windows,
# поэтому файл сначала копируется сюда, правится и перемещается обратно.
TEMP_DIR = r"C:\Temp\exif_work"
# Дописывать дату в метаданные, если она отсутствует/нереальна, но надёжно
# (с датой И временем) распознана в имени файла
WRITE_METADATA_IF_MISSING = True
# Метаданные с датой ДО этого года считаются нереальными (цифровых фото/видео
# с настоящей датой съёмки раньше практически не бывает — стандарт Exif с 1995 г.)
MIN_VALID_METADATA_YEAR = 1990
# Типичные "сброшенные" даты прошивок при севшей батарейке/часах (ровно 00:00:00
# на 1 января этих годов) — тоже считаются нереальными, даже если год валидный
SUSPICIOUS_RESET_DATES = {
(1970, 1, 1), (1980, 1, 1), (2000, 1, 1),
(2002, 1, 1), (2004, 1, 1), (2010, 1, 1),
}
# Допустимая разница между датой из имени файла и датой из метаданных, при
# которой они считаются "тем же событием", а не конфликтом. Для видео порог
# шире: creation_time в метаданных обычно хранится в UTC, а в имени файла —
# локальное время камеры/телефона, разница может доходить до нескольких часов
# просто из-за часового пояса (не путать с настоящей ошибкой).
PHOTO_DIFF_TOLERANCE = timedelta(minutes=15)
VIDEO_DIFF_TOLERANCE = timedelta(hours=3)
# ============================================
os.makedirs(SOURCE, exist_ok=True)
os.makedirs(TARGET, exist_ok=True)
os.makedirs(TEMP_DIR, exist_ok=True)
PHOTO_EXT = ('.jpg', '.jpeg', '.tif', '.tiff', '.png', '.heic', '.heif')
VIDEO_EXT = ('.mp4', '.mov', '.avi', '.mkv', '.wmv', '.m4v', '.3gp', '.mpg', '.mpeg')
def write_log(text, log_file):
with open(log_file, "a", encoding="utf-8") as f:
f.write("\n" + "=" * 70 + "\n")
f.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n")
f.write(text + "\n")
def clean_date_prefix(filename):
"""Убирает префикс вида ГГГГ-ММ-ДД_ЧЧ-ММ-СС_, который сам скрипт добавляет
при сортировке — чтобы повторный прогон не путался в датах (не цеплялся за
уже добавленный префикс) и не плодил дублирующиеся префиксы."""
name, ext = os.path.splitext(filename)
pattern = r'^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})_'
cleaned = re.sub(pattern, '', name)
if cleaned == name or len(cleaned.strip()) < 3:
return filename
return cleaned + ext
def get_date_from_filename(filename):
"""Извлечение даты из (уже очищенного от старого префикса) имени файла.
Возвращает (date, has_time):
has_time = True — распознаны И дата, И время (можно доверять для записи
обратно в метаданные)
has_time = False — распознана только дата, время — заглушка 00:00:00
(для метаданных использовать нельзя)
"""
name = Path(filename).stem
# 1. Полный формат ГГГГ-ММ-ДД_ЧЧ-ММ-СС
m = re.search(r'(\d{4})[-_](\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})', name)
if m:
try:
dt = datetime(*map(int, m.groups()))
return dt, True
except:
pass
# 2. VID_/IMG_/Screenshot_ + ГГГГММДД_ЧЧММСС
m = re.search(r'(?:VID_|IMG_|Screenshot_)?(\d{4})(\d{2})(\d{2})[_-]?(\d{2})(\d{2})(\d{2})', name)
if m:
try:
dt = datetime(*map(int, m.groups()))
return dt, True
except:
pass
# 3. Старый формат 3gp: 23-11-10_0920 -> 2010-11-23 09:20
m = re.search(r'(\d{2})[-_](\d{2})[-_](\d{2})[-_]?(\d{2})(\d{2})', name)
if m:
try:
day, month, year, hour, minute = map(int, m.groups())
if year < 100:
year += 2000
if (2000 <= year <= 2030 and 1 <= month <= 12 and 1 <= day <= 31 and
0 <= hour <= 23 and 0 <= minute <= 59):
return datetime(year, month, day, hour, minute), True
except:
pass
# 4. Только дата ГГГГММДД / ГГГГ-ММ-ДД — без времени, для метаданных не годится
m = re.search(r'(\d{4})[-_]?(\d{2})[-_]?(\d{2})', name)
if m:
try:
y, mo, d = map(int, m.groups())
if 1980 <= y <= 2030 and 1 <= mo <= 12 and 1 <= d <= 31:
return datetime(y, mo, d), False
except:
pass
return None, False
def is_metadata_date_valid(date):
"""Проверка, похожа ли дата из EXIF/видео-метаданных на настоящую дату
съёмки, а не на 'мусор' (нет метаданных / сброшенные часы камеры)."""
if date is None:
return False
if date.year < MIN_VALID_METADATA_YEAR:
return False
if date > datetime.now():
return False
if ((date.year, date.month, date.day) in SUSPICIOUS_RESET_DATES and
date.hour == 0 and date.minute == 0 and date.second == 0):
return False
return True
def get_date_from_exif(filepath):
"""Только DateTimeOriginal и CreateDate"""
try:
with Image.open(filepath) as img:
exif = img._getexif()
if not exif:
return None
for tag in (36867, 36868): # DateTimeOriginal, CreateDate/DateTimeDigitized
dt = exif.get(tag)
if dt:
try:
return datetime.strptime(str(dt)[:19], "%Y:%m:%d %H:%M:%S")
except:
pass
except:
pass
return None
def get_date_from_video(filepath):
"""Дата из метаданных видео через ffprobe (creation_time, обычно UTC)."""
try:
cmd = [
FFPROBE, "-v", "quiet", "-print_format", "json",
"-show_entries", "format_tags=creation_time:stream_tags=creation_time",
filepath
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="ignore")
if result.returncode != 0:
return None
data = json.loads(result.stdout)
creation = None
if "format" in data and "tags" in data["format"]:
creation = data["format"]["tags"].get("creation_time")
if not creation and "streams" in data:
for stream in data.get("streams", []):
if "tags" in stream and "creation_time" in stream["tags"]:
creation = stream["tags"]["creation_time"]
break
if creation:
creation = creation.replace("T", " ").replace("Z", "").split(".")[0]
return datetime.strptime(creation[:19], "%Y-%m-%d %H:%M:%S")
except:
pass
return None
def decide_date(filepath, clean_name, ext):
"""Сопоставляет дату из имени файла и дату из метаданных.
Возвращает (date, source, conflict, has_time):
source: 'name+meta' (совпали в допуске, метаданные валидны — трогать не нужно)
| 'meta' (метаданные валидны, имя не дало даты — не трогаем)
| 'name' (метаданные нет/невалидны, дата только из имени —
кандидат на дозапись в метаданные)
| 'none' (даты нет вообще)
conflict: True, если и имя, и валидные метаданные дают дату, но расходятся
больше допуска -> файл уходит в KONFLICT для ручной проверки,
а не получает случайно выбранную дату
"""
name_date, has_time = get_date_from_filename(clean_name)
if ext in PHOTO_EXT:
meta_date_raw = get_date_from_exif(filepath)
else:
meta_date_raw = get_date_from_video(filepath)
meta_ok = is_metadata_date_valid(meta_date_raw)
tolerance = VIDEO_DIFF_TOLERANCE if ext in VIDEO_EXT else PHOTO_DIFF_TOLERANCE
if name_date and meta_ok:
diff = abs(name_date - meta_date_raw)
if diff <= tolerance:
# Согласуются (с учётом допуска, например часовой пояс у видео).
# Метаданные уже валидны -- ничего чинить не нужно.
return name_date, "name+meta", False, has_time
else:
return None, "conflict", True, has_time
if meta_ok:
return meta_date_raw, "meta", False, has_time
if name_date:
return name_date, "name", False, has_time
return None, "none", False, has_time
def write_metadata_safe(filepath, dt, ext, log_file):
"""Пишет дату в метаданные через временную ASCII-папку (обход проблем
exiftool.exe на Windows с кириллицей в пути)."""
temp_path = None
try:
file_ext = Path(filepath).suffix
temp_name = f"tmp_{os.getpid()}_{datetime.now().strftime('%H%M%S%f')}{file_ext}"
temp_path = os.path.join(TEMP_DIR, temp_name)
shutil.copy2(filepath, temp_path)
dt_str = dt.strftime("%Y:%m:%d %H:%M:%S")
if ext in PHOTO_EXT:
cmd = [
EXIFTOOL, "-overwrite_original",
f"-DateTimeOriginal={dt_str}",
f"-CreateDate={dt_str}",
temp_path,
]
else:
cmd = [
EXIFTOOL, "-overwrite_original",
f"-CreateDate={dt_str}",
f"-MediaCreateDate={dt_str}",
f"-TrackCreateDate={dt_str}",
temp_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="ignore")
if result.returncode != 0:
write_log(
f"НЕ УДАЛОСЬ ЗАПИСАТЬ МЕТАДАННЫЕ:\n{filepath}\n{result.stderr.strip()}",
log_file
)
if os.path.exists(temp_path):
os.remove(temp_path)
return False
shutil.move(temp_path, filepath)
write_log(f"МЕТАДАННЫЕ ДОБАВЛЕНЫ ({dt_str}):\n{filepath}", log_file)
return True
except Exception as e:
write_log(f"ОШИБКА ЗАПИСИ МЕТАДАННЫХ:\n{filepath}\n{e}", log_file)
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
return False
def compute_hash(filepath):
sha = hashlib.sha256()
with open(filepath, "rb") as f:
while True:
block = f.read(1024 * 1024)
if not block:
break
sha.update(block)
return sha.hexdigest()
def files_are_identical(a, b):
try:
if os.path.getsize(a) != os.path.getsize(b):
return False
return compute_hash(a) == compute_hash(b)
except:
return False
def move_to_special(filepath, special_name, base_path, log_file):
"""Перемещает файл в служебную подпапку (DUBLIKATY или KONFLICT)."""
special_dir = os.path.join(base_path, special_name)
os.makedirs(special_dir, exist_ok=True)
filename = os.path.basename(filepath)
destination = os.path.join(special_dir, filename)
counter = 1
while os.path.exists(destination):
name, ext = os.path.splitext(filename)
destination = os.path.join(special_dir, f"{name}_{counter:02d}{ext}")
counter += 1
shutil.move(filepath, destination)
write_log(f"{special_name}\nFROM:\n{filepath}\nTO:\n{destination}", log_file)
print(f"→ {special_name}: {filename}")
return destination
def resolve_collision(filepath, target_dir, filename):
target = os.path.join(target_dir, filename)
if not os.path.exists(target):
return target
if files_are_identical(filepath, target):
return None
name, ext = os.path.splitext(filename)
i = 1
while True:
candidate = os.path.join(target_dir, f"{name}_{i:02d}{ext}")
if not os.path.exists(candidate):
return candidate
if files_are_identical(filepath, candidate):
return None
i += 1
# ================= ОСНОВНОЙ КОД =================
print("=" * 60)
print(" Сортировка фото и видео (v9)")
print("=" * 60)
print("\n1. Откуда копировать файлы в SOURCE?")
print(" Оставьте пустым, если SOURCE уже заполнен")
from_path = input("Путь к папке с альбомом: ").strip().strip('"')
process_path = SOURCE
log_file = os.path.join(SOURCE, "sort_log.txt")
if from_path:
if not os.path.exists(from_path):
print("Ошибка: папка не найдена")
input()
exit()
album_folder_name = os.path.basename(from_path.rstrip("\\/"))
dest_album = os.path.join(SOURCE, album_folder_name)
print("\nКопирование альбома:")
print(from_path)
print("→")
print(dest_album)
for root, dirs, files in os.walk(from_path):
rel = os.path.relpath(root, from_path)
dest_dir = dest_album if rel == "." else os.path.join(dest_album, rel)
os.makedirs(dest_dir, exist_ok=True)
for file in files:
src = os.path.join(root, file)
dst = os.path.join(dest_dir, file)
if not os.path.exists(dst):
shutil.copy2(src, dst)
process_path = dest_album
log_file = os.path.join(dest_album, "sort_log.txt")
print("Копирование завершено")
write_log("НАЧАЛО НОВОГО ЗАПУСКА (v9)", log_file)
print("\n2. Использовать имя папки как альбом?")
print("1 - Да")
print("2 - Нет")
choice = input("Выбор: ").strip()
use_album = choice != "2"
print("\nНачало обработки\n")
moved = 0
duplicates = 0
conflicts = 0
no_date = 0
metadata_added = 0
for root, dirs, files in os.walk(process_path):
# не заходим в служебные папки
dirs[:] = [d for d in dirs if d.upper() not in ("DUBLIKATY", "KONFLICT")]
for file in files:
ext = os.path.splitext(file)[1].lower()
if ext not in PHOTO_EXT + VIDEO_EXT:
continue
filepath = os.path.join(root, file)
# определяем альбом
if use_album:
if process_path != SOURCE:
album_name = os.path.basename(process_path)
else:
rel = os.path.relpath(root, SOURCE)
album_name = rel.split(os.sep)[0] if rel != "." else None
else:
album_name = None
# ===== 1. Сначала очищаем имя от старого префикса (если это повторный прогон) =====
clean_name = clean_date_prefix(file)
# ===== 2. Сопоставляем дату из имени и из метаданных =====
date, source, is_conflict, has_time = decide_date(filepath, clean_name, ext)
if is_conflict:
move_to_special(filepath, "KONFLICT", process_path, log_file)
conflicts += 1
write_log(
f"КОНФЛИКТ (имя vs метаданные, расхождение больше допуска):\n"
f"{filepath}\nОчищенное имя: {clean_name}",
log_file
)
print(f"⚠ {file} — конфликт даты (имя vs метаданные), перемещено в KONFLICT")
continue
if not date:
no_date += 1
# Если у файла уже был префикс с датой (добавленный ранее, но который
# мы сейчас не смогли подтвердить) -- снимаем его на месте, чтобы не
# оставлять в имени ложную метку времени. Файл специально НЕ переносим
# в отдельную папку -- он остаётся в своей исходной папке/альбоме,
# потому что именно это окружение может подсказать реальную дату при
# ручной проверке.
if clean_name != file:
new_path_same_dir = os.path.join(root, clean_name)
if os.path.exists(new_path_same_dir) and new_path_same_dir != filepath:
base_cn, ext_cn = os.path.splitext(clean_name)
i = 1
candidate = new_path_same_dir
while os.path.exists(candidate):
candidate = os.path.join(root, f"{base_cn}_{i:02d}{ext_cn}")
i += 1
new_path_same_dir = candidate
os.rename(filepath, new_path_same_dir)
write_log(
f"СНЯТ НЕПОДТВЕРЖДЁННЫЙ ПРЕФИКС ДАТЫ:\n{filepath}\n->\n{new_path_same_dir}",
log_file
)
filepath = new_path_same_dir
display_name = os.path.basename(filepath)
else:
display_name = file
print(f"✗ {display_name} — нет даты, оставлено")
write_log(f"БЕЗ ДАТЫ:\n{filepath}\nОчищенное имя: {clean_name}", log_file)
continue
# ===== 3. Дозаписываем метаданные, только если их реально не было/были "мусорными"
# И дата из имени файла надёжна (есть время, а не заглушка 00:00:00) =====
if WRITE_METADATA_IF_MISSING and source == "name" and has_time:
if write_metadata_safe(filepath, date, ext, log_file):
metadata_added += 1
print(f" ↳ метаданные добавлены из имени файла: {date}")
# ===== 4. Переименование и перемещение =====
date_prefix = date.strftime("%Y-%m-%d_%H-%M-%S")
new_filename = f"{date_prefix}_{clean_name}"
year_month = date.strftime("%Y\\%Y-%m")
if album_name:
target_dir = os.path.join(TARGET, year_month, album_name)
else:
target_dir = os.path.join(TARGET, year_month)
os.makedirs(target_dir, exist_ok=True)
target_path = resolve_collision(filepath, target_dir, new_filename)
if target_path is None:
move_to_special(filepath, "DUBLIKATY", process_path, log_file)
duplicates += 1
continue
final_name = os.path.basename(target_path)
shutil.move(filepath, target_path)
moved += 1
print(f"✓ {file} → {final_name} [{source}]")
write_log(
f"ПЕРЕМЕЩЕНИЕ\nFROM:\n{filepath}\nTO:\n{target_path}\n"
f"Очищенное имя: {clean_name}\nИсточник даты: {source}",
log_file
)
print("\n============================")
print("ЗАВЕРШЕНО")
print("============================")
print(f"Перемещено : {moved}")
print(f"Метаданных добавлено : {metadata_added}")
print(f"Дубликатов : {duplicates}")
print(f"Конфликтов (KONFLICT) : {conflicts}")
print(f"Без даты (оставлено на месте) : {no_date}")
print(f"\nЛог: {log_file}")
write_log(
f"ЗАВЕРШЕНИЕ\nПеремещено: {moved}\nМетаданных добавлено: {metadata_added}\n"
f"Дубликатов: {duplicates}\nКонфликтов: {conflicts}\nБез даты: {no_date}",
log_file
)
input("\nНажмите Enter...")
Вот добавлю описание скрипта (поручил ИИшке но думаю разберетесь… единственое она забыла указать, что скрипт сначала копирует альбом в папку для разбора и только с этой копией и работает - оригинальная папка не затрагивается при этом копирование и переносы сохраняют дату самих файлов)…
Скрипт organize_photos_v9.py — это инструмент для автоматической сортировки и упорядочивания большого количества фотографий и видео по дате съёмки.
Что делает скрипт
Берёт файлы из указанной папки (или копирует туда альбом по запросу).
Определяет дату для каждого файла двумя способами:
из имени файла (поддерживает много популярных форматов: YYYY-MM-DD_HH-MM-SS, IMG_/VID_/Screenshot_YYYYMMDD_HHMMSS, старые форматы 3gp и т.д.);
из метаданных (EXIF для фото, creation_time для видео через ffprobe).
Сравнивает эти две даты:
если они совпадают (с допуском: 15 минут для фото, 3 часа для видео — чтобы учитывать часовые пояса) — берёт эту дату;
если метаданные валидные, а в имени даты нет — использует метаданные;
если даты есть только в имени и они надёжные (есть и дата, и время) — использует имя и может записать дату в метаданные;
если даты сильно расходятся — отправляет файл в папку KONFLICT для ручной проверки.
Очищает старые префиксы даты, которые сам же раньше добавлял (чтобы повторные запуски не ломались).
Переименовывает файл в формат YYYY-MM-DD_HH-MM-SS_оригинальное_имя.
Раскладывает по структуре:
SORTED/
2023/
2023-07/
[Название_альбома]/
2023-07-15_14-32-01_IMG_1234.jpg
Обрабатывает дубликаты — сравнивает файлы по размеру + SHA-256. Точные копии уходят в DUBLIKATY.
Ведёт подробный лог всех действий.
Основные плюсы
Умная работа с датами — не слепо верит ни имени файла, ни метаданным. Учитывает типичные «мусорные» даты (сброшенные часы камеры, 1970/1980/2000 годы и т.д.).
Безопасность — конфликты дат и дубликаты не перезаписываются молча, а откладываются в отдельные папки.
Дописывание метаданных — если в файле нет нормальной даты съёмки, но она надёжно извлечена из имени (с временем), скрипт может прописать её в EXIF/видео-теги. Это удобно для последующей работы в программах, которые смотрят только на метаданные.
Повторные запуски — корректно обрабатывает уже отсортированные файлы (снимает старый префикс даты).
Поддержка альбомов — можно сохранять исходную структуру папок как названия альбомов.
Подробное логирование — всё записывается в sort_log.txt.
Работа с кириллицей — обходит известные проблемы exiftool с русскими путями через временную ASCII-папку.
Гибкость — можно сначала скопировать альбом в рабочую папку или работать уже с заполненным SOURCE.
Для кого подходит
Людям, у которых накопилось много разрозненных фото и видео с телефонов, камер, старых архивов, и нужно привести всё к единому порядку «по дате + по альбомам» с минимальным риском потерять или перепутать файлы.
Скрипт рассчитан на Windows (пути, exiftool.exe, ffprobe.exe), требует установленных ExifTool и FFmpeg/ffprobe.