418 lines
10 KiB
Go
418 lines
10 KiB
Go
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.BoolVar(&config.DryRun, "dry-run", false, "Show what would be done without actually moving files")
|
||
flag.Parse()
|
||
|
||
// Получаем позиционные аргументы
|
||
args := flag.Args()
|
||
if len(args) != 2 {
|
||
fmt.Println("Usage: photorg <source_dir> <dest_dir> [-dry-run]")
|
||
fmt.Println("\nArguments:")
|
||
fmt.Println(" source_dir Source directory to scan for photos")
|
||
fmt.Println(" dest_dir Destination directory to organize photos")
|
||
fmt.Println("\nOptions:")
|
||
flag.PrintDefaults()
|
||
os.Exit(1)
|
||
}
|
||
|
||
config.SourceDir = args[0]
|
||
config.DestDir = args[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)
|
||
}
|