Предотвращение загрузки и создания дубликата фотографий в immich

Дано:

  1. Сервер immich, на котором синхронизируются фотографии с телефонов.

  2. Свалка фотографий на компьютере, которые копились до появления в моей жизни самохостинга и всех сопутствующих плюшек.

Много фотографий с телефонов так же лежат в этой свалке на ПК.
Если отправить их в immich то он конечно не станет грузить дубликаты, но сделает копии фотографий в том месте куда они загружаются. Вручную чистить это все невозможно.
Очень хотелось бы чтобы он не только не грузил дубликаты но и не создавал копии.
Может кто сталкивался с такой проблемой и имеет решение?

Нормального решения нет, есть сторонние костыли.

  1. можно найти что-то для дедупликации файлов, условно, скрипт пробегается по файлам, находит дубликаты и создает хард линки. Это когда несколько имен файлов ссылаются на один блок данных на диске. Но при этом если изменить 1 файл, то поменяются и другие. Удаление же файла физически происходит когда счетчик указателей на него обнуляется.
  2. Дедупликация на уровне ZFS и других ФС
    Вот тут у меня есть dataset с фотками, причем, есть “Разобрать” и “С камеры”, “NEW” и распределенные по каталогам, надо добраться и почистить все + частично загружаю эти фотки в immich
    Вот пишет, что 37% от данных в пуле определены как дубликаты
% zpool get dedupratio data                  
NAME  PROPERTY    VALUE  SOURCE
data  dedupratio  1.37x  -

хорошая идея самому поискать дубликаты на самих дисках. Если найду рабочий вариант то поделюсь

Нашел 2 варианта, доступных в репозиториях, лично не пробовал

rdfind

Установка

apt install rdfind

Поиск дубликатов

rdfind -dryrun true /path/to/directory

Поиск и замена хардлинками

rdfind -makehardlinks true /path/to/directory

fdupes

Установка

apt install fdupes

Поиск дубликатов

fdupes -r /path/to/directory

Поиск и замена хардлинками

fdupes -rL /path/to/directory

А чем не подходит разбор дубликатов в самом интерфейсе immich(вкладка утилиты-дубликаты)? Он отлично распознает одинаковые фото, причем по похожести, а не по размеру файла или названию. Через него же можно удалять фотографии, с удалением и из базы, и с внешнего хранилища.

Тут вопрос в другом: есть immich с фотками и есть условная файловая шара куда скидываются фотографии, immich не знает о существования внешних хранилищ

У меня так просто файловая шара с фотками за 15 лет, в immich переношу коллекциями.

Тогда как варинат в immich добавить эту шару как внешнее хранилище. immich найдет дубликаты, их удалить, и отключить хранилище. У меня так же подключено. Папка куда сам immich синхронизирует + внешнее хранилище, которое просканировал immich

Сейчас приводил в порядок (да и в процессе еще…) свой архив и “забытые” места хранения фото/видео и в помощь навайбкодил это, сильно помогло (пути на свои поменяйте ну или придётся создать как у меня :slight_smile: )
В связи с ограничением форума

Вот файл 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.

… вообще вопрос дедупликации в имич оказался не тривиальным, я в процессе создания своего проекта под семейный архив наткнулся вот на это и теперь в проекте буду считать хэш сам :slight_smile:
“Content-hash — проверил фактическую сторону и нашёл подтверждение прямо в issue-трекере Immich (discussions#7804, issue#17023, официальный FAQ): для ассетов из External Library checksum — это не хэш содержимого, а нечто вроде хэша пути.”

Если кто не понял как пользоваться:

  1. загоните в ИИ и она объяснит

  2. если сами, то скачайте пайтон (для скрипта), ExifTool и FFmpeg/ffprobe (для обработки фото и видео) и укажите все пути в настройках :backhand_index_pointing_right:

    ================= НАСТРОЙКИ =================
    
    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
    

    (это просто пример а не все настройки!)

    • откройте блокнот , вставьте и сохраните как organize_photos_v9.py ( просто на всякий случай при сохранении в блокноте виндовс (сохранить как) дополнительно слева от кнопки сохранить выберите кодировку UTF-8 со спецификацией, а не просто UTF-8…