From ef799610aea67b703a7d056b7eee227534ba78a5 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 7 Mar 2020 14:37:11 -0800 Subject: [PATCH] Added --exiftool to CLI export --- README.md | 7 +-- osxphotos/__main__.py | 14 ++++++ osxphotos/_version.py | 2 +- osxphotos/exiftool.py | 20 ++++++-- osxphotos/photoinfo.py | 72 ++++++++++++++++++++------- tests/test_export_catalina_10_15_1.py | 45 ++++++++++------- tests/test_export_mojave_10_14_6.py | 45 ++++++++++------- 7 files changed, 141 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 16541d86..9a9b4d0f 100644 --- a/README.md +++ b/README.md @@ -655,7 +655,7 @@ Returns the path to the live video component of a [live photo](#live_photo). If #### `json()` 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. - 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 - 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 -- 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_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_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 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. - 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: diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index cb3b6369..544b77e4 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -662,6 +662,13 @@ def query( "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.", ) +@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 @click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.pass_obj @@ -707,6 +714,7 @@ def export( not_live, download_missing, dest, + exiftool, ): """ Export photos from the Photos database. Export path DEST is required. @@ -810,6 +818,7 @@ def export( original_name, export_live, download_missing, + exiftool, ) else: for p in photos: @@ -824,6 +833,7 @@ def export( original_name, export_live, download_missing, + exiftool, ) if export_path: click.echo(f"Exported {p.filename} to {export_path}") @@ -1078,6 +1088,7 @@ def export_photo( original_name, export_live, download_missing, + exiftool, ): """ Helper function for export that does the actual export photo: PhotoInfo object @@ -1090,6 +1101,7 @@ def export_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 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 """ @@ -1139,6 +1151,7 @@ def export_photo( live_photo=export_live, overwrite=overwrite, use_photos_export=download_missing, + exiftool=exiftool, ) # if export-edited, also export the edited version @@ -1157,6 +1170,7 @@ def export_photo( overwrite=overwrite, edited=True, use_photos_export=download_missing, + exiftool=exiftool, ) else: click.echo(f"Skipping missing edited photo for {filename}") diff --git a/osxphotos/_version.py b/osxphotos/_version.py index f3e6f1d6..17e24109 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.22.11" +__version__ = "0.22.12" diff --git a/osxphotos/exiftool.py b/osxphotos/exiftool.py index c6b4ca0a..d2337a53 100644 --- a/osxphotos/exiftool.py +++ b/osxphotos/exiftool.py @@ -138,8 +138,13 @@ class _ExifToolProc: class ExifTool: """ Basic exiftool interface for reading and writing EXIF tags """ - def __init__(self, file, exiftool=None): - self.file = file + def __init__(self, filepath, exiftool=None, overwrite=True): + """ 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._exiftoolproc = _ExifToolProc(exiftool=exiftool) self._process = self._exiftoolproc.process @@ -151,8 +156,11 @@ class ExifTool: if value is None: value = "" - command = f"-{tag}={value}" - self.run_commands(command) + command = [] + command.append(f"-{tag}={value}") + if self.overwrite: + command.append("-overwrite_original") + self.run_commands(*command) def addvalues(self, tag, *values): """ Add one or more value(s) to tag @@ -174,6 +182,9 @@ class ExifTool: raise ValueError("Can't add None value to tag") command.append(f"-{tag}+={value}") + if self.overwrite: + command.append("-overwrite_original") + if command: self.run_commands(*command) @@ -237,3 +248,4 @@ class ExifTool: def __str__(self): str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}" return str_ + diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 4164c015..77afb5cd 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -32,6 +32,7 @@ from .utils import ( _get_resource_loc, dd_to_dms_str, ) +from .exiftool import ExifTool class PhotoInfo: @@ -465,6 +466,7 @@ class PhotoInfo: sidecar_xmp=False, use_photos_export=False, timeout=120, + exiftool=False, ): """ export photo dest: must be valid destination path (or exception raised) @@ -481,11 +483,15 @@ class PhotoInfo: 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 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 """ # TODO: add this docs: # ( 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) if filename and len(filename) > 2: raise TypeError( @@ -586,6 +592,7 @@ class PhotoInfo: # copy the file, _copy_file uses ditto to preserve Mac extended attributes _copy_file(src, dest) + exported_files.append(str(dest)) # copy live photo associated .mov if requested if live_photo and self.live_photo: @@ -597,6 +604,7 @@ class PhotoInfo: f"Exporting live photo video of {filename} as {live_name.name}" ) _copy_file(src_live, str(live_name)) + exported_files.append(str(live_name)) else: logging.warning(f"Skipping missing live movie for {filename}") else: @@ -634,7 +642,9 @@ class PhotoInfo: 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}") if sidecar_json: @@ -657,8 +667,32 @@ class PhotoInfo: logging.warning(f"Error writing xmp sidecar to {sidecar_filename}") 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) + 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): """ 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 @@ -680,27 +714,27 @@ class PhotoInfo: exif = {} exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos" - exif["FileName"] = self.filename + exif["File:FileName"] = self.filename if self.description: - exif["ImageDescription"] = self.description - exif["Description"] = self.description + exif["EXIF:ImageDescription"] = self.description + exif["XMP:Description"] = self.description if self.title: - exif["Title"] = self.title + exif["XMP:Title"] = self.title 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" - exif["Subject"] = list(self.keywords) + exif["XMP:Subject"] = list(self.keywords) 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" - if "Subject" in exif: - exif["Subject"].extend(self.persons) + if "XMP:Subject" in exif: + exif["XMP:Subject"].extend(self.persons) else: - exif["Subject"] = self.persons + exif["XMP:Subject"] = self.persons # if self.favorite(): # exif["Rating"] = 5 @@ -708,13 +742,13 @@ class PhotoInfo: (lat, lon) = self.location if lat is not None and lon is not None: lat_str, lon_str = dd_to_dms_str(lat, lon) - exif["GPSLatitude"] = lat_str - exif["GPSLongitude"] = lon_str - exif["GPSPosition"] = f"{lat_str}, {lon_str}" + exif["EXIF:GPSLatitude"] = lat_str + exif["EXIF:GPSLongitude"] = lon_str + exif["Composite:GPSPosition"] = f"{lat_str}, {lon_str}" lat_ref = "North" if lat >= 0 else "South" lon_ref = "East" if lon >= 0 else "West" - exif["GPSLatitudeRef"] = lat_ref - exif["GPSLongitudeRef"] = lon_ref + exif["EXIF:GPSLatitudeRef"] = lat_ref + exif["EXIF:GPSLongitudeRef"] = lon_ref # process date/time and timezone offset date = self.date @@ -725,11 +759,11 @@ class PhotoInfo: offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime) offset = offset[0] # findall returns list of tuples offsettime = f"{offset[0]}{offset[1]}:{offset[2]}" - exif["DateTimeOriginal"] = datetimeoriginal - exif["OffsetTimeOriginal"] = offsettime + exif["EXIF:DateTimeOriginal"] = datetimeoriginal + exif["EXIF:OffsetTimeOriginal"] = offsettime 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]) return json_str diff --git a/tests/test_export_catalina_10_15_1.py b/tests/test_export_catalina_10_15_1.py index 9fde2132..bc229229 100644 --- a/tests/test_export_catalina_10_15_1.py +++ b/tests/test_export_catalina_10_15_1.py @@ -446,29 +446,38 @@ def test_exiftool_json_sidecar(): json_expected = json.loads( """ - [{"FileName": "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg", - "Title": "St. James\'s Park", - "TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], - "Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], - "Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], - "GPSLatitude": "51 deg 30\' 12.86\\" N", - "GPSLongitude": "0 deg 7\' 54.50\\" W", - "GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W", - "GPSLatitudeRef": "North", "GPSLongitudeRef": "West", - "DateTimeOriginal": "2018:10:13 09:18:12", - "OffsetTimeOriginal": "-04:00", - "ModifyDate": "2019:12:08 14:06:44"}] """ - ) + [{"File:FileName": "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg", + "XMP:Title": "St. James\'s Park", + "XMP:TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], + "IPTC:Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], + "XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], + "EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N", + "EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W", + "Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W", + "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West", + "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", + "EXIF:OffsetTimeOriginal": "-04:00", + "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 = json.loads(json_got) + json_got = json.loads(json_got)[0] # 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())): - if type(item[0][1]) in (list, tuple): - assert sorted(item[0][1]) == sorted(item[1][1]) + # some gymnastics to account for different sort order in different pythons + for k, v in json_got.items(): + if type(v) in (list, tuple): + assert sorted(json_expected[k]) == sorted(v) 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(): diff --git a/tests/test_export_mojave_10_14_6.py b/tests/test_export_mojave_10_14_6.py index 69621050..3013f9c7 100644 --- a/tests/test_export_mojave_10_14_6.py +++ b/tests/test_export_mojave_10_14_6.py @@ -390,30 +390,37 @@ def test_exiftool_json_sidecar(): json_expected = json.loads( """ - [{"FileName": "St James Park.jpg", - "Title": "St. James\'s Park", - "TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], - "Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], - "Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], - "GPSLatitude": "51 deg 30\' 12.86\\" N", - "GPSLongitude": "0 deg 7\' 54.50\\" W", - "GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W", - "GPSLatitudeRef": "North", "GPSLongitudeRef": "West", - "DateTimeOriginal": "2018:10:13 09:18:12", - "OffsetTimeOriginal": "-04:00", - "ModifyDate": "2019:12:01 11:43:45"}] - """ - ) + [{"File:FileName": "St James Park.jpg", + "XMP:Title": "St. James\'s Park", + "XMP:TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], + "IPTC:Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], + "XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"], + "EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N", + "EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W", + "Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W", + "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West", + "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", + "EXIF:OffsetTimeOriginal": "-04:00", + "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 = json.loads(json_got) + json_got = json.loads(json_got)[0] # 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())): - if type(item[0][1]) in (list, tuple): - assert sorted(item[0][1]) == sorted(item[1][1]) + for k, v in json_got.items(): + if type(v) in (list, tuple): + assert sorted(json_expected[k]) == sorted(v) 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():