commit db9238c6618f6b3e662347c9020952052b98e2ea Author: Anton Vakhrushev Date: Wed Jul 23 12:03:06 2025 +0300 photorg: initial commit gen with claude-sonnet-4 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c18531b --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# PhotOrg - Организатор фотографий и медиафайлов + +PhotOrg - это утилита командной строки на Go для автоматической организации фотографий и видеофайлов по датам съемки. + +## Возможности + +- Рекурсивное сканирование исходной директории +- Извлечение даты съемки из EXIF и других метаданных +- Организация файлов в структуру `YYYY/YYYY-MM-DD` +- Обработка дубликатов по хеш-сумме SHA256 +- Автоматическое переименование при конфликтах имен +- Перемещение файлов без метаданных в папку "Unsorted" +- Режим dry-run для предварительного просмотра изменений +- Удаление дубликатов в корзину (Linux) + +## Поддерживаемые форматы + +### Изображения +- JPEG (.jpg, .jpeg) +- PNG (.png) +- GIF (.gif) +- BMP (.bmp) +- TIFF (.tiff, .tif) +- RAW форматы (.cr2, .nef, .arw, .dng, .raf, .orf, .rw2) + +### Видео +- MP4 (.mp4) +- AVI (.avi) +- MOV (.mov) +- MKV (.mkv) +- WMV (.wmv) +- FLV (.flv) +- WebM (.webm) + +## Установка + +### Предварительные требования + +Для работы с расширенными метаданными требуется установка ExifTool: + +```bash +# Ubuntu/Debian +sudo apt-get install exiftool + +# CentOS/RHEL/Fedora +sudo yum install perl-Image-ExifTool +# или для новых версий Fedora +sudo dnf install perl-Image-ExifTool + +# Arch Linux +sudo pacman -S perl-image-exiftool +``` + +### Сборка программы + +```bash +git clone +cd photorg +go mod tidy +go build -o photorg +``` + +## Использование + +### Основной синтаксис + +```bash +./photorg -source <исходная_директория> -dest <целевая_директория> [опции] +``` + +### Параметры + +- `-source` - исходная директория для сканирования (обязательный) +- `-dest` - целевая директория для организации файлов (обязательный) +- `-dry-run` - режим предварительного просмотра без фактического перемещения файлов + +### Примеры использования + +#### Предварительный просмотр +```bash +./photorg -source /home/user/Pictures -dest /home/user/Organized -dry-run +``` + +#### Организация фотографий +```bash +./photorg -source /home/user/Pictures -dest /home/user/Organized +``` + +## Структура результата + +Программа создает следующую структуру директорий: + +``` +dest_directory/ +├── 2023/ +│ ├── 2023-01-15/ +│ │ ├── IMG_001.jpg +│ │ └── IMG_002.jpg +│ └── 2023-12-25/ +│ └── video.mp4 +├── 2024/ +│ └── 2024-03-10/ +│ ├── photo.jpg +│ └── photo_1.jpg # конфликт имен +└── Unsorted/ + └── file_without_date.png +``` + +## Обработка конфликтов + +### Идентичные файлы +Если файл с таким же именем уже существует в целевой директории и имеет идентичную хеш-сумму SHA256, исходный файл удаляется в корзину. + +### Разные файлы с одинаковыми именами +Если файлы имеют разные хеш-суммы, к имени добавляется суффикс `_1`, `_2` и т.д. + +## Безопасность + +- Программа проверяет, что целевая директория не является поддиректорией исходной +- Дубликаты удаляются в корзину, а не окончательно +- Режим dry-run позволяет предварительно оценить изменения +- Все операции логируются + +## Ограничения + +- Поддерживается только Linux (корзина) +- Требуется установка ExifTool для расширенной поддержки метаданных +- Программа не обрабатывает символические ссылки + +## Примеры вывода + +### Режим dry-run +``` +[DRY RUN] Would move: /source/IMG_001.jpg -> /dest/2023/2023-01-15/IMG_001.jpg +[DRY RUN] Would move: /source/video.mp4 -> /dest/2023/2023-12-25/video.mp4 +[DRY RUN] Would move: /source/no_date.png -> /dest/Unsorted/no_date.png +``` + +### Обычный режим +``` +Moved: /source/IMG_001.jpg -> /dest/2023/2023-01-15/IMG_001.jpg +Moved: /source/video.mp4 -> /dest/2023/2023-12-25/video.mp4 +Moved: /source/no_date.png -> /dest/Unsorted/no_date.png +``` + +## Лицензия + +MIT License diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8d182a4 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.vakhrushev.me/av/photorg + +go 1.24.5 + +require ( + github.com/barasher/go-exiftool v1.10.0 + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9fcf36c --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= +github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2bbe427 --- /dev/null +++ b/main.go @@ -0,0 +1,410 @@ +package main + +import ( + "crypto/sha256" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/barasher/go-exiftool" + "github.com/rwcarlsen/goexif/exif" +) + +type Config struct { + SourceDir string + DestDir string + DryRun bool +} + +type FileInfo struct { + Path string + DateTaken *time.Time + Hash string + Size int64 +} + +func main() { + var config Config + + flag.StringVar(&config.SourceDir, "source", "", "Source directory to scan for photos") + flag.StringVar(&config.DestDir, "dest", "", "Destination directory to organize photos") + flag.BoolVar(&config.DryRun, "dry-run", false, "Show what would be done without actually moving files") + flag.Parse() + + if config.SourceDir == "" || config.DestDir == "" { + fmt.Println("Usage: photorg -source -dest [-dry-run]") + flag.PrintDefaults() + os.Exit(1) + } + + if err := validateDirectories(config.SourceDir, config.DestDir); err != nil { + log.Fatal(err) + } + + if err := organizePhotos(config); err != nil { + log.Fatal(err) + } +} + +func validateDirectories(sourceDir, destDir string) error { + // Проверяем, что sourceDir существует и доступен для чтения + sourceStat, err := os.Stat(sourceDir) + if err != nil { + return fmt.Errorf("source directory error: %v", err) + } + if !sourceStat.IsDir() { + return fmt.Errorf("source path is not a directory: %s", sourceDir) + } + + // Проверяем доступ на чтение + sourceFile, err := os.Open(sourceDir) + if err != nil { + return fmt.Errorf("cannot read source directory: %v", err) + } + sourceFile.Close() + + // Проверяем, что destDir не пустая + if strings.TrimSpace(destDir) == "" { + return fmt.Errorf("destination directory cannot be empty") + } + + // Получаем абсолютные пути для сравнения + absSource, err := filepath.Abs(sourceDir) + if err != nil { + return fmt.Errorf("cannot get absolute path for source: %v", err) + } + + absDest, err := filepath.Abs(destDir) + if err != nil { + return fmt.Errorf("cannot get absolute path for destination: %v", err) + } + + // Проверяем, что destDir не является поддиректорией sourceDir + if strings.HasPrefix(absDest, absSource+string(filepath.Separator)) { + return fmt.Errorf("destination directory cannot be a subdirectory of source directory") + } + + return nil +} + +func organizePhotos(config Config) error { + return filepath.Walk(config.SourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + log.Printf("Error accessing %s: %v", path, err) + return nil + } + + if info.IsDir() { + return nil + } + + // Проверяем, является ли файл изображением или видео + if !isMediaFile(path) { + return nil + } + + fileInfo, err := analyzeFile(path) + if err != nil { + log.Printf("Error analyzing file %s: %v", path, err) + return nil + } + + destPath := generateDestinationPath(config.DestDir, fileInfo) + + if config.DryRun { + fmt.Printf("[DRY RUN] Would move: %s -> %s\n", path, destPath) + return nil + } + + if err := moveFile(fileInfo, destPath); err != nil { + log.Printf("Error moving file %s: %v", path, err) + } else { + fmt.Printf("Moved: %s -> %s\n", path, destPath) + } + + return nil + }) +} + +func isMediaFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + mediaExtensions := []string{ + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", + ".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm", + ".cr2", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2", + } + + for _, mediaExt := range mediaExtensions { + if ext == mediaExt { + return true + } + } + return false +} + +func analyzeFile(path string) (*FileInfo, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return nil, err + } + + // Вычисляем хеш файла + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return nil, err + } + hash := fmt.Sprintf("%x", hasher.Sum(nil)) + + // Сбрасываем указатель файла для чтения EXIF + file.Seek(0, 0) + + fileInfo := &FileInfo{ + Path: path, + Hash: hash, + Size: stat.Size(), + } + + // Пытаемся извлечь дату из метаданных + dateTaken := extractDateFromMetadata(path) + if dateTaken != nil { + fileInfo.DateTaken = dateTaken + } else { + // Если метаданные не содержат дату, используем дату модификации файла + modTime := stat.ModTime() + fileInfo.DateTaken = &modTime + } + + return fileInfo, nil +} + +func extractDateFromMetadata(path string) *time.Time { + // Сначала пытаемся использовать простой EXIF парсер + if date := extractDateFromEXIF(path); date != nil { + return date + } + + // Если не получилось, используем exiftool для более широкой поддержки форматов + if date := extractDateFromExifTool(path); date != nil { + return date + } + + return nil +} + +func extractDateFromEXIF(path string) *time.Time { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + + exifData, err := exif.Decode(file) + if err != nil { + return nil + } + + // Пытаемся получить дату съемки из различных EXIF тегов + dateFields := []exif.FieldName{ + exif.DateTimeOriginal, + exif.DateTime, + exif.DateTimeDigitized, + } + + for _, field := range dateFields { + tag, err := exifData.Get(field) + if err != nil { + continue + } + + dateStr, err := tag.StringVal() + if err != nil { + continue + } + + // Парсим дату в формате EXIF: "2006:01:02 15:04:05" + date, err := time.Parse("2006:01:02 15:04:05", dateStr) + if err != nil { + continue + } + + return &date + } + + return nil +} + +func extractDateFromExifTool(path string) *time.Time { + et, err := exiftool.NewExiftool() + if err != nil { + return nil + } + defer et.Close() + + fileInfos := et.ExtractMetadata(path) + if len(fileInfos) == 0 { + return nil + } + + fileInfo := fileInfos[0] + if fileInfo.Err != nil { + return nil + } + + // Пытаемся найти дату в различных полях метаданных + dateFields := []string{ + "DateTimeOriginal", + "CreateDate", + "DateTime", + "DateTimeDigitized", + "MediaCreateDate", + "TrackCreateDate", + } + + for _, field := range dateFields { + if dateValue, ok := fileInfo.Fields[field]; ok { + // Приводим к строке + dateStr, ok := dateValue.(string) + if !ok { + continue + } + + // Пытаемся парсить различные форматы дат + formats := []string{ + "2006:01:02 15:04:05", + "2006-01-02 15:04:05", + "2006:01:02 15:04:05-07:00", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05-07:00", + } + + for _, format := range formats { + if date, err := time.Parse(format, dateStr); err == nil { + return &date + } + } + } + } + + return nil +} + +func generateDestinationPath(destDir string, fileInfo *FileInfo) string { + var targetDir string + + if fileInfo.DateTaken != nil { + year := fileInfo.DateTaken.Format("2006") + date := fileInfo.DateTaken.Format("2006-01-02") + targetDir = filepath.Join(destDir, year, date) + } else { + targetDir = filepath.Join(destDir, "Unsorted") + } + + fileName := filepath.Base(fileInfo.Path) + targetPath := filepath.Join(targetDir, fileName) + + // Проверяем, нужно ли добавить суффикс для избежания конфликтов + return resolveFileConflict(targetPath, fileInfo.Hash) +} + +func resolveFileConflict(targetPath, newFileHash string) string { + if _, err := os.Stat(targetPath); os.IsNotExist(err) { + return targetPath + } + + // Файл существует, проверяем хеш + existingHash, err := calculateFileHash(targetPath) + if err != nil { + log.Printf("Error calculating hash for existing file %s: %v", targetPath, err) + return generateUniqueFileName(targetPath) + } + + if existingHash == newFileHash { + // Файлы идентичны, можно удалить исходный + return targetPath + } + + // Файлы разные, нужно создать уникальное имя + return generateUniqueFileName(targetPath) +} + +func generateUniqueFileName(basePath string) string { + dir := filepath.Dir(basePath) + ext := filepath.Ext(basePath) + nameWithoutExt := strings.TrimSuffix(filepath.Base(basePath), ext) + + for i := 1; ; i++ { + newName := fmt.Sprintf("%s_%d%s", nameWithoutExt, i, ext) + newPath := filepath.Join(dir, newName) + + if _, err := os.Stat(newPath); os.IsNotExist(err) { + return newPath + } + } +} + +func calculateFileHash(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + + return fmt.Sprintf("%x", hasher.Sum(nil)), nil +} + +func moveFile(fileInfo *FileInfo, destPath string) error { + // Создаем директорию назначения, если она не существует + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %v", err) + } + + // Проверяем, нужно ли удалить исходный файл (если файлы идентичны) + if _, err := os.Stat(destPath); err == nil { + existingHash, err := calculateFileHash(destPath) + if err == nil && existingHash == fileInfo.Hash { + // Файлы идентичны, удаляем исходный в корзину + return moveToTrash(fileInfo.Path) + } + } + + // Перемещаем файл + return os.Rename(fileInfo.Path, destPath) +} + +func moveToTrash(path string) error { + // Простая реализация перемещения в корзину для Linux + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot get home directory: %v", err) + } + + trashDir := filepath.Join(homeDir, ".local/share/Trash/files") + if err := os.MkdirAll(trashDir, 0755); err != nil { + return fmt.Errorf("cannot create trash directory: %v", err) + } + + fileName := filepath.Base(path) + trashPath := filepath.Join(trashDir, fileName) + + // Если файл с таким именем уже есть в корзине, добавляем суффикс + trashPath = generateUniqueFileName(trashPath) + + return os.Rename(path, trashPath) +}