From e1b6ac69c7df353f9952bb6c01658f4e724db053 Mon Sep 17 00:00:00 2001
From: spf13
Date: Tue, 14 May 2019 21:34:23 -0400
Subject: [PATCH 01/12] fixing broken error message
---
main.go | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/main.go b/main.go
index b5b75c0..f6c5ddd 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,6 @@
package main
import (
- "errors"
"flag"
"os"
)
@@ -14,18 +13,17 @@ var (
)
func init() {
-
if version != "undefined" {
println("mgphoto ", version, "\n")
}
- outputPtr := flag.String("o", "./output", "Output path - defaults to ./output")
+ outputPtr := flag.String("o", "./photos", "Output path - defaults to ./photos")
dupPtr := flag.Bool("d", false, "Copy duplicates to 'duplicates' folder")
flag.Parse()
if len(flag.Args()) < 1 {
- println(errors.New("Invalid arguments - please supply a source directory"))
+ println("Invalid arguments - please supply a source directory")
os.Exit(0)
}
From 11a2241688aae46d4d54fbb5583d913cc5f929ce Mon Sep 17 00:00:00 2001
From: spf13
Date: Tue, 14 May 2019 21:34:52 -0400
Subject: [PATCH 02/12] using YYYY/MM/DD format
---
media-file.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/media-file.go b/media-file.go
index d8af21f..9197603 100644
--- a/media-file.go
+++ b/media-file.go
@@ -91,8 +91,9 @@ func (m *MediaFile) writeToDestination(dest string, copyDuplicates bool) error {
if m.date != nil {
year := m.date.Format("2006")
- month := m.date.Format("2006-01-02")
- dir = path.Join(dir, year, month)
+ month := m.date.Format("01")
+ day := m.date.Format("02")
+ dir = path.Join(dir, year, month, day)
} else {
dir = path.Join(dir, "unknown")
}
From db652fbe30719a8f6bea41066791605d7d8b13a3 Mon Sep 17 00:00:00 2001
From: spf13
Date: Wed, 15 May 2019 21:29:40 -0400
Subject: [PATCH 03/12] support videos and sidecar files and more images.
Adding organization function to move files to the correct location.
Ignoring tiny image files.
Adding support for a lot more image types.
Adding exiftool support when libexif can't find the dates.
---
file-util.go | 72 ++++++++++++++-
main.go | 88 +++++++++++++++----
media-file.go | 237 +++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 365 insertions(+), 32 deletions(-)
diff --git a/file-util.go b/file-util.go
index a2ee931..bfa26d8 100644
--- a/file-util.go
+++ b/file-util.go
@@ -9,6 +9,17 @@ import (
"path/filepath"
"strconv"
"strings"
+
+ "gopkg.in/djherbis/times.v1"
+)
+
+var (
+ // This map is used to define extensions to examine
+ knownTypes = map[string][]string{
+ "video": []string{"mp4", "avi", "m4v", "mov"},
+ "photo": []string{"heic", "jpeg", "jpg", "raw", "arw", "png", "psd", "gpr", "gif", "tiff"},
+ "sidecar": []string{"thm", "xmp", "on1", "lrv", "xml"},
+ }
)
func fileExists(path string) bool {
@@ -79,9 +90,60 @@ func copyFile(src, dest string) error {
return err
}
+ t, err := times.Stat(src)
+ if err != nil {
+ log.Fatal(err.Error())
+ }
+
+ // Keep the original mod time
+ err = os.Chtimes(dest, t.AccessTime(), t.ModTime())
+ if err != nil {
+ log.Fatal(err.Error())
+ }
+
return nil
}
+func validFileType(path string) bool {
+ return isPhoto(path) || isVideo(path) || isSidecar(path)
+}
+
+func isPhoto(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+
+ for _, e := range knownTypes["photo"] {
+ if ext == "."+e {
+ return true
+ }
+ }
+
+ return false
+}
+
+func isVideo(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+
+ for _, e := range knownTypes["video"] {
+ if ext == "."+e {
+ return true
+ }
+ }
+
+ return false
+}
+
+func isSidecar(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+
+ for _, e := range knownTypes["sidecar"] {
+ if ext == "."+e {
+ return true
+ }
+ }
+
+ return false
+}
+
// recursively read directory and get all file paths
func getAllFilePaths(dir string) []string {
@@ -95,11 +157,15 @@ func getAllFilePaths(dir string) []string {
for _, f := range files {
+ fullPath := path.Join(dir, f.Name())
if f.IsDir() {
- filePaths = append(filePaths, getAllFilePaths(path.Join(dir, f.Name()))...)
+ filePaths = append(filePaths, getAllFilePaths(fullPath)...)
} else {
-
- filePaths = append(filePaths, path.Join(dir, f.Name()))
+ if validFileType(fullPath) {
+ filePaths = append(filePaths, path.Join(fullPath))
+ } else {
+ log.Println("skipping", fullPath)
+ }
}
}
diff --git a/main.go b/main.go
index f6c5ddd..a5cb9b8 100644
--- a/main.go
+++ b/main.go
@@ -1,15 +1,28 @@
package main
import (
+ "errors"
"flag"
+ "fmt"
+ "io"
+ "log"
"os"
+ "regexp"
)
var (
- inputPath string
- outputPath string
- copyDuplicates bool
- version = "undefined"
+ inputPath string
+ outputPath string
+ copyDuplicates bool
+ mvDuplicates bool
+ tinyFiles bool
+ logPath string
+ version = "undefined"
+ reDateTime = regexp.MustCompile(`(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})`)
+ errMissingCreateTime = errors.New(`Missing create time`)
+ Info *log.Logger
+ Warn *log.Logger
+ Error *log.Logger
)
func init() {
@@ -18,7 +31,10 @@ func init() {
}
outputPtr := flag.String("o", "./photos", "Output path - defaults to ./photos")
+ logPtr := flag.String("l", "./transfer.log", "Log path - defaults to ./transfer.log")
dupPtr := flag.Bool("d", false, "Copy duplicates to 'duplicates' folder")
+ mvPtr := flag.Bool("m", false, "Move duplicates to their correct location")
+ tinyPtr := flag.Bool("t", false, "Copy really small images (<5kb)")
flag.Parse()
@@ -29,7 +45,22 @@ func init() {
outputPath = *outputPtr
copyDuplicates = *dupPtr
+ mvDuplicates = *mvPtr
+ tinyFiles = *tinyPtr
+ logPath = *logPtr
inputPath = flag.Args()[0]
+
+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
+ if err != nil {
+ log.Fatalln("Failed to open log file", output, ":", err)
+ }
+
+ multiWarn := io.MultiWriter(file, os.Stdout)
+ multiErr := io.MultiWriter(file, os.Stderr)
+
+ Info = log.New(logFile, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)
+ Warn = log.New(multiWarn, "WARNING: ", log.Ldate|log.Ltime|log.Lshortfile)
+ Error = log.New(multiErr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)
}
func main() {
@@ -42,31 +73,58 @@ func main() {
println("Processing source files...")
sourceMediaFiles := getMediaFiles(sourceFiles, true)
+ if !tinyFiles {
+ for k, f := range sourceMediaFiles {
+ if f.isPhoto() && f.size < 5000 {
+ delete(sourceMediaFiles, k)
+ }
+ }
+ }
+
println("Scanning destination for duplicates...")
- destMediaFiles := getMediaFiles(destFiles, false)
+ destMediaFiles := getMediaFiles(destFiles, mvDuplicates)
+
+ dupeDestFiles := make(map[[20]byte]*MediaFile)
+ originalMediaFiles := make(map[[20]byte]*MediaFile)
- // if we are not copying duplicates omit them
- if !copyDuplicates {
+ // if we are not copying and not moving duplicates omit them
+ if !copyDuplicates || mvDuplicates {
for k := range sourceMediaFiles {
if destMediaFiles[k] != nil {
+ if mvDuplicates {
+ dupeDestFiles[k] = destMediaFiles[k]
+ originalMediaFiles[k] = sourceMediaFiles[k]
+ }
delete(sourceMediaFiles, k)
}
}
}
- if len(sourceMediaFiles) == 0 {
- println("No new files to copy.")
+ if len(sourceMediaFiles) == 0 && len(dupeDestFiles) == 0 {
+ println("No new files to copy or move.")
return
}
- println("Copying new files to destination...")
- progressBar := NewProgressBar(len(sourceMediaFiles))
- for k, val := range sourceMediaFiles {
- val.writeToDestination(outputPath, copyDuplicates && destMediaFiles[k] != nil)
- progressBar.increment()
+ if len(sourceMediaFiles) > 0 {
+ println("Copying new files to destination...")
+ progressBar := NewProgressBar(len(sourceMediaFiles))
+ for k, val := range sourceMediaFiles {
+ val.writeToDestination(outputPath, copyDuplicates && destMediaFiles[k] != nil)
+ progressBar.increment()
+ }
+
+ progressBar.wait()
}
- progressBar.wait()
+ if mvDuplicates && len(dupeDestFiles) > 0 {
+ fmt.Println("Moving existing files to the correct destination...")
+ dupeProgressBar := NewProgressBar(len(dupeDestFiles))
+ for k, val := range dupeDestFiles {
+ val.moveToDestination(outputPath, originalMediaFiles[k])
+ dupeProgressBar.increment()
+ }
+ dupeProgressBar.wait()
+ }
}
// get media file objects from file path list
diff --git a/media-file.go b/media-file.go
index 9197603..575c977 100644
--- a/media-file.go
+++ b/media-file.go
@@ -1,30 +1,38 @@
package main
import (
+ "bytes"
"crypto/sha1"
+ "fmt"
+ "io"
"io/ioutil"
"log"
"os"
+ "os/exec"
"path"
"path/filepath"
+ "strconv"
+ "strings"
"time"
"github.com/rwcarlsen/goexif/exif"
"github.com/rwcarlsen/goexif/mknote"
+ "gopkg.in/djherbis/times.v1"
)
// MediaFile - contains file information
type MediaFile struct {
- name string
- path string
- date *time.Time
- sha1 [20]byte
+ name string
+ path string
+ date *time.Time
+ sha1 [20]byte
+ filetype string
+ size int64
}
// NewMediaFile - generate new file and process meta data
// returns nil if file cannot be handled
func NewMediaFile(path string, processMetaData bool) *MediaFile {
-
file, err := os.Open(path)
if err != nil {
@@ -34,18 +42,33 @@ func NewMediaFile(path string, processMetaData bool) *MediaFile {
defer file.Close()
- // read bytes from file
- bytes, err := ioutil.ReadAll(file)
-
+ fi, err := file.Stat()
if err != nil {
- log.Println(err)
+ log.Println(path, "not accessible")
return nil
}
+ bytes := make([]byte, 4000000)
+
+ // Only read the first 4 MB of large files
+ if fi.Size() > 4000000 {
+ if _, err = io.ReadFull(file, bytes); err != nil {
+ log.Println(err)
+ return nil
+ }
+ } else {
+ // read bytes from file
+ if bytes, err = ioutil.ReadAll(file); err != nil {
+ log.Println(err)
+ return nil
+ }
+ }
+
mediaFile := &MediaFile{
path: path,
name: filepath.Base(file.Name()),
sha1: sha1.Sum(bytes),
+ size: fi.Size(),
}
if processMetaData {
@@ -59,8 +82,102 @@ func (m *MediaFile) unknownCreation(file *os.File) bool {
return m.date == nil
}
+func (m *MediaFile) isPhoto() bool {
+ return isPhoto(m.path)
+}
+
+func (m *MediaFile) isVideo() bool {
+ return isVideo(m.path)
+}
+
+func (m *MediaFile) isSidecar() bool {
+ return isSidecar(m.path)
+}
+
func (m *MediaFile) processMetaData(file *os.File) {
+ // fmt.Println(m.path)
+
+ var d *time.Time
+ if m.isVideo() {
+ d = m.getExifDateExifTool()
+ }
+
+ if m.isPhoto() {
+ d = getExifDate(file)
+ if d == nil {
+ d = m.getExifDateExifTool()
+ }
+ }
+
+ // No Exif Data found
+ if d == nil {
+ d = m.getFileTime()
+ }
+
+ if d == nil {
+ fmt.Println("unable to find date")
+ }
+
+ m.date = d
+}
+
+func (m *MediaFile) getFileTime() *time.Time {
+ t, err := times.Stat(m.path)
+ if err != nil {
+ log.Fatal(err.Error())
+ }
+
+ if t.HasBirthTime() {
+ cr := t.BirthTime()
+ mod := t.ModTime()
+ if cr.Before(mod) {
+ return &cr
+ } else {
+ return &mod
+ }
+ } else {
+ d := t.ModTime()
+ return &d
+ }
+}
+
+func (m *MediaFile) getExifDateExifTool() *time.Time {
+ tags, err := getTagsViaExifTool(m.path)
+
+ if err != nil {
+ return nil
+ }
+ date, err := getExifCreateDateFromTags(tags)
+ if err != nil {
+ return nil
+ }
+ return &date
+}
+
+func getTagsViaExifTool(file string) (map[string]string, error) {
+ var out bytes.Buffer
+ cmd := exec.Command("exiftool", file)
+
+ cmd.Stdout = &out
+ if err := cmd.Run(); err != nil {
+ return nil, err
+ }
+
+ tags := make(map[string]string)
+
+ data := strings.Trim(out.String(), " \r\n")
+ lines := strings.Split(data, "\n")
+
+ for _, line := range lines {
+ k, v := strings.Replace(strings.TrimSpace(line[0:32]), " ", "", -1), strings.TrimSpace(line[33:])
+ // k = normalizeEXIFTag(k)
+ tags[k] = v
+ }
+
+ return tags, nil
+}
+func getExifDate(file *os.File) *time.Time {
// make sure file starts at beginning
file.Seek(0, 0)
@@ -69,26 +186,106 @@ func (m *MediaFile) processMetaData(file *os.File) {
x, err := exif.Decode(file)
if err != nil {
- return
+ return nil
}
t, err := x.DateTime()
if err != nil {
+ return nil
+ }
+
+ return &t
+}
+
+// getExifCreateDate attempts to get the given file's original creation date
+// from its EXIF tags.
+func getExifCreateDateFromTags(tags map[string]string) (time.Time, error) {
+ // Looking for the first tag that sounds like a date.
+ dateTimeFields := []string{
+ "DateAndTimeOriginal",
+ "DateTimeOriginal",
+ "Date/TimeOriginal",
+ "DateTaken",
+ "CreateDate",
+ "MediaCreateDate",
+ "TrackCreateDate",
+ "ModifyDate",
+ "FileModificationDateTime",
+ "FileAccessDateTime",
+ "EncodedDate",
+ "TaggedDate",
+ }
+
+ toInt := func(s string) (i int) {
+ i, _ = strconv.Atoi(s)
return
}
- m.date = &t
+ for _, field := range dateTimeFields {
+ taken, ok := tags[field]
+ if !ok {
+ continue
+ }
+
+ all := reDateTime.FindAllStringSubmatch(taken, -1)
+
+ if len(all) < 1 || len(all[0]) < 6 {
+ return time.Time{}, errMissingCreateTime
+ }
+
+ y := toInt(all[0][1])
+ if y == 0 {
+ continue
+ }
+
+ t := time.Date(
+ y,
+ time.Month(toInt(all[0][2])),
+ toInt(all[0][3]),
+ toInt(all[0][4]),
+ toInt(all[0][5]),
+ toInt(all[0][6]),
+ 0,
+ time.Local,
+ )
+
+ if t.IsZero() {
+ continue
+ }
+
+ return t, nil
+ }
+
+ return time.Time{}, errMissingCreateTime
}
func (m *MediaFile) writeToDestination(dest string, copyDuplicates bool) error {
-
dir := dest
if copyDuplicates {
- dir = path.Join(dest, "duplicates")
+ dir = path.Join(dir, "duplicates")
}
+ dir = m.destinationPath(dir)
+
+ createDirIfNotExists(dir)
+
+ fullPath := renameIfFileExists(path.Join(dir, m.name))
+
+ err := copyFile(m.path, fullPath)
+
+ if err != nil {
+ log.Println(err)
+ return err
+ }
+
+ return nil
+}
+
+func (m *MediaFile) destinationPath(dest string) string {
+ dir := dest
+
if m.date != nil {
year := m.date.Format("2006")
month := m.date.Format("01")
@@ -98,11 +295,23 @@ func (m *MediaFile) writeToDestination(dest string, copyDuplicates bool) error {
dir = path.Join(dir, "unknown")
}
+ return dir
+}
+
+func (m *MediaFile) moveToDestination(dest string, original *MediaFile) error {
+ dir := m.destinationPath(dest)
+
createDirIfNotExists(dir)
+ if path.Join(dir, m.name) == m.path && m.sha1 == original.sha1 {
+ fmt.Println(m.path, "is already in the correct location")
+ return nil
+ }
+
fullPath := renameIfFileExists(path.Join(dir, m.name))
- err := copyFile(m.path, fullPath)
+ fmt.Println("Moving", m.path, "==>>", fullPath)
+ err := os.Rename(m.path, fullPath)
if err != nil {
log.Println(err)
From 43650bf26eefd4faae3713758a88b8c972533c4b Mon Sep 17 00:00:00 2001
From: spf13
Date: Thu, 16 May 2019 10:01:14 -0400
Subject: [PATCH 04/12] Add logging and dry run
---
file-util.go | 8 +++++---
main.go | 38 +++++++++++++++++++++++-----------
media-file.go | 57 ++++++++++++++++++++++++++++++++++++++++-----------
3 files changed, 76 insertions(+), 27 deletions(-)
diff --git a/file-util.go b/file-util.go
index bfa26d8..0f1d261 100644
--- a/file-util.go
+++ b/file-util.go
@@ -56,8 +56,10 @@ func renameIfFileExists(path string) string {
}
func createDirIfNotExists(dir string) {
- if _, err := os.Stat(dir); os.IsNotExist(err) {
- os.MkdirAll(dir, 0755)
+ if !dryRun {
+ if _, err := os.Stat(dir); os.IsNotExist(err) {
+ os.MkdirAll(dir, 0755)
+ }
}
}
@@ -164,7 +166,7 @@ func getAllFilePaths(dir string) []string {
if validFileType(fullPath) {
filePaths = append(filePaths, path.Join(fullPath))
} else {
- log.Println("skipping", fullPath)
+ Info.Println(fullPath, "\tskipping, unrecognized filetype")
}
}
}
diff --git a/main.go b/main.go
index a5cb9b8..87e9052 100644
--- a/main.go
+++ b/main.go
@@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"io"
+ "io/ioutil"
"log"
"os"
"regexp"
@@ -16,6 +17,7 @@ var (
copyDuplicates bool
mvDuplicates bool
tinyFiles bool
+ dryRun bool
logPath string
version = "undefined"
reDateTime = regexp.MustCompile(`(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})`)
@@ -30,11 +32,12 @@ func init() {
println("mgphoto ", version, "\n")
}
- outputPtr := flag.String("o", "./photos", "Output path - defaults to ./photos")
- logPtr := flag.String("l", "./transfer.log", "Log path - defaults to ./transfer.log")
- dupPtr := flag.Bool("d", false, "Copy duplicates to 'duplicates' folder")
- mvPtr := flag.Bool("m", false, "Move duplicates to their correct location")
- tinyPtr := flag.Bool("t", false, "Copy really small images (<5kb)")
+ outputPtr := flag.String("out", "./photos", "Output path - defaults to ./photos")
+ logPtr := flag.String("log", "./transfer.log", "Log path - defaults to ./transfer.log")
+ dupPtr := flag.Bool("copy-dupes", false, "Copy duplicates to 'duplicates' folder")
+ mvPtr := flag.Bool("move-dupes", false, "Move duplicates to their correct location")
+ tinyPtr := flag.Bool("copy-tiny", false, "Copy really small images (<5kb)")
+ dryPtr := flag.Bool("dryrun", false, "Don't actually do anything")
flag.Parse()
@@ -48,19 +51,28 @@ func init() {
mvDuplicates = *mvPtr
tinyFiles = *tinyPtr
logPath = *logPtr
+ dryRun = *dryPtr
+
inputPath = flag.Args()[0]
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
- log.Fatalln("Failed to open log file", output, ":", err)
+ log.Fatalln("Failed to open log file", logPath, ":", err)
}
- multiWarn := io.MultiWriter(file, os.Stdout)
- multiErr := io.MultiWriter(file, os.Stderr)
-
- Info = log.New(logFile, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)
- Warn = log.New(multiWarn, "WARNING: ", log.Ldate|log.Ltime|log.Lshortfile)
- Error = log.New(multiErr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)
+ multiWarn := io.MultiWriter(logFile, ioutil.Discard)
+ multiErr := io.MultiWriter(logFile, os.Stderr)
+
+ Info = log.New(logFile, "INFO: \t", log.Ldate|log.Ltime)
+ Warn = log.New(multiWarn, "WARN: \t", log.Ldate|log.Ltime)
+ Error = log.New(multiErr, "ERROR: \t", log.Ldate|log.Ltime|log.Lshortfile)
+ Info.Println("************************************************")
+ if dryRun {
+ Info.Println(" * * * * DRY RUN * * * * ")
+ } else {
+ Info.Println(" > > > > NEW RUN < < < < ")
+ }
+ Info.Println("************************************************")
}
func main() {
@@ -76,6 +88,7 @@ func main() {
if !tinyFiles {
for k, f := range sourceMediaFiles {
if f.isPhoto() && f.size < 5000 {
+ f.Info("skipping too small photo")
delete(sourceMediaFiles, k)
}
}
@@ -95,6 +108,7 @@ func main() {
dupeDestFiles[k] = destMediaFiles[k]
originalMediaFiles[k] = sourceMediaFiles[k]
}
+ sourceMediaFiles[k].Info("Duplicate of", destMediaFiles[k].path)
delete(sourceMediaFiles, k)
}
}
diff --git a/media-file.go b/media-file.go
index 575c977..82760df 100644
--- a/media-file.go
+++ b/media-file.go
@@ -3,7 +3,6 @@ package main
import (
"bytes"
"crypto/sha1"
- "fmt"
"io"
"io/ioutil"
"log"
@@ -94,6 +93,33 @@ func (m *MediaFile) isSidecar() bool {
return isSidecar(m.path)
}
+func (m *MediaFile) Info(input ...string) {
+ var wrap []interface{} = make([]interface{}, len(input)+1)
+ wrap[0] = m.path + "\t"
+ for i, d := range input {
+ wrap[i+1] = d
+ }
+ Info.Println(wrap...)
+}
+
+func (m *MediaFile) Warn(input ...string) {
+ var wrap []interface{} = make([]interface{}, len(input)+1)
+ wrap[0] = m.path + "\t"
+ for i, d := range input {
+ wrap[i+1] = d
+ }
+ Warn.Println(wrap...)
+}
+
+func (m *MediaFile) Error(input ...string) {
+ var wrap []interface{} = make([]interface{}, len(input)+1)
+ wrap[0] = m.path + "\t"
+ for i, d := range input {
+ wrap[i+1] = d
+ }
+ Error.Println(wrap)
+}
+
func (m *MediaFile) processMetaData(file *os.File) {
// fmt.Println(m.path)
@@ -111,11 +137,12 @@ func (m *MediaFile) processMetaData(file *os.File) {
// No Exif Data found
if d == nil {
+ m.Warn("No EXIF data found, using file mod time")
d = m.getFileTime()
}
if d == nil {
- fmt.Println("unable to find date")
+ m.Error("unable to find date")
}
m.date = d
@@ -273,11 +300,14 @@ func (m *MediaFile) writeToDestination(dest string, copyDuplicates bool) error {
fullPath := renameIfFileExists(path.Join(dir, m.name))
- err := copyFile(m.path, fullPath)
+ m.Info("copying to\t", fullPath)
+ if !dryRun {
+ err := copyFile(m.path, fullPath)
- if err != nil {
- log.Println(err)
- return err
+ if err != nil {
+ m.Error(err.Error())
+ return err
+ }
}
return nil
@@ -304,18 +334,21 @@ func (m *MediaFile) moveToDestination(dest string, original *MediaFile) error {
createDirIfNotExists(dir)
if path.Join(dir, m.name) == m.path && m.sha1 == original.sha1 {
- fmt.Println(m.path, "is already in the correct location")
+ m.Info("is already in the correct location")
return nil
}
fullPath := renameIfFileExists(path.Join(dir, m.name))
- fmt.Println("Moving", m.path, "==>>", fullPath)
- err := os.Rename(m.path, fullPath)
+ m.Info("Moving to\t", fullPath)
+ if !dryRun {
+ err := os.Rename(m.path, fullPath)
+
+ if err != nil {
+ m.Error(err.Error())
+ return err
+ }
- if err != nil {
- log.Println(err)
- return err
}
return nil
From fb034585cb06d0fa9239f3809633ee2e43aceff1 Mon Sep 17 00:00:00 2001
From: spf13
Date: Thu, 16 May 2019 11:13:03 -0400
Subject: [PATCH 05/12] Adding basic analysis and organizing the logs better
---
file-util.go | 2 +-
main.go | 28 ++++++++++++++++++++--------
media-file.go | 7 +++++++
3 files changed, 28 insertions(+), 9 deletions(-)
diff --git a/file-util.go b/file-util.go
index 0f1d261..9f7e402 100644
--- a/file-util.go
+++ b/file-util.go
@@ -166,7 +166,7 @@ func getAllFilePaths(dir string) []string {
if validFileType(fullPath) {
filePaths = append(filePaths, path.Join(fullPath))
} else {
- Info.Println(fullPath, "\tskipping, unrecognized filetype")
+ Info.Println(fullPath, "\t skipping, unrecognized filetype")
}
}
}
diff --git a/main.go b/main.go
index 87e9052..3d692c3 100644
--- a/main.go
+++ b/main.go
@@ -9,6 +9,8 @@ import (
"log"
"os"
"regexp"
+ "text/tabwriter"
+ "time"
)
var (
@@ -18,6 +20,7 @@ var (
mvDuplicates bool
tinyFiles bool
dryRun bool
+ analyze bool
logPath string
version = "undefined"
reDateTime = regexp.MustCompile(`(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})`)
@@ -38,6 +41,7 @@ func init() {
mvPtr := flag.Bool("move-dupes", false, "Move duplicates to their correct location")
tinyPtr := flag.Bool("copy-tiny", false, "Copy really small images (<5kb)")
dryPtr := flag.Bool("dryrun", false, "Don't actually do anything")
+ analyzePtr := flag.Bool("analyze", false, "Track how long operations are taking")
flag.Parse()
@@ -52,20 +56,25 @@ func init() {
tinyFiles = *tinyPtr
logPath = *logPtr
dryRun = *dryPtr
+ analyze = *analyzePtr
inputPath = flag.Args()[0]
+}
+
+func main() {
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Fatalln("Failed to open log file", logPath, ":", err)
}
- multiWarn := io.MultiWriter(logFile, ioutil.Discard)
- multiErr := io.MultiWriter(logFile, os.Stderr)
+ wr := tabwriter.NewWriter(logFile, 10, 8, 3, ' ', 0)
+ multiWarn := io.MultiWriter(wr, ioutil.Discard)
+ multiErr := io.MultiWriter(wr, os.Stderr)
- Info = log.New(logFile, "INFO: \t", log.Ldate|log.Ltime)
- Warn = log.New(multiWarn, "WARN: \t", log.Ldate|log.Ltime)
- Error = log.New(multiErr, "ERROR: \t", log.Ldate|log.Ltime|log.Lshortfile)
+ Info = log.New(wr, "INFO: ", log.Ldate|log.Ltime)
+ Warn = log.New(multiWarn, "WARN: ", log.Ldate|log.Ltime)
+ Error = log.New(multiErr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)
Info.Println("************************************************")
if dryRun {
Info.Println(" * * * * DRY RUN * * * * ")
@@ -73,9 +82,7 @@ func init() {
Info.Println(" > > > > NEW RUN < < < < ")
}
Info.Println("************************************************")
-}
-
-func main() {
+ defer wr.Flush()
createDirIfNotExists(outputPath)
@@ -165,3 +172,8 @@ func getMediaFiles(paths []string, processMetaData bool) map[[20]byte]*MediaFile
return outputMap
}
+
+func timeTrack(start time.Time, name string) {
+ elapsed := time.Since(start)
+ log.Printf("%s took %s", name, elapsed)
+}
diff --git a/media-file.go b/media-file.go
index 82760df..764f3ac 100644
--- a/media-file.go
+++ b/media-file.go
@@ -47,6 +47,7 @@ func NewMediaFile(path string, processMetaData bool) *MediaFile {
return nil
}
+ startSHA := time.Now()
bytes := make([]byte, 4000000)
// Only read the first 4 MB of large files
@@ -62,6 +63,9 @@ func NewMediaFile(path string, processMetaData bool) *MediaFile {
return nil
}
}
+ if analyze {
+ timeTrack(startSHA, "SHA Generation")
+ }
mediaFile := &MediaFile{
path: path,
@@ -121,6 +125,9 @@ func (m *MediaFile) Error(input ...string) {
}
func (m *MediaFile) processMetaData(file *os.File) {
+ if analyze {
+ defer timeTrack(time.Now(), "EXIF analysis")
+ }
// fmt.Println(m.path)
var d *time.Time
From 008a058968537dd9d140664f89e4b2a79406c361 Mon Sep 17 00:00:00 2001
From: spf13
Date: Thu, 16 May 2019 12:08:49 -0400
Subject: [PATCH 06/12] Process files concurrently
---
main.go | 28 ++++++++++++++++++----------
media-file.go | 6 ++++--
2 files changed, 22 insertions(+), 12 deletions(-)
diff --git a/main.go b/main.go
index 3d692c3..06e09ae 100644
--- a/main.go
+++ b/main.go
@@ -9,6 +9,7 @@ import (
"log"
"os"
"regexp"
+ "sync"
"text/tabwriter"
"time"
)
@@ -28,6 +29,8 @@ var (
Info *log.Logger
Warn *log.Logger
Error *log.Logger
+ wg sync.WaitGroup
+ maplock sync.RWMutex
)
func init() {
@@ -150,25 +153,30 @@ func main() {
// get media file objects from file path list
func getMediaFiles(paths []string, processMetaData bool) map[[20]byte]*MediaFile {
-
outputMap := map[[20]byte]*MediaFile{}
if len(paths) < 1 {
return outputMap
}
- progressBar := NewProgressBar(len(paths))
+ // progressBar := NewProgressBar(len(paths))
for _, path := range paths {
- mediaFile := NewMediaFile(path, processMetaData)
-
- if mediaFile != nil {
- outputMap[mediaFile.sha1] = mediaFile
- }
- progressBar.increment()
+ wg.Add(1)
+ go func(path string) {
+ defer wg.Done()
+ mediaFile := NewMediaFile(path, processMetaData)
+
+ if mediaFile != nil {
+ maplock.Lock()
+ outputMap[mediaFile.sha1] = mediaFile
+ maplock.Unlock()
+ }
+ // progressBar.increment()
+ }(path)
}
-
- progressBar.wait()
+ wg.Wait()
+ // progressBar.wait()
return outputMap
}
diff --git a/media-file.go b/media-file.go
index 764f3ac..05db2a2 100644
--- a/media-file.go
+++ b/media-file.go
@@ -29,6 +29,10 @@ type MediaFile struct {
size int64
}
+func init() {
+ exif.RegisterParsers(mknote.All...)
+}
+
// NewMediaFile - generate new file and process meta data
// returns nil if file cannot be handled
func NewMediaFile(path string, processMetaData bool) *MediaFile {
@@ -215,8 +219,6 @@ func getExifDate(file *os.File) *time.Time {
// make sure file starts at beginning
file.Seek(0, 0)
- exif.RegisterParsers(mknote.All...)
-
x, err := exif.Decode(file)
if err != nil {
From 47a286775350a870621455e972bc98d61d8c3501 Mon Sep 17 00:00:00 2001
From: spf13
Date: Thu, 16 May 2019 12:24:10 -0400
Subject: [PATCH 07/12] restore progress bar (now safe for concurrent use)
---
main.go | 7 +++----
progress-bar.go | 7 +++++++
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/main.go b/main.go
index 06e09ae..f337b23 100644
--- a/main.go
+++ b/main.go
@@ -159,8 +159,7 @@ func getMediaFiles(paths []string, processMetaData bool) map[[20]byte]*MediaFile
return outputMap
}
- // progressBar := NewProgressBar(len(paths))
-
+ progressBar := NewProgressBar(len(paths))
for _, path := range paths {
wg.Add(1)
go func(path string) {
@@ -172,11 +171,11 @@ func getMediaFiles(paths []string, processMetaData bool) map[[20]byte]*MediaFile
outputMap[mediaFile.sha1] = mediaFile
maplock.Unlock()
}
- // progressBar.increment()
+ progressBar.increment()
}(path)
}
wg.Wait()
- // progressBar.wait()
+ progressBar.wait()
return outputMap
}
diff --git a/progress-bar.go b/progress-bar.go
index 6a277eb..b7ba850 100644
--- a/progress-bar.go
+++ b/progress-bar.go
@@ -1,12 +1,17 @@
package main
import (
+ "sync"
"time"
"github.com/vbauerster/mpb"
"github.com/vbauerster/mpb/decor"
)
+var (
+ barlock sync.RWMutex
+)
+
type ProgressBar struct {
progress *mpb.Progress
bar *mpb.Bar
@@ -33,7 +38,9 @@ func NewProgressBar(total int) *ProgressBar {
}
func (p *ProgressBar) increment() {
+ barlock.Lock()
p.bar.IncrBy(1, time.Since(p.start))
+ barlock.Unlock()
}
func (p *ProgressBar) wait() {
From a02be91101e8861308b213c64c99ef5fd9cb66dc Mon Sep 17 00:00:00 2001
From: spf13
Date: Fri, 17 May 2019 09:42:59 -0400
Subject: [PATCH 08/12] Performance improvements Switch to workers Add support
for DNG files Make default only check for duplicates in the right destination
folders Lower sum to first 2M
---
file-util.go | 41 ++++++++++++++++++++++++++++++++-
main.go | 63 ++++++++++++++++++++++++++++++++++++++-------------
media-file.go | 8 ++++---
3 files changed, 92 insertions(+), 20 deletions(-)
diff --git a/file-util.go b/file-util.go
index 9f7e402..8637ffa 100644
--- a/file-util.go
+++ b/file-util.go
@@ -17,7 +17,7 @@ var (
// This map is used to define extensions to examine
knownTypes = map[string][]string{
"video": []string{"mp4", "avi", "m4v", "mov"},
- "photo": []string{"heic", "jpeg", "jpg", "raw", "arw", "png", "psd", "gpr", "gif", "tiff"},
+ "photo": []string{"heic", "jpeg", "jpg", "raw", "arw", "png", "psd", "gpr", "gif", "tiff", "dng"},
"sidecar": []string{"thm", "xmp", "on1", "lrv", "xml"},
}
)
@@ -173,3 +173,42 @@ func getAllFilePaths(dir string) []string {
return filePaths
}
+
+func getFilePathsFromSource(dir string, sourceMedia map[[20]byte]*MediaFile) []string {
+
+ dirlist := make(map[string]struct{})
+
+ for _, med := range sourceMedia {
+ dirlist[med.destinationPath(dir)] = struct{}{}
+ }
+
+ filePaths := []string{}
+ for subdir := range dirlist {
+
+ if _, err := os.Stat(subdir); err == nil { // only process dir if it exists
+
+ files, err := ioutil.ReadDir(subdir)
+
+ if err != nil {
+ log.Println(err)
+ return filePaths
+ }
+
+ for _, f := range files {
+ fullPath := path.Join(subdir, f.Name())
+ if f.IsDir() {
+ filePaths = append(filePaths, getAllFilePaths(fullPath)...)
+ } else {
+ if validFileType(fullPath) {
+ filePaths = append(filePaths, path.Join(fullPath))
+ } else {
+ Info.Println(fullPath, "\t skipping, unrecognized filetype")
+ }
+ }
+ }
+ }
+
+ }
+
+ return filePaths
+}
diff --git a/main.go b/main.go
index f337b23..93c8a89 100644
--- a/main.go
+++ b/main.go
@@ -22,6 +22,7 @@ var (
tinyFiles bool
dryRun bool
analyze bool
+ fullDestScan bool
logPath string
version = "undefined"
reDateTime = regexp.MustCompile(`(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})`)
@@ -31,6 +32,7 @@ var (
Error *log.Logger
wg sync.WaitGroup
maplock sync.RWMutex
+ workercount int = 100
)
func init() {
@@ -45,6 +47,7 @@ func init() {
tinyPtr := flag.Bool("copy-tiny", false, "Copy really small images (<5kb)")
dryPtr := flag.Bool("dryrun", false, "Don't actually do anything")
analyzePtr := flag.Bool("analyze", false, "Track how long operations are taking")
+ fullDestPtr := flag.Bool("full-scan", false, "Scan the entire Destination for duplicates")
flag.Parse()
@@ -60,6 +63,7 @@ func init() {
logPath = *logPtr
dryRun = *dryPtr
analyze = *analyzePtr
+ fullDestScan = *fullDestPtr
inputPath = flag.Args()[0]
}
@@ -90,7 +94,6 @@ func main() {
createDirIfNotExists(outputPath)
sourceFiles := getAllFilePaths(inputPath)
- destFiles := getAllFilePaths(outputPath)
println("Processing source files...")
sourceMediaFiles := getMediaFiles(sourceFiles, true)
@@ -104,6 +107,14 @@ func main() {
}
}
+ var destFiles []string
+
+ if fullDestScan {
+ destFiles = getAllFilePaths(outputPath)
+ } else { // Only get paths from directories we're placing things into
+ destFiles = getFilePathsFromSource(outputPath, sourceMediaFiles)
+ }
+
println("Scanning destination for duplicates...")
destMediaFiles := getMediaFiles(destFiles, mvDuplicates)
@@ -154,32 +165,52 @@ func main() {
// get media file objects from file path list
func getMediaFiles(paths []string, processMetaData bool) map[[20]byte]*MediaFile {
outputMap := map[[20]byte]*MediaFile{}
+ count := len(paths)
- if len(paths) < 1 {
+ if count < 1 {
return outputMap
}
- progressBar := NewProgressBar(len(paths))
+ progressBar := NewProgressBar(count)
+ jobs := make(chan pathBool, count)
+ results := make(chan *MediaFile, count)
+
+ for w := 1; w <= workercount; w++ {
+ go worker(jobs, results)
+ }
+
for _, path := range paths {
- wg.Add(1)
- go func(path string) {
- defer wg.Done()
- mediaFile := NewMediaFile(path, processMetaData)
-
- if mediaFile != nil {
- maplock.Lock()
- outputMap[mediaFile.sha1] = mediaFile
- maplock.Unlock()
- }
- progressBar.increment()
- }(path)
+ jobs <- pathBool{path: path, processMetaData: processMetaData}
+ }
+ close(jobs)
+
+ for r := 1; r <= count; r++ {
+ mediaFile := <-results
+
+ if mediaFile != nil {
+ maplock.Lock()
+ outputMap[mediaFile.sha1] = mediaFile
+ maplock.Unlock()
+ }
+ progressBar.increment()
}
- wg.Wait()
progressBar.wait()
return outputMap
}
+type pathBool struct {
+ path string
+ processMetaData bool
+}
+
+func worker(jobs <-chan pathBool, results chan<- *MediaFile) {
+ for j := range jobs {
+ mediaFile := NewMediaFile(j.path, j.processMetaData)
+ results <- mediaFile
+ }
+}
+
func timeTrack(start time.Time, name string) {
elapsed := time.Since(start)
log.Printf("%s took %s", name, elapsed)
diff --git a/media-file.go b/media-file.go
index 05db2a2..a517bc8 100644
--- a/media-file.go
+++ b/media-file.go
@@ -29,6 +29,8 @@ type MediaFile struct {
size int64
}
+var byteMax int64 = 2000000
+
func init() {
exif.RegisterParsers(mknote.All...)
}
@@ -52,10 +54,10 @@ func NewMediaFile(path string, processMetaData bool) *MediaFile {
}
startSHA := time.Now()
- bytes := make([]byte, 4000000)
+ bytes := make([]byte, byteMax)
- // Only read the first 4 MB of large files
- if fi.Size() > 4000000 {
+ // Only read the first X MB of large files
+ if fi.Size() > byteMax {
if _, err = io.ReadFull(file, bytes); err != nil {
log.Println(err)
return nil
From 1be943ce0c4ddec018c288628c895403584a6172 Mon Sep 17 00:00:00 2001
From: spf13
Date: Fri, 17 May 2019 11:39:07 -0400
Subject: [PATCH 09/12] Skip small videos & don't read EXIF of small media
---
main.go | 2 +-
media-file.go | 11 +++++++++--
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/main.go b/main.go
index 93c8a89..3716f04 100644
--- a/main.go
+++ b/main.go
@@ -100,7 +100,7 @@ func main() {
if !tinyFiles {
for k, f := range sourceMediaFiles {
- if f.isPhoto() && f.size < 5000 {
+ if (f.isPhoto() || f.isVideo()) && f.size < 5000 {
f.Info("skipping too small photo")
delete(sourceMediaFiles, k)
}
diff --git a/media-file.go b/media-file.go
index a517bc8..fdd77d1 100644
--- a/media-file.go
+++ b/media-file.go
@@ -134,14 +134,21 @@ func (m *MediaFile) processMetaData(file *os.File) {
if analyze {
defer timeTrack(time.Now(), "EXIF analysis")
}
+
+ skipEXIF := false
+ if !tinyFiles {
+ if (m.isPhoto() || m.isVideo()) && m.size < 5000 {
+ skipEXIF = true
+ }
+ }
// fmt.Println(m.path)
var d *time.Time
- if m.isVideo() {
+ if m.isVideo() && !skipEXIF {
d = m.getExifDateExifTool()
}
- if m.isPhoto() {
+ if m.isPhoto() && !skipEXIF {
d = getExifDate(file)
if d == nil {
d = m.getExifDateExifTool()
From 026646f70b2d8904e372b76a9300dd791de273d8 Mon Sep 17 00:00:00 2001
From: spf13
Date: Fri, 7 Jun 2019 07:33:55 -0400
Subject: [PATCH 10/12] replace duplicate files with smaller file size
---
file-util.go | 13 +++++++++----
main.go | 16 +++++++++++-----
media-file.go | 26 ++++++++++++++++++--------
3 files changed, 38 insertions(+), 17 deletions(-)
diff --git a/file-util.go b/file-util.go
index 8637ffa..45f5639 100644
--- a/file-util.go
+++ b/file-util.go
@@ -17,8 +17,9 @@ var (
// This map is used to define extensions to examine
knownTypes = map[string][]string{
"video": []string{"mp4", "avi", "m4v", "mov"},
- "photo": []string{"heic", "jpeg", "jpg", "raw", "arw", "png", "psd", "gpr", "gif", "tiff", "dng"},
- "sidecar": []string{"thm", "xmp", "on1", "lrv", "xml"},
+ "photo": []string{"heic", "jpeg", "jpg", "raw", "arw", "png", "psd", "gpr", "gif", "tiff", "tif", "dng"},
+ "sidecar": []string{"xmp", "on1", "xml"},
+ // Don't really need LRV - Low Resolution Video or THM - Thumbnail
}
)
@@ -161,7 +162,9 @@ func getAllFilePaths(dir string) []string {
fullPath := path.Join(dir, f.Name())
if f.IsDir() {
- filePaths = append(filePaths, getAllFilePaths(fullPath)...)
+ if f.Name() != "@eaDir" && f.Name() != "thumbnails" {
+ filePaths = append(filePaths, getAllFilePaths(fullPath)...)
+ }
} else {
if validFileType(fullPath) {
filePaths = append(filePaths, path.Join(fullPath))
@@ -197,7 +200,9 @@ func getFilePathsFromSource(dir string, sourceMedia map[[20]byte]*MediaFile) []s
for _, f := range files {
fullPath := path.Join(subdir, f.Name())
if f.IsDir() {
- filePaths = append(filePaths, getAllFilePaths(fullPath)...)
+ if f.Name() != "@eaDir" && f.Name() != "thumbnails" {
+ filePaths = append(filePaths, getAllFilePaths(fullPath)...)
+ }
} else {
if validFileType(fullPath) {
filePaths = append(filePaths, path.Join(fullPath))
diff --git a/main.go b/main.go
index 3716f04..cc87f1d 100644
--- a/main.go
+++ b/main.go
@@ -32,7 +32,8 @@ var (
Error *log.Logger
wg sync.WaitGroup
maplock sync.RWMutex
- workercount int = 100
+ workercount int = 100
+ minBytes int64 = 50000
)
func init() {
@@ -100,7 +101,7 @@ func main() {
if !tinyFiles {
for k, f := range sourceMediaFiles {
- if (f.isPhoto() || f.isVideo()) && f.size < 5000 {
+ if (f.isPhoto() || f.isVideo()) && f.size < minBytes {
f.Info("skipping too small photo")
delete(sourceMediaFiles, k)
}
@@ -124,13 +125,18 @@ func main() {
// if we are not copying and not moving duplicates omit them
if !copyDuplicates || mvDuplicates {
for k := range sourceMediaFiles {
- if destMediaFiles[k] != nil {
+ if destMediaFiles[k] != nil { // file exists in src & dest && has same hash (of first 2k bytes)
if mvDuplicates {
dupeDestFiles[k] = destMediaFiles[k]
originalMediaFiles[k] = sourceMediaFiles[k]
}
- sourceMediaFiles[k].Info("Duplicate of", destMediaFiles[k].path)
- delete(sourceMediaFiles, k)
+ if sourceMediaFiles[k].size > destMediaFiles[k].size { // file in destination may not be complete
+ sourceMediaFiles[k].Info("is larger than duplicate, replacing", destMediaFiles[k].path)
+ sourceMediaFiles[k].replace = true
+ } else {
+ sourceMediaFiles[k].Info("Duplicate of", destMediaFiles[k].path)
+ delete(sourceMediaFiles, k)
+ }
}
}
}
diff --git a/media-file.go b/media-file.go
index fdd77d1..23202a5 100644
--- a/media-file.go
+++ b/media-file.go
@@ -27,6 +27,7 @@ type MediaFile struct {
sha1 [20]byte
filetype string
size int64
+ replace bool
}
var byteMax int64 = 2000000
@@ -74,10 +75,11 @@ func NewMediaFile(path string, processMetaData bool) *MediaFile {
}
mediaFile := &MediaFile{
- path: path,
- name: filepath.Base(file.Name()),
- sha1: sha1.Sum(bytes),
- size: fi.Size(),
+ path: path,
+ name: filepath.Base(file.Name()),
+ sha1: sha1.Sum(bytes),
+ size: fi.Size(),
+ replace: false,
}
if processMetaData {
@@ -137,7 +139,7 @@ func (m *MediaFile) processMetaData(file *os.File) {
skipEXIF := false
if !tinyFiles {
- if (m.isPhoto() || m.isVideo()) && m.size < 5000 {
+ if (m.isPhoto() || m.isVideo()) && m.size < minBytes {
skipEXIF = true
}
}
@@ -157,7 +159,9 @@ func (m *MediaFile) processMetaData(file *os.File) {
// No Exif Data found
if d == nil {
- m.Warn("No EXIF data found, using file mod time")
+ if m.isPhoto() || m.isVideo() {
+ m.Warn("No EXIF data found, using file mod time")
+ }
d = m.getFileTime()
}
@@ -316,9 +320,15 @@ func (m *MediaFile) writeToDestination(dest string, copyDuplicates bool) error {
createDirIfNotExists(dir)
- fullPath := renameIfFileExists(path.Join(dir, m.name))
+ var fullPath = path.Join(dir, m.name)
+
+ if !m.replace {
+ fullPath = renameIfFileExists(path.Join(dir, m.name))
+ m.Info("copying to\t", fullPath)
+ } else {
+ m.Info("replacing\t", fullPath)
+ }
- m.Info("copying to\t", fullPath)
if !dryRun {
err := copyFile(m.path, fullPath)
From f480518f99dcd473d0b8293020973c07500b6a2b Mon Sep 17 00:00:00 2001
From: Steve Francia
Date: Wed, 16 Oct 2019 20:42:54 -0400
Subject: [PATCH 11/12] adding support for insta 360
---
file-util.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/file-util.go b/file-util.go
index 45f5639..39486a0 100644
--- a/file-util.go
+++ b/file-util.go
@@ -16,8 +16,8 @@ import (
var (
// This map is used to define extensions to examine
knownTypes = map[string][]string{
- "video": []string{"mp4", "avi", "m4v", "mov"},
- "photo": []string{"heic", "jpeg", "jpg", "raw", "arw", "png", "psd", "gpr", "gif", "tiff", "tif", "dng"},
+ "video": []string{"mp4", "avi", "m4v", "mov", "insv"},
+ "photo": []string{"heic", "jpeg", "jpg", "raw", "arw", "png", "psd", "gpr", "gif", "tiff", "tif", "dng", "insp"},
"sidecar": []string{"xmp", "on1", "xml"},
// Don't really need LRV - Low Resolution Video or THM - Thumbnail
}
From 3f46895261ba5328094ba701e67b929d20390da1 Mon Sep 17 00:00:00 2001
From: Mitchell
Date: Mon, 8 Jun 2020 17:43:14 -0500
Subject: [PATCH 12/12] reorganize files - update readme
---
.gitignore | 3 +
Gopkg.lock | 9 +
common/cmd.go | 205 +++++++++++++++++++++
common/exiftool.go | 110 +++++++++++
file-util.go => common/file-util.go | 4 +-
common/logger.go | 34 ++++
media-file.go => common/media-file.go | 97 +---------
progress-bar.go => common/progress-bar.go | 2 +-
main.go | 214 +---------------------
makefile | 8 +-
readme.md | 74 ++++----
readme_files/demo.gif | Bin 0 -> 289237 bytes
readme_files/mgphoto.png | Bin 0 -> 38044 bytes
readme_files/mgphoto.svg | 1 +
14 files changed, 417 insertions(+), 344 deletions(-)
create mode 100644 common/cmd.go
create mode 100644 common/exiftool.go
rename file-util.go => common/file-util.go (97%)
create mode 100644 common/logger.go
rename media-file.go => common/media-file.go (74%)
rename progress-bar.go => common/progress-bar.go (98%)
create mode 100644 readme_files/demo.gif
create mode 100644 readme_files/mgphoto.png
create mode 100644 readme_files/mgphoto.svg
diff --git a/.gitignore b/.gitignore
index cd6d99a..fced0ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
/vendor
/ignore
/dist
+/transfer.log
+/mgphoto
+/photos
\ No newline at end of file
diff --git a/Gopkg.lock b/Gopkg.lock
index 54621af..4185aa9 100644
--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -61,6 +61,14 @@
pruneopts = "UT"
revision = "41f3e6584952bb034a481797859f6ab34b6803bd"
+[[projects]]
+ digest = "1:b7b063b8125b7aeaf179aa4564ba60c896551d702f7c824ace98cc654fc813a0"
+ name = "gopkg.in/djherbis/times.v1"
+ packages = ["."]
+ pruneopts = "UT"
+ revision = "847c5208d8924cea0acea3376ff62aede93afe39"
+ version = "v1.2.0"
+
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
@@ -69,6 +77,7 @@
"github.com/rwcarlsen/goexif/mknote",
"github.com/vbauerster/mpb",
"github.com/vbauerster/mpb/decor",
+ "gopkg.in/djherbis/times.v1",
]
solver-name = "gps-cdcl"
solver-version = 1
diff --git a/common/cmd.go b/common/cmd.go
new file mode 100644
index 0000000..646363d
--- /dev/null
+++ b/common/cmd.go
@@ -0,0 +1,205 @@
+package common
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "regexp"
+ "sync"
+ "time"
+)
+
+var (
+ inputPath string
+ outputPath string
+ copyDuplicates bool
+ mvDuplicates bool
+ ignoreTinyFiles bool
+ sidecarFiles bool
+ dryRun bool
+ analyze bool
+ fullDestScan bool
+ logPath string
+ reDateTime = regexp.MustCompile(`(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})`)
+ errMissingCreateTime = errors.New(`Missing create time`)
+ Info *log.Logger
+ Warn *log.Logger
+ Error *log.Logger
+ wg sync.WaitGroup
+ maplock sync.RWMutex
+ workercount int = 100
+ minBytes int64 = 50000
+)
+
+func init() {
+
+ outputPtr := flag.String("out", "./photos", "Output path - defaults to ./photos")
+ logPtr := flag.String("log", "./transfer.log", "Log path - defaults to ./transfer.log")
+ dupPtr := flag.Bool("copy-dupes", false, "Copy duplicates to 'duplicates' folder")
+ mvPtr := flag.Bool("move-dupes", false, "Move duplicates to their correct location **EXPERIMENTAL**")
+ tinyPtr := flag.Bool("ignore-tiny", false, "Ignore really small images (<5kb)")
+ dryPtr := flag.Bool("dryrun", false, "Don't actually do anything")
+ analyzePtr := flag.Bool("analyze", false, "Track how long operations are taking")
+ fullDestPtr := flag.Bool("full-scan", false, "Scan the entire Destination for duplicates")
+ sidecarPtr := flag.Bool("sidecar", false, "Include sidecar files e.g. .xml, .on1, .xmp")
+
+ flag.Parse()
+
+ if len(flag.Args()) < 1 {
+ println("Invalid arguments - please supply a source directory")
+ os.Exit(0)
+ }
+
+ outputPath = *outputPtr
+ copyDuplicates = *dupPtr
+ mvDuplicates = *mvPtr
+ ignoreTinyFiles = *tinyPtr
+ logPath = *logPtr
+ dryRun = *dryPtr
+ analyze = *analyzePtr
+ fullDestScan = *fullDestPtr
+ sidecarFiles = *sidecarPtr
+
+ inputPath = flag.Args()[0]
+}
+
+// Start - start command line tool
+func Start() {
+
+ wr := initLogger()
+ defer wr.Flush()
+
+ createDirIfNotExists(outputPath)
+
+ sourceFiles := getAllFilePaths(inputPath)
+
+ println("Processing source files...")
+ sourceMediaFiles := getMediaFiles(sourceFiles, true)
+
+ if ignoreTinyFiles {
+ for k, f := range sourceMediaFiles {
+ if (f.isPhoto() || f.isVideo()) && f.size < minBytes {
+ f.Info("skipping too small photo")
+ delete(sourceMediaFiles, k)
+ }
+ }
+ }
+
+ var destFiles []string
+
+ if fullDestScan {
+ destFiles = getAllFilePaths(outputPath)
+ } else { // Only get paths from directories we're placing things into
+ destFiles = getFilePathsFromSource(outputPath, sourceMediaFiles)
+ }
+
+ println("Scanning destination for duplicates...")
+ destMediaFiles := getMediaFiles(destFiles, mvDuplicates)
+
+ dupeDestFiles := make(map[[20]byte]*MediaFile)
+ originalMediaFiles := make(map[[20]byte]*MediaFile)
+
+ // if we are not copying and not moving duplicates omit them
+ if !copyDuplicates || mvDuplicates {
+ for k := range sourceMediaFiles {
+ if destMediaFiles[k] != nil { // file exists in src & dest && has same hash (of first 2k bytes)
+ if mvDuplicates {
+ dupeDestFiles[k] = destMediaFiles[k]
+ originalMediaFiles[k] = sourceMediaFiles[k]
+ }
+ if sourceMediaFiles[k].size > destMediaFiles[k].size { // file in destination may not be complete
+ sourceMediaFiles[k].Info("is larger than duplicate, replacing", destMediaFiles[k].path)
+ sourceMediaFiles[k].replace = true
+ } else {
+ sourceMediaFiles[k].Info("Duplicate of", destMediaFiles[k].path)
+ delete(sourceMediaFiles, k)
+ }
+ }
+ }
+ }
+
+ if len(sourceMediaFiles) == 0 && len(dupeDestFiles) == 0 {
+ println("No new files to copy or move.")
+ return
+ }
+
+ if len(sourceMediaFiles) > 0 {
+ println("Copying new files to destination...")
+ progressBar := NewProgressBar(len(sourceMediaFiles))
+ for k, val := range sourceMediaFiles {
+ val.writeToDestination(outputPath, copyDuplicates && destMediaFiles[k] != nil)
+ progressBar.increment()
+ }
+
+ progressBar.wait()
+ }
+
+ // TODO: rework move logic - duplicate files in destination are sticking around
+ // This original intentions of this project were to not manipulate existing files
+ // e.g. deleting or renaming
+ if mvDuplicates && len(dupeDestFiles) > 0 {
+ fmt.Println("Moving existing files to the correct destination...")
+ dupeProgressBar := NewProgressBar(len(dupeDestFiles))
+ for k, val := range dupeDestFiles {
+ val.moveToDestination(outputPath, originalMediaFiles[k])
+ dupeProgressBar.increment()
+ }
+ dupeProgressBar.wait()
+ }
+}
+
+// get media file objects from file path list
+func getMediaFiles(paths []string, processMetaData bool) map[[20]byte]*MediaFile {
+ outputMap := map[[20]byte]*MediaFile{}
+ count := len(paths)
+
+ if count < 1 {
+ return outputMap
+ }
+
+ progressBar := NewProgressBar(count)
+ jobs := make(chan pathBool, count)
+ results := make(chan *MediaFile, count)
+
+ for w := 1; w <= workercount; w++ {
+ go worker(jobs, results)
+ }
+
+ for _, path := range paths {
+ jobs <- pathBool{path: path, processMetaData: processMetaData}
+ }
+ close(jobs)
+
+ for r := 1; r <= count; r++ {
+ mediaFile := <-results
+
+ if mediaFile != nil {
+ maplock.Lock()
+ outputMap[mediaFile.sha1] = mediaFile
+ maplock.Unlock()
+ }
+ progressBar.increment()
+ }
+ progressBar.wait()
+
+ return outputMap
+}
+
+type pathBool struct {
+ path string
+ processMetaData bool
+}
+
+func worker(jobs <-chan pathBool, results chan<- *MediaFile) {
+ for j := range jobs {
+ mediaFile := NewMediaFile(j.path, j.processMetaData)
+ results <- mediaFile
+ }
+}
+
+func timeTrack(start time.Time, name string) {
+ elapsed := time.Since(start)
+ log.Printf("%s took %s", name, elapsed)
+}
diff --git a/common/exiftool.go b/common/exiftool.go
new file mode 100644
index 0000000..97398b5
--- /dev/null
+++ b/common/exiftool.go
@@ -0,0 +1,110 @@
+package common
+
+import (
+ "bytes"
+ "os/exec"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var exiftoolExecutable = "exiftool"
+
+// check if exiftool is installed
+func init() {
+ checkForExifToolInstallation()
+}
+
+func checkForExifToolInstallation() {
+ cmd := exec.Command(exiftoolExecutable)
+ err := cmd.Run()
+ if err != nil {
+ println("----------------------------------------")
+ println("It looks like Exiftool is not installed. For more accurate timestamp readings,\nit is recommended to install exiftool and make sure it exists in your path: https://exiftool.org/install.html")
+ println("----------------------------------------\n")
+ }
+}
+
+func getTagsViaExifTool(file string) (map[string]string, error) {
+ var out bytes.Buffer
+ cmd := exec.Command("exiftool", file)
+
+ cmd.Stdout = &out
+ if err := cmd.Run(); err != nil {
+ return nil, err
+ }
+
+ tags := make(map[string]string)
+
+ data := strings.Trim(out.String(), " \r\n")
+ lines := strings.Split(data, "\n")
+
+ for _, line := range lines {
+ k, v := strings.Replace(strings.TrimSpace(line[0:32]), " ", "", -1), strings.TrimSpace(line[33:])
+ tags[k] = v
+ }
+
+ return tags, nil
+}
+
+// getExifCreateDate attempts to get the given file's original creation date
+// from its EXIF tags.
+func getExifCreateDateFromTags(tags map[string]string) (time.Time, error) {
+ // Looking for the first tag that sounds like a date.
+ dateTimeFields := []string{
+ "DateAndTimeOriginal",
+ "DateTimeOriginal",
+ "Date/TimeOriginal",
+ "DateTaken",
+ "CreateDate",
+ "MediaCreateDate",
+ "TrackCreateDate",
+ "ModifyDate",
+ "FileModificationDateTime",
+ "FileAccessDateTime",
+ "EncodedDate",
+ "TaggedDate",
+ }
+
+ toInt := func(s string) (i int) {
+ i, _ = strconv.Atoi(s)
+ return
+ }
+
+ for _, field := range dateTimeFields {
+ taken, ok := tags[field]
+ if !ok {
+ continue
+ }
+
+ all := reDateTime.FindAllStringSubmatch(taken, -1)
+
+ if len(all) < 1 || len(all[0]) < 6 {
+ return time.Time{}, errMissingCreateTime
+ }
+
+ y := toInt(all[0][1])
+ if y == 0 {
+ continue
+ }
+
+ t := time.Date(
+ y,
+ time.Month(toInt(all[0][2])),
+ toInt(all[0][3]),
+ toInt(all[0][4]),
+ toInt(all[0][5]),
+ toInt(all[0][6]),
+ 0,
+ time.Local,
+ )
+
+ if t.IsZero() {
+ continue
+ }
+
+ return t, nil
+ }
+
+ return time.Time{}, errMissingCreateTime
+}
diff --git a/file-util.go b/common/file-util.go
similarity index 97%
rename from file-util.go
rename to common/file-util.go
index 39486a0..2d98c46 100644
--- a/file-util.go
+++ b/common/file-util.go
@@ -1,4 +1,4 @@
-package main
+package common
import (
"io"
@@ -108,7 +108,7 @@ func copyFile(src, dest string) error {
}
func validFileType(path string) bool {
- return isPhoto(path) || isVideo(path) || isSidecar(path)
+ return isPhoto(path) || isVideo(path) || (sidecarFiles && isSidecar(path))
}
func isPhoto(path string) bool {
diff --git a/common/logger.go b/common/logger.go
new file mode 100644
index 0000000..1abb44e
--- /dev/null
+++ b/common/logger.go
@@ -0,0 +1,34 @@
+package common
+
+import (
+ "io"
+ "io/ioutil"
+ "log"
+ "os"
+ "text/tabwriter"
+)
+
+func initLogger() *tabwriter.Writer {
+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
+ if err != nil {
+ log.Fatalln("Failed to open log file", logPath, ":", err)
+ }
+
+ wr := tabwriter.NewWriter(logFile, 10, 8, 3, ' ', 0)
+ multiWarn := io.MultiWriter(wr, ioutil.Discard)
+ multiErr := io.MultiWriter(wr, os.Stderr)
+
+ Info = log.New(wr, "INFO: ", log.Ldate|log.Ltime)
+ Warn = log.New(multiWarn, "WARN: ", log.Ldate|log.Ltime)
+ Error = log.New(multiErr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)
+
+ Info.Println("************************************************")
+ if dryRun {
+ Info.Println(" * * * * DRY RUN * * * * ")
+ } else {
+ Info.Println(" > > > > NEW RUN < < < < ")
+ }
+ Info.Println("************************************************")
+
+ return wr
+}
diff --git a/media-file.go b/common/media-file.go
similarity index 74%
rename from media-file.go
rename to common/media-file.go
index 23202a5..6b4db5d 100644
--- a/media-file.go
+++ b/common/media-file.go
@@ -1,17 +1,13 @@
-package main
+package common
import (
- "bytes"
"crypto/sha1"
"io"
"io/ioutil"
"log"
"os"
- "os/exec"
"path"
"path/filepath"
- "strconv"
- "strings"
"time"
"github.com/rwcarlsen/goexif/exif"
@@ -138,12 +134,11 @@ func (m *MediaFile) processMetaData(file *os.File) {
}
skipEXIF := false
- if !tinyFiles {
+ if ignoreTinyFiles {
if (m.isPhoto() || m.isVideo()) && m.size < minBytes {
skipEXIF = true
}
}
- // fmt.Println(m.path)
var d *time.Time
if m.isVideo() && !skipEXIF {
@@ -193,6 +188,8 @@ func (m *MediaFile) getFileTime() *time.Time {
}
func (m *MediaFile) getExifDateExifTool() *time.Time {
+ m.Info("Falling back to exiftool")
+
tags, err := getTagsViaExifTool(m.path)
if err != nil {
@@ -202,30 +199,8 @@ func (m *MediaFile) getExifDateExifTool() *time.Time {
if err != nil {
return nil
}
- return &date
-}
-
-func getTagsViaExifTool(file string) (map[string]string, error) {
- var out bytes.Buffer
- cmd := exec.Command("exiftool", file)
-
- cmd.Stdout = &out
- if err := cmd.Run(); err != nil {
- return nil, err
- }
-
- tags := make(map[string]string)
-
- data := strings.Trim(out.String(), " \r\n")
- lines := strings.Split(data, "\n")
-
- for _, line := range lines {
- k, v := strings.Replace(strings.TrimSpace(line[0:32]), " ", "", -1), strings.TrimSpace(line[33:])
- // k = normalizeEXIFTag(k)
- tags[k] = v
- }
- return tags, nil
+ return &date
}
func getExifDate(file *os.File) *time.Time {
@@ -247,68 +222,6 @@ func getExifDate(file *os.File) *time.Time {
return &t
}
-// getExifCreateDate attempts to get the given file's original creation date
-// from its EXIF tags.
-func getExifCreateDateFromTags(tags map[string]string) (time.Time, error) {
- // Looking for the first tag that sounds like a date.
- dateTimeFields := []string{
- "DateAndTimeOriginal",
- "DateTimeOriginal",
- "Date/TimeOriginal",
- "DateTaken",
- "CreateDate",
- "MediaCreateDate",
- "TrackCreateDate",
- "ModifyDate",
- "FileModificationDateTime",
- "FileAccessDateTime",
- "EncodedDate",
- "TaggedDate",
- }
-
- toInt := func(s string) (i int) {
- i, _ = strconv.Atoi(s)
- return
- }
-
- for _, field := range dateTimeFields {
- taken, ok := tags[field]
- if !ok {
- continue
- }
-
- all := reDateTime.FindAllStringSubmatch(taken, -1)
-
- if len(all) < 1 || len(all[0]) < 6 {
- return time.Time{}, errMissingCreateTime
- }
-
- y := toInt(all[0][1])
- if y == 0 {
- continue
- }
-
- t := time.Date(
- y,
- time.Month(toInt(all[0][2])),
- toInt(all[0][3]),
- toInt(all[0][4]),
- toInt(all[0][5]),
- toInt(all[0][6]),
- 0,
- time.Local,
- )
-
- if t.IsZero() {
- continue
- }
-
- return t, nil
- }
-
- return time.Time{}, errMissingCreateTime
-}
-
func (m *MediaFile) writeToDestination(dest string, copyDuplicates bool) error {
dir := dest
diff --git a/progress-bar.go b/common/progress-bar.go
similarity index 98%
rename from progress-bar.go
rename to common/progress-bar.go
index b7ba850..88be777 100644
--- a/progress-bar.go
+++ b/common/progress-bar.go
@@ -1,4 +1,4 @@
-package main
+package common
import (
"sync"
diff --git a/main.go b/main.go
index cc87f1d..5cb27da 100644
--- a/main.go
+++ b/main.go
@@ -1,223 +1,15 @@
package main
-import (
- "errors"
- "flag"
- "fmt"
- "io"
- "io/ioutil"
- "log"
- "os"
- "regexp"
- "sync"
- "text/tabwriter"
- "time"
-)
+import "github.com/mgerb/mgphoto/common"
-var (
- inputPath string
- outputPath string
- copyDuplicates bool
- mvDuplicates bool
- tinyFiles bool
- dryRun bool
- analyze bool
- fullDestScan bool
- logPath string
- version = "undefined"
- reDateTime = regexp.MustCompile(`(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})`)
- errMissingCreateTime = errors.New(`Missing create time`)
- Info *log.Logger
- Warn *log.Logger
- Error *log.Logger
- wg sync.WaitGroup
- maplock sync.RWMutex
- workercount int = 100
- minBytes int64 = 50000
-)
+var version = "undefined"
func init() {
if version != "undefined" {
println("mgphoto ", version, "\n")
}
-
- outputPtr := flag.String("out", "./photos", "Output path - defaults to ./photos")
- logPtr := flag.String("log", "./transfer.log", "Log path - defaults to ./transfer.log")
- dupPtr := flag.Bool("copy-dupes", false, "Copy duplicates to 'duplicates' folder")
- mvPtr := flag.Bool("move-dupes", false, "Move duplicates to their correct location")
- tinyPtr := flag.Bool("copy-tiny", false, "Copy really small images (<5kb)")
- dryPtr := flag.Bool("dryrun", false, "Don't actually do anything")
- analyzePtr := flag.Bool("analyze", false, "Track how long operations are taking")
- fullDestPtr := flag.Bool("full-scan", false, "Scan the entire Destination for duplicates")
-
- flag.Parse()
-
- if len(flag.Args()) < 1 {
- println("Invalid arguments - please supply a source directory")
- os.Exit(0)
- }
-
- outputPath = *outputPtr
- copyDuplicates = *dupPtr
- mvDuplicates = *mvPtr
- tinyFiles = *tinyPtr
- logPath = *logPtr
- dryRun = *dryPtr
- analyze = *analyzePtr
- fullDestScan = *fullDestPtr
-
- inputPath = flag.Args()[0]
}
func main() {
-
- logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
- if err != nil {
- log.Fatalln("Failed to open log file", logPath, ":", err)
- }
-
- wr := tabwriter.NewWriter(logFile, 10, 8, 3, ' ', 0)
- multiWarn := io.MultiWriter(wr, ioutil.Discard)
- multiErr := io.MultiWriter(wr, os.Stderr)
-
- Info = log.New(wr, "INFO: ", log.Ldate|log.Ltime)
- Warn = log.New(multiWarn, "WARN: ", log.Ldate|log.Ltime)
- Error = log.New(multiErr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)
- Info.Println("************************************************")
- if dryRun {
- Info.Println(" * * * * DRY RUN * * * * ")
- } else {
- Info.Println(" > > > > NEW RUN < < < < ")
- }
- Info.Println("************************************************")
- defer wr.Flush()
-
- createDirIfNotExists(outputPath)
-
- sourceFiles := getAllFilePaths(inputPath)
-
- println("Processing source files...")
- sourceMediaFiles := getMediaFiles(sourceFiles, true)
-
- if !tinyFiles {
- for k, f := range sourceMediaFiles {
- if (f.isPhoto() || f.isVideo()) && f.size < minBytes {
- f.Info("skipping too small photo")
- delete(sourceMediaFiles, k)
- }
- }
- }
-
- var destFiles []string
-
- if fullDestScan {
- destFiles = getAllFilePaths(outputPath)
- } else { // Only get paths from directories we're placing things into
- destFiles = getFilePathsFromSource(outputPath, sourceMediaFiles)
- }
-
- println("Scanning destination for duplicates...")
- destMediaFiles := getMediaFiles(destFiles, mvDuplicates)
-
- dupeDestFiles := make(map[[20]byte]*MediaFile)
- originalMediaFiles := make(map[[20]byte]*MediaFile)
-
- // if we are not copying and not moving duplicates omit them
- if !copyDuplicates || mvDuplicates {
- for k := range sourceMediaFiles {
- if destMediaFiles[k] != nil { // file exists in src & dest && has same hash (of first 2k bytes)
- if mvDuplicates {
- dupeDestFiles[k] = destMediaFiles[k]
- originalMediaFiles[k] = sourceMediaFiles[k]
- }
- if sourceMediaFiles[k].size > destMediaFiles[k].size { // file in destination may not be complete
- sourceMediaFiles[k].Info("is larger than duplicate, replacing", destMediaFiles[k].path)
- sourceMediaFiles[k].replace = true
- } else {
- sourceMediaFiles[k].Info("Duplicate of", destMediaFiles[k].path)
- delete(sourceMediaFiles, k)
- }
- }
- }
- }
-
- if len(sourceMediaFiles) == 0 && len(dupeDestFiles) == 0 {
- println("No new files to copy or move.")
- return
- }
-
- if len(sourceMediaFiles) > 0 {
- println("Copying new files to destination...")
- progressBar := NewProgressBar(len(sourceMediaFiles))
- for k, val := range sourceMediaFiles {
- val.writeToDestination(outputPath, copyDuplicates && destMediaFiles[k] != nil)
- progressBar.increment()
- }
-
- progressBar.wait()
- }
-
- if mvDuplicates && len(dupeDestFiles) > 0 {
- fmt.Println("Moving existing files to the correct destination...")
- dupeProgressBar := NewProgressBar(len(dupeDestFiles))
- for k, val := range dupeDestFiles {
- val.moveToDestination(outputPath, originalMediaFiles[k])
- dupeProgressBar.increment()
- }
- dupeProgressBar.wait()
- }
-}
-
-// get media file objects from file path list
-func getMediaFiles(paths []string, processMetaData bool) map[[20]byte]*MediaFile {
- outputMap := map[[20]byte]*MediaFile{}
- count := len(paths)
-
- if count < 1 {
- return outputMap
- }
-
- progressBar := NewProgressBar(count)
- jobs := make(chan pathBool, count)
- results := make(chan *MediaFile, count)
-
- for w := 1; w <= workercount; w++ {
- go worker(jobs, results)
- }
-
- for _, path := range paths {
- jobs <- pathBool{path: path, processMetaData: processMetaData}
- }
- close(jobs)
-
- for r := 1; r <= count; r++ {
- mediaFile := <-results
-
- if mediaFile != nil {
- maplock.Lock()
- outputMap[mediaFile.sha1] = mediaFile
- maplock.Unlock()
- }
- progressBar.increment()
- }
- progressBar.wait()
-
- return outputMap
-}
-
-type pathBool struct {
- path string
- processMetaData bool
-}
-
-func worker(jobs <-chan pathBool, results chan<- *MediaFile) {
- for j := range jobs {
- mediaFile := NewMediaFile(j.path, j.processMetaData)
- results <- mediaFile
- }
-}
-
-func timeTrack(start time.Time, name string) {
- elapsed := time.Since(start)
- log.Printf("%s took %s", name, elapsed)
+ common.Start()
}
diff --git a/makefile b/makefile
index 41be6a8..b7063b0 100644
--- a/makefile
+++ b/makefile
@@ -1,16 +1,16 @@
VERSION := $(shell git describe --tags)
linux32:
- GOOS=linux GOARCH=386 go build -o ./dist/mgphoto-linux32 -ldflags="-X main.version=${VERSION}" ./*.go
+ GOOS=linux GOARCH=386 go build -o ./dist/linux-32/mgphoto -ldflags="-X main.version=${VERSION}" ./*.go
linux64:
- GOOS=linux GOARCH=amd64 go build -o ./dist/mgphoto-linux64 -ldflags="-X main.version=${VERSION}" ./*.go
+ GOOS=linux GOARCH=amd64 go build -o ./dist/linux-64/mgphoto -ldflags="-X main.version=${VERSION}" ./*.go
mac:
- GOOS=darwin GOARCH=amd64 go build -o ./dist/mgphoto-mac -ldflags="-X main.version=${VERSION}" ./*.go
+ GOOS=darwin GOARCH=amd64 go build -o ./dist/mac/mgphoto -ldflags="-X main.version=${VERSION}" ./*.go
windows:
- GOOS=windows GOARCH=386 go build -o ./dist/mgphoto-windows.exe -ldflags="-X main.version=${VERSION}" ./*.go
+ GOOS=windows GOARCH=386 go build -o ./dist/windows/mgphoto.exe -ldflags="-X main.version=${VERSION}" ./*.go
clean:
rm -rf ./dist
diff --git a/readme.md b/readme.md
index 30546c9..9c92b35 100644
--- a/readme.md
+++ b/readme.md
@@ -1,36 +1,42 @@
-# MGPhoto
+
+
+
A dead simple command line photo import tool
+
+
+
+
+
+
+
+
+ Download the latest release here
+
+
+- extremely fast
+- duplicate file handling
+ - skip duplicates
+ - copy duplicates to separate folder
+- preserve original files and file names
+- uses **optional** [Exiftool](https://exiftool.org/install.html) for higher precision exif readings (just install and make sure `exiftool` is in your path to benefit from this)
+- Photos are not renamed unless a file already exists with that name e.g. **IMG_1.jpg** will be renamed to **IMG_1_1.jpg**.
+- recursively scan directory for new photos
+
+Courtesy of [gophers](https://github.com/egonelbre/gophers) for the logo.
+
+## Options
+
+| Argument | Default | Description
+|--|--|--|
+| out | ./photos | Output path - defaults to ./photos |
+| log | ./transfer.log | Log path - defaults to ./transfer.log |
+| copy-dupes | false | Copy duplicates to 'duplicates' folder |
+| ignore-tiny | false | Ignore really small images (<5kb) |
+| dryrun | false | Don't actually do anything |
+| analyze | false | Track how long operations are taking |
+| sidecar | false | Include sidecar files e.g. .xml, .on1, .xmp |
+
+## Example Usage
-[Download the latest release here.](https://github.com/mgerb/mgphoto/releases)
-
-- duplicates are skipped
-- preserve original files
-- no files will be overwritten
-- currently only works with [EXIF](https://en.wikipedia.org/wiki/Exif) for dates
-- unknown dates will go in **unknown** folder
-
-Photos are not renamed unless a file already exists with that name
-e.g. **IMG_1.jpg** will be renamed to **IMG_1_1.jpg**.
-
-## Usage
-
-```
-mgphoto -o ./outputPath ./photos
-```
-
-Recursively scans entire directory along with
-nested directories and outputs image/video files
-in the following format.
-
-```
-2017/
-└── 2017-08-15/
- └── IMG_1.jpg
-
-2018/
-├── 2018-02-23/
-│ ├── IMG_2.jpg
-│ ├── IMG_3.jpg
-│ ├── IMG_4.jpg
-└── 2018-03-01/
- └── IMG_5.jpg
```
+mgphoto -out=/home/photos -copy-dupes -ignore-tiny -analyze /path/to/input/files
+```
\ No newline at end of file
diff --git a/readme_files/demo.gif b/readme_files/demo.gif
new file mode 100644
index 0000000000000000000000000000000000000000..a4c8227d0b1b0a54285c495261c2c646b50b8d4f
GIT binary patch
literal 289237
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