Files
osxphotos/osxphotos/photoinfo/_photoinfo_export.py
2020-11-25 21:42:27 -08:00

1381 lines
54 KiB
Python

""" Export methods for PhotoInfo
The following methods are defined and must be imported into PhotoInfo as instance methods:
export
export2
_export_photo
_write_exif_data
_exiftool_json_sidecar
_exiftool_dict
_xmp_sidecar
_write_sidecar
"""
# TODO: should this be its own PhotoExporter class?
import glob
import json
import logging
import os
import pathlib
import re
import tempfile
from collections import namedtuple # pylint: disable=syntax-error
import photoscript
from mako.template import Template
# from .._applescript import AppleScript
from .._constants import (
_MAX_IPTC_KEYWORD_LEN,
_OSXPHOTOS_NONE_SENTINEL,
_TEMPLATE_DIR,
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
)
from ..exiftool import ExifTool
from ..export_db import ExportDBNoOp
from ..fileutil import FileUtil
from ..photokit import (
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PhotoLibrary,
PhotoKitFetchFailed,
)
from ..utils import dd_to_dms_str, findfiles
ExportResults = namedtuple(
"ExportResults",
["exported", "new", "updated", "skipped", "exif_updated", "touched"],
)
# _export_photo_uuid_applescript is not a class method, don't import this into PhotoInfo
def _export_photo_uuid_applescript(
uuid,
dest,
filestem=None,
original=True,
edited=False,
live_photo=False,
timeout=120,
burst=False,
dry_run=False,
):
""" Export photo to dest path using applescript to control Photos
If photo is a live photo, exports both the photo and associated .mov file
uuid: UUID of photo to export
dest: destination path to export to
filestem: (string) if provided, exported filename will be named stem.ext
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
If not provided, file will be named with whatever name Photos uses
If filestem.ext exists, it wil be overwritten
original: (boolean) if True, export original image; default = True
edited: (boolean) if True, export edited photo; default = False
If photo not edited and edited=True, will still export the original image
caller must verify image has been edited
*Note*: must be called with either edited or original but not both,
will raise error if called with both edited and original = True
live_photo: (boolean) if True, export associated .mov live photo; default = False
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
burst: (boolean) set to True if file is a burst image to avoid Photos export error
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
Returns: list of paths to exported file(s) or None if export failed
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
has not been edited. This is due to how Photos Applescript interface works.
"""
# setup the applescript to do the export
# export_scpt = AppleScript(
# """
# on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
# tell application "Photos"
# set thePath to thePath
# set theItem to media item id theUUID
# set theFilename to filename of theItem
# set itemList to {theItem}
# if original then
# with timeout of theTimeOut seconds
# export itemList to POSIX file thePath with using originals
# end timeout
# end if
# if edited then
# with timeout of theTimeOut seconds
# export itemList to POSIX file thePath
# end timeout
# end if
# return theFilename
# end tell
# end export_by_uuid
# """
# )
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError(f"dest {dest} must be a directory")
if not original ^ edited:
raise ValueError(f"edited or original must be True but not both")
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
exported_files = []
filename = None
try:
photo = photoscript.Photo(uuid)
filename = photo.filename
exported_files = photo.export(tmpdir.name, original=original, timeout=timeout)
# filename = export_scpt.call(
# "export_by_uuid", uuid, tmpdir.name, original, edited, timeout
# )
except Exception as e:
logging.warning(f"Error exporting uuid {uuid}: {e}")
return None
if exported_files and filename:
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
# TemporaryDirectory will cleanup on return
filename_stem = pathlib.Path(filename).stem
exported_paths = []
for fname in exported_files:
path = pathlib.Path(tmpdir.name) / fname
if (
len(exported_files) > 1
and not live_photo
and path.suffix.lower() == ".mov"
):
# it's the .mov part of live photo but not requested, so don't export
continue
if len(exported_files) > 1 and burst and path.stem != filename_stem:
# skip any burst photo that's not the one we asked for
continue
if filestem:
# rename the file based on filestem, keeping original extension
dest_new = dest / f"{filestem}{path.suffix}"
else:
# use the name Photos provided
dest_new = dest / path.name
if not dry_run:
FileUtil.copy(str(path), str(dest_new))
exported_paths.append(str(dest_new))
return exported_paths
else:
return None
# _check_export_suffix is not a class method, don't import this into PhotoInfo
def _check_export_suffix(src, dest, edited):
"""Helper function for exporting photos to check file extensions of destination path.
Checks that dst file extension is appropriate for the src.
If edited=True, will use src file extension of ".jpeg" if None provided for src.
Args:
src: path to source file or None.
dest: path to destination file.
edited: set to True if exporting an edited photo.
Returns:
True if src and dest extensions are OK, else False.
Raises:
ValueError if edited is False and src is None
"""
# check extension of destination
if src is not None:
# use suffix from edited file
actual_suffix = pathlib.Path(src).suffix
elif edited:
# use .jpeg as that's probably correct
actual_suffix = ".jpeg"
else:
raise ValueError("src must not be None if edited=False")
# Photo's often converts .JPG to .jpeg or .tif to .tiff on import
dest_ext = dest.suffix.lower()
actual_ext = actual_suffix.lower()
suffixes = sorted([dest_ext, actual_ext])
return (
dest_ext == actual_ext
or suffixes == [".jpeg", ".jpg"]
or suffixes == [".tif", ".tiff"]
)
def export(
self,
dest,
*filename,
edited=False,
live_photo=False,
raw_photo=False,
export_as_hardlink=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_xmp=False,
use_photos_export=False,
timeout=120,
exiftool=False,
no_xattr=False,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" export photo
dest: must be valid destination path (or exception raised)
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
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
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
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
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
returns list of full paths to the exported files
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
returns: list of photos exported
"""
# Implementation note: calls export2 to actually do the work
results = self.export2(
dest,
*filename,
edited=edited,
live_photo=live_photo,
raw_photo=raw_photo,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
increment=increment,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
use_photos_export=use_photos_export,
timeout=timeout,
exiftool=exiftool,
no_xattr=no_xattr,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
return results.exported
def export2(
self,
dest,
*filename,
edited=False,
live_photo=False,
raw_photo=False,
export_as_hardlink=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_xmp=False,
use_photos_export=False,
timeout=120,
exiftool=False,
no_xattr=False,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
update=False,
export_db=None,
fileutil=FileUtil,
dry_run=False,
touch_file=False,
convert_to_jpeg=False,
jpeg_quality=1.0,
ignore_date_modified=False,
use_photokit=False,
):
""" export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
will export the photo using the incorrect file extension (unless use_photos_export is true,
in which case export will use the extension provided by Photos upon export.
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
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
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
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
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
update: (boolean, default=False); if True export will run in update mode, that is, it will
not export the photo if the current version already exists in the destination
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
for getting/setting data related to exported files to compare update state
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
dry_run: (boolean, default=False); set to True to run in "dry run" mode
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
"""
# NOTE: This function is very complex and does a lot of things.
# Don't modify this code if you don't fully understand everything it does.
# TODO: This is a good candidate for refactoring.
# when called from export(), won't get an export_db, so use no-op version
if export_db is None:
export_db = ExportDBNoOp()
# suffix to add to edited files
# e.g. name will be filename_edited.jpg
edited_identifier = "_edited"
# list of all files exported during this call to export
exported_files = []
# list of new files during update
update_new_files = []
# list of files that were updated
update_updated_files = []
# list of all files skipped because they do not need to be updated (for use with update=True)
update_skipped_files = []
# list of all files with utime touched (touch_file = True)
touched_files = []
# check edited and raise exception trying to export edited version of
# photo that hasn't been edited
if edited and not self.hasadjustments:
raise ValueError(
"Photo does not have adjustments, cannot export edited version"
)
# check arguments and get destination path and filename (if provided)
if filename and len(filename) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
elif not dry_run and not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if filename and len(filename) == 1:
# if filename passed, use it
fname = filename[0]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited and not use_photos_export:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited:
raise FileNotFoundError(
"edited=True but path_edited is none; hasadjustments: "
f" {self.hasadjustments}"
)
edited_name = pathlib.Path(self.path_edited).name
edited_suffix = pathlib.Path(edited_name).suffix
fname = pathlib.Path(self.filename).stem + edited_identifier + edited_suffix
else:
fname = self.filename
uti = self.uti if edited else self.uti_original
if convert_to_jpeg and self.isphoto and uti != "public.jpeg":
# not a jpeg but will convert to jpeg upon export so fix file extension
fname_new = pathlib.Path(fname)
fname = str(fname_new.parent / f"{fname_new.stem}.jpeg")
else:
# nothing to convert
convert_to_jpeg = False
# check destination path
dest = pathlib.Path(dest)
fname = pathlib.Path(fname)
dest = dest / fname
# check to see if file exists and if so, add (1), (2), etc until we find one that works
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
# e.g. exporting sidecar for file1.png and file1.jpeg
# if file1.png exists and exporting file1.jpeg,
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
if not update and increment and not overwrite:
count = 1
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest.stem
while dest_new.lower() in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not update and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
if not use_photos_export:
# find the source file on disk and export
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"Cannot export edited photo if path_edited is None"
)
else:
if self.path is not None:
src = self.path
else:
raise FileNotFoundError("Cannot export photo if path is None")
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
# found source now try to find right destination
if update and dest.exists():
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
dest_uuid = self.uuid
export_db.set_data(
dest,
self.uuid,
fileutil.file_sig(dest),
(None, None, None),
(None, None, None),
(None, None, None),
self.json(),
None,
)
if dest_uuid != self.uuid:
# not the right file, find the right one
count = 1
glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
dest_files = glob.glob(glob_str)
found_match = False
for file_ in dest_files:
dest_uuid = export_db.get_uuid_for_file(file_)
if dest_uuid == self.uuid:
dest = pathlib.Path(file_)
found_match = True
break
elif dest_uuid is None and fileutil.cmp(src, file_):
# files match, update the UUID
dest = pathlib.Path(file_)
found_match = True
export_db.set_data(
dest,
self.uuid,
fileutil.file_sig(dest),
(None, None, None),
(None, None, None),
(None, None, None),
self.json(),
None,
)
break
if not found_match:
# increment the destination file
count = 1
glob_str = str(dest.parent / f"{dest.stem}*")
dest_files = glob.glob(glob_str)
dest_files = [pathlib.Path(f).stem for f in dest_files]
dest_new = dest.stem
while dest_new in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
# export the dest file
results = self._export_photo(
src,
dest,
update,
export_db,
overwrite,
no_xattr,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
edited=edited,
jpeg_quality=jpeg_quality,
)
exported_files = results.exported
update_new_files = results.new
update_updated_files = results.updated
update_skipped_files = results.skipped
touched_files = results.touched
# copy live photo associated .mov if requested
if live_photo and self.live_photo:
live_name = dest.parent / f"{dest.stem}.mov"
src_live = self.path_live_photo
if src_live is not None:
results = self._export_photo(
src_live,
live_name,
update,
export_db,
overwrite,
no_xattr,
export_as_hardlink,
exiftool,
touch_file,
False,
fileutil=fileutil,
)
exported_files.extend(results.exported)
update_new_files.extend(results.new)
update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched)
# copy associated RAW image if requested
if raw_photo and self.has_raw:
raw_path = pathlib.Path(self.path_raw)
raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
if raw_path is not None:
results = self._export_photo(
raw_path,
raw_name,
update,
export_db,
overwrite,
no_xattr,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=fileutil,
jpeg_quality=jpeg_quality,
)
exported_files.extend(results.exported)
update_new_files.extend(results.new)
update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched)
else:
# use_photo_export
exported = []
# export live_photo .mov file?
live_photo = True if live_photo and self.live_photo else False
if edited or self.shared:
# exported edited version and not original
# shared photos (in shared albums) show up as not having adjustments (not edited)
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
# so tell Photos to export the current version in this case
if filename:
# use filename stem provided
filestem = dest.stem
else:
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}{edited_identifier}"
dest = dest.parent / f"{filestem}.jpeg"
if use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self._info["burstUUID"], all=True
)
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if photo:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
)
else:
exported = []
else:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=False,
edited=True,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
else:
# export original version and not edited
filestem = dest.stem
if use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self._info["burstUUID"], all=True
)
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if photo:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
)
else:
exported = []
else:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=True,
edited=False,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
if exported:
if touch_file:
for exported_file in exported:
touched_files.append(exported_file)
ts = int(self.date.timestamp())
fileutil.utime(exported_file, (ts, ts))
exported_files.extend(exported)
if update:
update_new_files.extend(exported)
else:
logging.warning(
f"Error exporting photo {self.uuid} to {dest} with use_photos_export"
)
# export metadata
if sidecar_json:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json")
sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
if not dry_run:
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
raise e
if sidecar_xmp:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
sidecar_str = self._xmp_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
extension=dest.suffix[1:] if dest.suffix else None,
)
if not dry_run:
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e
# if exiftool, write the metadata
if update:
exif_files = update_new_files + update_updated_files + update_skipped_files
else:
exif_files = exported_files
exif_files_updated = []
if exiftool and update and exif_files:
for exported_file in exif_files:
files_are_different = False
old_data = export_db.get_exifdata_for_file(exported_file)
if old_data is not None:
old_data = json.loads(old_data)[0]
current_data = json.loads(
self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
)[0]
if old_data != current_data:
files_are_different = True
if old_data is None or files_are_different:
# didn't have old data, assume we need to write it
# or files were different
if not dry_run:
self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
export_db.set_exifdata_for_file(
exported_file,
self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
),
)
export_db.set_stat_exif_for_file(
exported_file, fileutil.file_sig(exported_file)
)
exif_files_updated.append(exported_file)
elif exiftool and exif_files:
for exported_file in exif_files:
if not dry_run:
self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
export_db.set_exifdata_for_file(
exported_file,
self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
),
)
export_db.set_stat_exif_for_file(
exported_file, fileutil.file_sig(exported_file)
)
exif_files_updated.append(exported_file)
if touch_file:
for exif_file in exif_files_updated:
touched_files.append(exif_file)
ts = int(self.date.timestamp())
fileutil.utime(exif_file, (ts, ts))
touched_files = list(set(touched_files))
results = ExportResults(
exported_files,
update_new_files,
update_updated_files,
update_skipped_files,
exif_files_updated,
touched_files,
)
return results
def _export_photo(
self,
src,
dest,
update,
export_db,
overwrite,
no_xattr,
export_as_hardlink,
exiftool,
touch_file,
convert_to_jpeg,
fileutil=FileUtil,
edited=False,
jpeg_quality=1.0,
):
""" Helper function for export()
Does the actual copy or hardlink taking the appropriate
action depending on update, overwrite, export_as_hardlink
Assumes destination is the right destination (e.g. UUID matches)
sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido
Args:
src: src path (string)
dest: dest path (pathlib.Path)
update: bool
export_db: instance of ExportDB that conforms to ExportDB_ABC interface
overwrite: bool
no_xattr: don't copy extended attributes
export_as_hardlink: bool
exiftool: bool
touch_file: bool
convert_to_jpeg: bool; if True, convert file to jpeg on export
fileutil: FileUtil class that conforms to fileutil.FileUtilABC
edited: bool; set to True if exporting edited version of photo
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
Returns:
ExportResults
Raises:
ValueError if export_as_hardlink and convert_to_jpeg both True
"""
if export_as_hardlink and convert_to_jpeg:
raise ValueError("export_as_hardlink and convert_to_jpeg cannot both be True")
exported_files = []
update_updated_files = []
update_new_files = []
update_skipped_files = []
touched_files = []
dest_str = str(dest)
dest_exists = dest.exists()
op_desc = "export_as_hardlink" if export_as_hardlink else "export_by_copying"
if update: # updating
cmp_touch, cmp_orig = False, False
if dest_exists:
# update, destination exists, but we might not need to replace it...
if exiftool:
sig_exif = export_db.get_stat_exif_for_file(dest_str)
cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif)
sig_exif = (sig_exif[0], sig_exif[1], int(self.date.timestamp()))
cmp_touch = fileutil.cmp_file_sig(dest_str, sig_exif)
elif convert_to_jpeg:
sig_converted = export_db.get_stat_converted_for_file(dest_str)
cmp_orig = fileutil.cmp_file_sig(dest_str, sig_converted)
sig_converted = (
sig_converted[0],
sig_converted[1],
int(self.date.timestamp()),
)
cmp_touch = fileutil.cmp_file_sig(dest_str, sig_converted)
else:
cmp_orig = fileutil.cmp(src, dest)
cmp_touch = fileutil.cmp(src, dest, mtime1=int(self.date.timestamp()))
sig_cmp = cmp_touch if touch_file else cmp_orig
if edited:
# requested edited version of photo
# need to see if edited version in Photos library has changed
# (e.g. it's been edited again)
sig_edited = export_db.get_stat_edited_for_file(dest_str)
cmp_edited = (
fileutil.cmp_file_sig(src, sig_edited)
if sig_edited != (None, None, None)
else False
)
sig_cmp = sig_cmp and cmp_edited
if (export_as_hardlink and dest.samefile(src)) or (
not export_as_hardlink and not dest.samefile(src) and sig_cmp
):
# destination exists and signatures match, skip it
update_skipped_files.append(dest_str)
else:
# destination exists but signature is different
if touch_file and cmp_orig and not cmp_touch:
# destination exists, signature matches original but does not match expected touch time
# skip exporting but update touch time
update_skipped_files.append(dest_str)
touched_files.append(dest_str)
elif not touch_file and cmp_touch and not cmp_orig:
# destination exists, signature matches expected touch but not original
# user likely exported with touch_file and is now exporting without touch_file
# don't update the file because it's same but leave touch time
update_skipped_files.append(dest_str)
else:
# destination exists but is different
update_updated_files.append(dest_str)
if touch_file:
touched_files.append(dest_str)
else:
# update, destination doesn't exist (new file)
update_new_files.append(dest_str)
if touch_file:
touched_files.append(dest_str)
else:
# not update, export the file
exported_files.append(dest_str)
if touch_file:
sig = fileutil.file_sig(src)
sig = (sig[0], sig[1], int(self.date.timestamp()))
if not fileutil.cmp_file_sig(src, sig):
touched_files.append(dest_str)
if not update_skipped_files:
converted_stat = (None, None, None)
edited_stat = fileutil.file_sig(src) if edited else (None, None, None)
if dest_exists and (update or overwrite):
# need to remove the destination first
fileutil.unlink(dest)
if export_as_hardlink:
fileutil.hardlink(src, dest)
elif convert_to_jpeg:
# use convert_to_jpeg to export the file
fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality)
converted_stat = fileutil.file_sig(dest_str)
else:
fileutil.copy(src, dest_str, norsrc=no_xattr)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
converted_stat,
edited_stat,
self.json(),
None,
)
if touched_files:
ts = int(self.date.timestamp())
fileutil.utime(dest, (ts, ts))
return ExportResults(
exported_files + update_new_files + update_updated_files,
update_new_files,
update_updated_files,
update_skipped_files,
[],
touched_files,
)
def _write_exif_data(
self,
filepath,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" write exif data to image file at filepath
Args:
filepath: full path to the image file
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"Could not find file {filepath}")
exif_info = self._exiftool_dict(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
with ExifTool(filepath) as exiftool:
for exiftag, val in exif_info.items():
if exiftag == "_CreatedBy":
continue
elif type(val) == list:
for v in val:
exiftool.setvalue(exiftag, v)
else:
exiftool.setvalue(exiftag, val)
def _exiftool_dict(
self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
Args:
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
description_template: (list of strings); list of template strings to render for the description
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
Returns: dict with exiftool tags / values
Exports the following:
EXIF:ImageDescription
XMP:Description (may include template)
XMP:Title
XMP:TagsList
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:PersonInImage
EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate
IPTC:DateCreated
IPTC:TimeCreated
"""
exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
exif["EXIF:ImageDescription"] = description
exif["XMP:Description"] = description
elif self.description:
exif["EXIF:ImageDescription"] = self.description
exif["XMP:Description"] = self.description
if self.title:
exif["XMP:Title"] = self.title
keyword_list = []
if self.keywords:
keyword_list.extend(self.keywords)
person_list = []
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = sorted([p for p in self.persons if p != _UNKNOWN_PERSON])
if use_persons_as_keywords and person_list:
keyword_list.extend(sorted(person_list))
if use_albums_as_keywords and self.albums:
keyword_list.extend(sorted(self.albums))
if keyword_template:
rendered_keywords = []
for template_str in keyword_template:
rendered, unmatched = self.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched:
logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}"
)
rendered_keywords.extend(rendered)
# filter out any template values that didn't match by looking for sentinel
rendered_keywords = [
keyword
for keyword in sorted(rendered_keywords)
if _OSXPHOTOS_NONE_SENTINEL not in keyword
]
# check to see if any keywords too long
long_keywords = [
long_str
for long_str in rendered_keywords
if len(long_str) > _MAX_IPTC_KEYWORD_LEN
]
if long_keywords:
logging.warning(
f"Some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN}: {long_keywords}"
)
keyword_list.extend(rendered_keywords)
if keyword_list:
exif["XMP:TagsList"] = keyword_list.copy()
exif["IPTC:Keywords"] = keyword_list.copy()
if person_list:
exif["XMP:PersonInImage"] = person_list.copy()
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
# only use Photos' keywords for subject (e.g. don't include template values)
exif["XMP:Subject"] = self.keywords.copy() + person_list.copy()
# if self.favorite():
# exif["Rating"] = 5
(lat, lon) = self.location
if lat is not None and lon is not None:
exif["EXIF:GPSLatitude"] = lat
exif["EXIF:GPSLongitude"] = lon
lat_ref = "N" if lat >= 0 else "S"
lon_ref = "E" if lon >= 0 else "W"
exif["EXIF:GPSLatitudeRef"] = lat_ref
exif["EXIF:GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset
# Photos exports the following fields and sets modify date to creation date
# [EXIF] Modify Date : 2020:10:30 00:00:00
# [EXIF] Date/Time Original : 2020:10:30 00:00:00
# [EXIF] Create Date : 2020:10:30 00:00:00
# [IPTC] Digital Creation Date : 2020:10:30
# [IPTC] Date Created : 2020:10:30
#
# This code deviates from Photos in one regard:
# if photo has modification date, use it otherwise use creation date
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:CreateDate"] = datetimeoriginal
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
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["EXIF:OffsetTimeOriginal"] = offsettime
dateoriginal = date.strftime("%Y:%m:%d")
exif["IPTC:DateCreated"] = dateoriginal
timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
exif["IPTC:TimeCreated"] = timeoriginal
if self.date_modified is not None and not ignore_date_modified:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
else:
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
return exif
def _exiftool_json_sidecar(
self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
Args:
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
description_template: (list of strings); list of template strings to render for the description
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
Returns: dict with exiftool tags / values
Exports the following:
EXIF:ImageDescription
XMP:Description (may include template)
XMP:Title
XMP:TagsList
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:PersonInImage
EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate
IPTC:DigitalCreationDate
IPTC:DateCreated
"""
exif = self._exiftool_dict(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
return json.dumps([exif])
def _xmp_sidecar(
self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
extension=None,
):
""" returns string for XMP sidecar
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
description_template: string; optional template string that will be rendered for use as photo description """
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
if extension is None:
extension = pathlib.Path(self.original_filename)
extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
else:
description = self.description if self.description is not None else ""
keyword_list = []
if self.keywords:
keyword_list.extend(self.keywords)
# TODO: keyword handling in this and _exiftool_json_sidecar is
# good candidate for pulling out in a function
person_list = []
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
if use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
if use_albums_as_keywords and self.albums:
keyword_list.extend(self.albums)
if keyword_template:
rendered_keywords = []
for template_str in keyword_template:
rendered, unmatched = self.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched:
logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}"
)
rendered_keywords.extend(rendered)
# filter out any template values that didn't match by looking for sentinel
rendered_keywords = [
keyword
for keyword in rendered_keywords
if _OSXPHOTOS_NONE_SENTINEL not in keyword
]
# check to see if any keywords too long
long_keywords = [
long_str
for long_str in rendered_keywords
if len(long_str) > _MAX_IPTC_KEYWORD_LEN
]
if long_keywords:
logging.warning(
f"Some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN}: {long_keywords}"
)
keyword_list.extend(rendered_keywords)
subject_list = []
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
subject_list = list(self.keywords) + person_list
xmp_str = xmp_template.render(
photo=self,
description=description,
keywords=keyword_list,
persons=person_list,
subjects=subject_list,
extension=extension,
)
# remove extra lines that mako inserts from template
xmp_str = "\n".join([line for line in xmp_str.split("\n") if line.strip() != ""])
return xmp_str
def _write_sidecar(self, filename, sidecar_str):
""" write sidecar_str to filename
used for exporting sidecar info """
if not (filename or sidecar_str):
raise (
ValueError(
f"filename {filename} and sidecar_str {sidecar_str} must not be None"
)
)
# TODO: catch exception?
f = open(filename, "w")
f.write(sidecar_str)
f.close()