diff --git a/README.md b/README.md index d3bcf7db..2da28de4 100644 --- a/README.md +++ b/README.md @@ -2981,6 +2981,9 @@ Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the Returns list of PhotoInfo objects for *possible* duplicates or empty list if no matching duplicates. Photos are considered possible duplicates if the photo's original file size, date created, height, and width match another those of another photo. This does not do a byte-for-byte comparison or compute a hash which makes it fast and allows for identification of possible duplicates even if originals are not downloaded from iCloud. The signature-based approach should be robust enough to match duplicates created either through the "duplicate photo" menu item or imported twice into the library but you should not rely on this 100% for identification of all duplicates. +#### `hexdigest` +Returns a unique digest of the photo's properties and metadata; useful for detecting changes in any property/metadata of the photo. + #### `json()` Returns a JSON representation of all photo info. @@ -4174,6 +4177,7 @@ Attributes: * exiftool_error: list of errors generated by exiftool during export * xattr_written: list of files with extended attributes written during export * xattr_skipped: list of files where extended attributes were skipped when update=True +* metadata_changed: list of files where metadata changed since last export * deleted_files: reserved for use by osxphotos CLI * deleted_directories: reserved for use by osxphotos CLI * exported_album: reserved for use by osxphotos CLI diff --git a/examples/post_function.py b/examples/post_function.py index ba5080fd..c2d313d0 100644 --- a/examples/post_function.py +++ b/examples/post_function.py @@ -50,6 +50,7 @@ def post_function( # exported_album: list of tuples of (filename, album_name) for exported files added to album with --add-exported-to-album # skipped_album: list of tuples of (filename, album_name) for skipped files added to album with --add-skipped-to-album # missing_album: list of tuples of (filename, album_name) for missing files added to album with --add-missing-to-album + # metadata_changed: list of filenames that had metadata changes since last export for filename in results.exported: # do your processing here diff --git a/osxphotos/photoexporter.py b/osxphotos/photoexporter.py index 5fdb5285..5f41f182 100644 --- a/osxphotos/photoexporter.py +++ b/osxphotos/photoexporter.py @@ -2,13 +2,11 @@ """ import dataclasses -import hashlib import json import logging import os import pathlib import re -import tempfile import typing as t from collections import namedtuple # pylint: disable=syntax-error from dataclasses import asdict, dataclass @@ -45,14 +43,13 @@ from .photokit import ( from .phototemplate import RenderOptions from .rich_utils import add_rich_markup_tag from .uti import get_preferred_uti_extension -from .utils import increment_filename, lineno, list_directory +from .utils import hexdigest, increment_filename, lineno, list_directory __all__ = [ "ExportError", "ExportOptions", "ExportResults", "PhotoExporter", - "hexdigest", "rename_jpeg_files", ] @@ -266,6 +263,7 @@ class ExportResults: exported_album=None, skipped_album=None, missing_album=None, + metadata_changed=None, ): self.exported = exported or [] self.new = new or [] @@ -292,6 +290,7 @@ class ExportResults: self.exported_album = exported_album or [] self.skipped_album = skipped_album or [] self.missing_album = missing_album or [] + self.metadata_changed = metadata_changed or [] def all_files(self): """return all filenames contained in results""" @@ -342,6 +341,7 @@ class ExportResults: self.exported_album += other.exported_album self.skipped_album += other.skipped_album self.missing_album += other.missing_album + self.metadata_changed += other.metadata_changed return self @@ -371,12 +371,14 @@ class ExportResults: + f",exported_album={self.exported_album}" + f",skipped_album={self.skipped_album}" + f",missing_album={self.missing_album}" + + f",metadata_changed={self.metadata_changed}" + ")" ) class PhotoExporter: """Export a photo""" + def __init__(self, photo: "PhotoInfo", tmpdir: t.Optional[str] = None): self.photo = photo self._render_options = RenderOptions() @@ -739,7 +741,7 @@ class PhotoExporter: return ShouldUpdate.EDITED_SIG_DIFFERENT if options.force_update: - current_digest = hexdigest(self.photo.json()) + current_digest = self.photo.hexdigest if current_digest != file_record.digest: # metadata in Photos changed, force update return ShouldUpdate.DIGEST_DIFFERENT @@ -1179,8 +1181,9 @@ class PhotoExporter: rec.dest_sig = fileutil.file_sig(dest) if options.exiftool: rec.exifdata = self._exiftool_json_sidecar(options) - if options.force_update: - rec.digest = hexdigest(photoinfo) + if self.photo.hexdigest != rec.digest: + results.metadata_changed = [dest_str] + rec.digest = self.photo.hexdigest return results @@ -2011,13 +2014,6 @@ class PhotoExporter: f.close() -def hexdigest(strval): - """hexdigest of a string, using blake2b""" - h = hashlib.blake2b(digest_size=20) - h.update(bytes(strval, "utf-8")) - return h.hexdigest() - - def _check_export_suffix(src, dest, edited): """Helper function for exporting photos to check file extensions of destination path. diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 3dd4bf8b..f689f94b 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -11,6 +11,7 @@ import os import os.path import pathlib from datetime import timedelta, timezone +from functools import cached_property from typing import Optional import yaml @@ -54,7 +55,7 @@ from .scoreinfo import ScoreInfo from .searchinfo import SearchInfo from .text_detection import detect_text from .uti import get_preferred_uti_extension, get_uti_for_extension -from .utils import _get_resource_loc, list_directory +from .utils import _get_resource_loc, hexdigest, list_directory __all__ = ["PhotoInfo", "PhotoInfoNone"] @@ -1356,6 +1357,12 @@ class PhotoInfo: self._exiftool = exiftool return self._exiftool + @cached_property + def hexdigest(self): + """Returns a unique digest of the photo's properties and metadata; + useful for detecting changes in any property/metadata of the photo""" + return hexdigest(self.json()) + def detected_text(self, confidence_threshold=TEXT_DETECTION_CONFIDENCE_THRESHOLD): """Detects text in photo and returns lists of results as (detected text, confidence) diff --git a/osxphotos/utils.py b/osxphotos/utils.py index da100c58..a5e2a137 100644 --- a/osxphotos/utils.py +++ b/osxphotos/utils.py @@ -3,6 +3,7 @@ import datetime import fnmatch import glob +import hashlib import importlib import inspect import logging @@ -29,6 +30,7 @@ __all__ = [ "expand_and_validate_filepath", "get_last_library_path", "get_system_library_path", + "hexdigest", "increment_filename_with_count", "increment_filename", "lineno", @@ -517,3 +519,10 @@ def get_latest_version() -> Tuple[Optional[str], str]: def pluralize(count, singular, plural): """Return singular or plural based on count""" return singular if count == 1 else plural + + +def hexdigest(strval: str) -> str: + """hexdigest of a string, using blake2b""" + h = hashlib.blake2b(digest_size=20) + h.update(bytes(strval, "utf-8")) + return h.hexdigest()