Added --exiftool to CLI export

This commit is contained in:
Rhet Turnbull
2020-03-07 14:37:11 -08:00
parent 8dea41961b
commit ef799610ae
7 changed files with 141 additions and 64 deletions

View File

@@ -655,7 +655,7 @@ Returns the path to the live video component of a [live photo](#live_photo). If
#### `json()` #### `json()`
Returns a JSON representation of all photo info Returns a JSON representation of all photo info
#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120,)` #### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False)`
Export photo from the Photos library to another destination on disk. Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised). - dest: must be valid destination path as str (or exception raised).
@@ -664,10 +664,11 @@ Export photo from the Photos library to another destination on disk.
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist - overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
- live_photo: boolean; if True (default=False), will also export the associted .mov for live photos; exported live photo will be named filename.mov - live_photo: boolean; if True (default=False), will also export the associted .mov for live photos; exported live photo will be named filename.mov
- increment: boolean; if True (default=True), will increment file name until a non-existent name is found - increment: boolean; if True (default=True), will increment file name until a non-existent name is found
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name - sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name
- sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name - sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with metadata; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name
- use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using. - use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using.
- timeout: (int, default=120) timeout in seconds used with use_photos_export - timeout: (int, default=120) timeout in seconds used with use_photos_export
- exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path
The json sidecar file can be used by exiftool to apply the metadata from the json file to the image. For example: The json sidecar file can be used by exiftool to apply the metadata from the json file to the image. For example:

View File

@@ -662,6 +662,13 @@ def query(
"the photo does not exist on disk. This will be slow and will require internet connection. " "the photo does not exist on disk. This will be slow and will require internet connection. "
"This obviously only works if the Photos library is synched to iCloud.", "This obviously only works if the Photos library is synched to iCloud.",
) )
@click.option(
"--exiftool",
is_flag=True,
help="Use exiftool to write metadata directly to exported photos. "
"To use this option, exiftool must be installed and in the path. "
"exiftool may be installed from https://exiftool.org/",
)
@DB_ARGUMENT @DB_ARGUMENT
@click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.argument("dest", nargs=1, type=click.Path(exists=True))
@click.pass_obj @click.pass_obj
@@ -707,6 +714,7 @@ def export(
not_live, not_live,
download_missing, download_missing,
dest, dest,
exiftool,
): ):
""" Export photos from the Photos database. """ Export photos from the Photos database.
Export path DEST is required. Export path DEST is required.
@@ -810,6 +818,7 @@ def export(
original_name, original_name,
export_live, export_live,
download_missing, download_missing,
exiftool,
) )
else: else:
for p in photos: for p in photos:
@@ -824,6 +833,7 @@ def export(
original_name, original_name,
export_live, export_live,
download_missing, download_missing,
exiftool,
) )
if export_path: if export_path:
click.echo(f"Exported {p.filename} to {export_path}") click.echo(f"Exported {p.filename} to {export_path}")
@@ -1078,6 +1088,7 @@ def export_photo(
original_name, original_name,
export_live, export_live,
download_missing, download_missing,
exiftool,
): ):
""" Helper function for export that does the actual export """ Helper function for export that does the actual export
photo: PhotoInfo object photo: PhotoInfo object
@@ -1090,6 +1101,7 @@ def export_photo(
export_live: boolean; also export live video component if photo is a live photo export_live: boolean; also export live video component if photo is a live photo
live video will have same name as photo but with .mov extension live video will have same name as photo but with .mov extension
download_missing: attempt download of missing iCloud photos download_missing: attempt download of missing iCloud photos
exiftool: use exiftool to write EXIF metadata directly to exported photo
returns destination path of exported photo or None if photo was missing returns destination path of exported photo or None if photo was missing
""" """
@@ -1139,6 +1151,7 @@ def export_photo(
live_photo=export_live, live_photo=export_live,
overwrite=overwrite, overwrite=overwrite,
use_photos_export=download_missing, use_photos_export=download_missing,
exiftool=exiftool,
) )
# if export-edited, also export the edited version # if export-edited, also export the edited version
@@ -1157,6 +1170,7 @@ def export_photo(
overwrite=overwrite, overwrite=overwrite,
edited=True, edited=True,
use_photos_export=download_missing, use_photos_export=download_missing,
exiftool=exiftool,
) )
else: else:
click.echo(f"Skipping missing edited photo for {filename}") click.echo(f"Skipping missing edited photo for {filename}")

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.22.11" __version__ = "0.22.12"

View File

@@ -138,8 +138,13 @@ class _ExifToolProc:
class ExifTool: class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """ """ Basic exiftool interface for reading and writing EXIF tags """
def __init__(self, file, exiftool=None): def __init__(self, filepath, exiftool=None, overwrite=True):
self.file = file """ Return ExifTool object
file: path to image file
exiftool: path to exiftool, if not specified will look in path
overwrite: if True, will overwrite image file without creating backup, default=False """
self.file = filepath
self.overwrite = overwrite
self.data = {} self.data = {}
self._exiftoolproc = _ExifToolProc(exiftool=exiftool) self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
self._process = self._exiftoolproc.process self._process = self._exiftoolproc.process
@@ -151,8 +156,11 @@ class ExifTool:
if value is None: if value is None:
value = "" value = ""
command = f"-{tag}={value}" command = []
self.run_commands(command) command.append(f"-{tag}={value}")
if self.overwrite:
command.append("-overwrite_original")
self.run_commands(*command)
def addvalues(self, tag, *values): def addvalues(self, tag, *values):
""" Add one or more value(s) to tag """ Add one or more value(s) to tag
@@ -174,6 +182,9 @@ class ExifTool:
raise ValueError("Can't add None value to tag") raise ValueError("Can't add None value to tag")
command.append(f"-{tag}+={value}") command.append(f"-{tag}+={value}")
if self.overwrite:
command.append("-overwrite_original")
if command: if command:
self.run_commands(*command) self.run_commands(*command)
@@ -237,3 +248,4 @@ class ExifTool:
def __str__(self): def __str__(self):
str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}" str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
return str_ return str_

View File

@@ -32,6 +32,7 @@ from .utils import (
_get_resource_loc, _get_resource_loc,
dd_to_dms_str, dd_to_dms_str,
) )
from .exiftool import ExifTool
class PhotoInfo: class PhotoInfo:
@@ -465,6 +466,7 @@ class PhotoInfo:
sidecar_xmp=False, sidecar_xmp=False,
use_photos_export=False, use_photos_export=False,
timeout=120, timeout=120,
exiftool=False,
): ):
""" export photo """ export photo
dest: must be valid destination path (or exception raised) dest: must be valid destination path (or exception raised)
@@ -481,11 +483,15 @@ class PhotoInfo:
sidecar filename will be dest/filename.xmp sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
returns the full path to the exported file """ returns the full path to the exported file """
# TODO: add this docs: # TODO: add this docs:
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done ) # ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
# list of all files exported during this call to export
exported_files = []
# check arguments and get destination path and filename (if provided) # check arguments and get destination path and filename (if provided)
if filename and len(filename) > 2: if filename and len(filename) > 2:
raise TypeError( raise TypeError(
@@ -586,6 +592,7 @@ class PhotoInfo:
# copy the file, _copy_file uses ditto to preserve Mac extended attributes # copy the file, _copy_file uses ditto to preserve Mac extended attributes
_copy_file(src, dest) _copy_file(src, dest)
exported_files.append(str(dest))
# copy live photo associated .mov if requested # copy live photo associated .mov if requested
if live_photo and self.live_photo: if live_photo and self.live_photo:
@@ -597,6 +604,7 @@ class PhotoInfo:
f"Exporting live photo video of {filename} as {live_name.name}" f"Exporting live photo video of {filename} as {live_name.name}"
) )
_copy_file(src_live, str(live_name)) _copy_file(src_live, str(live_name))
exported_files.append(str(live_name))
else: else:
logging.warning(f"Skipping missing live movie for {filename}") logging.warning(f"Skipping missing live movie for {filename}")
else: else:
@@ -634,7 +642,9 @@ class PhotoInfo:
timeout=timeout, timeout=timeout,
) )
if exported is None: if exported is not None:
exported_files.extend(exported)
else:
logging.warning(f"Error exporting photo {self.uuid} to {dest}") logging.warning(f"Error exporting photo {self.uuid} to {dest}")
if sidecar_json: if sidecar_json:
@@ -657,8 +667,32 @@ class PhotoInfo:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}") logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e raise e
logging.debug(f"export exported_files: {exported_files}")
# if exiftool, write the metadata
if exiftool and exported_files:
for exported_file in exported_files:
self._write_exif_data(exported_file)
return str(dest) return str(dest)
def _write_exif_data(self, filepath):
""" write exif data to image file at filepath
filepath: full path to the image file """
if not os.path.exists(filepath):
raise FileNotFoundError(f"Could not find file {filepath}")
exiftool = ExifTool(filepath)
exif_info = json.loads(self._exiftool_json_sidecar())[0]
for exiftag, val in exif_info.items():
if type(val) == list:
# more than one, set first value the add additional values
exiftool.setvalue(exiftag, val.pop(0))
if val:
# add any remaining items
exiftool.addvalues(exiftag, *val)
else:
exiftool.setvalue(exiftag, val)
def _exiftool_json_sidecar(self): def _exiftool_json_sidecar(self):
""" return json string of EXIF details in exiftool sidecar format """ return json string of EXIF details in exiftool sidecar format
Does not include all the EXIF fields as those are likely already in the image Does not include all the EXIF fields as those are likely already in the image
@@ -680,27 +714,27 @@ class PhotoInfo:
exif = {} exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos" exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
exif["FileName"] = self.filename exif["File:FileName"] = self.filename
if self.description: if self.description:
exif["ImageDescription"] = self.description exif["EXIF:ImageDescription"] = self.description
exif["Description"] = self.description exif["XMP:Description"] = self.description
if self.title: if self.title:
exif["Title"] = self.title exif["XMP:Title"] = self.title
if self.keywords: if self.keywords:
exif["TagsList"] = exif["Keywords"] = list(self.keywords) exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords)
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP" # Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
exif["Subject"] = list(self.keywords) exif["XMP:Subject"] = list(self.keywords)
if self.persons: if self.persons:
exif["PersonInImage"] = self.persons exif["XMP:PersonInImage"] = self.persons
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP" # Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
if "Subject" in exif: if "XMP:Subject" in exif:
exif["Subject"].extend(self.persons) exif["XMP:Subject"].extend(self.persons)
else: else:
exif["Subject"] = self.persons exif["XMP:Subject"] = self.persons
# if self.favorite(): # if self.favorite():
# exif["Rating"] = 5 # exif["Rating"] = 5
@@ -708,13 +742,13 @@ class PhotoInfo:
(lat, lon) = self.location (lat, lon) = self.location
if lat is not None and lon is not None: if lat is not None and lon is not None:
lat_str, lon_str = dd_to_dms_str(lat, lon) lat_str, lon_str = dd_to_dms_str(lat, lon)
exif["GPSLatitude"] = lat_str exif["EXIF:GPSLatitude"] = lat_str
exif["GPSLongitude"] = lon_str exif["EXIF:GPSLongitude"] = lon_str
exif["GPSPosition"] = f"{lat_str}, {lon_str}" exif["Composite:GPSPosition"] = f"{lat_str}, {lon_str}"
lat_ref = "North" if lat >= 0 else "South" lat_ref = "North" if lat >= 0 else "South"
lon_ref = "East" if lon >= 0 else "West" lon_ref = "East" if lon >= 0 else "West"
exif["GPSLatitudeRef"] = lat_ref exif["EXIF:GPSLatitudeRef"] = lat_ref
exif["GPSLongitudeRef"] = lon_ref exif["EXIF:GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset # process date/time and timezone offset
date = self.date date = self.date
@@ -725,11 +759,11 @@ class PhotoInfo:
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime) offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}" offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["DateTimeOriginal"] = datetimeoriginal exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["OffsetTimeOriginal"] = offsettime exif["EXIF:OffsetTimeOriginal"] = offsettime
if self.date_modified is not None: if self.date_modified is not None:
exif["ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S") exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
json_str = json.dumps([exif]) json_str = json.dumps([exif])
return json_str return json_str

View File

@@ -446,29 +446,38 @@ def test_exiftool_json_sidecar():
json_expected = json.loads( json_expected = json.loads(
""" """
[{"FileName": "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg", [{"File:FileName": "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg",
"Title": "St. James\'s Park", "XMP:Title": "St. James\'s Park",
"TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], "XMP:TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], "IPTC:Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], "XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"GPSLatitude": "51 deg 30\' 12.86\\" N", "EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
"GPSLongitude": "0 deg 7\' 54.50\\" W", "EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
"GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W", "Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
"GPSLatitudeRef": "North", "GPSLongitudeRef": "West", "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
"DateTimeOriginal": "2018:10:13 09:18:12", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
"OffsetTimeOriginal": "-04:00", "EXIF:OffsetTimeOriginal": "-04:00",
"ModifyDate": "2019:12:08 14:06:44"}] """ "EXIF:ModifyDate": "2019:12:08 14:06:44",
) "_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos"
}] """
)[0]
json_got = photos[0]._exiftool_json_sidecar() json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got) json_got = json.loads(json_got)[0]
# some gymnastics to account for different sort order in different pythons # some gymnastics to account for different sort order in different pythons
for item in zip(sorted(json_got[0].items()), sorted(json_expected[0].items())): # some gymnastics to account for different sort order in different pythons
if type(item[0][1]) in (list, tuple): for k, v in json_got.items():
assert sorted(item[0][1]) == sorted(item[1][1]) if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else: else:
assert item[0][1] == item[1][1] assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
def test_xmp_sidecar(): def test_xmp_sidecar():

View File

@@ -390,30 +390,37 @@ def test_exiftool_json_sidecar():
json_expected = json.loads( json_expected = json.loads(
""" """
[{"FileName": "St James Park.jpg", [{"File:FileName": "St James Park.jpg",
"Title": "St. James\'s Park", "XMP:Title": "St. James\'s Park",
"TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], "XMP:TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], "IPTC:Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], "XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"GPSLatitude": "51 deg 30\' 12.86\\" N", "EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
"GPSLongitude": "0 deg 7\' 54.50\\" W", "EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
"GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W", "Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
"GPSLatitudeRef": "North", "GPSLongitudeRef": "West", "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
"DateTimeOriginal": "2018:10:13 09:18:12", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
"OffsetTimeOriginal": "-04:00", "EXIF:OffsetTimeOriginal": "-04:00",
"ModifyDate": "2019:12:01 11:43:45"}] "EXIF:ModifyDate": "2019:12:01 11:43:45",
""" "_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos"
) }] """
)[0]
json_got = photos[0]._exiftool_json_sidecar() json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got) json_got = json.loads(json_got)[0]
# some gymnastics to account for different sort order in different pythons # some gymnastics to account for different sort order in different pythons
for item in zip(sorted(json_got[0].items()), sorted(json_expected[0].items())): for k, v in json_got.items():
if type(item[0][1]) in (list, tuple): if type(v) in (list, tuple):
assert sorted(item[0][1]) == sorted(item[1][1]) assert sorted(json_expected[k]) == sorted(v)
else: else:
assert item[0][1] == item[1][1] assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
def test_xmp_sidecar(): def test_xmp_sidecar():