Files
photorg/main.go

392 lines
9.5 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"
"slices"
"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
}
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)
}
// Проверяем, что 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 {
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 analyzeFile(path string) (*FileInfo, error) {
hash, err := calculateFileHash(path)
if err == nil {
return nil, err
}
// Пытаемся извлечь дату из метаданных
dateTaken := extractDateFromMetadata(path)
fileInfo := &FileInfo{
Path: path,
Hash: hash,
DateTaken: dateTaken,
}
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
}
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 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
}
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(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)
}