Fix download missing when not needed 1086 (#1088)
* Fixed staging to not call download missing if not needed * Optimizations for #1086 * Memoize compiled XMP template, #1086
This commit is contained in:
@@ -1,15 +1,15 @@
|
|||||||
""" PhotoExport class to export photos
|
""" PhotoExport class to export photos
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
import typing as t
|
import typing as t
|
||||||
from collections import namedtuple # pylint: disable=syntax-error
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -39,10 +39,10 @@ from .phototemplate import RenderOptions
|
|||||||
from .rich_utils import add_rich_markup_tag
|
from .rich_utils import add_rich_markup_tag
|
||||||
from .uti import get_preferred_uti_extension
|
from .uti import get_preferred_uti_extension
|
||||||
from .utils import (
|
from .utils import (
|
||||||
is_macos,
|
|
||||||
hexdigest,
|
hexdigest,
|
||||||
increment_filename,
|
increment_filename,
|
||||||
increment_filename_with_count,
|
increment_filename_with_count,
|
||||||
|
is_macos,
|
||||||
lineno,
|
lineno,
|
||||||
list_directory,
|
list_directory,
|
||||||
lock_filename,
|
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)
|
# retry if download_missing/use_photos_export fails the first time (which sometimes it does)
|
||||||
MAX_PHOTOSCRIPT_RETRIES = 3
|
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
|
# return values for _should_update_photo
|
||||||
class ShouldUpdate(Enum):
|
class ShouldUpdate(Enum):
|
||||||
@@ -244,6 +248,9 @@ class StagedFiles:
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.asdict())
|
return str(self.asdict())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"StagedFiles({self.asdict()})"
|
||||||
|
|
||||||
def asdict(self):
|
def asdict(self):
|
||||||
return {
|
return {
|
||||||
"original": self.original,
|
"original": self.original,
|
||||||
@@ -479,6 +486,11 @@ class PhotoExporter:
|
|||||||
dest, options = self._should_convert_to_jpeg(dest, options)
|
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
|
# 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)
|
staged_files = self._stage_photos_for_export(options)
|
||||||
src = staged_files.edited if options.edited else staged_files.original
|
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
|
return lock_filename(filename) if lock else filename
|
||||||
|
|
||||||
# if overwrite==False and #increment==False, export should fail if file exists
|
# if overwrite==False and #increment==False, export should fail if file exists
|
||||||
if dest.exists() and not any(
|
if (
|
||||||
[options.increment, options.update, options.force_update, options.overwrite]
|
not any(
|
||||||
|
[
|
||||||
|
options.increment,
|
||||||
|
options.update,
|
||||||
|
options.force_update,
|
||||||
|
options.overwrite,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
and dest.exists()
|
||||||
):
|
):
|
||||||
raise FileExistsError(
|
raise FileExistsError(
|
||||||
f"destination exists ({dest}); overwrite={options.overwrite}, increment={options.increment}"
|
f"destination exists ({dest}); overwrite={options.overwrite}, increment={options.increment}"
|
||||||
@@ -725,9 +745,19 @@ class PhotoExporter:
|
|||||||
return dest
|
return dest
|
||||||
|
|
||||||
def _should_update_photo(
|
def _should_update_photo(
|
||||||
self, src: pathlib.Path, dest: pathlib.Path, options: ExportOptions
|
self, src: pathlib.Path | None, dest: pathlib.Path, options: ExportOptions
|
||||||
) -> t.Literal[True, False]:
|
) -> bool | ShouldUpdate:
|
||||||
"""Return True if photo should be updated, else False"""
|
"""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
|
# NOTE: The order of certain checks is important
|
||||||
# read the comments below to understand why before changing
|
# read the comments below to understand why before changing
|
||||||
|
|
||||||
@@ -740,11 +770,11 @@ class PhotoExporter:
|
|||||||
# photo doesn't exist in database, should update
|
# photo doesn't exist in database, should update
|
||||||
return ShouldUpdate.NOT_IN_DATABASE
|
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
|
# different files, should update
|
||||||
return ShouldUpdate.HARDLINK_DIFFERENT_FILES
|
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
|
# same file but not exporting as hardlink, should update
|
||||||
return ShouldUpdate.NOT_HARDLINK_SAME_FILES
|
return ShouldUpdate.NOT_HARDLINK_SAME_FILES
|
||||||
|
|
||||||
@@ -776,7 +806,9 @@ class PhotoExporter:
|
|||||||
# as exiftool will be used to update edited file
|
# as exiftool will be used to update edited file
|
||||||
return ShouldUpdate.EXIFTOOL_DIFFERENT if rv else False
|
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
|
# edited file in Photos doesn't match what was last exported
|
||||||
return ShouldUpdate.EDITED_SIG_DIFFERENT
|
return ShouldUpdate.EDITED_SIG_DIFFERENT
|
||||||
|
|
||||||
@@ -827,22 +859,46 @@ class PhotoExporter:
|
|||||||
|
|
||||||
# download any missing files
|
# download any missing files
|
||||||
if options.download_missing:
|
if options.download_missing:
|
||||||
live_photo = staged.edited_live if options.edited else staged.original_live
|
staged |= self._stage_missing_photos_for_export(
|
||||||
missing_options = ExportOptions(
|
staged=staged, options=options
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
if options.use_photokit:
|
|
||||||
missing_staged = self._stage_photo_for_export_with_photokit(
|
return staged
|
||||||
options=missing_options
|
|
||||||
)
|
def _stage_missing_photos_for_export(
|
||||||
else:
|
self, staged: StagedFiles, options: ExportOptions
|
||||||
missing_staged = self._stage_photo_for_export_with_applescript(
|
) -> StagedFiles:
|
||||||
options=missing_options
|
"""Download and stage any missing files for export"""
|
||||||
)
|
|
||||||
staged |= missing_staged
|
# 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
|
return staged
|
||||||
|
|
||||||
def _stage_photo_for_export_with_photokit(
|
def _stage_photo_for_export_with_photokit(
|
||||||
@@ -1959,10 +2015,7 @@ class PhotoExporter:
|
|||||||
|
|
||||||
options = options or ExportOptions()
|
options = options or ExportOptions()
|
||||||
|
|
||||||
xmp_template_file = (
|
xmp_template = self._xmp_template()
|
||||||
_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))
|
|
||||||
|
|
||||||
if extension is None:
|
if extension is None:
|
||||||
extension = pathlib.Path(self.photo.original_filename)
|
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() != "")
|
xmp_str = "\n".join(line for line in xmp_str.split("\n") if line.strip() != "")
|
||||||
return xmp_str
|
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):
|
def _write_sidecar(self, filename, sidecar_str):
|
||||||
"""write sidecar_str to filename
|
"""write sidecar_str to filename
|
||||||
used for exporting sidecar info"""
|
used for exporting sidecar info"""
|
||||||
|
|||||||
@@ -877,33 +877,10 @@ class PhotoInfo:
|
|||||||
|
|
||||||
photopath = None
|
photopath = None
|
||||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||||
if self.live_photo and not self.ismissing:
|
return self._path_live_photo_4()
|
||||||
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
|
|
||||||
elif self.live_photo and self.path and not self.ismissing:
|
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)
|
filename = pathlib.Path(self.path)
|
||||||
photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
|
photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
|
||||||
photopath = str(photopath)
|
photopath = str(photopath)
|
||||||
@@ -917,6 +894,50 @@ class PhotoInfo:
|
|||||||
|
|
||||||
return photopath
|
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
|
@cached_property
|
||||||
def path_derivatives(self):
|
def path_derivatives(self):
|
||||||
"""Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""
|
"""Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""
|
||||||
|
|||||||
Reference in New Issue
Block a user