Files
photorg/main.go
2025-07-23 12:03:06 +03:00

411 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <source_dir> -dest <dest_dir> [-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)
}