367 lines
8.8 KiB
Go
367 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/barasher/go-exiftool"
|
|
"github.com/rkoesters/xdg/trash"
|
|
"github.com/rwcarlsen/goexif/exif"
|
|
)
|
|
|
|
type Config struct {
|
|
SourceDir string
|
|
DestDir string
|
|
DryRun bool
|
|
}
|
|
|
|
type MoveOperation struct {
|
|
SourcePath string
|
|
DestPath string
|
|
DropSource bool
|
|
}
|
|
|
|
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 [-dry-run] <source_dir> <dest_dir>")
|
|
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)
|
|
}
|
|
|
|
// Проверяем, что 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(sourcePath string, sourceInfo os.FileInfo, err error) error {
|
|
if err != nil {
|
|
log.Printf("Error accessing %s: %v", sourcePath, err)
|
|
return nil
|
|
}
|
|
|
|
if sourceInfo.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
// Проверяем, является ли файл изображением или видео
|
|
if !isMediaFile(sourcePath) {
|
|
return nil
|
|
}
|
|
|
|
captureTime := extractDateFromMetadata(sourcePath)
|
|
|
|
moveOp, err := planMovement(config.DestDir, sourcePath, captureTime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if config.DryRun {
|
|
if moveOp.DropSource {
|
|
fmt.Printf("[DRY RUN] Would trash: %s\n", moveOp.SourcePath)
|
|
} else {
|
|
fmt.Printf("[DRY RUN] Would move: %s -> %s\n", moveOp.SourcePath, moveOp.DestPath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if err := moveFile(moveOp); err != nil {
|
|
log.Printf("Error moving file %s: %v", moveOp.SourcePath, err)
|
|
} else {
|
|
if moveOp.DropSource {
|
|
fmt.Printf("Trash: %s\n", moveOp.SourcePath)
|
|
} else {
|
|
fmt.Printf("Moved: %s -> %s\n", moveOp.SourcePath, moveOp.DestPath)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func isMediaFile(path string) bool {
|
|
mediaExtensions := []string{
|
|
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp",
|
|
".heif", ".heifs", ".heic", ".heics", ".avci", ".avcs", ".hif",
|
|
".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm",
|
|
".cr2", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2",
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
|
|
return slices.Contains(mediaExtensions, ext)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
date := parseDateTime(dateStr)
|
|
if date != nil {
|
|
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
|
|
}
|
|
|
|
date := parseDateTime(dateStr)
|
|
if date != nil {
|
|
return date
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func planMovement(destDir, sourcePath string, captureTime *time.Time) (*MoveOperation, error) {
|
|
var targetDir string
|
|
|
|
if captureTime != nil {
|
|
year := captureTime.Format("2006")
|
|
date := captureTime.Format("2006-01-02")
|
|
targetDir = filepath.Join(destDir, year, date)
|
|
} else {
|
|
targetDir = filepath.Join(destDir, "Unsorted")
|
|
}
|
|
|
|
fileName := filepath.Base(sourcePath)
|
|
destPath := filepath.Join(targetDir, fileName)
|
|
|
|
moveOp := &MoveOperation{
|
|
SourcePath: sourcePath,
|
|
DestPath: destPath,
|
|
}
|
|
|
|
if _, err := os.Stat(destPath); os.IsNotExist(err) {
|
|
// Файл не существует, можно перемещать без изменения имени
|
|
return moveOp, nil
|
|
}
|
|
|
|
// Файл существует, проверяем хеш
|
|
destHash, err := calculateFileHash(destPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot calc hash for file %s: %v", destPath, err)
|
|
}
|
|
|
|
sourceHash, err := calculateFileHash(sourcePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot calc hash for file %s: %v", sourcePath, err)
|
|
}
|
|
|
|
if destHash == sourceHash {
|
|
// Файлы идентичны, можно будет удалить исходный
|
|
moveOp.DropSource = true
|
|
return moveOp, nil
|
|
}
|
|
|
|
// Файл существует, но содержимое не идентично: нужно добавить суффикс
|
|
moveOp.DestPath = generateUniqueFileName(destPath)
|
|
|
|
return moveOp, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
hash := fmt.Sprintf("%x", hasher.Sum(nil))
|
|
|
|
return hash, nil
|
|
}
|
|
|
|
func parseDateTime(dateStr string) *time.Time {
|
|
formats := []string{
|
|
"2006:01:02 15:04:05",
|
|
"2006:01:02 15:04:05-07:00",
|
|
"2006-01-02 15:04:05",
|
|
"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 moveFile(moveOp *MoveOperation) error {
|
|
// Создаем директорию назначения, если она не существует
|
|
destDir := filepath.Dir(moveOp.DestPath)
|
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create destination directory: %v", err)
|
|
}
|
|
|
|
// Проверяем, нужно ли удалить исходный файл (если файлы идентичны)
|
|
if moveOp.DropSource {
|
|
// Файлы идентичны, удаляем исходный в корзину
|
|
return trash.Trash(moveOp.SourcePath)
|
|
}
|
|
|
|
// Перемещаем файл
|
|
return os.Rename(moveOp.SourcePath, moveOp.DestPath)
|
|
}
|