Add support for reading/writing titles for photos and videos

This commit is contained in:
Jaisen Mathai 2015-10-28 00:19:21 -07:00
parent c3b233fff2
commit 62f6e56bdb
8 changed files with 138 additions and 57 deletions

View File

@ -8,9 +8,9 @@ You don't love me yet but you will.
I only do 3 things. I only do 3 things.
* Firstly I organize your existing collection of photos. * Firstly I organize your existing collection of photos.
* Second I help make it easy for all the photos you haven't taken yet to flow into the exact location they belong. * Second I help make it easy for all the photos you haven't taken yet to flow into the exact location they belong.
* Third but not least I promise to do all this without a yucky propietary database that some colleagues of mine use. * Third but not least I promise to do all this without a yucky propietary database that some friends of mine use.
*NOTE: make sure you've installed me and my friends before running the commands below. [Instructions](#install-everything-you-need) at the bottom of this page.* *NOTE: make sure you've installed everything I need before running the commands below. [Instructions](#install-everything-you-need) at the bottom of this page.*
## See me in action ## See me in action
@ -53,7 +53,7 @@ You'll notice that your photos are now organized by date and location. Some phot
Don't fret if your photos don't have much EXIF information. I'll show you how you can fix them up later on but let's walk before we run. Don't fret if your photos don't have much EXIF information. I'll show you how you can fix them up later on but let's walk before we run.
Back to your photos. When I'm done you should see something like this. Notice that I've renamed your files by adding the date and time they were taken. This helps keep them in chronological order when using most viewing applications. You'll can thank me later. Back to your photos. When I'm done you should see something like this. Notice that I've renamed your files by adding the date and time they were taken. This helps keep them in chronological order when using most viewing applications. You'll thank me later.
``` ```
├── 2015-06-Jun ├── 2015-06-Jun

View File

@ -1,5 +1,6 @@
from os import path from os import path
debug = False
application_directory = '{}/.elodie'.format(path.expanduser('~')) application_directory = '{}/.elodie'.format(path.expanduser('~'))
hash_db = '{}/hash.json'.format(application_directory) hash_db = '{}/hash.json'.format(application_directory)
script_directory = path.dirname(path.dirname(path.abspath(__file__))) script_directory = path.dirname(path.dirname(path.abspath(__file__)))

View File

@ -8,6 +8,7 @@ import shutil
import time import time
from elodie import geolocation from elodie import geolocation
from elodie import constants
from elodie.localstorage import Db from elodie.localstorage import Db
""" """
@ -59,27 +60,31 @@ class FileSystem:
return os.getcwd() return os.getcwd()
""" """
Generate file name for a video using its metadata. Generate file name for a photo or video using its metadata.
We use an ISO8601-like format for the file name prefix. We use an ISO8601-like format for the file name prefix.
Instead of colons as the separator for hours, minutes and seconds we use a hyphen. Instead of colons as the separator for hours, minutes and seconds we use a hyphen.
https://en.wikipedia.org/wiki/ISO_8601#General_principles https://en.wikipedia.org/wiki/ISO_8601#General_principles
@param, video, Video, A Video instance @param, media, Photo|Video, A Photo or Video instance
@returns, string or None for non-videos @returns, string or None for non-photo or non-videos
""" """
def get_file_name(self, video): def get_file_name(self, media):
if(not video.is_valid()): if(not media.is_valid()):
return None return None
metadata = video.get_metadata() metadata = media.get_metadata()
if(metadata == None): if(metadata == None):
return None return None
# If the file has EXIF title we use that in the file name (i.e. my-favorite-photo-img_1234.jpg)
# We want to remove the date prefix we add to the name. # We want to remove the date prefix we add to the name.
# This helps when re-running the program on file which were already processed. # This helps when re-running the program on file which were already processed.
base_name = re.sub('^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-', '', metadata['base_name']) base_name = re.sub('^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-', '', metadata['base_name'])
if(len(base_name) == 0): if(len(base_name) == 0):
base_name = metadata['base_name'] base_name = metadata['base_name']
if('title' in metadata and metadata['title'] is not None and len(metadata['title']) > 0):
title_sanitized = re.sub('\W+', '-', metadata['title'].strip())
base_name = '%s-%s' % (title_sanitized , base_name)
file_name = '%s-%s.%s' % (time.strftime('%Y-%m-%d_%H-%M-%S', metadata['date_taken']), base_name, metadata['extension']) file_name = '%s-%s.%s' % (time.strftime('%Y-%m-%d_%H-%M-%S', metadata['date_taken']), base_name, metadata['extension'])
return file_name.lower() return file_name.lower()
@ -137,11 +142,13 @@ class FileSystem:
db = Db() db = Db()
checksum = db.checksum(_file) checksum = db.checksum(_file)
if(checksum == None): if(checksum == None):
if(constants.debug == True):
print 'Could not get checksum for %s. Skipping...' % _file print 'Could not get checksum for %s. Skipping...' % _file
return return
# If duplicates are not allowed and this hash exists in the db then we return # If duplicates are not allowed and this hash exists in the db then we return
if(allowDuplicate == False and db.check_hash(checksum) == True): if(allowDuplicate == False and db.check_hash(checksum) == True):
if(constants.debug == True):
print '%s already exists at %s. Skipping...' % (_file, db.get_hash(checksum)) print '%s already exists at %s. Skipping...' % (_file, db.get_hash(checksum))
return return

View File

@ -8,6 +8,8 @@ import requests
import sys import sys
import urllib import urllib
from elodie import constants
class Fraction(fractions.Fraction): class Fraction(fractions.Fraction):
"""Only create Fractions from floats. """Only create Fractions from floats.
@ -108,9 +110,11 @@ def reverse_lookup(lat, lon):
r = requests.get('http://open.mapquestapi.com/nominatim/v1/reverse.php?%s' % urllib.urlencode(params)) r = requests.get('http://open.mapquestapi.com/nominatim/v1/reverse.php?%s' % urllib.urlencode(params))
return r.json() return r.json()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
if(constants.debug == True):
print e print e
return None return None
except ValueError as e: except ValueError as e:
if(constants.debug == True):
print r.text print r.text
print e print e
return None return None
@ -123,13 +127,16 @@ def lookup(name):
try: try:
params = {'format': 'json', 'key': key, 'location': name} params = {'format': 'json', 'key': key, 'location': name}
if(constants.debug == True):
print 'http://open.mapquestapi.com/geocoding/v1/address?%s' % urllib.urlencode(params) print 'http://open.mapquestapi.com/geocoding/v1/address?%s' % urllib.urlencode(params)
r = requests.get('http://open.mapquestapi.com/geocoding/v1/address?%s' % urllib.urlencode(params)) r = requests.get('http://open.mapquestapi.com/geocoding/v1/address?%s' % urllib.urlencode(params))
return r.json() return r.json()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
if(constants.debug == True):
print e print e
return None return None
except ValueError as e: except ValueError as e:
if(constants.debug == True):
print r.text print r.text
print e print e
return None return None

View File

@ -125,6 +125,7 @@ class Media(object):
seconds_since_epoch = time.mktime(exif[key].value.timetuple()) seconds_since_epoch = time.mktime(exif[key].value.timetuple())
break; break;
except BaseException as e: except BaseException as e:
if(constants.debug == True):
print e print e
pass pass
@ -164,13 +165,26 @@ class Media(object):
process_output = subprocess.Popen(['%s "%s"' % (exiftool, source)], stdout=subprocess.PIPE, shell=True) process_output = subprocess.Popen(['%s "%s"' % (exiftool, source)], stdout=subprocess.PIPE, shell=True)
output = process_output.stdout.read() output = process_output.stdout.read()
# Get album from exiftool output
album = None album = None
album_regex = re.search('Album +: +(.+)', output) album_regex = re.search('Album +: +(.+)', output)
if(album_regex is not None): if(album_regex is not None):
album = album_regex.group(1) album = album_regex.group(1)
# Get title from exiftool output
title = None
for key in ['Displayname', 'Headline', 'Title', 'ImageDescription']:
title_regex = re.search('%s +: +(.+)' % key, output)
if(title_regex is not None):
title_return = title_regex.group(1).strip()
if(len(title_return) > 0):
title = title_return
break;
self.exiftool_attributes = { self.exiftool_attributes = {
'album': album 'album': album,
'title': title
} }
return self.exiftool_attributes return self.exiftool_attributes
@ -205,6 +219,7 @@ class Media(object):
'latitude': self.get_coordinate('latitude'), 'latitude': self.get_coordinate('latitude'),
'longitude': self.get_coordinate('longitude'), 'longitude': self.get_coordinate('longitude'),
'album': self.get_album(), 'album': self.get_album(),
'title': self.get_title(),
'mime_type': self.get_mimetype(), 'mime_type': self.get_mimetype(),
'base_name': os.path.splitext(os.path.basename(source))[0], 'base_name': os.path.splitext(os.path.basename(source))[0],
'extension': self.get_extension() 'extension': self.get_extension()
@ -228,6 +243,22 @@ class Media(object):
return mimetype[0] return mimetype[0]
"""
Get the title for a photo of video
@returns, string or None if no title is set or not a valid media type
"""
def get_title(self):
if(not self.is_valid()):
return None
exiftool_attributes = self.get_exiftool_attributes()
if('title' not in exiftool_attributes):
return None
return exiftool_attributes['title']
""" """
Set album for a photo Set album for a photo
@ -245,6 +276,8 @@ class Media(object):
source = self.source source = self.source
exiftool_config = constants.exiftool_config exiftool_config = constants.exiftool_config
if(constants.debug == True):
print '%s -config "%s" -xmp-elodie:Album="%s" "%s"' % (exiftool, exiftool_config, name, source)
process_output = subprocess.Popen(['%s -config "%s" -xmp-elodie:Album="%s" "%s"' % (exiftool, exiftool_config, name, source)], stdout=subprocess.PIPE, shell=True) process_output = subprocess.Popen(['%s -config "%s" -xmp-elodie:Album="%s" "%s"' % (exiftool, exiftool_config, name, source)], stdout=subprocess.PIPE, shell=True)
streamdata = process_output.communicate()[0] streamdata = process_output.communicate()[0]
if(process_output.returncode != 0): if(process_output.returncode != 0):

View File

@ -95,10 +95,29 @@ class Photo(Media):
exif_metadata.write() exif_metadata.write()
return True return True
"""
Set lat/lon for a photo
@param, latitude, float, Latitude of the file
@param, longitude, float, Longitude of the file
@returns, boolean
"""
def set_title(self, title):
if(title is None):
return False
source = self.source
exif_metadata = pyexiv2.ImageMetadata(source)
exif_metadata.read()
exif_metadata['Xmp.dc.title'] = title
exif_metadata.write()
return True
""" """
Static method to access static __valid_extensions variable. Static method to access static __valid_extensions variable.
def set_location(self, latitude, longitude):
return None
@returns, tuple @returns, tuple
""" """

View File

@ -16,6 +16,7 @@ import shutil
import subprocess import subprocess
import time import time
from elodie import constants
from elodie import plist_parser from elodie import plist_parser
from media import Media from media import Media
@ -123,30 +124,6 @@ class Video(Media):
process_output = subprocess.Popen(['%s "%s"' % (exiftool, source)], stdout=subprocess.PIPE, shell=True) process_output = subprocess.Popen(['%s "%s"' % (exiftool, source)], stdout=subprocess.PIPE, shell=True)
return process_output.stdout.read() return process_output.stdout.read()
"""
Get a dictionary of metadata for a video.
All keys will be present and have a value of None if not obtained.
@returns, dictionary or None for non-video files
"""
def get_metadata(self):
if(not self.is_valid()):
return None
source = self.source
metadata = {
"date_taken": self.get_date_taken(),
"latitude": self.get_coordinate('latitude'),
"longitude": self.get_coordinate('longitude'),
"album": self.get_album(),
#"length": self.get_duration(),
"mime_type": self.get_mimetype(),
"base_name": os.path.splitext(os.path.basename(source))[0],
"extension": self.get_extension()
}
return metadata
""" """
Set the date/time a photo was taken Set the date/time a photo was taken
@ -176,6 +153,20 @@ class Video(Media):
result = self.__update_using_plist(latitude=latitude, longitude=longitude) result = self.__update_using_plist(latitude=latitude, longitude=longitude)
return result return result
"""
Set title for a video
@param, title, string, Title for the file
@returns, boolean
"""
def set_title(self, title):
if(title is None):
return False
result = self.__update_using_plist(title=title)
return result
""" """
Updates video metadata using avmetareadwrite. Updates video metadata using avmetareadwrite.
@ -196,11 +187,13 @@ class Video(Media):
""" """
def __update_using_plist(self, **kwargs): def __update_using_plist(self, **kwargs):
if('latitude' not in kwargs and 'longitude' not in kwargs and 'time' not in kwargs): if('latitude' not in kwargs and 'longitude' not in kwargs and 'time' not in kwargs):
if(constants.debug == True):
print 'No lat/lon passed into __create_plist' print 'No lat/lon passed into __create_plist'
return False return False
avmetareadwrite = find_executable('avmetareadwrite') avmetareadwrite = find_executable('avmetareadwrite')
if(avmetareadwrite is None): if(avmetareadwrite is None):
if(constants.debug == True):
print 'Could not find avmetareadwrite' print 'Could not find avmetareadwrite'
return False return False
@ -214,6 +207,7 @@ class Video(Media):
write_process = subprocess.Popen([avmetareadwrite_generate_plist_command], stdout=subprocess.PIPE, shell=True) write_process = subprocess.Popen([avmetareadwrite_generate_plist_command], stdout=subprocess.PIPE, shell=True)
streamdata = write_process.communicate()[0] streamdata = write_process.communicate()[0]
if(write_process.returncode != 0): if(write_process.returncode != 0):
if(constants.debug == True):
print 'Failed to generate plist file' print 'Failed to generate plist file'
return False return False
@ -258,11 +252,16 @@ class Video(Media):
plist.update_key('common/creationDate', time_string) plist.update_key('common/creationDate', time_string)
plist_should_be_written = True plist_should_be_written = True
if('title' in kwargs):
if(len(kwargs['title']) > 0):
plist.update_key('common/title', kwargs['title'])
plist_should_be_written = True
if(plist_should_be_written is True): if(plist_should_be_written is True):
plist_final = plist_temp.name plist_final = plist_temp.name
plist.write_file(plist_final) plist.write_file(plist_final)
else: else:
if(constants.debug == True):
print 'Nothing to update, plist unchanged' print 'Nothing to update, plist unchanged'
return False return False
@ -279,6 +278,7 @@ class Video(Media):
update_process = subprocess.Popen([avmetareadwrite_command], stdout=subprocess.PIPE, shell=True) update_process = subprocess.Popen([avmetareadwrite_command], stdout=subprocess.PIPE, shell=True)
streamdata = update_process.communicate()[0] streamdata = update_process.communicate()[0]
if(update_process.returncode != 0): if(update_process.returncode != 0):
if(constants.debug == True):
print '%s did not complete successfully' % avmetareadwrite_command print '%s did not complete successfully' % avmetareadwrite_command
return False return False
@ -286,6 +286,7 @@ class Video(Media):
check_media = Video(temp_movie) check_media = Video(temp_movie)
check_metadata = check_media.get_metadata() check_metadata = check_media.get_metadata()
if(check_metadata['latitude'] is None or check_metadata['longitude'] is None or check_metadata['date_taken'] is None): if(check_metadata['latitude'] is None or check_metadata['longitude'] is None or check_metadata['date_taken'] is None):
if(constants.debug == True):
print 'Something went wrong updating video metadata' print 'Something went wrong updating video metadata'
return False return False

View File

@ -10,6 +10,7 @@ import time
from datetime import datetime from datetime import datetime
from elodie import arguments from elodie import arguments
from elodie import constants
from elodie import geolocation from elodie import geolocation
from elodie.media.photo import Media from elodie.media.photo import Media
from elodie.media.photo import Photo from elodie.media.photo import Photo
@ -31,13 +32,15 @@ def parse_arguments(args):
def main(config, args): def main(config, args):
location_coords = None location_coords = None
for arg in args: for arg in args:
file_path = arg
if(arg[:2] == '--'): if(arg[:2] == '--'):
continue continue
elif(not os.path.exists(arg)): elif(not os.path.exists(arg)):
if(constants.debug == True):
print 'Could not find %s' % arg print 'Could not find %s' % arg
print '{"source":"%s", "error_msg":"Could not find %s"}' % (file_path, arg)
continue continue
file_path = arg
destination = os.path.dirname(os.path.dirname(os.path.dirname(file_path))) destination = os.path.dirname(os.path.dirname(os.path.dirname(file_path)))
_class = None _class = None
@ -60,7 +63,9 @@ def main(config, args):
if(location_coords is not None and 'latitude' in location_coords and 'longitude' in location_coords): if(location_coords is not None and 'latitude' in location_coords and 'longitude' in location_coords):
location_status = media.set_location(location_coords['latitude'], location_coords['longitude']) location_status = media.set_location(location_coords['latitude'], location_coords['longitude'])
if(location_status != True): if(location_status != True):
if(constants.debug == True):
print 'Failed to update location' print 'Failed to update location'
print '{"source":"%s", "error_msg":"Failed to update location"}' % file_path
sys.exit(1) sys.exit(1)
updated = True updated = True
@ -72,7 +77,9 @@ def main(config, args):
time_string = '%s 00:00:00' % time_string time_string = '%s 00:00:00' % time_string
if(re.match('^\d{4}-\d{2}-\d{2}$', time_string) is None and re.match('^\d{4}-\d{2}-\d{2} \d{2}:\d{2}\d{2}$', time_string)): if(re.match('^\d{4}-\d{2}-\d{2}$', time_string) is None and re.match('^\d{4}-\d{2}-\d{2} \d{2}:\d{2}\d{2}$', time_string)):
if(constants.debug == True):
print 'Invalid time format. Use YYYY-mm-dd hh:ii:ss or YYYY-mm-dd' print 'Invalid time format. Use YYYY-mm-dd hh:ii:ss or YYYY-mm-dd'
print '{"source":"%s", "error_msg":"Invalid time format. Use YYYY-mm-dd hh:ii:ss or YYYY-mm-dd"}' % file_path
sys.exit(1) sys.exit(1)
if(time_format is not None): if(time_format is not None):
@ -84,10 +91,16 @@ def main(config, args):
media.set_album(config['album']) media.set_album(config['album'])
updated = True updated = True
if(config['title'] is not None):
media.set_title(config['title'])
updated = True
if(updated == True): if(updated == True):
dest_path = filesystem.process_file(file_path, destination, media, move=True, allowDuplicate=True) dest_path = filesystem.process_file(file_path, destination, media, move=True, allowDuplicate=True)
if(constants.debug == True):
print '%s -> %s' % (file_path, dest_path) print '%s -> %s' % (file_path, dest_path)
print '{"source":"%s", "destination":"%s"}' % (file_path, dest_path)
# If the folder we moved the file out of or its parent are empty we delete it. # If the folder we moved the file out of or its parent are empty we delete it.
filesystem.delete_directory_if_empty(os.path.dirname(file_path)) filesystem.delete_directory_if_empty(os.path.dirname(file_path))
filesystem.delete_directory_if_empty(os.path.dirname(os.path.dirname(file_path))) filesystem.delete_directory_if_empty(os.path.dirname(os.path.dirname(file_path)))