More refactoring of export code, #462

This commit is contained in:
Rhet Turnbull
2022-01-22 09:03:01 -08:00
parent 881832c92d
commit 6261a7b5c9

View File

@@ -40,6 +40,7 @@ from .fileutil import FileUtil
from .photokit import ( from .photokit import (
PHOTOS_VERSION_CURRENT, PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL, PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
PhotoKitFetchFailed, PhotoKitFetchFailed,
PhotoLibrary, PhotoLibrary,
) )
@@ -104,7 +105,8 @@ class ExportOptions:
update (bool, 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 update (bool, 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
use_albums_as_keywords (bool, default = False): if True, will include album names in keywords when exporting metadata with exiftool or sidecar use_albums_as_keywords (bool, default = False): if True, will include album names in keywords when exporting metadata with exiftool or sidecar
use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar
use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos (see also use_photokit)
use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output. verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
""" """
@@ -150,6 +152,54 @@ class ExportOptions:
return asdict(self) return asdict(self)
class StagedFiles:
"""Represents files staged for export"""
def __init__(
self,
original: Optional[str] = None,
original_live: Optional[str] = None,
edited: Optional[str] = None,
edited_live: Optional[str] = None,
preview: Optional[str] = None,
raw: Optional[str] = None,
error: Optional[List[str]] = None,
):
self.original = original
self.original_live = original_live
self.edited = edited
self.edited_live = edited_live
self.preview = preview
self.raw = raw
self.error = error or []
# TODO: bursts?
def __ior__(self, other):
self.original = self.original or other.original
self.original_live = self.original_live or other.original_live
self.edited = self.edited or other.edited
self.edited_live = self.edited_live or other.edited_live
self.preview = self.preview or other.preview
self.raw = self.raw or other.raw
self.error += other.error
return self
def __str__(self):
return str(self.asdict())
def asdict(self):
return {
"original": self.original,
"original_live": self.original_live,
"edited": self.edited,
"edited_live": self.edited_live,
"preview": self.preview,
"raw": self.raw,
"error": self.error,
}
class ExportResults: class ExportResults:
"""Results class which holds export results for export2""" """Results class which holds export results for export2"""
@@ -291,6 +341,12 @@ class PhotoExporter:
self._render_options = RenderOptions() self._render_options = RenderOptions()
self._verbose = self.photo._verbose self._verbose = self.photo._verbose
# temp directory for staging downloaded missing files
self._temp_dir = tempfile.TemporaryDirectory(
prefix=f"osxphotos_photo_exporter_{self.photo.uuid}_"
)
self._temp_dir_path = pathlib.Path(self._temp_dir.name)
def export( def export(
self, self,
dest, dest,
@@ -414,6 +470,8 @@ class PhotoExporter:
options: Optional[ExportOptions] = None, options: Optional[ExportOptions] = None,
): ):
"""export photo, like export but with update and dry_run options """export photo, like export but with update and dry_run options
Args:
dest: must be valid destination path or exception raised dest: must be valid destination path or exception raised
filename: (optional): name of exported picture; if not provided, will use current filename filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct. **NOTE**: if provided, user must ensure file extension (suffix) is correct.
@@ -423,28 +481,9 @@ class PhotoExporter:
in which case export will use the extension provided by Photos upon export. in which case export will use the extension provided by Photos upon export.
e.g. to get the extension of the edited photo, e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited reference PhotoInfo.path_edited
options (ExportOptions): optional ExportOptions instance
Returns: ExportResults class Returns: ExportResults instance
ExportResults has attributes:
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_exiftool_written",
"sidecar_exiftool_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
"error",
"error_str",
"exiftool_warning",
"exiftool_error",
Note: to use dry run mode, you must set options.dry_run=True and also pass in memory version of export_db, Note: to use dry run mode, you must set options.dry_run=True and also pass in memory version of export_db,
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp) in options.export_db and options.fileutil respectively and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp) in options.export_db and options.fileutil respectively
@@ -452,6 +491,14 @@ class PhotoExporter:
options = options or ExportOptions() options = options or ExportOptions()
verbose = options.verbose or self._verbose
if verbose and not callable(verbose):
raise TypeError("verbose must be callable")
# can't use export_as_hardlink with use_photos_export as can't hardlink the temporary files downloaded
if options.export_as_hardlink and options.use_photos_export:
raise ValueError("Cannot use export_as_hardlink with use_photos_export")
# when called from export(), won't get an export_db, so use no-op version # when called from export(), won't get an export_db, so use no-op version
options.export_db = options.export_db or ExportDBNoOp() options.export_db = options.export_db or ExportDBNoOp()
export_db = options.export_db export_db = options.export_db
@@ -460,10 +507,6 @@ class PhotoExporter:
options.fileutil = options.fileutil or FileUtil options.fileutil = options.fileutil or FileUtil
fileutil = options.fileutil fileutil = options.fileutil
verbose = options.verbose or self._verbose
if verbose and not callable(verbose):
raise TypeError("verbose must be callable")
self._render_options = options.render_options or RenderOptions() self._render_options = options.render_options or RenderOptions()
# export_original, and export_edited are just used for clarity in the code # export_original, and export_edited are just used for clarity in the code
@@ -515,18 +558,9 @@ class PhotoExporter:
self._render_options.filepath = str(dest) self._render_options.filepath = str(dest)
all_results = ExportResults() all_results = ExportResults()
if options.use_photos_export: staged_files = self._stage_photos_for_export(options)
self._export_photo_with_photos_export(
dest=dest, all_results=all_results, options=options
)
else:
# 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?
src = self.photo.path_edited if options.edited else self.photo.path
if src and not pathlib.Path(src).is_file():
raise FileNotFoundError(f"{src} does not appear to exist")
src = staged_files.edited if options.edited else staged_files.original
if src: if src:
# found source now try to find right destination # found source now try to find right destination
if options.update and dest.exists(): if options.update and dest.exists():
@@ -585,10 +619,10 @@ class PhotoExporter:
export_original export_original
and options.live_photo and options.live_photo
and self.photo.live_photo and self.photo.live_photo
and self.photo.path_live_photo and staged_files.original_live
): ):
live_name = dest.parent / f"{dest.stem}.mov" live_name = dest.parent / f"{dest.stem}.mov"
src_live = self.photo.path_live_photo src_live = staged_files.original_live
results = self._export_photo( results = self._export_photo(
src_live, src_live,
live_name, live_name,
@@ -601,10 +635,10 @@ class PhotoExporter:
export_edited export_edited
and options.live_photo and options.live_photo
and self.photo.live_photo and self.photo.live_photo
and self.photo.path_edited_live_photo and staged_files.edited_live
): ):
live_name = dest.parent / f"{dest.stem}.mov" live_name = dest.parent / f"{dest.stem}.mov"
src_live = self.photo.path_edited_live_photo src_live = staged_files.edited_live
results = self._export_photo( results = self._export_photo(
src_live, src_live,
live_name, live_name,
@@ -614,8 +648,8 @@ class PhotoExporter:
all_results += results all_results += results
# copy associated RAW image if requested # copy associated RAW image if requested
if options.raw_photo and self.photo.has_raw and self.photo.path_raw: if options.raw_photo and self.photo.has_raw and staged_files.raw:
raw_path = pathlib.Path(self.photo.path_raw) raw_path = pathlib.Path(staged_files.raw)
raw_ext = raw_path.suffix raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}" raw_name = dest.parent / f"{dest.stem}{raw_ext}"
if raw_path is not None: if raw_path is not None:
@@ -627,10 +661,10 @@ class PhotoExporter:
all_results += results all_results += results
# copy preview image if requested # copy preview image if requested
if options.preview and self.photo.path_derivatives: if options.preview and staged_files.preview:
# Photos keeps multiple different derivatives and path_derivatives returns list of them # Photos keeps multiple different derivatives and path_derivatives returns list of them
# first derivative is the largest so export that one # first derivative is the largest so export that one
preview_path = pathlib.Path(self.photo.path_derivatives[0]) preview_path = pathlib.Path(staged_files.preview)
preview_ext = preview_path.suffix preview_ext = preview_path.suffix
preview_name = ( preview_name = (
dest.parent / f"{dest.stem}{options.preview_suffix}{preview_ext}" dest.parent / f"{dest.stem}{options.preview_suffix}{preview_ext}"
@@ -721,25 +755,275 @@ class PhotoExporter:
) )
return dest, count return dest, count
def _export_photo_with_photos_export( def _stage_photos_for_export(self, options: ExportOptions) -> StagedFiles:
"""Stages photos for export
If photo is present on disk in the library, uses path to the photo on disk.
If photo is missing and use_photos_export is true, downloads the photo from iCloud to temporary location.
"""
# TODO: this changes behavior in that Photos download is only called if file is actually missing
# Need an option to force download if user wants to only use Photos export
staged = StagedFiles()
if options.raw_photo and self.photo.has_raw:
staged.raw = self.photo.path_raw
if options.preview and self.photo.path_derivatives:
staged.preview = self.photo.path_derivatives[0]
if not options.edited:
# original file
if self.photo.path:
staged.original = self.photo.path
if options.live_photo and self.photo.live_photo:
staged.original_live = self.photo.path_live_photo
if options.edited:
# edited file
staged.edited = self.photo.path_edited
if options.live_photo and self.photo.live_photo:
staged.edited_live = self.photo.path_edited_live_photo
# download any missing files
if options.use_photos_export:
live_photo = staged.edited_live if options.edited else staged.original_live
missing_options = ExportOptions(
edited=options.edited,
# TODO: missing previews are not generated/downloaded
preview=options.preview and not staged.preview,
raw_photo=options.raw_photo and not staged.raw,
live_photo=options.live_photo and not live_photo,
)
if options.use_photokit:
missing_staged = self._stage_photo_for_export_with_photokit(
options=missing_options
)
else:
missing_staged = self._stage_photo_for_export_with_applescript(
options=missing_options
)
staged |= missing_staged
return staged
# def _export_photo_with_photos_export(
# self,
# dest: pathlib.Path,
# all_results: ExportResults,
# options: ExportOptions,
# ):
# # TODO: if using applescript and exporting edited with live_photo doesn't seem to export the edited live photo
# # this does work with photokit, but not with applescript
# # TODO: duplicative code with the if edited/else--remove it
# fileutil = options.fileutil
# export_db = options.export_db
# # export live_photo .mov file?
# live_photo = bool(options.live_photo and self.photo.live_photo)
# overwrite = options.overwrite or options.update
# if options.edited or self.photo.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
# # didn't get passed a filename, add _edited
# uti = (
# self.photo.uti_edited
# if options.edited and self.photo.uti_edited
# else self.photo.uti
# )
# ext = get_preferred_uti_extension(uti)
# dest = dest.parent / f"{dest.stem}.{ext}"
# if options.use_photokit:
# photolib = PhotoLibrary()
# photo = None
# try:
# photo = photolib.fetch_uuid(self.photo.uuid)
# except PhotoKitFetchFailed as e:
# # if failed to find UUID, might be a burst photo
# if self.photo.burst and self.photo._info["burstUUID"]:
# bursts = photolib.fetch_burst_uuid(
# self.photo._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.photo.uuid)
# ]
# photo = photo[0] if photo else None
# if not photo:
# all_results.error.append(
# (
# str(dest),
# f"PhotoKitFetchFailed exception exporting photo {self.photo.uuid}: {e} ({lineno(__file__)})",
# )
# )
# if photo:
# if options.dry_run:
# # dry_run, don't actually export
# all_results.exported.append(str(dest))
# else:
# try:
# exported = photo.export(
# dest.parent,
# dest.name,
# version=PHOTOS_VERSION_CURRENT,
# overwrite=overwrite,
# video=live_photo,
# )
# all_results.exported.extend(exported)
# except Exception as e:
# all_results.error.append(
# (str(dest), f"{e} ({lineno(__file__)})")
# )
# else:
# try:
# exported = _export_photo_uuid_applescript(
# self.photo.uuid,
# dest.parent,
# filestem=dest.stem,
# original=False,
# edited=True,
# live_photo=live_photo,
# timeout=options.timeout,
# burst=self.photo.burst,
# dry_run=options.dry_run,
# overwrite=overwrite,
# )
# all_results.exported.extend(exported)
# except ExportError as e:
# all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
# else:
# # export original version and not edited
# if options.use_photokit:
# photolib = PhotoLibrary()
# photo = None
# try:
# photo = photolib.fetch_uuid(self.photo.uuid)
# except PhotoKitFetchFailed:
# # if failed to find UUID, might be a burst photo
# if self.photo.burst and self.photo._info["burstUUID"]:
# bursts = photolib.fetch_burst_uuid(
# self.photo._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.photo.uuid)
# ]
# photo = photo[0] if photo else None
# if photo:
# if not options.dry_run:
# try:
# exported = photo.export(
# dest.parent,
# dest.name,
# version=PHOTOS_VERSION_ORIGINAL,
# overwrite=overwrite,
# video=live_photo,
# )
# all_results.exported.extend(exported)
# except Exception as e:
# all_results.error.append(
# (str(dest), f"{e} ({lineno(__file__)})")
# )
# else:
# # dry_run, don't actually export
# all_results.exported.append(str(dest))
# else:
# try:
# exported = _export_photo_uuid_applescript(
# self.photo.uuid,
# dest.parent,
# filestem=dest.stem,
# original=True,
# edited=False,
# live_photo=live_photo,
# timeout=options.timeout,
# burst=self.photo.burst,
# dry_run=options.dry_run,
# overwrite=overwrite,
# )
# all_results.exported.extend(exported)
# except ExportError as e:
# all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
# if all_results.exported:
# for idx, photopath in enumerate(all_results.exported):
# converted_stat = (None, None, None)
# photopath = pathlib.Path(photopath)
# if (
# options.convert_to_jpeg
# and self.photo.isphoto
# and photopath.suffix.lower() not in LIVE_VIDEO_EXTENSIONS
# ):
# dest_str = photopath.parent / f"{photopath.stem}.jpeg"
# fileutil.convert_to_jpeg(
# photopath,
# dest_str,
# compression_quality=options.jpeg_quality,
# )
# converted_stat = fileutil.file_sig(dest_str)
# fileutil.unlink(photopath)
# all_results.exported[idx] = dest_str
# all_results.converted_to_jpeg.append(dest_str)
# photopath = dest_str
# photopath = str(photopath)
# export_db.set_data(
# filename=photopath,
# uuid=self.photo.uuid,
# orig_stat=fileutil.file_sig(photopath),
# exif_stat=(None, None, None),
# converted_stat=converted_stat,
# edited_stat=(None, None, None),
# info_json=self.photo.json(),
# exif_json=None,
# )
# # todo: handle signatures
# if options.jpeg_ext:
# # use_photos_export (both PhotoKit and AppleScript) don't use the
# # file extension provided (instead they use extension for UTI)
# # so if jpeg_ext is set, rename any non-conforming jpegs
# all_results.exported = rename_jpeg_files(
# all_results.exported, options.jpeg_ext, fileutil
# )
# if options.touch_file:
# for exported_file in all_results.exported:
# all_results.touched.append(exported_file)
# ts = int(self.photo.date.timestamp())
# fileutil.utime(exported_file, (ts, ts))
# if options.update:
# all_results.new.extend(all_results.exported)
def _stage_photo_for_export_with_photokit(
self, self,
dest: str,
all_results: ExportResults,
options: ExportOptions, options: ExportOptions,
): ) -> StagedFiles:
# TODO: duplicative code with the if edited/else--remove it """Stage a photo for export with photokit to a temporary directory"""
fileutil = options.fileutil
export_db = options.export_db if options.edited and not self.photo.hasadjustments:
raise ValueError("Edited version requested but photo has no adjustments")
dest = self._temp_dir_path / self.photo.original_filename
# export live_photo .mov file? # export live_photo .mov file?
live_photo = bool(options.live_photo and self.photo.live_photo) live_photo = bool(options.live_photo and self.photo.live_photo)
overwrite = options.overwrite or options.update overwrite = options.overwrite or options.update
# figure out which photo version to request
if options.edited or self.photo.shared: if options.edited or self.photo.shared:
# exported edited version and not original
# shared photos (in shared albums) show up as not having adjustments (not edited) # 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 # 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 # so tell Photos to export the current version in this case
# didn't get passed a filename, add _edited photos_version = PHOTOS_VERSION_CURRENT
elif self.photo.has_raw:
# PhotoKit always returns the raw photo of raw+jpeg pair for PHOTOS_VERSION_ORIGINAL even if JPEG is the original
photos_version = PHOTOS_VERSION_UNADJUSTED
else:
photos_version = PHOTOS_VERSION_ORIGINAL
uti = ( uti = (
self.photo.uti_edited self.photo.uti_edited
if options.edited and self.photo.uti_edited if options.edited and self.photo.uti_edited
@@ -748,8 +1032,8 @@ class PhotoExporter:
ext = get_preferred_uti_extension(uti) ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{dest.stem}.{ext}" dest = dest.parent / f"{dest.stem}.{ext}"
if options.use_photokit:
photolib = PhotoLibrary() photolib = PhotoLibrary()
results = StagedFiles()
photo = None photo = None
try: try:
photo = photolib.fetch_uuid(self.photo.uuid) photo = photolib.fetch_uuid(self.photo.uuid)
@@ -760,153 +1044,143 @@ class PhotoExporter:
self.photo._info["burstUUID"], all=True self.photo._info["burstUUID"], all=True
) )
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning # PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [ photo = [p for p in bursts if p.uuid.startswith(self.photo.uuid)]
p for p in bursts if p.uuid.startswith(self.photo.uuid)
]
photo = photo[0] if photo else None photo = photo[0] if photo else None
if not photo: if not photo:
all_results.error.append( results.error.append(
( (
str(dest), str(dest),
f"PhotoKitFetchFailed exception exporting photo {self.photo.uuid}: {e} ({lineno(__file__)})", f"PhotoKitFetchFailed exception exporting photo {self.photo.uuid}: {e} ({lineno(__file__)})",
) )
) )
if photo: return results
if options.dry_run:
# dry_run, don't actually export # now export the requested version of the photo
all_results.exported.append(str(dest))
else:
try: try:
exported = photo.export( exported = photo.export(
dest.parent, dest.parent,
dest.name, dest.name,
version=PHOTOS_VERSION_CURRENT, version=photos_version,
overwrite=overwrite, overwrite=overwrite,
video=live_photo, video=live_photo,
) )
all_results.exported.extend(exported) if len(exported) == 1:
except Exception as e: results_attr = "edited" if options.edited else "original"
all_results.error.append( setattr(results, results_attr, exported[0])
(str(dest), f"{e} ({lineno(__file__)})") elif len(exported) == 2:
for exported_file in exported:
if exported_file.lower().endswith(".mov"):
# live photo
results_attr = (
"edited_live" if options.edited else "original_live"
) )
else: else:
results_attr = "edited" if options.edited else "original"
setattr(results, results_attr, exported_file)
except Exception as e:
results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
if options.raw_photo and self.photo.has_raw:
# also request the raw photo
try:
exported = photo.export(
dest.parent,
dest.name,
version=photos_version,
raw=True,
overwrite=overwrite,
video=live_photo,
)
if exported:
results.raw = exported[0]
except Exception as e:
results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
return results
def _stage_photo_for_export_with_applescript(
self,
options: ExportOptions,
) -> StagedFiles:
"""Stage a photo for export with AppleScript to a temporary directory
Note: If exporting an edited live photo, the associated live video will not be exported.
This is a limitation of the Photos AppleScript interface and Photos behaves the same way."""
if options.edited and not self.photo.hasadjustments:
raise ValueError("Edited version requested but photo has no adjustments")
dest = self._temp_dir_path / self.photo.original_filename
dest = pathlib.Path(increment_filename(dest))
# export live_photo .mov file?
live_photo = bool(options.live_photo and self.photo.live_photo)
overwrite = options.overwrite or options.update
edited_version = options.edited or self.photo.shared
# 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
uti = (
self.photo.uti_edited
if options.edited and self.photo.uti_edited
else self.photo.uti
)
ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{dest.stem}.{ext}"
results = StagedFiles()
try: try:
exported = _export_photo_uuid_applescript( exported = _export_photo_uuid_applescript(
self.photo.uuid, self.photo.uuid,
dest.parent, dest.parent,
filestem=dest.stem, filestem=dest.stem,
original=False, original=not edited_version,
edited=True, edited=edited_version,
live_photo=live_photo, live_photo=live_photo,
timeout=options.timeout, timeout=options.timeout,
burst=self.photo.burst, burst=self.photo.burst,
dry_run=options.dry_run,
overwrite=overwrite, overwrite=overwrite,
) )
all_results.exported.extend(exported)
except ExportError as e: except ExportError as e:
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})")) results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
else: return results
# export original version and not edited
if options.use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.photo.uuid)
except PhotoKitFetchFailed:
# if failed to find UUID, might be a burst photo
if self.photo.burst and self.photo._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self.photo._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.photo.uuid)
]
photo = photo[0] if photo else None
if photo:
if not options.dry_run:
try:
exported = photo.export(
dest.parent,
dest.name,
version=PHOTOS_VERSION_ORIGINAL,
overwrite=overwrite,
video=live_photo,
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append(
(str(dest), f"{e} ({lineno(__file__)})")
)
else:
# dry_run, don't actually export
all_results.exported.append(str(dest))
else:
try:
exported = _export_photo_uuid_applescript(
self.photo.uuid,
dest.parent,
filestem=dest.stem,
original=True,
edited=False,
live_photo=live_photo,
timeout=options.timeout,
burst=self.photo.burst,
dry_run=options.dry_run,
overwrite=overwrite,
)
all_results.exported.extend(exported)
except ExportError as e:
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
if all_results.exported:
for idx, photopath in enumerate(all_results.exported):
converted_stat = (None, None, None)
photopath = pathlib.Path(photopath)
if (
options.convert_to_jpeg
and self.photo.isphoto
and photopath.suffix.lower() not in LIVE_VIDEO_EXTENSIONS
):
dest_str = photopath.parent / f"{photopath.stem}.jpeg"
fileutil.convert_to_jpeg(
photopath,
dest_str,
compression_quality=options.jpeg_quality,
)
converted_stat = fileutil.file_sig(dest_str)
fileutil.unlink(photopath)
all_results.exported[idx] = dest_str
all_results.converted_to_jpeg.append(dest_str)
photopath = dest_str
photopath = str(photopath) if len(exported) == 1:
export_db.set_data( results_attr = "edited" if options.edited else "original"
filename=photopath, setattr(results, results_attr, exported[0])
uuid=self.photo.uuid, elif len(exported) == 2:
orig_stat=fileutil.file_sig(photopath), # could be live or raw+jpeg
exif_stat=(None, None, None), for exported_file in exported:
converted_stat=converted_stat, if exported_file.lower().endswith(".mov"):
edited_stat=(None, None, None), # live photo
info_json=self.photo.json(), results_attr = (
exif_json=None, "edited_live"
if live_photo and options.edited
else "original_live"
if live_photo
else None
) )
elif self.photo.has_raw and pathlib.Path(
exported_file.lower()
).suffix not in [
".jpg",
".jpeg",
".heic",
]:
# assume raw photo if not a common non-raw image format
results_attr = "raw" if options.raw_photo else None
else:
results_attr = "edited" if options.edited else "original"
if results_attr:
setattr(results, results_attr, exported_file)
# todo: handle signatures return results
if options.jpeg_ext:
# use_photos_export (both PhotoKit and AppleScript) don't use the def _is_temp_file(self, filepath: str) -> bool:
# file extension provided (instead they use extension for UTI) """Returns True if file is in the PhotosExporter temp directory otherwise False"""
# so if jpeg_ext is set, rename any non-conforming jpegs filepath = pathlib.Path(filepath)
all_results.exported = rename_jpeg_files( return filepath.parent == self._temp_dir_path
all_results.exported, options.jpeg_ext, fileutil
)
if options.touch_file:
for exported_file in all_results.exported:
all_results.touched.append(exported_file)
ts = int(self.photo.date.timestamp())
fileutil.utime(exported_file, (ts, ts))
if options.update:
all_results.new.extend(all_results.exported)
def _export_photo( def _export_photo(
self, self,
@@ -918,7 +1192,7 @@ class PhotoExporter:
Does the actual copy or hardlink taking the appropriate Does the actual copy or hardlink taking the appropriate
action depending on update, overwrite, export_as_hardlink action depending on update, overwrite, export_as_hardlink
Assumes destination is the right destination (e.g. UUID matches) Assumes destination is the right destination (e.g. UUID matches)
sets UUID and JSON info foo exported file using set_uuid_for_file, set_info_for_uuid sets UUID and JSON info for exported file using set_uuid_for_file, set_info_for_uuid
Args: Args:
src (str): src path src (str): src path
@@ -937,6 +1211,9 @@ class PhotoExporter:
"export_as_hardlink and convert_to_jpeg cannot both be True" "export_as_hardlink and convert_to_jpeg cannot both be True"
) )
if options.export_as_hardlink and self._is_temp_file(src):
raise ValueError("export_as_hardlink cannot be used with temp files")
exported_files = [] exported_files = []
update_updated_files = [] update_updated_files = []
update_new_files = [] update_new_files = []