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()`
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:

View File

@@ -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}")

View File

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

View File

@@ -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_

View File

@@ -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

View File

@@ -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():

View File

@@ -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():