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
|
||||
"""
|
||||
|
||||
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"""
|
||||
|
||||
@@ -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)"""
|
||||
|
||||
Reference in New Issue
Block a user