Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ SortPhotos will *always* check to make sure something with the same file name do

python sortphotos.py --keep-duplicates /source /destination

## filtering file to be processed and cleaning the source directory
Sometimes, especially when working with directories coming from winwdows, it is desirable to filter for example Thumb.db files so they are no moved/copied to dest_dir.
The ``--filter`` option can be used for that. Ex: ``--filter '*.db'``.
Several filter can be applied to filter several type of files seprarting them with ',': Ex: ``--filter '.*,*.db'``.
Don't forget the quotes ``'`` to delimit filter or the terminal window can expand them.
Invoke the option ``--remove-filtered-files`` to add a step that remove filtered files before processing the whole directory. This is usefull when combined with move option (default) to get clean empty dirs once the source directory has been processed.
Invoke the option ``--remove-empty-dirs`` to delete empty dirs once the processing is finished

## using notification (OSX only)
To get a notification once directory has been processed use ``--notify``.

![Notify](notify.png)

## forcing a locale
When directories are created they use the python default locale. The result is that you get directory name in english as default.
Using ``--set-locale`` will force a locale to get directory name in your prefered language.
ex: ``--set-locale fr_FR`` will force for the French names for months.

<!-- ## choose which file types to search for
You can restrict what types of files SortPhotos looks for in your source directory. By default it only looks for the most common photo and video containers ('jpg', 'jpeg', 'tiff', 'arw', 'avi', 'mov', 'mp4', 'mts'). You can change this behavior through the ``extensions`` argument. Note that it is not case sensitive so if you specify 'jpg' as an extension it will search for both jpg and JPG files or even jPg files. For example say you want to copy and sort only the *.gif and *.avi files you would call

Expand Down Expand Up @@ -138,12 +156,35 @@ and you should see the Agent listed (I grep the results because you will typical

$ launchctl unload com.andrewning.sortphotos.plist

#File watcher
As an alternate way, a ``com.sortphotos.file_watcher.plist`` is provided to invoke the file_watcher.py script.
This script invoke sortphotos in ``-w`` watch mode.
Fswatch from https://github.com/emcrisostomo/fswatch must be installed prior to use file_watcher.py
In this mode the script waits on its stdin input to process the directory. Combined with file_watcher, this is an efficient way to automatically get your file processed 5 seconds after any IO are done on the watched directory.
You can tune the watched dir,sorted dir ans sortphotos options setting the parameters ``WATCH_DIR``, ``SORTED_DIR`` and ``USER_OPTIONS`` into file_watcher.py that is a warapper script that setup everything for you.

# Acknowledgments

SortPhotos grabs EXIF data from the photos/videos using the very excellent [ExifTool](http://www.sno.phy.queensu.ca/~phil/exiftool/) written by Phil Harvey.

Fswatch from:
https://github.com/emcrisostomo/fswatch
Must be installed prior to use file_watcher.py

Terminal-notifier recompiled to change default icon , original from:
https://github.com/julienXX/terminal-notifier

# ChangeLog (of major changes)

### 9/19/2015

- file_watcher script
- locales support
- new options to filter and clean source directory
- OSX notification support
- fix for exif field with "HH:MM:SS" only format
- fix for str/buffer python3 compatibility

### 7/17/2015

- @nueh fix for Python 2.5 (which you might be stuck with on a NAS for example).
Expand Down
Binary file added notify.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions src/com.sortphotos.file_watcher.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Disabled</key>
<false/>
<key>Label</key>
<string>com.sortphotos.file_watcher</string>
<key>ProgramArguments</key>
<array>
<string>python</string>
<string>~/Documents/sortphotos/src/file_watcher.py</string> <!-- full path to file_watcher.py -->
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
26 changes: 26 additions & 0 deletions src/file_watcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env python
# encoding: utf-8
"""
file_wathcer.py

Created on 14/9/2015
Copyright (c) L. Dufréchou. All rights reserved.

"""
import os

WATCH_DIR = "~/Documents/incoming_photos"
SORTED_DIR = "~/Documents/sorted_photos"

USER_OPTIONS = "--sort %Y/%m-%B --rename %y%m%d_%H%M_%S --set-locale fr_FR --notify"

# Below this line there is no need to change anything
#-----------------------------------------------------------------------------------------------------------------------
MANDATORY_OPT = "-r --watch -s --ignore '.*,*.db' --remove-ignored-files --remove-empty-dirs"
SORTPHOTOS = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'sortphotos.py')
FSWATCH = 'fswatch'

sortphotos_cmd = ' '.join(['python', SORTPHOTOS, MANDATORY_OPT, USER_OPTIONS, WATCH_DIR, SORTED_DIR])

fswatch_cmd = FSWATCH+' '+WATCH_DIR+' | ' + sortphotos_cmd
os.system(fswatch_cmd)
132 changes: 118 additions & 14 deletions src/sortphotos.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@
import os
import sys
import shutil
import fnmatch #used for filtering files
import select #used by stdin watcher
try:
import json
except:
import simplejson as json
import filecmp
from datetime import datetime, timedelta
import re

import locale

exiftool_location = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'Image-ExifTool', 'exiftool')

TERMINAL_APP = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'tools', 'terminal-notifier.app/Contents/MacOS/terminal-notifier')

# -------- convenience methods -------------

Expand All @@ -46,8 +48,8 @@ def parse_date_exif(date_string):
# parse year, month, day
date_entries = elements[0].split(':') # ['YYYY', 'MM', 'DD']

# check if three entries, nonzero data, and no decimal (which occurs for timestamps with only time but no date)
if len(date_entries) == 3 and date_entries[0] > '0000' and '.' not in ''.join(date_entries):
# check if three entries, nonzero data, and no decimal (which occurs for timestamps with only time but no date), and len year = 4 to workaround 'HH:MM:SS' entries
if len(date_entries) == 3 and date_entries[0] > '0000' and '.' not in ''.join(date_entries) and len(date_entries[0]) == 4:
year = int(date_entries[0])
month = int(date_entries[1])
day = int(date_entries[2])
Expand Down Expand Up @@ -188,17 +190,17 @@ def __enter__(self):
return self

def __exit__(self, exc_type, exc_value, traceback):
self.process.stdin.write("-stay_open\nFalse\n")
self.process.stdin.write(b"-stay_open\nFalse\n")
self.process.stdin.flush()

def execute(self, *args):
args = args + ("-execute\n",)
self.process.stdin.write(str.join("\n", args))
self.process.stdin.write(str.join("\n", args).encode('utf-8'))
self.process.stdin.flush()
output = ""
fd = self.process.stdout.fileno()
while not output.rstrip(' \t\n\r').endswith(self.sentinel):
increment = os.read(fd, 4096)
increment = os.read(fd, 4096).decode('utf-8')
if self.verbose:
sys.stdout.write(increment)
output += increment
Expand All @@ -210,7 +212,7 @@ def get_metadata(self, *args):
return json.loads(self.execute(*args))
except ValueError:
sys.stdout.write('No files to parse or invalid data\n')
exit()
return {}


# ---------------------------------------
Expand All @@ -220,7 +222,8 @@ def get_metadata(self, *args):
def sortPhotos(src_dir, dest_dir, sort_format, rename_format, recursive=False,
copy_files=False, test=False, remove_duplicates=True, day_begins=0,
additional_groups_to_ignore=['File'], additional_tags_to_ignore=[],
use_only_groups=None, use_only_tags=None, verbose=True):
use_only_groups=None, use_only_tags=None, verbose=True,
ignore_list=[], remove_ignored_files=False, remove_empty_dirs=False):
"""
This function is a convenience wrapper around ExifTool based on common usage scenarios for sortphotos.py

Expand Down Expand Up @@ -258,7 +261,12 @@ def sortPhotos(src_dir, dest_dir, sort_format, rename_format, recursive=False,
a list of tags that will be exclusived searched across for date info
verbose : bool
True if you want to see details of file processing

ignore : list(str)
a list of files to be ignored separated by ',' , example: --ignore '.*,*.db' (be aware to put the filter between bracket to avoid side effect with command line)
remove_ignored_files : bool
True to remove files that are ignored with ignore_list parameter
remove_empty_dirs : bool
True to empty dirs once processing is done
"""

# some error checking
Expand Down Expand Up @@ -289,6 +297,20 @@ def sortPhotos(src_dir, dest_dir, sort_format, rename_format, recursive=False,

args += [src_dir]

ignore_list = ignore_list.split(',')
print("Scanning for files matching:%s"%(ignore_list))
# in recursive mode, if the user ask to remove ignored files we scan and remove them before running exiftool
if recursive and remove_ignored_files and len(ignore_list) > 0:
for root, dirs, files in os.walk(src_dir):
for current_file in files:
for _filter in ignore_list:
if fnmatch.fnmatch(os.path.split(current_file)[-1], _filter):
file_to_delete = os.path.join(root,current_file)
print("File [%s] match ignored file filter [%s]: deleting."%(file_to_delete,_filter))
if not test:
os.remove(file_to_delete)
#once a filter has matched we break to next file to avoid removing several times
break

# get all metadata
with ExifTool(verbose=verbose) as e:
Expand Down Expand Up @@ -331,6 +353,17 @@ def sortPhotos(src_dir, dest_dir, sort_format, rename_format, recursive=False,
# sys.stdout.flush()
continue

# filter ignored files and remove them if requested
for _filter in ignore_list:
if fnmatch.fnmatch(os.path.split(src_file)[-1], _filter):
if remove_ignored_files:
print("file [%s] match filter [%s]: deleting." % (src_file, _filter))
if not test:
os.remove(src_file)
else:
print("file [%s] match filter [%s]: ignoring." % (src_file, _filter))
continue

# ignore hidden files
if os.path.basename(src_file).startswith('.'):
print('hidden file. will be skipped')
Expand All @@ -357,6 +390,10 @@ def sortPhotos(src_dir, dest_dir, sort_format, rename_format, recursive=False,
# rename file if necessary
filename = os.path.basename(src_file)

# patch to support foreign characters under python 2.x
if sys.version_info.major < 3:
dest_file = dest_file.decode('utf-8')

if rename_format is not None:
_, ext = os.path.splitext(filename)
filename = date.strftime(rename_format) + ext
Expand Down Expand Up @@ -429,6 +466,57 @@ def sortPhotos(src_dir, dest_dir, sort_format, rename_format, recursive=False,
if not verbose:
print()

if remove_empty_dirs:
# use topdown false to scan from bottom to top to avoid trying to delete top directory while child haven't
# been processed
for dirpath, dirnames, files in os.walk(src_dir, topdown=False):
if not files and dirpath != src_dir:
print("[Cleaning] Removing empty directory: %s" % dirpath)
if not test:
os.rmdir(dirpath)

return num_files

def run_stdin_watcher(args):
verbose = not args.silent
file_present = []
while True:
try:
i, o, e = select.select( [sys.stdin], [], [], 5)

if(i):
for new_file in sys.stdin.readline()[:-1].split('\n'):
if os.path.exists(new_file):
if verbose:
print("New file present:", new_file)
file_present.append(new_file)
else:
if verbose:
print("No activity detected.")
if len(file_present) > 0:
print("Sorting files...")
run_sortphotos(args)
print("Done!")
file_present = []
except KeyboardInterrupt:
sys.exit(0)
except:
import traceback
e = sys.exc_info()[0]
print("Exception detected:",e)
print('-'*60)
traceback.print_exc(file=sys.stdout)
print('-'*60)

def run_sortphotos(args):
sorted = sortPhotos(args.src_dir, args.dest_dir, args.sort, args.rename, args.recursive,
args.copy, args.test, not args.keep_duplicates, args.day_begins,
args.ignore_groups, args.ignore_tags, args.use_only_groups,
args.use_only_tags, not args.silent, args.ignore, args.remove_ignored_files, args.remove_empty_dirs)

if sys.platform == 'darwin' and args.notify and sorted > 0:
terminal_app_cmd = TERMINAL_APP + " -title 'Sortphoto' -message '"+str(sorted)+" photos sorted.' -sound 'default' -execute 'open "+args.dest_dir+"'"
os.system(terminal_app_cmd)

def main():
import argparse
Expand Down Expand Up @@ -474,14 +562,30 @@ def main():
default=None,
help='specify a restricted set of tags to search for date information\n\
e.g., EXIF:CreateDate')
parser.add_argument('--ignore', type=str,
default="",
help="a list of files to be ignored separated by ','\n\
example: --ignore '.*,*.db' \n\
(be aware to put the filter between bracket to avoid side effect with command line)")
parser.add_argument('--remove-ignored-files', action='store_true', help='remove ignored files')
parser.add_argument('--remove-empty-dirs', action='store_true', help='remove empty dirs')
parser.add_argument('-w','--watch', action='store_true', help='long running mode whare the source dir is constantly watched')
parser.add_argument('--notify', action='store_true', help='notify once sorting is done')
parser.add_argument('--set-locale', type=str,
default=None,
help='specify a locale like fr_FR fro french, useful to get month directory name in your own locale')

# parse command line arguments
args = parser.parse_args()
#print(args)

sortPhotos(args.src_dir, args.dest_dir, args.sort, args.rename, args.recursive,
args.copy, args.test, not args.keep_duplicates, args.day_begins,
args.ignore_groups, args.ignore_tags, args.use_only_groups,
args.use_only_tags, not args.silent)
if args.set_locale:
locale.setlocale(locale.LC_TIME, args.set_locale)

if args.watch:
run_stdin_watcher(args)
else:
run_sortphotos(args)

if __name__ == '__main__':
main()
52 changes: 52 additions & 0 deletions src/tools/terminal-notifier.app/Contents/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildMachineOSBuild</key>
<string>14F27</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>terminal-notifier</string>
<key>CFBundleIconFile</key>
<string>Terminal</string>
<key>CFBundleIdentifier</key>
<string>eu.neko-labs.terminal-notifier</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>terminal-notifier</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.6.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>14</string>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTPlatformBuild</key>
<string>6E35b</string>
<key>DTPlatformVersion</key>
<string>GM</string>
<key>DTSDKBuild</key>
<string>14D125</string>
<key>DTSDKName</key>
<string>macosx10.10</string>
<key>DTXcode</key>
<string>0640</string>
<key>DTXcodeBuild</key>
<string>6E35b</string>
<key>LSMinimumSystemVersion</key>
<string>10.8</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2012-2015 Eloy Durán, Julien Blanchard. All rights reserved.</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
Binary file not shown.
1 change: 1 addition & 0 deletions src/tools/terminal-notifier.app/Contents/PkgInfo
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
APPL????
Binary file not shown.
Loading