diff --git a/osxphotos/photoexporter.py b/osxphotos/photoexporter.py index e05883a3..ccd3f72b 100644 --- a/osxphotos/photoexporter.py +++ b/osxphotos/photoexporter.py @@ -1,15 +1,15 @@ """ PhotoExport class to export photos """ +from __future__ import annotations + import dataclasses import json import logging import os import pathlib import re -import sys import typing as t -from collections import namedtuple # pylint: disable=syntax-error from dataclasses import asdict, dataclass from datetime import datetime from enum import Enum @@ -39,10 +39,10 @@ from .phototemplate import RenderOptions from .rich_utils import add_rich_markup_tag from .uti import get_preferred_uti_extension from .utils import ( - is_macos, hexdigest, increment_filename, increment_filename_with_count, + is_macos, lineno, list_directory, lock_filename, @@ -76,6 +76,10 @@ if t.TYPE_CHECKING: # retry if download_missing/use_photos_export fails the first time (which sometimes it does) MAX_PHOTOSCRIPT_RETRIES = 3 +# Global to hold the compiled XMP template +# This is expensive to compile so we only want to do it once +_global_xmp_template: Template | None = None + # return values for _should_update_photo class ShouldUpdate(Enum): @@ -244,6 +248,9 @@ class StagedFiles: def __str__(self): return str(self.asdict()) + def __repr__(self): + return f"StagedFiles({self.asdict()})" + def asdict(self): return { "original": self.original, @@ -479,6 +486,11 @@ class PhotoExporter: dest, options = self._should_convert_to_jpeg(dest, options) # stage files for export by finding path in local library or downloading from iCloud as appropriate + # for `--download-missing` and `--update` case, this may cause unnecessary downloads + # as it will download the file even if it's not needed (won't be checked until the _should_update_photo() call from _export_photo() + # fixing this will require major refactoring of the export code, see #1086 + # leaving it for now as this should not be a common use case + # (if using `--update` it is much better to be using "Download originals to this Mac" in Photos) staged_files = self._stage_photos_for_export(options) src = staged_files.edited if options.edited else staged_files.original @@ -665,8 +677,16 @@ class PhotoExporter: return lock_filename(filename) if lock else filename # if overwrite==False and #increment==False, export should fail if file exists - if dest.exists() and not any( - [options.increment, options.update, options.force_update, options.overwrite] + if ( + not any( + [ + options.increment, + options.update, + options.force_update, + options.overwrite, + ] + ) + and dest.exists() ): raise FileExistsError( f"destination exists ({dest}); overwrite={options.overwrite}, increment={options.increment}" @@ -725,9 +745,19 @@ class PhotoExporter: return dest def _should_update_photo( - self, src: pathlib.Path, dest: pathlib.Path, options: ExportOptions - ) -> t.Literal[True, False]: - """Return True if photo should be updated, else False""" + self, src: pathlib.Path | None, dest: pathlib.Path, options: ExportOptions + ) -> bool | ShouldUpdate: + """Return True if photo should be updated, else False + + Args: + src (pathlib.Path | None): source path; if None, photo is missing and + any checks that require src will return True + dest (pathlib.Path): destination path + + Returns: + False if photo should not be updated otherwise a truthy ShouldUpdate value + """ + # NOTE: The order of certain checks is important # read the comments below to understand why before changing @@ -740,11 +770,11 @@ class PhotoExporter: # photo doesn't exist in database, should update return ShouldUpdate.NOT_IN_DATABASE - if options.export_as_hardlink and not dest.samefile(src): + if options.export_as_hardlink and (not src or not dest.samefile(src)): # different files, should update return ShouldUpdate.HARDLINK_DIFFERENT_FILES - if not options.export_as_hardlink and dest.samefile(src): + if not options.export_as_hardlink and (not src or dest.samefile(src)): # same file but not exporting as hardlink, should update return ShouldUpdate.NOT_HARDLINK_SAME_FILES @@ -776,7 +806,9 @@ class PhotoExporter: # as exiftool will be used to update edited file return ShouldUpdate.EXIFTOOL_DIFFERENT if rv else False - if options.edited and not fileutil.cmp_file_sig(src, file_record.src_sig): + if options.edited and ( + not src or not fileutil.cmp_file_sig(src, file_record.src_sig) + ): # edited file in Photos doesn't match what was last exported return ShouldUpdate.EDITED_SIG_DIFFERENT @@ -827,22 +859,46 @@ class PhotoExporter: # download any missing files if options.download_missing: - live_photo = staged.edited_live if options.edited else staged.original_live - missing_options = ExportOptions( - edited=options.edited, - 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, + staged |= self._stage_missing_photos_for_export( + staged=staged, options=options ) - 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 _stage_missing_photos_for_export( + self, staged: StagedFiles, options: ExportOptions + ) -> StagedFiles: + """Download and stage any missing files for export""" + + # if live photo and requesting edited version need the edited live photo + live_photo = staged.edited_live if options.edited else staged.original_live + + # is there actually a missing file? (#1086) + something_to_download = ( + (self.photo.hasadjustments and options.edited and not staged.edited) + or (self.photo.live_photo and options.live_photo and not live_photo) + or (self.photo.has_raw and options.raw_photo and not staged.raw) + or (options.preview and not staged.preview) + or (not options.edited and not staged.original) + ) + if not something_to_download: + return staged + + missing_options = ExportOptions( + edited=options.edited, + preview=options.preview and not staged.preview, + raw_photo=self.photo.has_raw and options.raw_photo and not staged.raw, + live_photo=self.photo.live_photo and 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 _stage_photo_for_export_with_photokit( @@ -1959,10 +2015,7 @@ class PhotoExporter: options = options or ExportOptions() - xmp_template_file = ( - _XMP_TEMPLATE_NAME if not self.photo._db._beta else _XMP_TEMPLATE_NAME_BETA - ) - xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, xmp_template_file)) + xmp_template = self._xmp_template() if extension is None: extension = pathlib.Path(self.photo.original_filename) @@ -2069,6 +2122,20 @@ class PhotoExporter: xmp_str = "\n".join(line for line in xmp_str.split("\n") if line.strip() != "") return xmp_str + def _xmp_template(self): + """Return the mako template for XMP sidecar, creating it if necessary""" + global _global_xmp_template + if _global_xmp_template is not None: + return _global_xmp_template + + xmp_template_file = ( + _XMP_TEMPLATE_NAME_BETA if self.photo._db._beta else _XMP_TEMPLATE_NAME + ) + _global_xmp_template = Template( + filename=os.path.join(_TEMPLATE_DIR, xmp_template_file) + ) + return _global_xmp_template + def _write_sidecar(self, filename, sidecar_str): """write sidecar_str to filename used for exporting sidecar info""" diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 46b3faa5..bb704986 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -877,33 +877,10 @@ class PhotoInfo: photopath = None if self._db._db_version <= _PHOTOS_4_VERSION: - if self.live_photo and not self.ismissing: - live_model_id = self._info["live_model_id"] - if live_model_id is None: - logger.debug(f"missing live_model_id: {self._uuid}") - photopath = None - else: - folder_id, file_id, nn_id = _get_resource_loc(live_model_id) - library_path = self._db.library_path - photopath = os.path.join( - library_path, - "resources", - "media", - "master", - folder_id, - nn_id, - f"jpegvideocomplement_{file_id}.mov", - ) - if not os.path.isfile(photopath): - # In testing, I've seen occasional missing movie for live photo - # These appear to be valid -- e.g. live component hasn't been downloaded from iCloud - # photos 4 has "isOnDisk" column we could check - # or could do the actual check with "isfile" - # TODO: should this be a warning or debug? - photopath = None - else: - photopath = None + return self._path_live_photo_4() elif self.live_photo and self.path and not self.ismissing: + if self.shared: + return self._path_live_photo_shared_5() filename = pathlib.Path(self.path) photopath = filename.parent.joinpath(f"{filename.stem}_3.mov") photopath = str(photopath) @@ -917,6 +894,50 @@ class PhotoInfo: return photopath + def _path_live_photo_shared_5(self): + """Return path for live photo for shared photos""" + if not self.shared: + raise ValueError(f"photo {self.uuid} is not a shared photo") + if not self.live_photo: + raise ValueError(f"photo {self.uuid} is not a live photo") + + photopath = self._path_5_shared() + if photopath: + photopath = pathlib.Path(photopath).with_suffix(".MOV") + if not photopath.exists(): + photopath = None + return photopath + + def _path_live_photo_4(self): + """Return path for live edited photo for Photos <= 4""" + if self.live_photo and not self.ismissing: + live_model_id = self._info["live_model_id"] + if live_model_id is None: + logger.debug(f"missing live_model_id: {self._uuid}") + photopath = None + else: + folder_id, file_id, nn_id = _get_resource_loc(live_model_id) + library_path = self._db.library_path + photopath = os.path.join( + library_path, + "resources", + "media", + "master", + folder_id, + nn_id, + f"jpegvideocomplement_{file_id}.mov", + ) + if not os.path.isfile(photopath): + # In testing, I've seen occasional missing movie for live photo + # These appear to be valid -- e.g. live component hasn't been downloaded from iCloud + # photos 4 has "isOnDisk" column we could check + # or could do the actual check with "isfile" + # TODO: should this be a warning or debug? + photopath = None + else: + photopath = None + return photopath + @cached_property def path_derivatives(self): """Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""