1381 lines
54 KiB
Python
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()
|