diff --git a/docs/Makefile b/docs/Makefile index d4bb2cbb..d0c3cbf1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,8 +5,8 @@ # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build +SOURCEDIR = source +BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: diff --git a/docs/build/doctrees/cli.doctree b/docs/build/doctrees/cli.doctree new file mode 100644 index 00000000..8c589b7c Binary files /dev/null and b/docs/build/doctrees/cli.doctree differ diff --git a/docs/build/doctrees/environment.pickle b/docs/build/doctrees/environment.pickle new file mode 100644 index 00000000..3c36d2e1 Binary files /dev/null and b/docs/build/doctrees/environment.pickle differ diff --git a/docs/build/doctrees/index.doctree b/docs/build/doctrees/index.doctree new file mode 100644 index 00000000..5cfe191a Binary files /dev/null and b/docs/build/doctrees/index.doctree differ diff --git a/docs/build/doctrees/modules.doctree b/docs/build/doctrees/modules.doctree new file mode 100644 index 00000000..cf3c71c9 Binary files /dev/null and b/docs/build/doctrees/modules.doctree differ diff --git a/docs/build/doctrees/reference.doctree b/docs/build/doctrees/reference.doctree new file mode 100644 index 00000000..ca6a59ab Binary files /dev/null and b/docs/build/doctrees/reference.doctree differ diff --git a/docs/build/html/.buildinfo b/docs/build/html/.buildinfo new file mode 100644 index 00000000..b96c5bdb --- /dev/null +++ b/docs/build/html/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 547179dc83846d861e5f79c600fa9301 +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/build/html/_modules/index.html b/docs/build/html/_modules/index.html new file mode 100644 index 00000000..e6ae3db5 --- /dev/null +++ b/docs/build/html/_modules/index.html @@ -0,0 +1,105 @@ + + + + +
+ + +
+""" PhotoInfo methods to expose EXIF info from the library """
+
+import logging
+from dataclasses import dataclass
+
+from .._constants import _PHOTOS_4_VERSION
+
+
+@dataclass(frozen=True)
+class ExifInfo:
+ """ EXIF info associated with a photo from the Photos library """
+
+ flash_fired: bool
+ iso: int
+ metering_mode: int
+ sample_rate: int
+ track_format: int
+ white_balance: int
+ aperture: float
+ bit_rate: float
+ duration: float
+ exposure_bias: float
+ focal_length: float
+ fps: float
+ latitude: float
+ longitude: float
+ shutter_speed: float
+ camera_make: str
+ camera_model: str
+ codec: str
+ lens_model: str
+
+
+@property
+def exif_info(self):
+ """ Returns an ExifInfo object with the EXIF data for photo
+ Note: the returned EXIF data is the data Photos stores in the database on import;
+ ExifInfo does not provide access to the EXIF info in the actual image file
+ Some or all of the fields may be None
+ Only valid for Photos 5; on earlier database returns None
+ """
+
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ logging.debug(f"exif_info not implemented for this database version")
+ return None
+
+ try:
+ exif = self._db._db_exifinfo_uuid[self.uuid]
+ exif_info = ExifInfo(
+ iso=exif["ZISO"],
+ flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
+ metering_mode=exif["ZMETERINGMODE"],
+ sample_rate=exif["ZSAMPLERATE"],
+ track_format=exif["ZTRACKFORMAT"],
+ white_balance=exif["ZWHITEBALANCE"],
+ aperture=exif["ZAPERTURE"],
+ bit_rate=exif["ZBITRATE"],
+ duration=exif["ZDURATION"],
+ exposure_bias=exif["ZEXPOSUREBIAS"],
+ focal_length=exif["ZFOCALLENGTH"],
+ fps=exif["ZFPS"],
+ latitude=exif["ZLATITUDE"],
+ longitude=exif["ZLONGITUDE"],
+ shutter_speed=exif["ZSHUTTERSPEED"],
+ camera_make=exif["ZCAMERAMAKE"],
+ camera_model=exif["ZCAMERAMODEL"],
+ codec=exif["ZCODEC"],
+ lens_model=exif["ZLENSMODEL"],
+ )
+ except KeyError:
+ logging.debug(f"Could not find exif record for uuid {self.uuid}")
+ exif_info = ExifInfo(
+ iso=None,
+ flash_fired=None,
+ metering_mode=None,
+ sample_rate=None,
+ track_format=None,
+ white_balance=None,
+ aperture=None,
+ bit_rate=None,
+ duration=None,
+ exposure_bias=None,
+ focal_length=None,
+ fps=None,
+ latitude=None,
+ longitude=None,
+ shutter_speed=None,
+ camera_make=None,
+ camera_model=None,
+ codec=None,
+ lens_model=None,
+ )
+
+ return exif_info
+
+""" Export methods for PhotoInfo
+ The following methods are defined and must be imported into PhotoInfo as instance methods:
+ export
+ export2
+ _export_photo
+ _write_exif_data
+ _exiftool_json_sidecar
+ _get_exif_keywords
+ _get_exif_persons
+ _exiftool_dict
+ _xmp_sidecar
+ _write_sidecar
+ """
+
+# TODO: should this be its own PhotoExporter class?
+# TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask
+
+import glob
+import hashlib
+import json
+import logging
+import os
+import pathlib
+import re
+import tempfile
+from collections import namedtuple # pylint: disable=syntax-error
+
+import photoscript
+from mako.template import Template
+
+# from .._applescript import AppleScript
+from .._constants import (
+ _MAX_IPTC_KEYWORD_LEN,
+ _OSXPHOTOS_NONE_SENTINEL,
+ _TEMPLATE_DIR,
+ _UNKNOWN_PERSON,
+ _XMP_TEMPLATE_NAME,
+ _XMP_TEMPLATE_NAME_BETA,
+ SIDECAR_EXIFTOOL,
+ SIDECAR_JSON,
+ SIDECAR_XMP,
+)
+from .._version import __version__
+from ..datetime_utils import datetime_tz_to_utc
+from ..exiftool import ExifTool
+from ..export_db import ExportDBNoOp
+from ..fileutil import FileUtil
+from ..photokit import (
+ PHOTOS_VERSION_CURRENT,
+ PHOTOS_VERSION_ORIGINAL,
+ PhotoKitFetchFailed,
+ PhotoLibrary,
+)
+from ..utils import findfiles, get_preferred_uti_extension, lineno, noop
+
+# retry if use_photos_export fails the first time (which sometimes it does)
+MAX_PHOTOSCRIPT_RETRIES = 3
+
+
+class ExportError(Exception):
+ """ error during export """
+
+ pass
+
+
+class ExportResults:
+ """ holds export results for export2 """
+
+ def __init__(
+ self,
+ exported=None,
+ new=None,
+ updated=None,
+ skipped=None,
+ exif_updated=None,
+ touched=None,
+ converted_to_jpeg=None,
+ sidecar_json_written=None,
+ sidecar_json_skipped=None,
+ sidecar_exiftool_written=None,
+ sidecar_exiftool_skipped=None,
+ sidecar_xmp_written=None,
+ sidecar_xmp_skipped=None,
+ missing=None,
+ error=None,
+ exiftool_warning=None,
+ exiftool_error=None,
+ xattr_written=None,
+ xattr_skipped=None,
+ ):
+ self.exported = exported or []
+ self.new = new or []
+ self.updated = updated or []
+ self.skipped = skipped or []
+ self.exif_updated = exif_updated or []
+ self.touched = touched or []
+ self.converted_to_jpeg = converted_to_jpeg or []
+ self.sidecar_json_written = sidecar_json_written or []
+ self.sidecar_json_skipped = sidecar_json_skipped or []
+ self.sidecar_exiftool_written = sidecar_exiftool_written or []
+ self.sidecar_exiftool_skipped = sidecar_exiftool_skipped or []
+ self.sidecar_xmp_written = sidecar_xmp_written or []
+ self.sidecar_xmp_skipped = sidecar_xmp_skipped or []
+ self.missing = missing or []
+ self.error = error or []
+ self.exiftool_warning = exiftool_warning or []
+ self.exiftool_error = exiftool_error or []
+ self.xattr_written = xattr_written or []
+ self.xattr_skipped = xattr_skipped or []
+
+ def all_files(self):
+ """ return all filenames contained in results """
+ files = (
+ self.exported
+ + self.new
+ + self.updated
+ + self.skipped
+ + self.exif_updated
+ + self.touched
+ + self.converted_to_jpeg
+ + self.sidecar_json_written
+ + self.sidecar_json_skipped
+ + self.sidecar_exiftool_written
+ + self.sidecar_exiftool_skipped
+ + self.sidecar_xmp_written
+ + self.sidecar_xmp_skipped
+ + self.missing
+ )
+ files += [x[0] for x in self.exiftool_warning]
+ files += [x[0] for x in self.exiftool_error]
+ files += [x[0] for x in self.error]
+
+ files = list(set(files))
+ return files
+
+ def __iadd__(self, other):
+ self.exported += other.exported
+ self.new += other.new
+ self.updated += other.updated
+ self.skipped += other.skipped
+ self.exif_updated += other.exif_updated
+ self.touched += other.touched
+ self.converted_to_jpeg += other.converted_to_jpeg
+ self.sidecar_json_written += other.sidecar_json_written
+ self.sidecar_json_skipped += other.sidecar_json_skipped
+ self.sidecar_exiftool_written += other.sidecar_exiftool_written
+ self.sidecar_exiftool_skipped += other.sidecar_exiftool_skipped
+ self.sidecar_xmp_written += other.sidecar_xmp_written
+ self.sidecar_xmp_skipped += other.sidecar_xmp_skipped
+ self.missing += other.missing
+ self.error += other.error
+ self.exiftool_warning += other.exiftool_warning
+ self.exiftool_error += other.exiftool_error
+ return self
+
+ def __str__(self):
+ return (
+ "ExportResults("
+ + f"exported={self.exported}"
+ + f",new={self.new}"
+ + f",updated={self.updated}"
+ + f",skipped={self.skipped}"
+ + f",exif_updated={self.exif_updated}"
+ + f",touched={self.touched}"
+ + f",converted_to_jpeg={self.converted_to_jpeg}"
+ + f",sidecar_json_written={self.sidecar_json_written}"
+ + f",sidecar_json_skipped={self.sidecar_json_skipped}"
+ + f",sidecar_exiftool_written={self.sidecar_exiftool_written}"
+ + f",sidecar_exiftool_skipped={self.sidecar_exiftool_skipped}"
+ + f",sidecar_xmp_written={self.sidecar_xmp_written}"
+ + f",sidecar_xmp_skipped={self.sidecar_xmp_skipped}"
+ + f",missing={self.missing}"
+ + f",error={self.error}"
+ + f",exiftool_warning={self.exiftool_warning}"
+ + f",exiftool_error={self.exiftool_error}"
+ + ")"
+ )
+
+
+# hexdigest is not a class method, don't import this into PhotoInfo
+def hexdigest(strval):
+ """ hexdigest of a string, using blake2b """
+ h = hashlib.blake2b(digest_size=20)
+ h.update(bytes(strval, "utf-8"))
+ return h.hexdigest()
+
+
+# _export_photo_uuid_applescript is not a class method, don't import this into PhotoInfo
+def _export_photo_uuid_applescript(
+ uuid,
+ dest,
+ filestem=None,
+ original=True,
+ edited=False,
+ live_photo=False,
+ timeout=120,
+ burst=False,
+ dry_run=False,
+):
+ """Export photo to dest path using applescript to control Photos
+ If photo is a live photo, exports both the photo and associated .mov file
+
+ Args:
+ uuid: UUID of photo to export
+ dest: destination path to export to
+ filestem: (string) if provided, exported filename will be named stem.ext
+ where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
+ If not provided, file will be named with whatever name Photos uses
+ If filestem.ext exists, it wil be overwritten
+ original: (boolean) if True, export original image; default = True
+ edited: (boolean) if True, export edited photo; default = False
+ If photo not edited and edited=True, will still export the original image
+ caller must verify image has been edited
+ *Note*: must be called with either edited or original but not both,
+ will raise error if called with both edited and original = True
+ live_photo: (boolean) if True, export associated .mov live photo; default = False
+ timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
+ burst: (boolean) set to True if file is a burst image to avoid Photos export error
+ dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
+
+ Returns: list of paths to exported file(s) or None if export failed
+
+ Raises: ExportError if error during export
+
+ Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
+ has not been edited. This is due to how Photos Applescript interface works.
+ """
+
+ dest = pathlib.Path(dest)
+ if not dest.is_dir():
+ raise ValueError(f"dest {dest} must be a directory")
+
+ if not original ^ edited:
+ raise ValueError(f"edited or original must be True but not both")
+
+ tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
+
+ exported_files = []
+ filename = None
+ try:
+ # I've seen intermittent failures with the PhotoScript export so retry if
+ # export doesn't return anything
+ retries = 0
+ while not exported_files and retries < MAX_PHOTOSCRIPT_RETRIES:
+ photo = photoscript.Photo(uuid)
+ filename = photo.filename
+ exported_files = photo.export(
+ tmpdir.name, original=original, timeout=timeout
+ )
+ retries += 1
+ except Exception as e:
+ raise ExportError(e)
+
+ if not exported_files or not filename:
+ # nothing got exported
+ raise ExportError(f"Could not export photo {uuid}")
+
+ # need to find actual filename as sometimes Photos renames JPG to jpeg on export
+ # may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
+ # TemporaryDirectory will cleanup on return
+ filename_stem = pathlib.Path(filename).stem
+ exported_paths = []
+ for fname in exported_files:
+ path = pathlib.Path(tmpdir.name) / fname
+ if len(exported_files) > 1 and not live_photo and path.suffix.lower() == ".mov":
+ # it's the .mov part of live photo but not requested, so don't export
+ continue
+ if len(exported_files) > 1 and burst and path.stem != filename_stem:
+ # skip any burst photo that's not the one we asked for
+ continue
+ if filestem:
+ # rename the file based on filestem, keeping original extension
+ dest_new = dest / f"{filestem}{path.suffix}"
+ else:
+ # use the name Photos provided
+ dest_new = dest / path.name
+ if not dry_run:
+ FileUtil.copy(str(path), str(dest_new))
+ exported_paths.append(str(dest_new))
+ return exported_paths
+
+
+# _check_export_suffix is not a class method, don't import this into PhotoInfo
+def _check_export_suffix(src, dest, edited):
+ """Helper function for exporting photos to check file extensions of destination path.
+
+ Checks that dst file extension is appropriate for the src.
+ If edited=True, will use src file extension of ".jpeg" if None provided for src.
+
+ Args:
+ src: path to source file or None.
+ dest: path to destination file.
+ edited: set to True if exporting an edited photo.
+
+ Returns:
+ True if src and dest extensions are OK, else False.
+
+ Raises:
+ ValueError if edited is False and src is None
+ """
+
+ # check extension of destination
+ if src is not None:
+ # use suffix from edited file
+ actual_suffix = pathlib.Path(src).suffix
+ elif edited:
+ # use .jpeg as that's probably correct
+ actual_suffix = ".jpeg"
+ else:
+ raise ValueError("src must not be None if edited=False")
+
+ # Photo's often converts .JPG to .jpeg or .tif to .tiff on import
+ dest_ext = dest.suffix.lower()
+ actual_ext = actual_suffix.lower()
+ suffixes = sorted([dest_ext, actual_ext])
+ return (
+ dest_ext == actual_ext
+ or suffixes == [".jpeg", ".jpg"]
+ or suffixes == [".tif", ".tiff"]
+ )
+
+
+# not a class method, don't import into PhotoInfo
+def rename_jpeg_files(files, jpeg_ext, fileutil):
+ """ rename any jpeg files in files so that extension matches jpeg_ext
+
+ Args:
+ files: list of file paths
+ jpeg_ext: extension to use for jpeg files found in files, e.g. "jpg"
+ fileutil: a FileUtil object
+
+ Returns:
+ list of files with updated names
+
+ Note: If non-jpeg files found, they will be ignore and returned in the return list
+ """
+ jpeg_ext = "." + jpeg_ext
+ jpegs = [".jpeg", ".jpg"]
+ new_files = []
+ for file in files:
+ path = pathlib.Path(file)
+ if path.suffix.lower() in jpegs and path.suffix != jpeg_ext:
+ new_file = path.parent / (path.stem + jpeg_ext)
+ fileutil.rename(file, new_file)
+ new_files.append(new_file)
+ else:
+ new_files.append(file)
+ return new_files
+
+
+def export(
+ self,
+ dest,
+ *filename,
+ edited=False,
+ live_photo=False,
+ raw_photo=False,
+ export_as_hardlink=False,
+ overwrite=False,
+ increment=True,
+ sidecar_json=False,
+ sidecar_exiftool=False,
+ sidecar_xmp=False,
+ use_photos_export=False,
+ timeout=120,
+ exiftool=False,
+ use_albums_as_keywords=False,
+ use_persons_as_keywords=False,
+ keyword_template=None,
+ description_template=None,
+):
+ """export photo
+ dest: must be valid destination path (or exception raised)
+ filename: (optional): name of exported picture; if not provided, will use current filename
+ **NOTE**: if provided, user must ensure file extension (suffix) is correct.
+ For example, if photo is .CR2 file, edited image may be .jpeg.
+ If you provide an extension different than what the actual file is,
+ export will print a warning but will export the photo using the
+ incorrect file extension (unless use_photos_export is true, in which case export will
+ use the extension provided by Photos upon export; in this case, an incorrect extension is
+ silently ignored).
+ e.g. to get the extension of the edited photo,
+ reference PhotoInfo.path_edited
+ edited: (boolean, default=False); if True will export the edited version of the photo
+ (or raise exception if no edited version)
+ live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
+ raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
+ export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
+ overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
+ increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
+ if overwrite=False and increment=False, export will fail if destination file already exists
+ sidecar_json: if set will write a json sidecar with data in format readable by exiftool
+ sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
+ sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
+ sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
+ sidecar_xmp: if set will write an XMP sidecar with IPTC data
+ sidecar filename will be dest/filename.xmp
+ use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
+ timeout: (int, default=120) timeout in seconds used with use_photos_export
+ exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
+ returns list of full paths to the exported files
+ use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
+ when exporting metadata with exiftool or sidecar
+ use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
+ when exporting metadata with exiftool or sidecar
+ keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
+ description_template: string; optional template string that will be rendered for use as photo description
+
+ Returns: list of photos exported
+ """
+
+ # Implementation note: calls export2 to actually do the work
+
+ sidecar = 0
+ if sidecar_json:
+ sidecar |= SIDECAR_JSON
+ if sidecar_exiftool:
+ sidecar |= SIDECAR_EXIFTOOL
+ if sidecar_xmp:
+ sidecar |= SIDECAR_XMP
+
+ results = self.export2(
+ dest,
+ *filename,
+ edited=edited,
+ live_photo=live_photo,
+ raw_photo=raw_photo,
+ export_as_hardlink=export_as_hardlink,
+ overwrite=overwrite,
+ increment=increment,
+ sidecar=sidecar,
+ use_photos_export=use_photos_export,
+ timeout=timeout,
+ exiftool=exiftool,
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ )
+
+ return results.exported
+
+
+def export2(
+ self,
+ dest,
+ *filename,
+ edited=False,
+ live_photo=False,
+ raw_photo=False,
+ export_as_hardlink=False,
+ overwrite=False,
+ increment=True,
+ sidecar=0,
+ sidecar_drop_ext=False,
+ use_photos_export=False,
+ timeout=120,
+ exiftool=False,
+ use_albums_as_keywords=False,
+ use_persons_as_keywords=False,
+ keyword_template=None,
+ description_template=None,
+ update=False,
+ ignore_signature=False,
+ export_db=None,
+ fileutil=FileUtil,
+ dry_run=False,
+ touch_file=False,
+ convert_to_jpeg=False,
+ jpeg_quality=1.0,
+ ignore_date_modified=False,
+ use_photokit=False,
+ verbose=None,
+ exiftool_flags=None,
+ merge_exif_keywords=False,
+ merge_exif_persons=False,
+ jpeg_ext=None,
+):
+ """export photo, like export but with update and dry_run options
+ dest: must be valid destination path or exception raised
+ filename: (optional): name of exported picture; if not provided, will use current filename
+ **NOTE**: if provided, user must ensure file extension (suffix) is correct.
+ For example, if photo is .CR2 file, edited image may be .jpeg.
+ If you provide an extension different than what the actual file is,
+ will export the photo using the incorrect file extension (unless use_photos_export is true,
+ in which case export will use the extension provided by Photos upon export.
+ e.g. to get the extension of the edited photo,
+ reference PhotoInfo.path_edited
+ edited: (boolean, default=False); if True will export the edited version of the photo
+ (or raise exception if no edited version)
+ live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
+ raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
+ export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
+ overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
+ increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
+ if overwrite=False and increment=False, export will fail if destination file already exists
+ sidecar: bit field: set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
+ SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool
+ sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
+ SIDECAR_EXIFTOOL: if set will write a json sidecar with data in format readable by exiftool
+ sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
+ SIDECAR_XMP: if set will write an XMP sidecar with IPTC data
+ sidecar filename will be dest/filename.xmp
+ sidecar_drop_ext: (boolean, default=False); if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
+ use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
+ timeout: (int, default=120) timeout in seconds used with use_photos_export
+ exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
+ use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
+ when exporting metadata with exiftool or sidecar
+ use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
+ when exporting metadata with exiftool or sidecar
+ keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
+ description_template: string; optional template string that will be rendered for use as photo description
+ update: (boolean, 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
+ ignore_signature: (bool, default=False), ignore file signature when used with update (look only at filename)
+ export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
+ for getting/setting data related to exported files to compare update state
+ fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
+ dry_run: (boolean, default=False); set to True to run in "dry run" mode
+ touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
+ convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
+ jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
+ ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
+ verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
+ exiftool_flags: optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
+ merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
+ merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
+ jpeg_ext: if set, will use this value for extension on jpegs converted to jpeg with convert_to_jpeg; if not set, uses jpeg; do not include the leading "."
+
+ Returns: ExportResults class
+ 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 dry_run=True and also pass in memory version of export_db,
+ and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
+ """
+
+ # NOTE: This function is very complex and does a lot of things.
+ # Don't modify this code if you don't fully understand everything it does.
+ # TODO: This is a good candidate for refactoring.
+
+ # when called from export(), won't get an export_db, so use no-op version
+ if export_db is None:
+ export_db = ExportDBNoOp()
+
+ if verbose is None:
+ verbose = noop
+ elif not callable(verbose):
+ raise TypeError("verbose must be callable")
+ self._verbose = verbose
+
+ # suffix to add to edited files
+ # e.g. name will be filename_edited.jpg
+ edited_identifier = "_edited"
+
+ # check edited and raise exception trying to export edited version of
+ # photo that hasn't been edited
+ if edited and not self.hasadjustments:
+ raise ValueError(
+ "Photo does not have adjustments, cannot export edited version"
+ )
+
+ # check arguments and get destination path and filename (if provided)
+ if filename and len(filename) > 2:
+ raise TypeError(
+ "Too many positional arguments. Should be at most two: destination, filename."
+ )
+
+ # verify destination is a valid path
+ if dest is None:
+ raise ValueError("Destination must not be None")
+ elif not dry_run and not os.path.isdir(dest):
+ raise FileNotFoundError("Invalid path passed to export")
+
+ if filename and len(filename) == 1:
+ # if filename passed, use it
+ fname = filename[0]
+ else:
+ # no filename provided so use the default
+ # if edited file requested, use filename but add _edited
+ # need to use file extension from edited file as Photos saves a jpeg once edited
+ if edited and not use_photos_export:
+ # verify we have a valid path_edited and use that to get filename
+ if not self.path_edited:
+ raise FileNotFoundError(
+ "edited=True but path_edited is none; hasadjustments: "
+ f" {self.hasadjustments}"
+ )
+ edited_name = pathlib.Path(self.path_edited).name
+ edited_suffix = pathlib.Path(edited_name).suffix
+ fname = pathlib.Path(self.filename).stem + edited_identifier + edited_suffix
+ else:
+ fname = self.filename
+
+ uti = self.uti if edited else self.uti_original
+ if convert_to_jpeg and self.isphoto and uti != "public.jpeg":
+ # not a jpeg but will convert to jpeg upon export so fix file extension
+ fname_new = pathlib.Path(fname)
+ ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
+ fname = str(fname_new.parent / f"{fname_new.stem}{ext}")
+ else:
+ # nothing to convert
+ convert_to_jpeg = False
+
+ # check destination path
+ dest = pathlib.Path(dest)
+ fname = pathlib.Path(fname)
+ dest = dest / fname
+
+ # check to see if file exists and if so, add (1), (2), etc until we find one that works
+ # Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
+ # e.g. exporting sidecar for file1.png and file1.jpeg
+ # if file1.png exists and exporting file1.jpeg,
+ # dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
+ if not update and increment and not overwrite:
+ count = 1
+ dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
+ dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
+ dest_new = dest.stem
+ while dest_new.lower() in dest_files:
+ dest_new = f"{dest.stem} ({count})"
+ count += 1
+ dest = dest.parent / f"{dest_new}{dest.suffix}"
+
+ # if overwrite==False and #increment==False, export should fail if file exists
+ if dest.exists() and not update and not overwrite and not increment:
+ raise FileExistsError(
+ f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
+ )
+
+ all_results = ExportResults()
+ if not use_photos_export:
+ # 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?
+ if edited:
+ if self.path_edited is not None:
+ src = self.path_edited
+ else:
+ raise FileNotFoundError(
+ f"Cannot export edited photo if path_edited is None"
+ )
+ else:
+ if self.path is not None:
+ src = self.path
+ else:
+ raise FileNotFoundError("Cannot export photo if path is None")
+
+ if not os.path.isfile(src):
+ raise FileNotFoundError(f"{src} does not appear to exist")
+
+ # found source now try to find right destination
+ if update and dest.exists():
+ # destination exists, check to see if destination is the right UUID
+ dest_uuid = export_db.get_uuid_for_file(dest)
+ if dest_uuid is None and fileutil.cmp(src, dest):
+ # might be exporting into a pre-ExportDB folder or the DB got deleted
+ dest_uuid = self.uuid
+ export_db.set_data(
+ filename=dest,
+ uuid=self.uuid,
+ orig_stat=fileutil.file_sig(dest),
+ exif_stat=(None, None, None),
+ converted_stat=(None, None, None),
+ edited_stat=(None, None, None),
+ info_json=self.json(),
+ exif_json=None,
+ )
+ if dest_uuid != self.uuid:
+ # not the right file, find the right one
+ count = 1
+ glob_str = str(dest.parent / f"{dest.stem} (*{dest.suffix}")
+ dest_files = glob.glob(glob_str)
+ found_match = False
+ for file_ in dest_files:
+ dest_uuid = export_db.get_uuid_for_file(file_)
+ if dest_uuid == self.uuid:
+ dest = pathlib.Path(file_)
+ found_match = True
+ break
+ elif dest_uuid is None and fileutil.cmp(src, file_):
+ # files match, update the UUID
+ dest = pathlib.Path(file_)
+ found_match = True
+ export_db.set_data(
+ filename=dest,
+ uuid=self.uuid,
+ orig_stat=fileutil.file_sig(dest),
+ exif_stat=(None, None, None),
+ converted_stat=(None, None, None),
+ edited_stat=(None, None, None),
+ info_json=self.json(),
+ exif_json=None,
+ )
+ break
+
+ if not found_match:
+ # increment the destination file
+ count = 1
+ glob_str = str(dest.parent / f"{dest.stem}*")
+ dest_files = glob.glob(glob_str)
+ dest_files = [pathlib.Path(f).stem for f in dest_files]
+ dest_new = dest.stem
+ while dest_new in dest_files:
+ dest_new = f"{dest.stem} ({count})"
+ count += 1
+ dest = dest.parent / f"{dest_new}{dest.suffix}"
+
+ # export the dest file
+ results = self._export_photo(
+ src,
+ dest,
+ update,
+ export_db,
+ overwrite,
+ export_as_hardlink,
+ exiftool,
+ touch_file,
+ convert_to_jpeg,
+ fileutil=fileutil,
+ edited=edited,
+ jpeg_quality=jpeg_quality,
+ ignore_signature=ignore_signature,
+ )
+ all_results += results
+
+ # copy live photo associated .mov if requested
+ if live_photo and self.live_photo:
+ live_name = dest.parent / f"{dest.stem}.mov"
+ src_live = self.path_live_photo
+
+ if src_live is not None:
+ results = self._export_photo(
+ src_live,
+ live_name,
+ update,
+ export_db,
+ overwrite,
+ export_as_hardlink,
+ exiftool,
+ touch_file,
+ False,
+ fileutil=fileutil,
+ ignore_signature=ignore_signature,
+ )
+ all_results += results
+
+ # copy associated RAW image if requested
+ if raw_photo and self.has_raw:
+ raw_path = pathlib.Path(self.path_raw)
+ raw_ext = raw_path.suffix
+ raw_name = dest.parent / f"{dest.stem}{raw_ext}"
+ if raw_path is not None:
+ results = self._export_photo(
+ raw_path,
+ raw_name,
+ update,
+ export_db,
+ overwrite,
+ export_as_hardlink,
+ exiftool,
+ touch_file,
+ convert_to_jpeg,
+ fileutil=fileutil,
+ jpeg_quality=jpeg_quality,
+ ignore_signature=ignore_signature,
+ )
+ all_results += results
+ else:
+ # TODO: move this big if/else block to separate functions
+ # e.g. _export_with_photos_export or such
+ # use_photo_export
+ # export live_photo .mov file?
+ live_photo = True if live_photo and self.live_photo else False
+ if edited or self.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
+ if filename:
+ # use filename stem provided
+ filestem = dest.stem
+ else:
+ # didn't get passed a filename, add _edited
+ filestem = f"{dest.stem}{edited_identifier}"
+ uti = self.uti_edited if edited and self.uti_edited else self.uti
+ ext = get_preferred_uti_extension(uti)
+ dest = dest.parent / f"{filestem}{ext}"
+
+ if use_photokit:
+ photolib = PhotoLibrary()
+ photo = None
+ try:
+ photo = photolib.fetch_uuid(self.uuid)
+ except PhotoKitFetchFailed as e:
+ # if failed to find UUID, might be a burst photo
+ if self.burst and self._info["burstUUID"]:
+ bursts = photolib.fetch_burst_uuid(
+ self._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.uuid)]
+ photo = photo[0] if photo else None
+ if not photo:
+ all_results.error.append(
+ (
+ str(dest),
+ f"PhotoKitFetchFailed exception exporting photo {self.uuid}: {e} ({lineno(__file__)})",
+ )
+ )
+ if photo:
+ if not dry_run:
+ try:
+ exported = photo.export(
+ dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
+ )
+ 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.uuid,
+ dest.parent,
+ filestem=filestem,
+ original=False,
+ edited=True,
+ live_photo=live_photo,
+ timeout=timeout,
+ burst=self.burst,
+ dry_run=dry_run,
+ )
+ 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
+ filestem = dest.stem
+ if use_photokit:
+ photolib = PhotoLibrary()
+ photo = None
+ try:
+ photo = photolib.fetch_uuid(self.uuid)
+ except PhotoKitFetchFailed:
+ # if failed to find UUID, might be a burst photo
+ if self.burst and self._info["burstUUID"]:
+ bursts = photolib.fetch_burst_uuid(
+ self._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.uuid)]
+ photo = photo[0] if photo else None
+ if photo:
+ if not dry_run:
+ try:
+ exported = photo.export(
+ dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
+ )
+ 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.uuid,
+ dest.parent,
+ filestem=filestem,
+ original=True,
+ edited=False,
+ live_photo=live_photo,
+ timeout=timeout,
+ burst=self.burst,
+ dry_run=dry_run,
+ )
+ all_results.exported.extend(exported)
+ except ExportError as e:
+ all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
+ if all_results.exported:
+ if 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, jpeg_ext, fileutil
+ )
+ if touch_file:
+ for exported_file in all_results.exported:
+ all_results.touched.append(exported_file)
+ ts = int(self.date.timestamp())
+ fileutil.utime(exported_file, (ts, ts))
+ if update:
+ all_results.new.extend(all_results.exported)
+
+ # export metadata
+ sidecars = []
+ sidecar_json_files_skipped = []
+ sidecar_json_files_written = []
+ sidecar_exiftool_files_skipped = []
+ sidecar_exiftool_files_written = []
+ sidecar_xmp_files_skipped = []
+ sidecar_xmp_files_written = []
+
+ dest_suffix = "" if sidecar_drop_ext else dest.suffix
+ if sidecar & SIDECAR_JSON:
+ sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
+ sidecar_str = self._exiftool_json_sidecar(
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ filename=dest.name,
+ )
+ sidecars.append(
+ (
+ sidecar_filename,
+ sidecar_str,
+ sidecar_json_files_written,
+ sidecar_json_files_skipped,
+ "JSON",
+ )
+ )
+
+ if sidecar & SIDECAR_EXIFTOOL:
+ sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
+ sidecar_str = self._exiftool_json_sidecar(
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ tag_groups=False,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ filename=dest.name,
+ )
+ sidecars.append(
+ (
+ sidecar_filename,
+ sidecar_str,
+ sidecar_exiftool_files_written,
+ sidecar_exiftool_files_skipped,
+ "exiftool",
+ )
+ )
+
+ if sidecar & SIDECAR_XMP:
+ sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.xmp")
+ sidecar_str = self._xmp_sidecar(
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ extension=dest.suffix[1:] if dest.suffix else None,
+ )
+ sidecars.append(
+ (
+ sidecar_filename,
+ sidecar_str,
+ sidecar_xmp_files_written,
+ sidecar_xmp_files_skipped,
+ "XMP",
+ )
+ )
+
+ for data in sidecars:
+ sidecar_filename = data[0]
+ sidecar_str = data[1]
+ files_written = data[2]
+ files_skipped = data[3]
+ sidecar_type = data[4]
+
+ sidecar_digest = hexdigest(sidecar_str)
+ old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
+ sidecar_filename
+ )
+ write_sidecar = (
+ not update
+ or (update and not sidecar_filename.exists())
+ or (
+ update
+ and (sidecar_digest != old_sidecar_digest)
+ or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
+ )
+ )
+ if write_sidecar:
+ verbose(f"Writing {sidecar_type} sidecar {sidecar_filename}")
+ files_written.append(str(sidecar_filename))
+ if not dry_run:
+ self._write_sidecar(sidecar_filename, sidecar_str)
+ export_db.set_sidecar_for_file(
+ sidecar_filename,
+ sidecar_digest,
+ fileutil.file_sig(sidecar_filename),
+ )
+ else:
+ verbose(f"Skipped up to date {sidecar_type} sidecar {sidecar_filename}")
+ files_skipped.append(str(sidecar_filename))
+
+ # if exiftool, write the metadata
+ if update:
+ exif_files = all_results.new + all_results.updated + all_results.skipped
+ else:
+ exif_files = all_results.exported
+
+ # TODO: remove duplicative code from below
+ if exiftool and update and exif_files:
+ for exported_file in exif_files:
+ files_are_different = False
+ old_data = export_db.get_exifdata_for_file(exported_file)
+ if old_data is not None:
+ old_data = json.loads(old_data)[0]
+ current_data = json.loads(
+ self._exiftool_json_sidecar(
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ )
+ )[0]
+ if old_data != current_data:
+ files_are_different = True
+
+ if old_data is None or files_are_different:
+ # didn't have old data, assume we need to write it
+ # or files were different
+ verbose(f"Writing metadata with exiftool for {exported_file}")
+ if not dry_run:
+ warning_, error_ = self._write_exif_data(
+ exported_file,
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ flags=exiftool_flags,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ )
+ if warning_:
+ all_results.exiftool_warning.append((exported_file, warning_))
+ if error_:
+ all_results.exiftool_error.append((exported_file, error_))
+ all_results.error.append((exported_file, error_))
+
+ export_db.set_exifdata_for_file(
+ exported_file,
+ self._exiftool_json_sidecar(
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ ),
+ )
+ export_db.set_stat_exif_for_file(
+ exported_file, fileutil.file_sig(exported_file)
+ )
+ all_results.exif_updated.append(exported_file)
+ else:
+ verbose(f"Skipped up to date exiftool metadata for {exported_file}")
+ elif exiftool and exif_files:
+ for exported_file in exif_files:
+ verbose(f"Writing metadata with exiftool for {exported_file}")
+ if not dry_run:
+ warning_, error_ = self._write_exif_data(
+ exported_file,
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ flags=exiftool_flags,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ )
+ if warning_:
+ all_results.exiftool_warning.append((exported_file, warning_))
+ if error_:
+ all_results.exiftool_error.append((exported_file, error_))
+ all_results.error.append((exported_file, error_))
+
+ export_db.set_exifdata_for_file(
+ exported_file,
+ self._exiftool_json_sidecar(
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ ),
+ )
+ export_db.set_stat_exif_for_file(
+ exported_file, fileutil.file_sig(exported_file)
+ )
+ all_results.exif_updated.append(exported_file)
+
+ if touch_file:
+ for exif_file in all_results.exif_updated:
+ verbose(f"Updating file modification time for {exif_file}")
+ all_results.touched.append(exif_file)
+ ts = int(self.date.timestamp())
+ fileutil.utime(exif_file, (ts, ts))
+
+ all_results.touched = list(set(all_results.touched))
+
+ all_results.sidecar_json_written = sidecar_json_files_written
+ all_results.sidecar_json_skipped = sidecar_json_files_skipped
+ all_results.sidecar_exiftool_written = sidecar_exiftool_files_written
+ all_results.sidecar_exiftool_skipped = sidecar_exiftool_files_skipped
+ all_results.sidecar_xmp_written = sidecar_xmp_files_written
+ all_results.sidecar_xmp_skipped = sidecar_xmp_files_skipped
+
+ return all_results
+
+
+def _export_photo(
+ self,
+ src,
+ dest,
+ update,
+ export_db,
+ overwrite,
+ export_as_hardlink,
+ exiftool,
+ touch_file,
+ convert_to_jpeg,
+ fileutil=FileUtil,
+ edited=False,
+ jpeg_quality=1.0,
+ ignore_signature=None,
+):
+ """Helper function for export()
+ Does the actual copy or hardlink taking the appropriate
+ action depending on update, overwrite, export_as_hardlink
+ Assumes destination is the right destination (e.g. UUID matches)
+ sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido
+
+ Args:
+ src: src path (string)
+ dest: dest path (pathlib.Path)
+ update: bool
+ export_db: instance of ExportDB that conforms to ExportDB_ABC interface
+ overwrite: bool
+ export_as_hardlink: bool
+ exiftool: bool
+ touch_file: bool
+ convert_to_jpeg: bool; if True, convert file to jpeg on export
+ fileutil: FileUtil class that conforms to fileutil.FileUtilABC
+ edited: bool; set to True if exporting edited version of photo
+ jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
+ ignore_signature: bool, ignore file signature when used with update (look only at filename)
+
+ Returns:
+ ExportResults
+
+ Raises:
+ ValueError if export_as_hardlink and convert_to_jpeg both True
+ """
+
+ if export_as_hardlink and convert_to_jpeg:
+ raise ValueError("export_as_hardlink and convert_to_jpeg cannot both be True")
+
+ exported_files = []
+ update_updated_files = []
+ update_new_files = []
+ update_skipped_files = []
+ touched_files = []
+ converted_to_jpeg_files = []
+
+ dest_str = str(dest)
+ dest_exists = dest.exists()
+
+ if update: # updating
+ cmp_touch, cmp_orig = False, False
+ if dest_exists:
+ # update, destination exists, but we might not need to replace it...
+ if ignore_signature:
+ cmp_orig = True
+ cmp_touch = fileutil.cmp(src, dest, mtime1=int(self.date.timestamp()))
+ elif exiftool:
+ sig_exif = export_db.get_stat_exif_for_file(dest_str)
+ cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif)
+ sig_exif = (sig_exif[0], sig_exif[1], int(self.date.timestamp()))
+ cmp_touch = fileutil.cmp_file_sig(dest_str, sig_exif)
+ elif convert_to_jpeg:
+ sig_converted = export_db.get_stat_converted_for_file(dest_str)
+ cmp_orig = fileutil.cmp_file_sig(dest_str, sig_converted)
+ sig_converted = (
+ sig_converted[0],
+ sig_converted[1],
+ int(self.date.timestamp()),
+ )
+ cmp_touch = fileutil.cmp_file_sig(dest_str, sig_converted)
+ else:
+ cmp_orig = fileutil.cmp(src, dest)
+ cmp_touch = fileutil.cmp(src, dest, mtime1=int(self.date.timestamp()))
+
+ sig_cmp = cmp_touch if touch_file else cmp_orig
+
+ if edited:
+ # requested edited version of photo
+ # need to see if edited version in Photos library has changed
+ # (e.g. it's been edited again)
+ sig_edited = export_db.get_stat_edited_for_file(dest_str)
+ cmp_edited = (
+ fileutil.cmp_file_sig(src, sig_edited)
+ if sig_edited != (None, None, None)
+ else False
+ )
+ sig_cmp = sig_cmp and cmp_edited
+
+ if (export_as_hardlink and dest.samefile(src)) or (
+ not export_as_hardlink and not dest.samefile(src) and sig_cmp
+ ):
+ # destination exists and signatures match, skip it
+ update_skipped_files.append(dest_str)
+ else:
+ # destination exists but signature is different
+ if touch_file and cmp_orig and not cmp_touch:
+ # destination exists, signature matches original but does not match expected touch time
+ # skip exporting but update touch time
+ update_skipped_files.append(dest_str)
+ touched_files.append(dest_str)
+ elif not touch_file and cmp_touch and not cmp_orig:
+ # destination exists, signature matches expected touch but not original
+ # user likely exported with touch_file and is now exporting without touch_file
+ # don't update the file because it's same but leave touch time
+ update_skipped_files.append(dest_str)
+ else:
+ # destination exists but is different
+ update_updated_files.append(dest_str)
+ if touch_file:
+ touched_files.append(dest_str)
+
+ else:
+ # update, destination doesn't exist (new file)
+ update_new_files.append(dest_str)
+ if touch_file:
+ touched_files.append(dest_str)
+ else:
+ # not update, export the file
+ exported_files.append(dest_str)
+ if touch_file:
+ sig = fileutil.file_sig(src)
+ sig = (sig[0], sig[1], int(self.date.timestamp()))
+ if not fileutil.cmp_file_sig(src, sig):
+ touched_files.append(dest_str)
+ if not update_skipped_files:
+ converted_stat = (None, None, None)
+ edited_stat = fileutil.file_sig(src) if edited else (None, None, None)
+ if dest_exists and (update or overwrite):
+ # need to remove the destination first
+ fileutil.unlink(dest)
+ if export_as_hardlink:
+ fileutil.hardlink(src, dest)
+ elif convert_to_jpeg:
+ # use convert_to_jpeg to export the file
+ fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality)
+ converted_stat = fileutil.file_sig(dest_str)
+ converted_to_jpeg_files.append(dest_str)
+ else:
+ fileutil.copy(src, dest_str)
+
+ export_db.set_data(
+ filename=dest_str,
+ uuid=self.uuid,
+ orig_stat=fileutil.file_sig(dest_str),
+ exif_stat=(None, None, None),
+ converted_stat=converted_stat,
+ edited_stat=edited_stat,
+ info_json=self.json(),
+ exif_json=None,
+ )
+
+ if touched_files:
+ ts = int(self.date.timestamp())
+ fileutil.utime(dest, (ts, ts))
+
+ return ExportResults(
+ exported=exported_files + update_new_files + update_updated_files,
+ new=update_new_files,
+ updated=update_updated_files,
+ skipped=update_skipped_files,
+ exif_updated=[],
+ touched=touched_files,
+ converted_to_jpeg=converted_to_jpeg_files,
+ sidecar_json_written=[],
+ sidecar_json_skipped=[],
+ sidecar_exiftool_written=[],
+ sidecar_exiftool_skipped=[],
+ sidecar_xmp_written=[],
+ sidecar_xmp_skipped=[],
+ missing=[],
+ error=[],
+ )
+
+
+def _write_exif_data(
+ self,
+ filepath,
+ use_albums_as_keywords=False,
+ use_persons_as_keywords=False,
+ keyword_template=None,
+ description_template=None,
+ ignore_date_modified=False,
+ flags=None,
+ merge_exif_keywords=False,
+ merge_exif_persons=False,
+):
+ """write exif data to image file at filepath
+
+ Args:
+ filepath: full path to the image file
+ use_albums_as_keywords: treat album names as keywords
+ use_persons_as_keywords: treat person names as keywords
+ keyword_template: (list of strings); list of template strings to render as keywords
+ ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
+ flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
+
+ Returns:
+ (warning, error) of warning and error strings if exiftool produces warnings or errors
+ """
+ if not os.path.exists(filepath):
+ raise FileNotFoundError(f"Could not find file {filepath}")
+ exif_info = self._exiftool_dict(
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ )
+
+ with ExifTool(filepath, flags=flags, exiftool=self._db._exiftool_path) as exiftool:
+ for exiftag, val in exif_info.items():
+ if type(val) == list:
+ for v in val:
+ exiftool.setvalue(exiftag, v)
+ else:
+ exiftool.setvalue(exiftag, val)
+ return exiftool.warning, exiftool.error
+
+
+def _exiftool_dict(
+ self,
+ use_albums_as_keywords=False,
+ use_persons_as_keywords=False,
+ keyword_template=None,
+ description_template=None,
+ ignore_date_modified=False,
+ merge_exif_keywords=False,
+ merge_exif_persons=False,
+ filename=None,
+):
+ """Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
+ Does not include all the EXIF fields as those are likely already in the image.
+
+ Args:
+ filename: name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included
+ use_albums_as_keywords: treat album names as keywords
+ use_persons_as_keywords: treat person names as keywords
+ keyword_template: (list of strings); list of template strings to render as keywords
+ description_template: (list of strings); list of template strings to render for the description
+ ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
+ merge_exif_keywords: merge keywords in the file's exif metadata (requires exiftool)
+ merge_exif_persons: merge persons in the file's exif metadata (requires exiftool)
+
+ Returns: dict with exiftool tags / values
+
+ Exports the following:
+ EXIF:ImageDescription (may include template)
+ XMP:Description (may include template)
+ XMP:Title
+ XMP:TagsList (may include album name, person name, or template)
+ IPTC:Keywords (may include album name, person name, or template)
+ XMP:Subject (set to keywords + persons)
+ XMP:PersonInImage
+ EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
+ EXIF:GPSLatitude, EXIF:GPSLongitude
+ EXIF:GPSPosition
+ EXIF:DateTimeOriginal
+ EXIF:OffsetTimeOriginal
+ EXIF:ModifyDate
+ IPTC:DateCreated
+ IPTC:TimeCreated
+ QuickTime:CreationDate
+ QuickTime:CreateDate (UTC)
+ QuickTime:ModifyDate (UTC)
+ QuickTime:GPSCoordinates
+ UserData:GPSCoordinates
+ """
+
+ exif = (
+ {
+ "SourceFile": filename,
+ "ExifTool:ExifToolVersion": "12.00",
+ "File:FileName": filename,
+ }
+ if filename is not None
+ else {}
+ )
+
+ if description_template is not None:
+ rendered = self.render_template(
+ description_template, expand_inplace=True, inplace_sep=", "
+ )[0]
+ description = " ".join(rendered) if rendered else ""
+ exif["EXIF:ImageDescription"] = description
+ exif["XMP:Description"] = description
+ elif self.description:
+ exif["EXIF:ImageDescription"] = self.description
+ exif["XMP:Description"] = self.description
+
+ if self.title:
+ exif["XMP:Title"] = self.title
+
+ keyword_list = []
+ if merge_exif_keywords:
+ keyword_list.extend(self._get_exif_keywords())
+
+ if self.keywords:
+ keyword_list.extend(self.keywords)
+
+ person_list = []
+ if merge_exif_persons:
+ person_list.extend(self._get_exif_persons())
+
+ if self.persons:
+ # filter out _UNKNOWN_PERSON
+ person_list.extend([p for p in self.persons if p != _UNKNOWN_PERSON])
+
+ if use_persons_as_keywords and person_list:
+ keyword_list.extend(person_list)
+
+ if use_albums_as_keywords and self.albums:
+ keyword_list.extend(self.albums)
+
+ if keyword_template:
+ rendered_keywords = []
+ for template_str in keyword_template:
+ rendered, unmatched = self.render_template(
+ template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
+ )
+ if unmatched:
+ logging.warning(
+ f"Unmatched template substitution for template: {template_str} {unmatched}"
+ )
+ rendered_keywords.extend(rendered)
+
+ # filter out any template values that didn't match by looking for sentinel
+ rendered_keywords = [
+ keyword
+ for keyword in sorted(rendered_keywords)
+ if _OSXPHOTOS_NONE_SENTINEL not in keyword
+ ]
+
+ # check to see if any keywords too long
+ long_keywords = [
+ long_str
+ for long_str in rendered_keywords
+ if len(long_str) > _MAX_IPTC_KEYWORD_LEN
+ ]
+ if long_keywords:
+ logging.warning(
+ f"Some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN}: {long_keywords}"
+ )
+
+ keyword_list.extend(rendered_keywords)
+
+ if keyword_list:
+ # remove duplicates
+ keyword_list = sorted(list(set([str(keyword) for keyword in keyword_list])))
+ exif["IPTC:Keywords"] = keyword_list.copy()
+ exif["XMP:Subject"] = keyword_list.copy()
+ exif["XMP:TagsList"] = keyword_list.copy()
+
+ if person_list:
+ person_list = sorted(list(set(person_list)))
+ exif["XMP:PersonInImage"] = person_list.copy()
+
+ # if self.favorite():
+ # exif["Rating"] = 5
+
+ (lat, lon) = self.location
+ if lat is not None and lon is not None:
+ if self.isphoto:
+ exif["EXIF:GPSLatitude"] = lat
+ exif["EXIF:GPSLongitude"] = lon
+ lat_ref = "N" if lat >= 0 else "S"
+ lon_ref = "E" if lon >= 0 else "W"
+ exif["EXIF:GPSLatitudeRef"] = lat_ref
+ exif["EXIF:GPSLongitudeRef"] = lon_ref
+ elif self.ismovie:
+ exif["Keys:GPSCoordinates"] = f"{lat} {lon}"
+ exif["UserData:GPSCoordinates"] = f"{lat} {lon}"
+
+ # process date/time and timezone offset
+ # Photos exports the following fields and sets modify date to creation date
+ # [EXIF] Modify Date : 2020:10:30 00:00:00
+ # [EXIF] Date/Time Original : 2020:10:30 00:00:00
+ # [EXIF] Create Date : 2020:10:30 00:00:00
+ # [IPTC] Digital Creation Date : 2020:10:30
+ # [IPTC] Date Created : 2020:10:30
+ #
+ # for videos:
+ # [QuickTime] CreateDate : 2020:12:11 06:10:10
+ # [QuickTime] ModifyDate : 2020:12:11 06:10:10
+ # [Keys] CreationDate : 2020:12:10 22:10:10-08:00
+ # This code deviates from Photos in one regard:
+ # if photo has modification date, use it otherwise use creation date
+
+ date = self.date
+ offsettime = date.strftime("%z")
+ # find timezone offset in format "-04:00"
+ offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
+ offset = offset[0] # findall returns list of tuples
+ offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
+
+ # exiftool expects format to "2015:01:18 12:00:00"
+ datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
+
+ if self.isphoto:
+ exif["EXIF:DateTimeOriginal"] = datetimeoriginal
+ exif["EXIF:CreateDate"] = datetimeoriginal
+ exif["EXIF:OffsetTimeOriginal"] = offsettime
+
+ dateoriginal = date.strftime("%Y:%m:%d")
+ exif["IPTC:DateCreated"] = dateoriginal
+
+ timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
+ exif["IPTC:TimeCreated"] = timeoriginal
+
+ if self.date_modified is not None and not ignore_date_modified:
+ exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
+ else:
+ exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
+ elif self.ismovie:
+ # QuickTime spec specifies times in UTC
+ # QuickTime:CreateDate and ModifyDate are in UTC w/ no timezone
+ # QuickTime:CreationDate must include time offset or Photos shows invalid values
+ # reference: https://exiftool.org/TagNames/QuickTime.html#Keys
+ # https://exiftool.org/forum/index.php?topic=11927.msg64369#msg64369
+ exif["QuickTime:CreationDate"] = f"{datetimeoriginal}{offsettime}"
+
+ date_utc = datetime_tz_to_utc(date)
+ creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S")
+ exif["QuickTime:CreateDate"] = creationdate
+ if self.date_modified is None or ignore_date_modified:
+ exif["QuickTime:ModifyDate"] = creationdate
+ else:
+ exif["QuickTime:ModifyDate"] = datetime_tz_to_utc(
+ self.date_modified
+ ).strftime("%Y:%m:%d %H:%M:%S")
+ return exif
+
+
+def _get_exif_keywords(self):
+ """ returns list of keywords found in the file's exif metadata """
+ keywords = []
+ exif = self.exiftool
+ if exif:
+ exifdict = exif.asdict()
+ for field in ["IPTC:Keywords", "XMP:TagsList", "XMP:Subject"]:
+ try:
+ kw = exifdict[field]
+ if kw and type(kw) != list:
+ kw = [kw]
+ kw = [str(k) for k in kw]
+ keywords.extend(kw)
+ except KeyError:
+ pass
+ return keywords
+
+
+def _get_exif_persons(self):
+ """ returns list of persons found in the file's exif metadata """
+ persons = []
+ exif = self.exiftool
+ if exif:
+ exifdict = exif.asdict()
+ try:
+ p = exifdict["XMP:PersonInImage"]
+ if p and type(p) != list:
+ p = [p]
+ p = [str(p_) for p_ in p]
+ persons.extend(p)
+ except KeyError:
+ pass
+ return persons
+
+
+def _exiftool_json_sidecar(
+ self,
+ use_albums_as_keywords=False,
+ use_persons_as_keywords=False,
+ keyword_template=None,
+ description_template=None,
+ ignore_date_modified=False,
+ tag_groups=True,
+ merge_exif_keywords=False,
+ merge_exif_persons=False,
+ filename=None,
+):
+ """Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
+ Does not include all the EXIF fields as those are likely already in the image.
+
+ Args:
+ use_albums_as_keywords: treat album names as keywords
+ use_persons_as_keywords: treat person names as keywords
+ keyword_template: (list of strings); list of template strings to render as keywords
+ description_template: (list of strings); list of template strings to render for the description
+ ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
+ tag_groups: if True, tags are in form Group:TagName, e.g. IPTC:Keywords, otherwise group name is omitted, e.g. Keywords
+ merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
+ merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
+ filename: filename of the destination image file for including in exiftool signature in JSON sidecar
+
+ Returns: dict with exiftool tags / values
+
+ Exports the following:
+ EXIF:ImageDescription
+ XMP:Description (may include template)
+ XMP:Title
+ XMP:TagsList
+ IPTC:Keywords (may include album name, person name, or template)
+ XMP:Subject (set to keywords + person)
+ XMP:PersonInImage
+ EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
+ EXIF:GPSLatitude, EXIF:GPSLongitude
+ EXIF:GPSPosition
+ EXIF:DateTimeOriginal
+ EXIF:OffsetTimeOriginal
+ EXIF:ModifyDate
+ IPTC:DigitalCreationDate
+ IPTC:DateCreated
+ QuickTime:CreationDate
+ QuickTime:CreateDate (UTC)
+ QuickTime:ModifyDate (UTC)
+ QuickTime:GPSCoordinates
+ UserData:GPSCoordinates
+ """
+ exif = self._exiftool_dict(
+ use_albums_as_keywords=use_albums_as_keywords,
+ use_persons_as_keywords=use_persons_as_keywords,
+ keyword_template=keyword_template,
+ description_template=description_template,
+ ignore_date_modified=ignore_date_modified,
+ merge_exif_keywords=merge_exif_keywords,
+ merge_exif_persons=merge_exif_persons,
+ filename=filename,
+ )
+
+ if not tag_groups:
+ # strip tag groups
+ exif_new = {}
+ for k, v in exif.items():
+ k = re.sub(r".*:", "", k)
+ exif_new[k] = v
+ exif = exif_new
+
+ return json.dumps([exif])
+
+
+def _xmp_sidecar(
+ self,
+ use_albums_as_keywords=False,
+ use_persons_as_keywords=False,
+ keyword_template=None,
+ description_template=None,
+ extension=None,
+ merge_exif_keywords=False,
+ merge_exif_persons=False,
+):
+ """returns string for XMP sidecar
+ use_albums_as_keywords: treat album names as keywords
+ use_persons_as_keywords: treat person names as keywords
+ keyword_template: (list of strings); list of template strings to render as keywords
+ description_template: string; optional template string that will be rendered for use as photo description
+ extension: which extension to use for SidecarForExtension property
+ merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
+ merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
+ """
+
+ xmp_template_file = (
+ _XMP_TEMPLATE_NAME if not self._db._beta else _XMP_TEMPLATE_NAME_BETA
+ )
+ xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, xmp_template_file))
+
+ if extension is None:
+ extension = pathlib.Path(self.original_filename)
+ extension = extension.suffix[1:] if extension.suffix else None
+
+ if description_template is not None:
+ rendered = self.render_template(
+ description_template, expand_inplace=True, inplace_sep=", "
+ )[0]
+ description = " ".join(rendered) if rendered else ""
+ else:
+ description = self.description if self.description is not None else ""
+
+ keyword_list = []
+ if merge_exif_keywords:
+ keyword_list.extend(self._get_exif_keywords())
+
+ if self.keywords:
+ keyword_list.extend(self.keywords)
+
+ # TODO: keyword handling in this and _exiftool_json_sidecar is
+ # good candidate for pulling out in a function
+
+ person_list = []
+ if merge_exif_persons:
+ person_list.extend(self._get_exif_persons())
+
+ if self.persons:
+ # filter out _UNKNOWN_PERSON
+ person_list.extend([p for p in self.persons if p != _UNKNOWN_PERSON])
+
+ if use_persons_as_keywords and person_list:
+ keyword_list.extend(person_list)
+
+ if use_albums_as_keywords and self.albums:
+ keyword_list.extend(self.albums)
+
+ if keyword_template:
+ rendered_keywords = []
+ for template_str in keyword_template:
+ rendered, unmatched = self.render_template(
+ template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
+ )
+ if unmatched:
+ logging.warning(
+ f"Unmatched template substitution for template: {template_str} {unmatched}"
+ )
+ rendered_keywords.extend(rendered)
+
+ # filter out any template values that didn't match by looking for sentinel
+ rendered_keywords = [
+ keyword
+ for keyword in rendered_keywords
+ if _OSXPHOTOS_NONE_SENTINEL not in keyword
+ ]
+
+ # check to see if any keywords too long
+ long_keywords = [
+ long_str
+ for long_str in rendered_keywords
+ if len(long_str) > _MAX_IPTC_KEYWORD_LEN
+ ]
+ if long_keywords:
+ logging.warning(
+ f"Some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN}: {long_keywords}"
+ )
+
+ keyword_list.extend(rendered_keywords)
+
+ # remove duplicates
+ # sorted mainly to make testing the XMP file easier
+ if keyword_list:
+ keyword_list = sorted(list(set(keyword_list)))
+ if person_list:
+ person_list = sorted(list(set(person_list)))
+
+ subject_list = keyword_list
+
+ xmp_str = xmp_template.render(
+ photo=self,
+ description=description,
+ keywords=keyword_list,
+ persons=person_list,
+ subjects=subject_list,
+ extension=extension,
+ version=__version__,
+ )
+
+ # remove extra lines that mako inserts from template
+ xmp_str = "\n".join(line for line in xmp_str.split("\n") if line.strip() != "")
+ return xmp_str
+
+
+def _write_sidecar(self, filename, sidecar_str):
+ """write sidecar_str to filename
+ used for exporting sidecar info"""
+ if not (filename or sidecar_str):
+ raise (
+ ValueError(
+ f"filename {filename} and sidecar_str {sidecar_str} must not be None"
+ )
+ )
+
+ # TODO: catch exception?
+ f = open(filename, "w")
+ f.write(sidecar_str)
+ f.close()
+
+""" PhotoInfo methods to expose computed score info from the library """
+
+import logging
+from dataclasses import dataclass
+
+from .._constants import _PHOTOS_4_VERSION
+
+
+@dataclass(frozen=True)
+class ScoreInfo:
+ """ Computed photo score info associated with a photo from the Photos library """
+
+ overall: float
+ curation: float
+ promotion: float
+ highlight_visibility: float
+ behavioral: float
+ failure: float
+ harmonious_color: float
+ immersiveness: float
+ interaction: float
+ interesting_subject: float
+ intrusive_object_presence: float
+ lively_color: float
+ low_light: float
+ noise: float
+ pleasant_camera_tilt: float
+ pleasant_composition: float
+ pleasant_lighting: float
+ pleasant_pattern: float
+ pleasant_perspective: float
+ pleasant_post_processing: float
+ pleasant_reflection: float
+ pleasant_symmetry: float
+ sharply_focused_subject: float
+ tastefully_blurred: float
+ well_chosen_subject: float
+ well_framed_subject: float
+ well_timed_shot: float
+
+
+@property
+def score(self):
+ """ Computed score information for a photo
+
+ Returns:
+ ScoreInfo instance
+ """
+
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ logging.debug(f"score not implemented for this database version")
+ return None
+
+ try:
+ return self._scoreinfo # pylint: disable=access-member-before-definition
+ except AttributeError:
+ try:
+ scores = self._db._db_scoreinfo_uuid[self.uuid]
+ self._scoreinfo = ScoreInfo(
+ overall=scores["overall_aesthetic"],
+ curation=scores["curation"],
+ promotion=scores["promotion"],
+ highlight_visibility=scores["highlight_visibility"],
+ behavioral=scores["behavioral"],
+ failure=scores["failure"],
+ harmonious_color=scores["harmonious_color"],
+ immersiveness=scores["immersiveness"],
+ interaction=scores["interaction"],
+ interesting_subject=scores["interesting_subject"],
+ intrusive_object_presence=scores["intrusive_object_presence"],
+ lively_color=scores["lively_color"],
+ low_light=scores["low_light"],
+ noise=scores["noise"],
+ pleasant_camera_tilt=scores["pleasant_camera_tilt"],
+ pleasant_composition=scores["pleasant_composition"],
+ pleasant_lighting=scores["pleasant_lighting"],
+ pleasant_pattern=scores["pleasant_pattern"],
+ pleasant_perspective=scores["pleasant_perspective"],
+ pleasant_post_processing=scores["pleasant_post_processing"],
+ pleasant_reflection=scores["pleasant_reflection"],
+ pleasant_symmetry=scores["pleasant_symmetry"],
+ sharply_focused_subject=scores["sharply_focused_subject"],
+ tastefully_blurred=scores["tastefully_blurred"],
+ well_chosen_subject=scores["well_chosen_subject"],
+ well_framed_subject=scores["well_framed_subject"],
+ well_timed_shot=scores["well_timed_shot"],
+ )
+ return self._scoreinfo
+ except KeyError:
+ self._scoreinfo = ScoreInfo(
+ overall=0.0,
+ curation=0.0,
+ promotion=0.0,
+ highlight_visibility=0.0,
+ behavioral=0.0,
+ failure=0.0,
+ harmonious_color=0.0,
+ immersiveness=0.0,
+ interaction=0.0,
+ interesting_subject=0.0,
+ intrusive_object_presence=0.0,
+ lively_color=0.0,
+ low_light=0.0,
+ noise=0.0,
+ pleasant_camera_tilt=0.0,
+ pleasant_composition=0.0,
+ pleasant_lighting=0.0,
+ pleasant_pattern=0.0,
+ pleasant_perspective=0.0,
+ pleasant_post_processing=0.0,
+ pleasant_reflection=0.0,
+ pleasant_symmetry=0.0,
+ sharply_focused_subject=0.0,
+ tastefully_blurred=0.0,
+ well_chosen_subject=0.0,
+ well_framed_subject=0.0,
+ well_timed_shot=0.0,
+ )
+ return self._scoreinfo
+
+""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
+ Adds the following properties to PhotoInfo (valid only for Photos 5):
+ search_info: returns a SearchInfo object
+ search_info_normalized: returns a SearchInfo object with properties that produce normalized results
+ labels: returns list of labels
+ labels_normalized: returns list of normalized labels
+"""
+
+from .._constants import (
+ _PHOTOS_4_VERSION,
+ SEARCH_CATEGORY_CITY,
+ SEARCH_CATEGORY_LABEL,
+ SEARCH_CATEGORY_NEIGHBORHOOD,
+ SEARCH_CATEGORY_PLACE_NAME,
+ SEARCH_CATEGORY_STREET,
+ SEARCH_CATEGORY_ALL_LOCALITY,
+ SEARCH_CATEGORY_COUNTRY,
+ SEARCH_CATEGORY_STATE,
+ SEARCH_CATEGORY_STATE_ABBREVIATION,
+ SEARCH_CATEGORY_BODY_OF_WATER,
+ SEARCH_CATEGORY_MONTH,
+ SEARCH_CATEGORY_YEAR,
+ SEARCH_CATEGORY_HOLIDAY,
+ SEARCH_CATEGORY_ACTIVITY,
+ SEARCH_CATEGORY_SEASON,
+ SEARCH_CATEGORY_VENUE,
+ SEARCH_CATEGORY_VENUE_TYPE,
+ SEARCH_CATEGORY_MEDIA_TYPES,
+)
+
+
+@property
+def search_info(self):
+ """ returns SearchInfo object for photo
+ only valid on Photos 5, on older libraries, returns None
+ """
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ return None
+
+ # memoize SearchInfo object
+ try:
+ return self._search_info
+ except AttributeError:
+ self._search_info = SearchInfo(self)
+ return self._search_info
+
+
+@property
+def search_info_normalized(self):
+ """ returns SearchInfo object for photo that produces normalized results
+ only valid on Photos 5, on older libraries, returns None
+ """
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ return None
+
+ # memoize SearchInfo object
+ try:
+ return self._search_info_normalized
+ except AttributeError:
+ self._search_info_normalized = SearchInfo(self, normalized=True)
+ return self._search_info_normalized
+
+
+@property
+def labels(self):
+ """ returns list of labels applied to photo by Photos image categorization
+ only valid on Photos 5, on older libraries returns empty list
+ """
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ return []
+
+ return self.search_info.labels
+
+
+@property
+def labels_normalized(self):
+ """ returns normalized list of labels applied to photo by Photos image categorization
+ only valid on Photos 5, on older libraries returns empty list
+ """
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ return []
+
+ return self.search_info_normalized.labels
+
+
+class SearchInfo:
+ """ Info about search terms such as machine learning labels that Photos knows about a photo """
+
+ def __init__(self, photo, normalized=False):
+ """ photo: PhotoInfo object
+ normalized: if True, all properties return normalized (lower case) results """
+
+ if photo._db._db_version <= _PHOTOS_4_VERSION:
+ raise NotImplementedError(
+ f"search info not implemented for this database version"
+ )
+
+ self._photo = photo
+ self._normalized = normalized
+ self.uuid = photo.uuid
+ try:
+ # get search info for this UUID
+ # there might not be any search info data (e.g. if Photo was missing or photoanalysisd not run yet)
+ self._db_searchinfo = photo._db._db_searchinfo_uuid[self.uuid]
+ except KeyError:
+ self._db_searchinfo = None
+
+ @property
+ def labels(self):
+ """ return list of labels associated with Photo """
+ return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
+
+ @property
+ def place_names(self):
+ """ returns list of place names """
+ return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
+
+ @property
+ def streets(self):
+ """ returns list of street names """
+ return self._get_text_for_category(SEARCH_CATEGORY_STREET)
+
+ @property
+ def neighborhoods(self):
+ """ returns list of neighborhoods """
+ return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
+
+ @property
+ def locality_names(self):
+ """ returns list of other locality names """
+ locality = []
+ for category in SEARCH_CATEGORY_ALL_LOCALITY:
+ locality += self._get_text_for_category(category)
+ return locality
+
+ @property
+ def city(self):
+ """ returns city/town """
+ city = self._get_text_for_category(SEARCH_CATEGORY_CITY)
+ return city[0] if city else ""
+
+ @property
+ def state(self):
+ """ returns state name """
+ state = self._get_text_for_category(SEARCH_CATEGORY_STATE)
+ return state[0] if state else ""
+
+ @property
+ def state_abbreviation(self):
+ """ returns state abbreviation """
+ abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION)
+ return abbrev[0] if abbrev else ""
+
+ @property
+ def country(self):
+ """ returns country name """
+ country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY)
+ return country[0] if country else ""
+
+ @property
+ def month(self):
+ """ returns month name """
+ month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
+ return month[0] if month else ""
+
+ @property
+ def year(self):
+ """ returns year """
+ year = self._get_text_for_category(SEARCH_CATEGORY_YEAR)
+ return year[0] if year else ""
+
+ @property
+ def bodies_of_water(self):
+ """ returns list of body of water names """
+ return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER)
+
+ @property
+ def holidays(self):
+ """ returns list of holiday names """
+ return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
+
+ @property
+ def activities(self):
+ """ returns list of activity names """
+ return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
+
+ @property
+ def season(self):
+ """ returns season name """
+ season = self._get_text_for_category(SEARCH_CATEGORY_SEASON)
+ return season[0] if season else ""
+
+ @property
+ def venues(self):
+ """ returns list of venue names """
+ return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
+
+ @property
+ def venue_types(self):
+ """ returns list of venue types """
+ return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE)
+
+ @property
+ def media_types(self):
+ """ returns list of media types (photo, video, panorama, etc) """
+ types = []
+ for category in SEARCH_CATEGORY_MEDIA_TYPES:
+ types += self._get_text_for_category(category)
+ return types
+
+ @property
+ def all(self):
+ """ return all search info properties in a single list """
+ all = (
+ self.labels
+ + self.place_names
+ + self.streets
+ + self.neighborhoods
+ + self.locality_names
+ + self.bodies_of_water
+ + self.holidays
+ + self.activities
+ + self.venues
+ + self.venue_types
+ + self.media_types
+ )
+ if self.city:
+ all += [self.city]
+ if self.state:
+ all += [self.state]
+ if self.state_abbreviation:
+ all += [self.state_abbreviation]
+ if self.country:
+ all += [self.country]
+ if self.month:
+ all += [self.month]
+ if self.year:
+ all += [self.year]
+ if self.season:
+ all += [self.season]
+
+ return all
+
+ def asdict(self):
+ """ return dict of search info """
+ return {
+ "labels": self.labels,
+ "place_names": self.place_names,
+ "streets": self.streets,
+ "neighborhoods": self.neighborhoods,
+ "city": self.city,
+ "locality_names": self.locality_names,
+ "state": self.state,
+ "state_abbreviation": self.state_abbreviation,
+ "country": self.country,
+ "bodies_of_water": self.bodies_of_water,
+ "month": self.month,
+ "year": self.year,
+ "holidays": self.holidays,
+ "activities": self.activities,
+ "season": self.season,
+ "venues": self.venues,
+ "venue_types": self.venue_types,
+ "media_types": self.media_types,
+ }
+
+ def _get_text_for_category(self, category):
+ """ return list of text for a specified category ID """
+ if self._db_searchinfo:
+ content = "normalized_string" if self._normalized else "content_string"
+ return [
+ rec[content]
+ for rec in self._db_searchinfo
+ if rec["category"] == category
+ ]
+ else:
+ return []
+
+"""
+PhotoInfo class
+Represents a single photo in the Photos library and provides access to the photo's attributes
+PhotosDB.photos() returns a list of PhotoInfo objects
+"""
+
+import dataclasses
+import datetime
+import json
+import logging
+import os
+import os.path
+import pathlib
+from datetime import timedelta, timezone
+
+import yaml
+
+from .._constants import (
+ _MOVIE_TYPE,
+ _PHOTO_TYPE,
+ _PHOTOS_4_ALBUM_KIND,
+ _PHOTOS_4_ROOT_FOLDER,
+ _PHOTOS_4_VERSION,
+ _PHOTOS_5_ALBUM_KIND,
+ _PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
+ _PHOTOS_5_SHARED_ALBUM_KIND,
+ _PHOTOS_5_SHARED_PHOTO_PATH,
+ _PHOTOS_5_VERSION,
+)
+from ..albuminfo import AlbumInfo, ImportInfo
+from ..personinfo import FaceInfo, PersonInfo
+from ..phototemplate import PhotoTemplate
+from ..placeinfo import PlaceInfo4, PlaceInfo5
+from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
+
+
+[docs]class PhotoInfo:
+ """
+ Info about a specific photo, contains all the details about the photo
+ including keywords, persons, albums, uuid, path, etc.
+ """
+
+ # import additional methods
+ from ._photoinfo_searchinfo import (
+ search_info,
+ search_info_normalized,
+ labels,
+ labels_normalized,
+ SearchInfo,
+ )
+ from ._photoinfo_exifinfo import exif_info, ExifInfo
+ from ._photoinfo_exiftool import exiftool
+ from ._photoinfo_export import (
+ export,
+ export2,
+ _export_photo,
+ _exiftool_dict,
+ _exiftool_json_sidecar,
+ _get_exif_keywords,
+ _get_exif_persons,
+ _write_exif_data,
+ _write_sidecar,
+ _xmp_sidecar,
+ ExportResults,
+ )
+ from ._photoinfo_scoreinfo import score, ScoreInfo
+ from ._photoinfo_comments import comments, likes
+
+ def __init__(self, db=None, uuid=None, info=None):
+ self._uuid = uuid
+ self._info = info
+ self._db = db
+
+ @property
+ def filename(self):
+ """ filename of the picture """
+ if (
+ self._db._db_version <= _PHOTOS_4_VERSION
+ and self.has_raw
+ and self.raw_original
+ ):
+ # return the JPEG version as that's what Photos 5+ does
+ return self._info["raw_pair_info"]["filename"]
+ else:
+ return self._info["filename"]
+
+ @property
+ def original_filename(self):
+ """original filename of the picture
+ Photos 5 mangles filenames upon import"""
+ if (
+ self._db._db_version <= _PHOTOS_4_VERSION
+ and self.has_raw
+ and self.raw_original
+ ):
+ # return the JPEG version as that's what Photos 5+ does
+ original_name = self._info["raw_pair_info"]["originalFilename"]
+ else:
+ original_name = self._info["originalFilename"]
+ return original_name or self.filename
+
+ @property
+ def date(self):
+ """ image creation date as timezone aware datetime object """
+ return self._info["imageDate"]
+
+ @property
+ def date_modified(self):
+ """image modification date as timezone aware datetime object
+ or None if no modification date set"""
+
+ # Photos <= 4 provides no way to get date of adjustment and will update
+ # lastmodifieddate anytime photo database record is updated (e.g. adding tags)
+ # only report lastmodified date for Photos <=4 if photo is edited;
+ # even in this case, the date could be incorrect
+ if not self.hasadjustments and self._db._db_version <= _PHOTOS_4_VERSION:
+ return None
+
+ imagedate = self._info["lastmodifieddate"]
+ if imagedate:
+ seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
+ delta = timedelta(seconds=seconds)
+ tz = timezone(delta)
+ return imagedate.astimezone(tz=tz)
+ else:
+ return None
+
+ @property
+ def tzoffset(self):
+ """ timezone offset from UTC in seconds """
+ return self._info["imageTimeZoneOffsetSeconds"]
+
+ @property
+ def path(self):
+ """ absolute path on disk of the original picture """
+ try:
+ return self._path
+ except AttributeError:
+ self._path = None
+ photopath = None
+ # TODO: should path try to return path even if ismissing?
+ if self._info["isMissing"] == 1:
+ return photopath # path would be meaningless until downloaded
+
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ if self._info["has_raw"]:
+ # return the path to JPEG even if RAW is original
+ vol = (
+ self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]]
+ if self._info["raw_pair_info"]["volumeId"] is not None
+ else None
+ )
+ if vol is not None:
+ photopath = os.path.join(
+ "/Volumes", vol, self._info["raw_pair_info"]["imagePath"]
+ )
+ else:
+ photopath = os.path.join(
+ self._db._masters_path,
+ self._info["raw_pair_info"]["imagePath"],
+ )
+ else:
+ vol = self._info["volume"]
+ if vol is not None:
+ photopath = os.path.join(
+ "/Volumes", vol, self._info["imagePath"]
+ )
+ else:
+ photopath = os.path.join(
+ self._db._masters_path, self._info["imagePath"]
+ )
+ if not os.path.isfile(photopath):
+ photopath = None
+ self._path = photopath
+ return photopath
+
+ if self._info["shared"]:
+ # shared photo
+ photopath = os.path.join(
+ self._db._library_path,
+ _PHOTOS_5_SHARED_PHOTO_PATH,
+ self._info["directory"],
+ self._info["filename"],
+ )
+ if not os.path.isfile(photopath):
+ photopath = None
+ self._path = photopath
+ return photopath
+
+ if self._info["directory"].startswith("/"):
+ photopath = os.path.join(
+ self._info["directory"], self._info["filename"]
+ )
+ else:
+ photopath = os.path.join(
+ self._db._masters_path,
+ self._info["directory"],
+ self._info["filename"],
+ )
+ if not os.path.isfile(photopath):
+ photopath = None
+ self._path = photopath
+ return photopath
+
+ @property
+ def path_edited(self):
+ """ absolute path on disk of the edited picture """
+ """ None if photo has not been edited """
+
+ try:
+ return self._path_edited
+ except AttributeError:
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ self._path_edited = self._path_edited_4()
+ else:
+ self._path_edited = self._path_edited_5()
+
+ return self._path_edited
+
+ def _path_edited_5(self):
+ """ return path_edited for Photos >= 5 """
+ # In Photos 5.0 / Catalina / MacOS 10.15:
+ # edited photos appear to always be converted to .jpeg and stored in
+ # library_name/resources/renders/X/UUID_1_201_a.jpeg
+ # where X = first letter of UUID
+ # and UUID = UUID of image
+ # this seems to be true even for photos not copied to Photos library and
+ # where original format was not jpg/jpeg
+ # if more than one edit, previous edit is stored as UUID_p.jpeg
+ #
+ # In Photos 6.0 / Big Sur, the edited image is a .heic if the photo isn't a jpeg,
+ # otherwise it's a jpeg. It could also be a jpeg if photo library upgraded from earlier
+ # version.
+
+ if self._db._db_version < _PHOTOS_5_VERSION:
+ raise RuntimeError("Wrong database format!")
+
+ if self._info["hasAdjustments"]:
+ library = self._db._library_path
+ directory = self._uuid[0] # first char of uuid
+ filename = None
+ if self._info["type"] == _PHOTO_TYPE:
+ # it's a photo
+ if self._db._photos_ver == 5:
+ filename = f"{self._uuid}_1_201_a.jpeg"
+ else:
+ # could be a heic or a jpeg
+ if self.uti == "public.heic":
+ filename = f"{self._uuid}_1_201_a.heic"
+ else:
+ filename = f"{self._uuid}_1_201_a.jpeg"
+ elif self._info["type"] == _MOVIE_TYPE:
+ # it's a movie
+ filename = f"{self._uuid}_2_0_a.mov"
+ else:
+ # don't know what it is!
+ logging.debug(f"WARNING: unknown type {self._info['type']}")
+ return None
+
+ photopath = os.path.join(
+ library, "resources", "renders", directory, filename
+ )
+
+ if not os.path.isfile(photopath):
+ logging.debug(
+ f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
+ )
+ photopath = None
+ else:
+ photopath = None
+
+ # TODO: might be possible for original/master to be missing but edit to still be there
+ # if self._info["isMissing"] == 1:
+ # photopath = None # path would be meaningless until downloaded
+
+ return photopath
+
+ def _path_edited_4(self):
+ """ return path_edited for Photos <= 4 """
+
+ if self._db._db_version > _PHOTOS_4_VERSION:
+ raise RuntimeError("Wrong database format!")
+
+ photopath = None
+ if self._info["hasAdjustments"]:
+ edit_id = self._info["edit_resource_id"]
+ if edit_id is not None:
+ library = self._db._library_path
+ folder_id, file_id = _get_resource_loc(edit_id)
+ # todo: is this always true or do we need to search file file_id under folder_id
+ # figure out what kind it is and build filename
+ filename = None
+ if self._info["type"] == _PHOTO_TYPE:
+ # it's a photo
+ filename = f"fullsizeoutput_{file_id}.jpeg"
+ elif self._info["type"] == _MOVIE_TYPE:
+ # it's a movie
+ filename = f"fullsizeoutput_{file_id}.mov"
+ else:
+ # don't know what it is!
+ logging.debug(f"WARNING: unknown type {self._info['type']}")
+ return None
+
+ # photopath appears to usually be in "00" subfolder but
+ # could be elsewhere--I haven't figured out this logic yet
+ # first see if it's in 00
+ photopath = os.path.join(
+ library, "resources", "media", "version", folder_id, "00", filename
+ )
+
+ if not os.path.isfile(photopath):
+ rootdir = os.path.join(
+ library, "resources", "media", "version", folder_id
+ )
+
+ for dirname, _, filelist in os.walk(rootdir):
+ if filename in filelist:
+ photopath = os.path.join(dirname, filename)
+ break
+
+ # check again to see if we found a valid file
+ if not os.path.isfile(photopath):
+ logging.debug(
+ f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
+ )
+ photopath = None
+ else:
+ logging.debug(
+ f"{self.uuid} hasAdjustments but edit_resource_id is None"
+ )
+ photopath = None
+ else:
+ photopath = None
+
+ return photopath
+
+ @property
+ def path_raw(self):
+ """ absolute path of associated RAW image or None if there is not one """
+
+ # In Photos 5, raw is in same folder as original but with _4.ext
+ # Unless "Copy Items to the Photos Library" is not checked
+ # then RAW image is not renamed but has same name is jpeg buth with raw extension
+ # Current implementation uses findfiles to find images with the correct raw UTI extension
+ # in same folder as the original and with same stem as original in form: original_stem*.raw_ext
+ # TODO: I don't like this -- would prefer a more deterministic approach but until I have more
+ # data on how Photos stores and retrieves RAW images, this seems to be working
+
+ if self._info["isMissing"] == 1:
+ return None # path would be meaningless until downloaded
+
+ if not self.has_raw:
+ return None # no raw image to get path for
+
+ # if self._info["shared"]:
+ # # shared photo
+ # photopath = os.path.join(
+ # self._db._library_path,
+ # _PHOTOS_5_SHARED_PHOTO_PATH,
+ # self._info["directory"],
+ # self._info["filename"],
+ # )
+ # return photopath
+
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ vol = self._info["raw_info"]["volume"]
+ if vol is not None:
+ photopath = os.path.join(
+ "/Volumes", vol, self._info["raw_info"]["imagePath"]
+ )
+ else:
+ photopath = os.path.join(
+ self._db._masters_path, self._info["raw_info"]["imagePath"]
+ )
+ if not os.path.isfile(photopath):
+ logging.debug(
+ f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
+ )
+ photopath = None
+ else:
+ filestem = pathlib.Path(self._info["filename"]).stem
+ raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
+
+ if self._info["directory"].startswith("/"):
+ filepath = self._info["directory"]
+ else:
+ filepath = os.path.join(self._db._masters_path, self._info["directory"])
+
+ glob_str = f"{filestem}*.{raw_ext}"
+ raw_file = findfiles(glob_str, filepath)
+ if len(raw_file) != 1:
+ # Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
+ # that are missing do not always trigger is_missing = True as happens
+ # in earlier version so it's possible for this check to fail, if so, return None
+ logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}")
+ photopath = None
+ else:
+ photopath = os.path.join(filepath, raw_file[0])
+ if not os.path.isfile(photopath):
+ logging.debug(
+ f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
+ )
+ photopath = None
+
+ return photopath
+
+ @property
+ def description(self):
+ """ long / extended description of picture """
+ return self._info["extendedDescription"]
+
+ @property
+ def persons(self):
+ """ list of persons in picture """
+ return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]]
+
+ @property
+ def person_info(self):
+ """ list of PersonInfo objects for person in picture """
+ try:
+ return self._personinfo
+ except AttributeError:
+ self._personinfo = [
+ PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]
+ ]
+ return self._personinfo
+
+ @property
+ def face_info(self):
+ """ list of FaceInfo objects for faces in picture """
+ try:
+ return self._faceinfo
+ except AttributeError:
+ try:
+ faces = self._db._db_faceinfo_uuid[self._uuid]
+ self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
+ except KeyError:
+ # no faces
+ self._faceinfo = []
+ return self._faceinfo
+
+ @property
+ def albums(self):
+ """ list of albums picture is contained in """
+ try:
+ return self._albums
+ except AttributeError:
+ album_uuids = self._get_album_uuids()
+ self._albums = list(
+ {self._db._dbalbum_details[album]["title"] for album in album_uuids}
+ )
+ return self._albums
+
+ @property
+ def album_info(self):
+ """ list of AlbumInfo objects representing albums the photos is contained in """
+ try:
+ return self._album_info
+ except AttributeError:
+ album_uuids = self._get_album_uuids()
+ self._album_info = [
+ AlbumInfo(db=self._db, uuid=album) for album in album_uuids
+ ]
+ return self._album_info
+
+ @property
+ def import_info(self):
+ """ ImportInfo object representing import session for the photo or None if no import session """
+ try:
+ return self._import_info
+ except AttributeError:
+ self._import_info = (
+ ImportInfo(db=self._db, uuid=self._info["import_uuid"])
+ if self._info["import_uuid"] is not None
+ else None
+ )
+ return self._import_info
+
+ @property
+ def keywords(self):
+ """ list of keywords for picture """
+ return self._info["keywords"]
+
+ @property
+ def title(self):
+ """ name / title of picture """
+ return self._info["name"]
+
+ @property
+ def uuid(self):
+ """ UUID of picture """
+ return self._uuid
+
+ @property
+ def ismissing(self):
+ """returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
+ NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
+ do not immediately get written to disk. In particular, I've noticed that downloading
+ an image from the cloud does not force the database to be updated until something else
+ e.g. an edit, keyword, etc. occurs forcing a database synch
+ The exact process / timing is a mystery to be but be aware that if some photos were recently
+ downloaded from cloud to local storate their status in the database might still show
+ isMissing = 1
+ """
+ return self._info["isMissing"] == 1
+
+ @property
+ def hasadjustments(self):
+ """ True if picture has adjustments / edits """
+ return self._info["hasAdjustments"] == 1
+
+ @property
+ def external_edit(self):
+ """ Returns True if picture was edited outside of Photos using external editor """
+ return self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
+
+ @property
+ def favorite(self):
+ """ True if picture is marked as favorite """
+ return self._info["favorite"] == 1
+
+ @property
+ def hidden(self):
+ """ True if picture is hidden """
+ return self._info["hidden"] == 1
+
+ @property
+ def visible(self):
+ """ True if picture is visble """
+ return self._info["visible"]
+
+ @property
+ def intrash(self):
+ """ True if picture is in trash ('Recently Deleted' folder)"""
+ return self._info["intrash"]
+
+ @property
+ def date_trashed(self):
+ """ Date asset was placed in the trash or None """
+ # TODO: add add_timezone(dt, offset_seconds) to datetime_utils
+ # also update date_modified
+ trasheddate = self._info["trasheddate"]
+ if trasheddate:
+ seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
+ delta = timedelta(seconds=seconds)
+ tz = timezone(delta)
+ return trasheddate.astimezone(tz=tz)
+ else:
+ return None
+
+ @property
+ def location(self):
+ """ returns (latitude, longitude) as float in degrees or None """
+ return (self._latitude, self._longitude)
+
+ @property
+ def shared(self):
+ """returns True if photos is in a shared iCloud album otherwise false
+ Only valid on Photos 5; returns None on older versions"""
+ if self._db._db_version > _PHOTOS_4_VERSION:
+ return self._info["shared"]
+ else:
+ return None
+
+ @property
+ def uti(self):
+ """Returns Uniform Type Identifier (UTI) for the image
+ for example: public.jpeg or com.apple.quicktime-movie
+ """
+ if self._db._db_version <= _PHOTOS_4_VERSION and self.hasadjustments:
+ return self._info["UTI_edited"]
+ elif (
+ self._db._db_version <= _PHOTOS_4_VERSION
+ and self.has_raw
+ and self.raw_original
+ ):
+ # return UTI of the non-raw image to match Photos 5+ behavior
+ return self._info["raw_pair_info"]["UTI"]
+ else:
+ return self._info["UTI"]
+
+ @property
+ def uti_original(self):
+ """Returns Uniform Type Identifier (UTI) for the original image
+ for example: public.jpeg or com.apple.quicktime-movie
+ """
+ if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
+ return self._info["raw_pair_info"]["UTI"]
+ elif self.shared:
+ # TODO: need reliable way to get original UTI for shared
+ return self.uti
+ else:
+ return self._info["UTI_original"]
+
+ @property
+ def uti_edited(self):
+ """Returns Uniform Type Identifier (UTI) for the edited image
+ if the photo has been edited, otherwise None;
+ for example: public.jpeg
+ """
+ if self._db._db_version >= _PHOTOS_5_VERSION:
+ return self.uti if self.hasadjustments else None
+ else:
+ return self._info["UTI_edited"]
+
+ @property
+ def uti_raw(self):
+ """Returns Uniform Type Identifier (UTI) for the RAW image if there is one
+ for example: com.canon.cr2-raw-image
+ Returns None if no associated RAW image
+ """
+ return self._info["UTI_raw"]
+
+ @property
+ def ismovie(self):
+ """Returns True if file is a movie, otherwise False"""
+ return self._info["type"] == _MOVIE_TYPE
+
+ @property
+ def isphoto(self):
+ """Returns True if file is an image, otherwise False"""
+ return self._info["type"] == _PHOTO_TYPE
+
+ @property
+ def incloud(self):
+ """Returns True if photo is cloud asset and is synched to cloud
+ False if photo is cloud asset and not yet synched to cloud
+ None if photo is not cloud asset
+ """
+ return self._info["incloud"]
+
+ @property
+ def iscloudasset(self):
+ """Returns True if photo is a cloud asset (in an iCloud library),
+ otherwise False
+ """
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ return (
+ True
+ if self._info["cloudLibraryState"] is not None
+ and self._info["cloudLibraryState"] != 0
+ else False
+ )
+ else:
+ return True if self._info["cloudAssetGUID"] is not None else False
+
+ @property
+ def isreference(self):
+ """ Returns True if photo is a reference (not copied to the Photos library), otherwise False """
+ return self._info["isreference"]
+
+ @property
+ def burst(self):
+ """ Returns True if photo is part of a Burst photo set, otherwise False """
+ return self._info["burst"]
+
+ @property
+ def burst_photos(self):
+ """If photo is a burst photo, returns list of PhotoInfo objects
+ that are part of the same burst photo set; otherwise returns empty list.
+ self is not included in the returned list"""
+ if self._info["burst"]:
+ burst_uuid = self._info["burstUUID"]
+ return [
+ PhotoInfo(db=self._db, uuid=u, info=self._db._dbphotos[u])
+ for u in self._db._dbphotos_burst[burst_uuid]
+ if u != self._uuid
+ ]
+ else:
+ return []
+
+ @property
+ def live_photo(self):
+ """ Returns True if photo is a live photo, otherwise False """
+ return self._info["live_photo"]
+
+ @property
+ def path_live_photo(self):
+ """Returns path to the associated video file for a live photo
+ If photo is not a live photo, returns None
+ If photo is missing, returns None"""
+
+ 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 == None:
+ logging.debug(f"missing live_model_id: {self._uuid}")
+ photopath = None
+ else:
+ folder_id, file_id = _get_resource_loc(live_model_id)
+ library_path = self._db.library_path
+ photopath = os.path.join(
+ library_path,
+ "resources",
+ "media",
+ "master",
+ folder_id,
+ "00",
+ 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?
+ logging.debug(
+ f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
+ )
+ photopath = None
+ else:
+ photopath = None
+ else:
+ # Photos 5
+ if self.live_photo and not self.ismissing:
+ filename = pathlib.Path(self.path)
+ photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
+ photopath = str(photopath)
+ if not os.path.isfile(photopath):
+ # In testing, I've seen occasional missing movie for live photo
+ # these appear to be valid -- e.g. video component not yet downloaded from iCloud
+ # TODO: should this be a warning or debug?
+ logging.debug(
+ f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
+ )
+ photopath = None
+ else:
+ photopath = None
+
+ return photopath
+
+ @property
+ def panorama(self):
+ """ Returns True if photo is a panorama, otherwise False """
+ return self._info["panorama"]
+
+ @property
+ def slow_mo(self):
+ """ Returns True if photo is a slow motion video, otherwise False """
+ return self._info["slow_mo"]
+
+ @property
+ def time_lapse(self):
+ """ Returns True if photo is a time lapse video, otherwise False """
+ return self._info["time_lapse"]
+
+ @property
+ def hdr(self):
+ """ Returns True if photo is an HDR photo, otherwise False """
+ return self._info["hdr"]
+
+ @property
+ def screenshot(self):
+ """ Returns True if photo is an HDR photo, otherwise False """
+ return self._info["screenshot"]
+
+ @property
+ def portrait(self):
+ """ Returns True if photo is a portrait, otherwise False """
+ return self._info["portrait"]
+
+ @property
+ def selfie(self):
+ """ Returns True if photo is a selfie (front facing camera), otherwise False """
+ return self._info["selfie"]
+
+ @property
+ def place(self):
+ """ Returns PlaceInfo object containing reverse geolocation info """
+
+ # implementation note: doesn't create the PlaceInfo object until requested
+ # then memoizes the object in self._place to avoid recreating the object
+
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ try:
+ return self._place # pylint: disable=access-member-before-definition
+ except AttributeError:
+ if self._info["placeNames"]:
+ self._place = PlaceInfo4(
+ self._info["placeNames"], self._info["countryCode"]
+ )
+ else:
+ self._place = None
+ return self._place
+ else:
+ try:
+ return self._place # pylint: disable=access-member-before-definition
+ except AttributeError:
+ if self._info["reverse_geolocation"]:
+ self._place = PlaceInfo5(self._info["reverse_geolocation"])
+ else:
+ self._place = None
+ return self._place
+
+ @property
+ def has_raw(self):
+ """ returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False """
+ return self._info["has_raw"]
+
+ @property
+ def israw(self):
+ """ returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw """
+ return "raw-image" in self.uti_original
+
+ @property
+ def raw_original(self):
+ """returns True if associated raw image and the raw image is selected in Photos
+ via "Use RAW as Original "
+ otherwise returns False"""
+ return self._info["raw_is_original"]
+
+ @property
+ def height(self):
+ """ returns height of the current photo version in pixels """
+ return self._info["height"]
+
+ @property
+ def width(self):
+ """ returns width of the current photo version in pixels """
+ return self._info["width"]
+
+ @property
+ def orientation(self):
+ """ returns EXIF orientation of the current photo version as int """
+ return self._info["orientation"]
+
+ @property
+ def original_height(self):
+ """ returns height of the original photo version in pixels """
+ return self._info["original_height"]
+
+ @property
+ def original_width(self):
+ """ returns width of the original photo version in pixels """
+ return self._info["original_width"]
+
+ @property
+ def original_orientation(self):
+ """ returns EXIF orientation of the original photo version as int """
+ return self._info["original_orientation"]
+
+ @property
+ def original_filesize(self):
+ """ returns filesize of original photo in bytes as int """
+ return self._info["original_filesize"]
+
+[docs] def render_template(
+ self,
+ template_str,
+ none_str="_",
+ path_sep=None,
+ expand_inplace=False,
+ inplace_sep=None,
+ filename=False,
+ dirname=False,
+ strip=False,
+ ):
+ """Renders a template string for PhotoInfo instance using PhotoTemplate
+
+ Args:
+ template_str: a template string with fields to render
+ none_str: a str to use if template field renders to None, default is "_".
+ path_sep: a single character str to use as path separator when joining
+ fields like folder_album; if not provided, defaults to os.path.sep
+ expand_inplace: expand multi-valued substitutions in-place as a single string
+ instead of returning individual strings
+ inplace_sep: optional string to use as separator between multi-valued keywords
+ with expand_inplace; default is ','
+ filename: if True, template output will be sanitized to produce valid file name
+ dirname: if True, template output will be sanitized to produce valid directory name
+ strip: if True, strips leading/trailing white space from resulting template
+
+ Returns:
+ ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
+ """
+ template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
+ return template.render(
+ template_str,
+ none_str=none_str,
+ path_sep=path_sep,
+ expand_inplace=expand_inplace,
+ inplace_sep=inplace_sep,
+ filename=filename,
+ dirname=dirname,
+ strip=strip,
+ )
+
+ @property
+ def _longitude(self):
+ """ Returns longitude, in degrees """
+ return self._info["longitude"]
+
+ @property
+ def _latitude(self):
+ """ Returns latitude, in degrees """
+ return self._info["latitude"]
+
+ def _get_album_uuids(self):
+ """Return list of album UUIDs this photo is found in
+
+ Filters out albums in the trash and any special album types
+
+ Returns: list of album UUIDs
+ """
+ if self._db._db_version <= _PHOTOS_4_VERSION:
+ version4 = True
+ album_kind = [_PHOTOS_4_ALBUM_KIND]
+ else:
+ version4 = False
+ album_kind = [_PHOTOS_5_SHARED_ALBUM_KIND, _PHOTOS_5_ALBUM_KIND]
+
+ album_list = []
+ for album in self._info["albums"]:
+ detail = self._db._dbalbum_details[album]
+ if (
+ detail["kind"] in album_kind
+ and not detail["intrash"]
+ and (
+ not version4
+ # in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
+ # but should not be listed here; they can be distinguished by looking
+ # for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
+ or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
+ )
+ ):
+ album_list.append(album)
+ return album_list
+
+ def __repr__(self):
+ return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
+
+ def __str__(self):
+ """ string representation of PhotoInfo object """
+
+ date_iso = self.date.isoformat()
+ date_modified_iso = (
+ self.date_modified.isoformat() if self.date_modified else None
+ )
+ exif = str(self.exif_info) if self.exif_info else None
+ score = str(self.score) if self.score else None
+
+ info = {
+ "uuid": self.uuid,
+ "filename": self.filename,
+ "original_filename": self.original_filename,
+ "date": date_iso,
+ "description": self.description,
+ "title": self.title,
+ "keywords": self.keywords,
+ "albums": self.albums,
+ "persons": self.persons,
+ "path": self.path,
+ "ismissing": self.ismissing,
+ "hasadjustments": self.hasadjustments,
+ "external_edit": self.external_edit,
+ "favorite": self.favorite,
+ "hidden": self.hidden,
+ "latitude": self._latitude,
+ "longitude": self._longitude,
+ "path_edited": self.path_edited,
+ "shared": self.shared,
+ "isphoto": self.isphoto,
+ "ismovie": self.ismovie,
+ "uti": self.uti,
+ "burst": self.burst,
+ "live_photo": self.live_photo,
+ "path_live_photo": self.path_live_photo,
+ "iscloudasset": self.iscloudasset,
+ "incloud": self.incloud,
+ "date_modified": date_modified_iso,
+ "portrait": self.portrait,
+ "screenshot": self.screenshot,
+ "slow_mo": self.slow_mo,
+ "time_lapse": self.time_lapse,
+ "hdr": self.hdr,
+ "selfie": self.selfie,
+ "panorama": self.panorama,
+ "has_raw": self.has_raw,
+ "uti_raw": self.uti_raw,
+ "path_raw": self.path_raw,
+ "place": self.place,
+ "exif": exif,
+ "score": score,
+ "intrash": self.intrash,
+ "height": self.height,
+ "width": self.width,
+ "orientation": self.orientation,
+ "original_height": self.original_height,
+ "original_width": self.original_width,
+ "original_orientation": self.original_orientation,
+ "original_filesize": self.original_filesize,
+ }
+ return yaml.dump(info, sort_keys=False)
+
+[docs] def asdict(self):
+ """ return dict representation """
+
+ folders = {album.title: album.folder_names for album in self.album_info}
+ exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
+ place = self.place.asdict() if self.place else {}
+ score = dataclasses.asdict(self.score) if self.score else {}
+ comments = [comment.asdict() for comment in self.comments]
+ likes = [like.asdict() for like in self.likes]
+ faces = [face.asdict() for face in self.face_info]
+ search_info = self.search_info.asdict() if self.search_info else {}
+
+ return {
+ "library": self._db._library_path,
+ "uuid": self.uuid,
+ "filename": self.filename,
+ "original_filename": self.original_filename,
+ "date": self.date,
+ "description": self.description,
+ "title": self.title,
+ "keywords": self.keywords,
+ "labels": self.labels,
+ "keywords": self.keywords,
+ "albums": self.albums,
+ "folders": folders,
+ "persons": self.persons,
+ "faces": faces,
+ "path": self.path,
+ "ismissing": self.ismissing,
+ "hasadjustments": self.hasadjustments,
+ "external_edit": self.external_edit,
+ "favorite": self.favorite,
+ "hidden": self.hidden,
+ "latitude": self._latitude,
+ "longitude": self._longitude,
+ "path_edited": self.path_edited,
+ "shared": self.shared,
+ "isphoto": self.isphoto,
+ "ismovie": self.ismovie,
+ "uti": self.uti,
+ "uti_original": self.uti_original,
+ "burst": self.burst,
+ "live_photo": self.live_photo,
+ "path_live_photo": self.path_live_photo,
+ "iscloudasset": self.iscloudasset,
+ "incloud": self.incloud,
+ "isreference": self.isreference,
+ "date_modified": self.date_modified,
+ "portrait": self.portrait,
+ "screenshot": self.screenshot,
+ "slow_mo": self.slow_mo,
+ "time_lapse": self.time_lapse,
+ "hdr": self.hdr,
+ "selfie": self.selfie,
+ "panorama": self.panorama,
+ "has_raw": self.has_raw,
+ "israw": self.israw,
+ "raw_original": self.raw_original,
+ "uti_raw": self.uti_raw,
+ "path_raw": self.path_raw,
+ "place": place,
+ "exif": exif,
+ "score": score,
+ "intrash": self.intrash,
+ "height": self.height,
+ "width": self.width,
+ "orientation": self.orientation,
+ "original_height": self.original_height,
+ "original_width": self.original_width,
+ "original_orientation": self.original_orientation,
+ "original_filesize": self.original_filesize,
+ "comments": comments,
+ "likes": likes,
+ "search_info": search_info,
+ }
+
+[docs] def json(self):
+ """ Return JSON representation """
+
+ def default(o):
+ if isinstance(o, (datetime.date, datetime.datetime)):
+ return o.isoformat()
+
+ return json.dumps(self.asdict(), sort_keys=True, default=default)
+
+ def __eq__(self, other):
+ """ Compare two PhotoInfo objects for equality """
+ # Can't just compare the two __dicts__ because some methods (like albums)
+ # memoize their value once called in an instance variable (e.g. self._albums)
+ if isinstance(other, self.__class__):
+ return (
+ self._db.db_path == other._db.db_path
+ and self.uuid == other.uuid
+ and self._info == other._info
+ )
+ return False
+
+ def __ne__(self, other):
+ """ Compare two PhotoInfo objects for inequality """
+ return not self.__eq__(other)
+
+"""
+PhotosDB class
+Processes a Photos.app library database to extract information about photos
+"""
+
+import logging
+import os
+import os.path
+import pathlib
+import platform
+import sys
+import tempfile
+from datetime import datetime, timedelta, timezone
+from pprint import pformat
+
+from .._constants import (
+ _DB_TABLE_NAMES,
+ _MOVIE_TYPE,
+ _PHOTO_TYPE,
+ _PHOTOS_3_VERSION,
+ _PHOTOS_4_ALBUM_KIND,
+ _PHOTOS_4_ROOT_FOLDER,
+ _PHOTOS_4_TOP_LEVEL_ALBUM,
+ _PHOTOS_4_VERSION,
+ _PHOTOS_5_ALBUM_KIND,
+ _PHOTOS_5_FOLDER_KIND,
+ _PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
+ _PHOTOS_5_ROOT_FOLDER_KIND,
+ _PHOTOS_5_SHARED_ALBUM_KIND,
+ _TESTED_OS_VERSIONS,
+ _UNKNOWN_PERSON,
+ TIME_DELTA,
+)
+from .._version import __version__
+from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo
+from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
+from ..fileutil import FileUtil
+from ..personinfo import PersonInfo
+from ..photoinfo import PhotoInfo
+from ..utils import (
+ _check_file_exists,
+ _db_is_locked,
+ _debug,
+ _get_os_version,
+ _open_sql_file,
+ get_last_library_path,
+ noop,
+ normalize_unicode,
+)
+from .photosdb_utils import get_db_model_version, get_db_version
+
+# TODO: Add test for imageTimeZoneOffsetSeconds = None
+# TODO: Add test for __str__
+# TODO: Add special albums and magic albums
+
+
+[docs]class PhotosDB:
+ """ Processes a Photos.app library database to extract information about photos """
+
+ # import additional methods
+ from ._photosdb_process_exif import _process_exifinfo
+ from ._photosdb_process_faceinfo import _process_faceinfo
+ from ._photosdb_process_searchinfo import (
+ _process_searchinfo,
+ labels,
+ labels_normalized,
+ labels_as_dict,
+ labels_normalized_as_dict,
+ )
+ from ._photosdb_process_scoreinfo import _process_scoreinfo
+ from ._photosdb_process_comments import _process_comments
+
+ def __init__(self, dbfile=None, verbose=None, exiftool=None):
+ """ Create a new PhotosDB object.
+
+ Args:
+ dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
+ verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
+ exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
+
+ Raises:
+ FileNotFoundError if dbfile is not a valid Photos library.
+ TypeError if verbose is not None and not callable.
+ """
+
+ # Check OS version
+ system = platform.system()
+ (ver, major, _) = _get_os_version()
+ if system != "Darwin" or ((ver, major) not in _TESTED_OS_VERSIONS):
+ logging.warning(
+ f"WARNING: This module has only been tested with macOS versions "
+ f"[{', '.join(f'{v}.{m}' for (v, m) in _TESTED_OS_VERSIONS)}]: "
+ f"you have {system}, OS version: {ver}.{major}"
+ )
+
+ if verbose is None:
+ verbose = noop
+ elif not callable(verbose):
+ raise TypeError("verbose must be callable")
+ self._verbose = verbose
+
+ # enable beta features
+ self._beta = False
+
+ self._exiftool_path = exiftool
+
+ # create a temporary directory
+ # tempfile.TemporaryDirectory gets cleaned up when the object does
+ self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
+ self._tempdir_name = self._tempdir.name
+
+ # set up the data structures used to store all the Photo database info
+
+ # TODO: I don't think these keywords flags are actually used
+ # if True, will treat persons as keywords when exporting metadata
+ self.use_persons_as_keywords = False
+
+ # if True, will treat albums as keywords when exporting metadata
+ self.use_albums_as_keywords = False
+
+ # Path to the Photos library database file
+ # photos.db in the photos library database/ directory
+ self._dbfile = None
+
+ # the actual file with library data
+ # in Photos 5 this is Photos.sqlite instead of photos.db
+ self._dbfile_actual = None
+
+ # Dict with information about all photos by uuid
+ # This is the "master" data structure, built by process_database
+ # key is a photo UUID, value is a dictionary with all the information
+ # known about a photo
+ # this is built by joining data from multiple queries against the photos database
+ # several of the keys in the info dictionary point to other data structures described below
+ # e.g. self._dbphotos[uuid]["keywords"] = self._dbkeywords_uuid[uuid]
+ # self._dbphotos[uuid]["persons"] = self._dbfaces_uuid[uuid]
+ # self._dbphotos[uuid]["albums"] = self._dbalbums_uuid[uuid]
+ self._dbphotos = {}
+
+ # Dict with information about all burst photos by burst uuid
+ # key is UUID of the burst set, value is a set of photo UUIDs in the burst set
+ # e.g. {'BD94B7C0-2EB8-43DB-98B4-3B8E9653C255': {'8B386814-CA8A-42AA-BCA8-97C1AA746D8A', '52B95550-DE4A-44DD-9E67-89E979F2E97F'}}
+ self._dbphotos_burst = {}
+
+ # Dict with additional information from RKMaster
+ # key is UUID from RKMaster, value is dict with info related to each master
+ # currently used to get information on RAW images
+ self._dbphotos_master = {}
+
+ # Dict with information about all persons by person PK
+ # key is person PK, value is dict with info about each person
+ # e.g. {3: {"pk": 3, "fullname": "Maria Smith"...}}
+ self._dbpersons_pk = {}
+
+ # Dict with information about all persons by person fullname
+ # key is person PK, value is list of person PKs with fullname
+ # there may be more than one person PK with the same fullname
+ # e.g. {"Maria Smith": [1, 2]}
+ self._dbpersons_fullname = {}
+
+ # Dict with information about all persons/photos by uuid
+ # key is photo UUID, value is list of person primary keys of persons in the photo
+ # Note: Photos 5 identifies faces even if not given a name
+ # and those are labeled by process_database as _UNKNOWN_
+ # e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': [1, 3, 5]}
+ self._dbfaces_uuid = {}
+
+ # Dict with information about detected faces by person primary key
+ # key is person pk, value is list of photo UUIDs
+ # e.g. {3: ['E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51']}
+ self._dbfaces_pk = {}
+
+ # Dict with information about all keywords/photos by uuid
+ # key is photo uuid and value is list of keywords
+ # e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': ['Kids']}
+ self._dbkeywords_uuid = {}
+
+ # Dict with information about all keywords/photos by keyword
+ # key is keyword and value is list of photo UUIDs that have that keyword
+ # e.g. {'England': ['DC99FBDD-7A52-4100-A5BB-344131646C30']}
+ self._dbkeywords_keyword = {}
+
+ # Dict with information about all albums/photos by uuid
+ # key is photo UUID, value is list of album UUIDs the photo is contained in
+ # e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': ['0C514A98-7B77-4E4F-801B-364B7B65EAFA']}
+ self._dbalbums_uuid = {}
+
+ # Dict with information about all albums/photos by primary key in the album database
+ # key is album pk, value is album uuid
+ # e.g. {'43': '0C514A98-7B77-4E4F-801B-364B7B65EAFA'}
+ # specific to Photos versions >= 5
+ self._dbalbums_pk = {}
+
+ # Dict with information about all albums/photos by album
+ # key is album UUID, value is list of tuples of (photo UUID, sort order) contained in that album
+ # e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': [('1EB2B765-0765-43BA-A90C-0D0580E6172C', 1024)]}
+ self._dbalbums_album = {}
+
+ # Dict with information about album details
+ # key is album UUID, value is a dict with some additional details
+ # (mostly about cloud status) of the album
+ # e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': {'cloudidentifier': None,
+ # 'cloudlibrarystate': None, 'cloudlocalstate': 0, 'cloudownderlastname': None,
+ # 'cloudownerfirstname': None, 'cloudownerhashedpersonid': None, 'title': 'Pumpkin Farm'}}
+ self._dbalbum_details = {}
+
+ # Dict with information about album titles
+ # key is title of album, value is list of album UUIDs with that title
+ # (It's possible to have more than one album with the same title)
+ # e.g. {'Pumpkin Farm': ['0C514A98-7B77-4E4F-801B-364B7B65EAFA']}
+ self._dbalbum_titles = {}
+
+ # Dict with information about all the file system volumes/photos by uuid
+ # key is volume UUID, value is name of file system volume
+ # e.g. {'8A0B2944-7B09-4D06-9AC3-4B0BF3F363F1': 'MacBook Mojave'}
+ # Used to find path of photos imported but not copied to the Photos library
+ self._dbvolumes = {}
+
+ # Dict with information about parent folders for folders and albums
+ # key is album or folder UUID and value is list of UUIDs of parent folder
+ # e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': ['92D68107-B6C7-453B-96D2-97B0F26D5B8B'],}
+ self._dbalbum_parent_folders = {}
+
+ # Dict with information about folder hierarchy for each album / folder
+ # key is uuid of album / folder, value is dict with uuid of descendant folder / album
+ # structure is recursive as a descendant may itself have descendants
+ # e.g. {'AA4145F5-098C-496E-9197-B7584958FF9B': {'99D24D3E-59E7-465F-B386-A48A94B00BC1': {'F2246D82-1A12-4994-9654-3DC6FE38A7A8': None}}, }
+ self._dbalbum_folders = {}
+
+ # Dict with information about folders
+ self._dbfolder_details = {}
+
+ # Will hold the primary key of root folder
+ self._folder_root_pk = None
+
+ if _debug():
+ logging.debug(f"dbfile = {dbfile}")
+
+ if dbfile is None:
+ dbfile = get_last_library_path()
+ if dbfile is None:
+ # get_last_library_path must have failed to find library
+ raise FileNotFoundError("Could not get path to photo library database")
+
+ if os.path.isdir(dbfile):
+ # passed a directory, assume it's a photoslibrary
+ dbfile = os.path.join(dbfile, "database/photos.db")
+
+ # if get here, should have a dbfile path; make sure it exists
+ if not _check_file_exists(dbfile):
+ raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile)
+
+ if _debug():
+ logging.debug(f"dbfile = {dbfile}")
+
+ # init database names
+ # _tmp_db is the file that will processed by _process_database4/5
+ # assume _tmp_db will be _dbfile or _dbfile_actual based on Photos version
+ # unless DB is locked, in which case _tmp_db will point to a temporary copy
+ # if Photos <=4, _dbfile = _dbfile_actual = photos.db
+ # if Photos >= 5, _dbfile = photos.db, from which we get DB version but the actual
+ # photos data is in Photos.sqlite
+ # In either case, a temporary copy will be made if the DB is locked by Photos
+ # or photosanalysisd
+ self._dbfile = self._dbfile_actual = self._tmp_db = os.path.abspath(dbfile)
+
+ verbose(f"Processing database {self._dbfile}")
+
+ # if database is exclusively locked, make a copy of it and use the copy
+ # Photos maintains an exclusive lock on the database file while Photos is open
+ # photoanalysisd sometimes maintains this lock even after Photos is closed
+ # In those cases, make a temp copy of the file for sqlite3 to read
+ if _db_is_locked(self._dbfile):
+ verbose(f"Database locked, creating temporary copy.")
+ self._tmp_db = self._copy_db_file(self._dbfile)
+
+ self._db_version = get_db_version(self._tmp_db)
+
+ # If Photos >= 5, actual data isn't in photos.db but in Photos.sqlite
+ if int(self._db_version) > int(_PHOTOS_4_VERSION):
+ dbpath = pathlib.Path(self._dbfile).parent
+ dbfile = dbpath / "Photos.sqlite"
+ if not _check_file_exists(dbfile):
+ raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile)
+ else:
+ self._dbfile_actual = self._tmp_db = dbfile
+ verbose(f"Processing database {self._dbfile_actual}")
+ # if database is exclusively locked, make a copy of it and use the copy
+ if _db_is_locked(self._dbfile_actual):
+ verbose(f"Database locked, creating temporary copy.")
+ self._tmp_db = self._copy_db_file(self._dbfile_actual)
+
+ if _debug():
+ logging.debug(
+ f"_dbfile = {self._dbfile}, _dbfile_actual = {self._dbfile_actual}"
+ )
+
+ library_path = os.path.dirname(os.path.abspath(dbfile))
+ (library_path, _) = os.path.split(library_path) # drop /database from path
+ self._library_path = library_path
+ if int(self._db_version) <= int(_PHOTOS_4_VERSION):
+ masters_path = os.path.join(library_path, "Masters")
+ self._masters_path = masters_path
+ else:
+ masters_path = os.path.join(library_path, "originals")
+ self._masters_path = masters_path
+
+ if _debug():
+ logging.debug(f"library = {library_path}, masters = {masters_path}")
+
+ if int(self._db_version) <= int(_PHOTOS_4_VERSION):
+ self._process_database4()
+ else:
+ self._process_database5()
+
+ @property
+ def keywords_as_dict(self):
+ """ return keywords as dict of keyword, count in reverse sorted order (descending) """
+ keywords = {
+ k: len(self._dbkeywords_keyword[k]) for k in self._dbkeywords_keyword.keys()
+ }
+
+ keywords = dict(sorted(keywords.items(), key=lambda kv: kv[1], reverse=True))
+ return keywords
+
+ @property
+ def persons_as_dict(self):
+ """ return persons as dict of person, count in reverse sorted order (descending) """
+ persons = {}
+ for pk in self._dbfaces_pk:
+ fullname = self._dbpersons_pk[pk]["fullname"]
+ try:
+ persons[fullname] += len(self._dbfaces_pk[pk])
+ except KeyError:
+ persons[fullname] = len(self._dbfaces_pk[pk])
+ persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
+ return persons
+
+ @property
+ def albums_as_dict(self):
+ """ return albums as dict of albums, count in reverse sorted order (descending) """
+ albums = {}
+ album_keys = self._get_album_uuids(shared=False)
+ for album in album_keys:
+ title = self._dbalbum_details[album]["title"]
+ if album in self._dbalbums_album:
+ try:
+ albums[title] += len(self._dbalbums_album[album])
+ except KeyError:
+ albums[title] = len(self._dbalbums_album[album])
+ else:
+ albums[title] = 0 # empty album
+ albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
+ return albums
+
+ @property
+ def albums_shared_as_dict(self):
+ """ returns shared albums as dict of albums, count in reverse sorted order (descending)
+ valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict """
+
+ albums = {}
+ album_keys = self._get_album_uuids(shared=True)
+ for album in album_keys:
+ title = self._dbalbum_details[album]["title"]
+ if album in self._dbalbums_album:
+ try:
+ albums[title] += len(self._dbalbums_album[album])
+ except KeyError:
+ albums[title] = len(self._dbalbums_album[album])
+ else:
+ albums[title] = 0 # empty album
+ albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
+ return albums
+
+ @property
+ def keywords(self):
+ """ return list of keywords found in photos database """
+ keywords = self._dbkeywords_keyword.keys()
+ return list(keywords)
+
+ @property
+ def persons(self):
+ """ return list of persons found in photos database """
+ persons = {self._dbpersons_pk[k]["fullname"] for k in self._dbfaces_pk}
+ return list(persons)
+
+ @property
+ def person_info(self):
+ """ return list of PersonInfo objects for each person in the photos database """
+ try:
+ return self._person_info
+ except AttributeError:
+ self._person_info = [
+ PersonInfo(db=self, pk=pk) for pk in self._dbpersons_pk
+ ]
+ return self._person_info
+
+ @property
+ def folder_info(self):
+ """ return list FolderInfo objects representing top-level folders in the photos database """
+ if self._db_version <= _PHOTOS_4_VERSION:
+ folders = [
+ FolderInfo(db=self, uuid=folder)
+ for folder, detail in self._dbfolder_details.items()
+ if not detail["intrash"]
+ and not detail["isMagic"]
+ and detail["parentFolderUuid"] == _PHOTOS_4_TOP_LEVEL_ALBUM
+ ]
+ else:
+ folders = [
+ FolderInfo(db=self, uuid=album)
+ for album, detail in self._dbalbum_details.items()
+ if not detail["intrash"]
+ and detail["kind"] == _PHOTOS_5_FOLDER_KIND
+ and detail["parentfolder"] == self._folder_root_pk
+ ]
+ return folders
+
+ @property
+ def folders(self):
+ """ return list of top-level folder names in the photos database """
+ if self._db_version <= _PHOTOS_4_VERSION:
+ folder_names = [
+ folder["name"]
+ for folder in self._dbfolder_details.values()
+ if not folder["intrash"]
+ and not folder["isMagic"]
+ and folder["parentFolderUuid"] == _PHOTOS_4_TOP_LEVEL_ALBUM
+ ]
+ else:
+ folder_names = [
+ detail["title"]
+ for detail in self._dbalbum_details.values()
+ if not detail["intrash"]
+ and detail["kind"] == _PHOTOS_5_FOLDER_KIND
+ and detail["parentfolder"] == self._folder_root_pk
+ ]
+ return folder_names
+
+ @property
+ def album_info(self):
+ """ return list of AlbumInfo objects for each album in the photos database """
+ try:
+ return self._album_info
+ except AttributeError:
+ self._album_info = [
+ AlbumInfo(db=self, uuid=album)
+ for album in self._get_album_uuids(shared=False)
+ ]
+ return self._album_info
+
+ @property
+ def album_info_shared(self):
+ """ return list of AlbumInfo objects for each shared album in the photos database
+ only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
+ # if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
+ try:
+ return self._album_info_shared
+ except AttributeError:
+ self._album_info_shared = [
+ AlbumInfo(db=self, uuid=album)
+ for album in self._get_album_uuids(shared=True)
+ ]
+ return self._album_info_shared
+
+ @property
+ def albums(self):
+ """ return list of albums found in photos database """
+
+ # Could be more than one album with same name
+ # Right now, they are treated as same album and photos are combined from albums with same name
+
+ try:
+ return self._albums
+ except AttributeError:
+ self._albums = self._get_albums(shared=False)
+ return self._albums
+
+ @property
+ def albums_shared(self):
+ """ return list of shared albums found in photos database
+ only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
+
+ # Could be more than one album with same name
+ # Right now, they are treated as same album and photos are combined from albums with same name
+
+ # if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
+
+ try:
+ return self._albums_shared
+ except AttributeError:
+ self._albums_shared = self._get_albums(shared=True)
+ return self._albums_shared
+
+ @property
+ def import_info(self):
+ """ return list of ImportInfo objects for each import session in the database """
+ try:
+ return self._import_info
+ except AttributeError:
+ self._import_info = [
+ ImportInfo(db=self, uuid=album)
+ for album in self._get_album_uuids(import_session=True)
+ ]
+ return self._import_info
+
+ @property
+ def db_version(self):
+ """ return the database version as stored in LiGlobals table """
+ return self._db_version
+
+ @property
+ def db_path(self):
+ """ returns path to the Photos library database PhotosDB was initialized with """
+ return os.path.abspath(self._dbfile)
+
+ @property
+ def library_path(self):
+ """ returns path to the Photos library PhotosDB was initialized with """
+ return self._library_path
+
+[docs] def get_db_connection(self):
+ """ Get connection to the working copy of the Photos database
+
+ Returns:
+ tuple of (connection, cursor) to sqlite3 database
+ """
+ return _open_sql_file(self._tmp_db)
+
+ def _copy_db_file(self, fname):
+ """ copies the sqlite database file to a temp file """
+ """ returns the name of the temp file """
+ """ If sqlite shared memory and write-ahead log files exist, those are copied too """
+ # required because python's sqlite3 implementation can't read a locked file
+ # _, suffix = os.path.splitext(fname)
+ dest_name = dest_path = ""
+ try:
+ dest_name = pathlib.Path(fname).name
+ dest_path = os.path.join(self._tempdir_name, dest_name)
+ FileUtil.copy(fname, dest_path)
+ # copy write-ahead log and shared memory files (-wal and -shm) files if they exist
+ if os.path.exists(f"{fname}-wal"):
+ FileUtil.copy(f"{fname}-wal", f"{dest_path}-wal")
+ if os.path.exists(f"{fname}-shm"):
+ FileUtil.copy(f"{fname}-shm", f"{dest_path}-shm")
+ except:
+ print(f"Error copying{fname} to {dest_path}", file=sys.stderr)
+ raise Exception
+
+ if _debug():
+ logging.debug(dest_path)
+
+ return dest_path
+
+ # NOTE: This method seems to cause problems with applescript
+ # Bummer...would'be been nice to avoid copying the DB
+ # def _link_db_file(self, fname):
+ # """ links the sqlite database file to a temp file """
+ # """ returns the name of the temp file """
+ # """ If sqlite shared memory and write-ahead log files exist, those are copied too """
+ # # required because python's sqlite3 implementation can't read a locked file
+ # # _, suffix = os.path.splitext(fname)
+ # dest_name = dest_path = ""
+ # try:
+ # dest_name = pathlib.Path(fname).name
+ # dest_path = os.path.join(self._tempdir_name, dest_name)
+ # FileUtil.hardlink(fname, dest_path)
+ # # link write-ahead log and shared memory files (-wal and -shm) files if they exist
+ # if os.path.exists(f"{fname}-wal"):
+ # FileUtil.hardlink(f"{fname}-wal", f"{dest_path}-wal")
+ # if os.path.exists(f"{fname}-shm"):
+ # FileUtil.hardlink(f"{fname}-shm", f"{dest_path}-shm")
+ # except:
+ # print("Error linking " + fname + " to " + dest_path, file=sys.stderr)
+ # raise Exception
+
+ # if _debug():
+ # logging.debug(dest_path)
+
+ # return dest_path
+
+ def _process_database4(self):
+ """ process the Photos database to extract info
+ works on Photos version <= 4.0 """
+
+ verbose = self._verbose
+ verbose("Processing database.")
+ verbose(f"Database version: {self._db_version}.")
+
+ (conn, c) = _open_sql_file(self._tmp_db)
+
+ # get info to associate persons with photos
+ # then get detected faces in each photo and link to persons
+ verbose("Processing persons in photos.")
+ c.execute(
+ """ SELECT
+ RKPerson.modelID,
+ RKPerson.uuid,
+ RKPerson.name,
+ RKPerson.faceCount,
+ RKPerson.displayName,
+ RKPerson.representativeFaceId
+ FROM RKPerson
+ """
+ )
+
+ # 0 RKPerson.modelID,
+ # 1 RKPerson.uuid,
+ # 2 RKPerson.name,
+ # 3 RKPerson.faceCount,
+ # 4 RKPerson.displayName
+ # 5 RKPerson.representativeFaceId
+
+ for person in c:
+ pk = person[0]
+ fullname = person[2] if person[2] is not None else _UNKNOWN_PERSON
+ self._dbpersons_pk[pk] = {
+ "pk": pk,
+ "uuid": person[1],
+ "fullname": fullname,
+ "facecount": person[3],
+ "keyface": person[5],
+ "displayname": person[4],
+ "photo_uuid": None,
+ "keyface_uuid": None,
+ }
+ try:
+ self._dbpersons_fullname[fullname].append(pk)
+ except KeyError:
+ self._dbpersons_fullname[fullname] = [pk]
+
+ # get info on key face
+ c.execute(
+ """ SELECT
+ RKPerson.modelID,
+ RKPerson.representativeFaceId,
+ RKVersion.uuid,
+ RKFace.uuid
+ FROM RKPerson, RKFace, RKVersion
+ WHERE
+ RKFace.modelId = RKPerson.representativeFaceId AND
+ RKVersion.modelId = RKFace.ImageModelId
+ """
+ )
+
+ # 0 RKPerson.modelID,
+ # 1 RKPerson.representativeFaceId
+ # 2 RKVersion.uuid,
+ # 3 RKFace.uuid
+
+ for person in c:
+ pk = person[0]
+ try:
+ self._dbpersons_pk[pk]["photo_uuid"] = person[2]
+ self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
+ except KeyError:
+ logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
+
+ # get information on detected faces
+ verbose("Processing detected faces in photos.")
+ c.execute(
+ """ SELECT
+ RKPerson.modelID,
+ RKVersion.uuid
+ FROM
+ RKFace, RKPerson, RKVersion, RKMaster
+ WHERE
+ RKFace.personID = RKperson.modelID AND
+ RKVersion.modelId = RKFace.ImageModelId AND
+ RKVersion.masterUuid = RKMaster.uuid
+ """
+ )
+
+ # 0 RKPerson.modelID
+ # 1 RKVersion.uuid
+
+ for face in c:
+ pk = face[0]
+ uuid = face[1]
+ try:
+ self._dbfaces_uuid[uuid].append(pk)
+ except KeyError:
+ self._dbfaces_uuid[uuid] = [pk]
+
+ try:
+ self._dbfaces_pk[pk].append(uuid)
+ except KeyError:
+ self._dbfaces_pk[pk] = [uuid]
+
+ if _debug():
+ logging.debug(f"Finished walking through persons")
+ logging.debug(pformat(self._dbpersons_pk))
+ logging.debug(pformat(self._dbpersons_fullname))
+ logging.debug(pformat(self._dbfaces_pk))
+ logging.debug(pformat(self._dbfaces_uuid))
+
+ # Get info on albums
+ verbose("Processing albums.")
+ c.execute(
+ """ SELECT
+ RKAlbum.uuid,
+ RKVersion.uuid,
+ RKCustomSortOrder.orderNumber
+ FROM RKVersion
+ JOIN RKCustomSortOrder on RKCustomSortOrder.objectUuid = RKVersion.uuid
+ JOIN RKAlbum on RKAlbum.uuid = RKCustomSortOrder.containerUuid
+ """
+ )
+
+ # 0 RKAlbum.uuid,
+ # 1 RKVersion.uuid,
+ # 2 RKCustomSortOrder.orderNumber
+
+ for album in c:
+ # store by uuid in _dbalbums_uuid and by album in _dbalbums_album
+ album_uuid = album[0]
+ photo_uuid = album[1]
+ sort_order = album[2]
+ try:
+ self._dbalbums_uuid[photo_uuid].append(album_uuid)
+ except KeyError:
+ self._dbalbums_uuid[photo_uuid] = [album_uuid]
+
+ try:
+ self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
+ except KeyError:
+ self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]
+
+ # now get additional details about albums
+ c.execute(
+ """ SELECT
+ uuid,
+ name,
+ cloudLibraryState,
+ cloudIdentifier,
+ isInTrash,
+ folderUuid,
+ albumType,
+ albumSubclass,
+ createDate
+ FROM RKAlbum """
+ )
+
+ # Order of results
+ # 0: uuid
+ # 1: name
+ # 2: cloudLibraryState
+ # 3: cloudIdentifier
+ # 4: isInTrash
+ # 5: folderUuid
+ # 6: albumType
+ # 7: albumSubclass -- if 3, normal user album
+ # 8: createDate
+
+ for album in c:
+ self._dbalbum_details[album[0]] = {
+ "_uuid": album[0],
+ "title": normalize_unicode(album[1]),
+ "cloudlibrarystate": album[2],
+ "cloudidentifier": album[3],
+ "intrash": False if album[4] == 0 else True,
+ "cloudlocalstate": None, # Photos 5
+ "cloudownerfirstname": None, # Photos 5
+ "cloudownderlastname": None, # Photos 5
+ "cloudownerhashedpersonid": None, # Photos 5
+ "folderUuid": album[5],
+ "albumType": album[6],
+ "albumSubclass": album[7],
+ # for compatability with Photos 5 where album kind is ZKIND
+ "kind": album[7],
+ "creation_date": album[8],
+ "start_date": None, # Photos 5 only
+ "end_date": None, # Photos 5 only
+ }
+
+ # get details about folders
+ c.execute(
+ """ SELECT
+ uuid,
+ modelId,
+ name,
+ isMagic,
+ isInTrash,
+ folderType,
+ parentFolderUuid,
+ folderPath
+ FROM RKFolder """
+ )
+
+ # Order of results
+ # 0 uuid,
+ # 1 modelId,
+ # 2 name,
+ # 3 isMagic,
+ # 4 isInTrash,
+ # 5 folderType,
+ # 6 parentFolderUuid,
+ # 7 folderPath
+
+ for row in c:
+ uuid = row[0]
+ self._dbfolder_details[uuid] = {
+ "_uuid": row[0],
+ "modelId": row[1],
+ "name": normalize_unicode(row[2]),
+ "isMagic": row[3],
+ "intrash": row[4],
+ "folderType": row[5],
+ "parentFolderUuid": row[6],
+ "folderPath": row[7],
+ }
+
+ # build _dbalbum_folders in form uuid: [parent uuid] to be consistent with _process_database5
+ for album, details in self._dbalbum_details.items():
+ # album can be in a single folder
+ parent = details["folderUuid"]
+ self._dbalbum_parent_folders[album] = [parent]
+
+ # build folder hierarchy
+ for album, details in self._dbalbum_details.items():
+ parent_folder = details["folderUuid"]
+ if details[
+ "albumSubclass"
+ ] == _PHOTOS_4_ALBUM_KIND and parent_folder not in [
+ _PHOTOS_4_TOP_LEVEL_ALBUM
+ ]:
+ folder_hierarchy = self._build_album_folder_hierarchy_4(parent_folder)
+ self._dbalbum_folders[album] = folder_hierarchy
+ else:
+ self._dbalbum_folders[album] = {}
+
+ if _debug():
+ logging.debug(f"Finished walking through albums")
+ logging.debug(pformat(self._dbalbums_album))
+ logging.debug(pformat(self._dbalbums_uuid))
+ logging.debug(pformat(self._dbalbum_details))
+ logging.debug(pformat(self._dbalbum_folders))
+ logging.debug(pformat(self._dbfolder_details))
+
+ # Get info on keywords
+ verbose("Processing keywords.")
+ c.execute(
+ """ SELECT
+ RKKeyword.name,
+ RKVersion.uuid,
+ RKMaster.uuid
+ FROM
+ RKKeyword, RKKeywordForVersion, RKVersion, RKMaster
+ WHERE
+ RKKeyword.modelId = RKKeyWordForVersion.keywordID AND
+ RKVersion.modelID = RKKeywordForVersion.versionID AND
+ RKMaster.uuid = RKVersion.masterUuid
+ """
+ )
+ for keyword in c:
+ if not keyword[1] in self._dbkeywords_uuid:
+ self._dbkeywords_uuid[keyword[1]] = []
+ if not keyword[0] in self._dbkeywords_keyword:
+ self._dbkeywords_keyword[keyword[0]] = []
+ self._dbkeywords_uuid[keyword[1]].append(keyword[0])
+ self._dbkeywords_keyword[keyword[0]].append(keyword[1])
+
+ # Get info on disk volumes
+ c.execute("select RKVolume.modelId, RKVolume.name from RKVolume")
+ for vol in c:
+ self._dbvolumes[vol[0]] = vol[1]
+
+ # Get photo details
+ verbose("Processing photo details.")
+ if self._db_version < _PHOTOS_3_VERSION:
+ # Photos < 3.0 doesn't have RKVersion.selfPortrait (selfie)
+ c.execute(
+ """ SELECT RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename,
+ RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating,
+ RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds,
+ RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name,
+ RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden,
+ RKVersion.latitude, RKVersion.longitude,
+ RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI,
+ RKVersion.burstUuid, RKVersion.burstPickType,
+ RKVersion.specialType, RKMaster.modelID, null, RKVersion.momentUuid,
+ RKVersion.rawMasterUuid,
+ RKVersion.nonRawMasterUuid,
+ RKMaster.alternateMasterUuid,
+ RKVersion.isInTrash,
+ RKVersion.processedHeight,
+ RKVersion.processedWidth,
+ RKVersion.orientation,
+ RKMaster.height,
+ RKMaster.width,
+ RKMaster.orientation,
+ RKMaster.fileSize,
+ RKVersion.subType,
+ RKVersion.inTrashDate,
+ RKVersion.showInLibrary,
+ RKMaster.fileIsReference
+ FROM RKVersion, RKMaster
+ WHERE RKVersion.masterUuid = RKMaster.uuid"""
+ )
+ else:
+ c.execute(
+ """ SELECT RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename,
+ RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating,
+ RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds,
+ RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name,
+ RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden,
+ RKVersion.latitude, RKVersion.longitude,
+ RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI,
+ RKVersion.burstUuid, RKVersion.burstPickType,
+ RKVersion.specialType, RKMaster.modelID,
+ RKVersion.selfPortrait,
+ RKVersion.momentUuid,
+ RKVersion.rawMasterUuid,
+ RKVersion.nonRawMasterUuid,
+ RKMaster.alternateMasterUuid,
+ RKVersion.isInTrash,
+ RKVersion.processedHeight,
+ RKVersion.processedWidth,
+ RKVersion.orientation,
+ RKMaster.height,
+ RKMaster.width,
+ RKMaster.orientation,
+ RKMaster.originalFileSize,
+ RKVersion.subType,
+ RKVersion.inTrashDate,
+ RKVersion.showInLibrary,
+ RKMaster.fileIsReference
+ FROM RKVersion, RKMaster
+ WHERE RKVersion.masterUuid = RKMaster.uuid"""
+ )
+
+ # order of results
+ # 0 RKVersion.uuid
+ # 1 RKVersion.modelId
+ # 2 RKVersion.masterUuid
+ # 3 RKVersion.filename
+ # 4 RKVersion.lastmodifieddate
+ # 5 RKVersion.imageDate
+ # 6 RKVersion.mainRating
+ # 7 RKVersion.hasAdjustments
+ # 8 RKVersion.hasKeywords
+ # 9 RKVersion.imageTimeZoneOffsetSeconds
+ # 10 RKMaster.volumeId
+ # 11 RKMaster.imagePath
+ # 12 RKVersion.extendedDescription
+ # 13 RKVersion.name
+ # 14 RKMaster.isMissing
+ # 15 RKMaster.originalFileName
+ # 16 RKVersion.isFavorite
+ # 17 RKVersion.isHidden
+ # 18 RKVersion.latitude
+ # 19 RKVersion.longitude
+ # 20 RKVersion.adjustmentUuid
+ # 21 RKVersion.type
+ # 22 RKMaster.UTI
+ # 23 RKVersion.burstUuid
+ # 24 RKVersion.burstPickType
+ # 25 RKVersion.specialType
+ # 26 RKMaster.modelID
+ # 27 RKVersion.selfPortrait -- 1 if selfie, Photos >= 3, not present for Photos < 3
+ # 28 RKVersion.momentID (# 27 for Photos < 3)
+ # 29 RKVersion.rawMasterUuid, -- UUID of RAW master
+ # 30 RKVersion.nonRawMasterUuid, -- UUID of non-RAW master
+ # 31 RKMaster.alternateMasterUuid -- UUID of alternate master (will be RAW master for JPEG and JPEG master for RAW)
+ # 32 RKVersion.isInTrash
+ # 33 RKVersion.processedHeight,
+ # 34 RKVersion.processedWidth,
+ # 35 RKVersion.orientation,
+ # 36 RKMaster.height,
+ # 37 RKMaster.width,
+ # 38 RKMaster.orientation,
+ # 39 RKMaster.originalFileSize
+ # 40 RKVersion.subType
+ # 41 RKVersion.inTrashDate
+ # 42 RKVersion.showInLibrary -- is item visible in library (e.g. non-selected burst images are not visible)
+ # 43 RKMaster.fileIsReference -- file is reference (imported without copying to Photos library)
+
+ for row in c:
+ uuid = row[0]
+ if _debug():
+ logging.debug(f"uuid = '{uuid}, master = '{row[2]}")
+ self._dbphotos[uuid] = {}
+ self._dbphotos[uuid]["_uuid"] = uuid # stored here for easier debugging
+ self._dbphotos[uuid]["modelID"] = row[1]
+ self._dbphotos[uuid]["masterUuid"] = row[2]
+ self._dbphotos[uuid]["filename"] = row[3]
+
+ # There are sometimes negative values for lastmodifieddate in the database
+ # I don't know what these mean but they will raise exception in datetime if
+ # not accounted for
+ self._dbphotos[uuid]["lastmodifieddate_timestamp"] = row[4]
+ try:
+ self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp(
+ row[4] + TIME_DELTA
+ )
+ except ValueError:
+ self._dbphotos[uuid]["lastmodifieddate"] = None
+ except TypeError:
+ self._dbphotos[uuid]["lastmodifieddate"] = None
+
+ self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
+ self._dbphotos[uuid]["imageDate_timestamp"] = row[5]
+
+ try:
+ imagedate = datetime.fromtimestamp(row[5] + TIME_DELTA)
+ seconds = self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] or 0
+ delta = timedelta(seconds=seconds)
+ tz = timezone(delta)
+ self._dbphotos[uuid]["imageDate"] = imagedate.astimezone(tz=tz)
+ except ValueError:
+ # sometimes imageDate is invalid so use 1 Jan 1970 in UTC as image date
+ imagedate = datetime(1970, 1, 1)
+ tz = timezone(timedelta(0))
+ self._dbphotos[uuid]["imageDate"] = imagedate.astimezone(tz=tz)
+
+ self._dbphotos[uuid]["mainRating"] = row[6]
+ self._dbphotos[uuid]["hasAdjustments"] = row[7]
+ self._dbphotos[uuid]["hasKeywords"] = row[8]
+ self._dbphotos[uuid]["volumeId"] = row[10]
+ self._dbphotos[uuid]["imagePath"] = row[11]
+ self._dbphotos[uuid]["extendedDescription"] = row[12]
+ self._dbphotos[uuid]["name"] = normalize_unicode(row[13])
+ self._dbphotos[uuid]["isMissing"] = row[14]
+ self._dbphotos[uuid]["originalFilename"] = row[15]
+ self._dbphotos[uuid]["favorite"] = row[16]
+ self._dbphotos[uuid]["hidden"] = row[17]
+ self._dbphotos[uuid]["latitude"] = row[18]
+ self._dbphotos[uuid]["longitude"] = row[19]
+ self._dbphotos[uuid]["adjustmentUuid"] = row[20]
+ self._dbphotos[uuid]["adjustmentFormatID"] = None
+
+ # find type and UTI
+ if row[21] == 2:
+ # photo
+ self._dbphotos[uuid]["type"] = _PHOTO_TYPE
+ elif row[21] == 8:
+ # movie
+ self._dbphotos[uuid]["type"] = _MOVIE_TYPE
+ else:
+ # unknown
+ if _debug():
+ logging.debug(f"WARNING: {uuid} found unknown type {row[21]}")
+ self._dbphotos[uuid]["type"] = None
+
+ self._dbphotos[uuid]["UTI"] = row[22]
+
+ # The UTI in RKMaster will always be UTI of the original
+ # Unlike Photos 5 which changes the UTI to match latest edit
+ self._dbphotos[uuid]["UTI_original"] = row[22]
+
+ # UTI edited will be read from RKModelResource
+ self._dbphotos[uuid]["UTI_edited"] = None
+
+ # handle burst photos
+ # if burst photo, determine whether or not it's a selected burst photo
+ self._dbphotos[uuid]["burstUUID"] = row[23]
+ self._dbphotos[uuid]["burstPickType"] = row[24]
+ if row[23] is not None:
+ # it's a burst photo
+ self._dbphotos[uuid]["burst"] = True
+ burst_uuid = row[23]
+ if burst_uuid not in self._dbphotos_burst:
+ self._dbphotos_burst[burst_uuid] = set()
+ self._dbphotos_burst[burst_uuid].add(uuid)
+ if row[24] != 2 and row[24] != 4:
+ self._dbphotos[uuid][
+ "burst_key"
+ ] = True # it's a key photo (selected from the burst)
+ else:
+ self._dbphotos[uuid][
+ "burst_key"
+ ] = False # it's a burst photo but not one that's selected
+ else:
+ # not a burst photo
+ self._dbphotos[uuid]["burst"] = False
+ self._dbphotos[uuid]["burst_key"] = None
+
+ # RKVersion.specialType
+ # 1 == panorama
+ # 2 == slow-mo movie
+ # 3 == time-lapse movie
+ # 4 == HDR
+ # 5 == live photo
+ # 6 == screenshot
+ # 7 == JPEG/RAW pair
+ # 8 == HDR live photo
+ # 9 = portrait
+
+ # get info on special types
+ self._dbphotos[uuid]["specialType"] = row[25]
+ self._dbphotos[uuid]["masterModelID"] = row[26]
+ self._dbphotos[uuid]["panorama"] = True if row[25] == 1 else False
+ self._dbphotos[uuid]["slow_mo"] = True if row[25] == 2 else False
+ self._dbphotos[uuid]["time_lapse"] = True if row[25] == 3 else False
+ self._dbphotos[uuid]["hdr"] = (
+ True if (row[25] == 4 or row[25] == 8) else False
+ )
+ self._dbphotos[uuid]["live_photo"] = (
+ True if (row[25] == 5 or row[25] == 8) else False
+ )
+ self._dbphotos[uuid]["screenshot"] = True if row[25] == 6 else False
+ self._dbphotos[uuid]["portrait"] = True if row[25] == 9 else False
+
+ # selfies (front facing camera, RKVersion.selfPortrait == 1)
+ if row[27] is not None:
+ self._dbphotos[uuid]["selfie"] = True if row[27] == 1 else False
+ else:
+ self._dbphotos[uuid]["selfie"] = None
+
+ self._dbphotos[uuid]["momentID"] = row[28]
+
+ # Init cloud details that will be filled in later if cloud asset
+ self._dbphotos[uuid]["cloudAssetGUID"] = None # Photos 5
+ self._dbphotos[uuid]["cloudLocalState"] = None # Photos 5
+ self._dbphotos[uuid]["cloudLibraryState"] = None
+ self._dbphotos[uuid]["cloudStatus"] = None
+ self._dbphotos[uuid]["cloudAvailable"] = None
+ self._dbphotos[uuid]["incloud"] = None
+
+ # associated RAW image info
+ self._dbphotos[uuid]["has_raw"] = True if row[25] == 7 else False
+ self._dbphotos[uuid]["UTI_raw"] = None
+ self._dbphotos[uuid]["raw_data_length"] = None
+ self._dbphotos[uuid]["raw_info"] = None
+ self._dbphotos[uuid]["resource_type"] = None # Photos 5
+ self._dbphotos[uuid]["datastore_subtype"] = None # Photos 5
+ self._dbphotos[uuid]["raw_master_uuid"] = row[29]
+ self._dbphotos[uuid]["non_raw_master_uuid"] = row[30]
+ self._dbphotos[uuid]["alt_master_uuid"] = row[31]
+
+ # original resource choice (e.g. RAW or jpeg)
+ # In Photos 5+, original_resource_choice set from:
+ # ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
+ # = 0 if jpeg is selected as "original" in Photos (the default)
+ # = 1 if RAW is selected as "original" in Photos
+ # RKVersion.subType, RAW always appears to be 16
+ # 4 = mov
+ # 16 = RAW
+ # 32 = JPEG
+ # 64 = TIFF
+ # 2048 = PNG
+ # 32768 = HIEC
+ self._dbphotos[uuid]["original_resource_choice"] = (
+ 1 if row[40] == 16 and self._dbphotos[uuid]["has_raw"] else 0
+ )
+ self._dbphotos[uuid]["raw_is_original"] = bool(
+ self._dbphotos[uuid]["original_resource_choice"]
+ )
+
+ # recently deleted items
+ self._dbphotos[uuid]["intrash"] = row[32] == 1
+ self._dbphotos[uuid]["trasheddate_timestamp"] = row[41]
+ try:
+ self._dbphotos[uuid]["trasheddate"] = datetime.fromtimestamp(
+ row[41] + TIME_DELTA
+ )
+ except (ValueError, TypeError):
+ self._dbphotos[uuid]["trasheddate"] = None
+
+ # height/width/orientation
+ self._dbphotos[uuid]["height"] = row[33]
+ self._dbphotos[uuid]["width"] = row[34]
+ self._dbphotos[uuid]["orientation"] = row[35]
+ self._dbphotos[uuid]["original_height"] = row[36]
+ self._dbphotos[uuid]["original_width"] = row[37]
+ self._dbphotos[uuid]["original_orientation"] = row[38]
+ self._dbphotos[uuid]["original_filesize"] = row[39]
+
+ # visibility state
+ self._dbphotos[uuid]["visibility_state"] = row[42]
+ self._dbphotos[uuid]["visible"] = row[42] == 1
+
+ # file is reference (not copied into Photos library)
+ self._dbphotos[uuid]["isreference"] = row[43] == 1
+ self._dbphotos[uuid]["saved_asset_type"] = None # Photos 5+
+
+ # import session not yet handled for Photos 4
+ self._dbphotos[uuid]["import_session"] = None
+ self._dbphotos[uuid]["import_uuid"] = None
+ self._dbphotos[uuid]["fok_import_session"] = None
+
+ # get additional details from RKMaster, needed for RAW processing
+ verbose("Processing additional photo details.")
+ c.execute(
+ """ SELECT
+ RKMaster.uuid,
+ RKMaster.volumeId,
+ RKMaster.imagePath,
+ RKMaster.isMissing,
+ RKMaster.originalFileName,
+ RKMaster.UTI,
+ RKMaster.modelID,
+ RKMaster.fileSize,
+ RKMaster.isTrulyRaw,
+ RKMaster.alternateMasterUuid,
+ RKMaster.filename
+ FROM RKMaster
+ """
+ )
+
+ # Order of results:
+ # 0 RKMaster.uuid,
+ # 1 RKMaster.volumeId,
+ # 2 RKMaster.imagePath,
+ # 3 RKMaster.isMissing,
+ # 4 RKMaster.originalFileName,
+ # 5 RKMaster.UTI,
+ # 6 RKMaster.modelID,
+ # 7 RKMaster.fileSize,
+ # 8 RKMaster.isTrulyRaw,
+ # 9 RKMaster.alternateMasterUuid
+ # 10 RKMaster.filename
+
+ for row in c:
+ uuid = row[0]
+ info = {}
+ info["_uuid"] = uuid
+ info["volumeId"] = row[1]
+ info["imagePath"] = row[2]
+ info["isMissing"] = row[3]
+ info["originalFilename"] = row[4]
+ info["UTI"] = row[5]
+ info["modelID"] = row[6]
+ info["fileSize"] = row[7]
+ info["isTrulyRAW"] = row[8]
+ info["alternateMasterUuid"] = row[9]
+ info["filename"] = row[10]
+ self._dbphotos_master[uuid] = info
+
+ # get details needed to find path of the edited photos
+ c.execute(
+ """ SELECT RKVersion.uuid, RKVersion.adjustmentUuid, RKModelResource.modelId,
+ RKModelResource.resourceTag, RKModelResource.UTI, RKVersion.specialType,
+ RKModelResource.attachedModelType, RKModelResource.resourceType
+ FROM RKVersion
+ JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId """
+ )
+
+ # Order of results:
+ # 0 RKVersion.uuid
+ # 1 RKVersion.adjustmentUuid
+ # 2 RKModelResource.modelId
+ # 3 RKModelResource.resourceTag
+ # 4 RKModelResource.UTI
+ # 5 RKVersion.specialType
+ # 6 RKModelResource.attachedModelType
+ # 7 RKModelResource.resourceType
+
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ # get info on adjustments (edits)
+ if self._dbphotos[uuid]["adjustmentUuid"] == row[3]:
+ if (
+ row[1] != "UNADJUSTEDNONRAW"
+ and row[1] != "UNADJUSTED"
+ and row[6] == 2
+ ):
+ if "edit_resource_id" in self._dbphotos[uuid]:
+ if _debug():
+ logging.debug(
+ f"WARNING: found more than one edit_resource_id for "
+ f"UUID {row[0]},adjustmentUUID {row[1]}, modelID {row[2]}"
+ )
+ # TODO: I think there should never be more than one edit but
+ # I've seen this once in my library
+ # should we return all edits or just most recent one?
+ # For now, return most recent edit
+ self._dbphotos[uuid]["edit_resource_id"] = row[2]
+ self._dbphotos[uuid]["UTI_edited"] = row[4]
+
+ # get details on external edits
+ c.execute(
+ """ SELECT RKVersion.uuid,
+ RKVersion.adjustmentUuid,
+ RKAdjustmentData.originator,
+ RKAdjustmentData.format
+ FROM RKVersion, RKAdjustmentData
+ WHERE RKVersion.adjustmentUuid = RKAdjustmentData.uuid """
+ )
+
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["adjustmentFormatID"] = row[3]
+
+ # get details to find path of live photos
+ c.execute(
+ """ SELECT
+ RKVersion.uuid,
+ RKModelResource.modelId,
+ RKModelResource.UTI,
+ RKVersion.specialType,
+ RKModelResource.attachedModelType,
+ RKModelResource.resourceType,
+ RKModelResource.isOnDisk
+ FROM RKVersion
+ INNER JOIN RKMaster on RKVersion.masterUuid = RKMaster.uuid
+ INNER JOIN RKModelResource on RKMaster.modelId = RKModelResource.attachedModelId
+ WHERE RKModelResource.UTI = 'com.apple.quicktime-movie'
+ """
+ )
+
+ # Order of results
+ # 0 RKVersion.uuid,
+ # 1 RKModelResource.modelId,
+ # 2 RKModelResource.UTI,
+ # 3 RKVersion.specialType,
+ # 4 RKModelResource.attachedModelType,
+ # 5 RKModelResource.resourceType
+ # 6 RKModelResource.isOnDisk
+
+ # TODO: don't think we need most of these fields, remove from SQL query?
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["live_model_id"] = row[1]
+ self._dbphotos[uuid]["modeResourceIsOnDisk"] = (
+ True if row[6] == 1 else False
+ )
+
+ # init any uuids that had no edits or live photos
+ for uuid in self._dbphotos:
+ if "edit_resource_id" not in self._dbphotos[uuid]:
+ self._dbphotos[uuid]["edit_resource_id"] = None
+ if "live_model_id" not in self._dbphotos[uuid]:
+ self._dbphotos[uuid]["live_model_id"] = None
+ self._dbphotos[uuid]["modeResourceIsOnDisk"] = None
+
+ # get cloud details
+ c.execute(
+ """ SELECT
+ RKVersion.uuid,
+ RKMaster.cloudLibraryState,
+ RKCloudResource.available,
+ RKCloudResource.status
+ FROM RKCloudResource
+ INNER JOIN RKMaster ON RKMaster.fingerprint = RKCloudResource.fingerprint
+ INNER JOIN RKVersion ON RKVersion.masterUuid = RKMaster.uuid """
+ )
+
+ # Order of results
+ # 0 RKVersion.uuid,
+ # 1 RKMaster.cloudLibraryState,
+ # 2 RKCloudResource.available,
+ # 3 RKCloudResource.status
+
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["cloudLibraryState"] = row[1]
+ self._dbphotos[uuid]["cloudAvailable"] = row[2]
+ self._dbphotos[uuid]["cloudStatus"] = row[3]
+ self._dbphotos[uuid]["incloud"] = True if row[2] == 1 else False
+
+ # get location data
+ verbose("Processing location data.")
+ # get the country codes
+ country_codes = c.execute(
+ "SELECT modelID, countryCode "
+ "FROM RKPlace "
+ "WHERE countryCode IS NOT NULL "
+ ).fetchall()
+ countries = {code[0]: code[1] for code in country_codes}
+ self._db_countries = countries
+
+ # get the place data
+ place_data = c.execute(
+ "SELECT modelID, defaultName, type, area " "FROM RKPlace "
+ ).fetchall()
+ places = {p[0]: p for p in place_data}
+ self._db_places = places
+
+ for uuid in self._dbphotos:
+ # get placeId which is then used to lookup defaultName
+ place_ids_query = c.execute(
+ "SELECT placeId "
+ "FROM RKPlaceForVersion "
+ f"WHERE versionId = '{self._dbphotos[uuid]['modelID']}'"
+ )
+
+ place_ids = [id[0] for id in place_ids_query.fetchall()]
+ self._dbphotos[uuid]["placeIDs"] = place_ids
+ country_code = [countries[x] for x in place_ids if x in countries]
+ if len(country_code) > 1:
+ logging.warning(f"Found more than one country code for uuid: {uuid}")
+
+ if country_code:
+ self._dbphotos[uuid]["countryCode"] = country_code[0]
+ else:
+ self._dbphotos[uuid]["countryCode"] = None
+
+ # get the place info that matches the RKPlace modelIDs for this photo
+ # (place_ids), sort by area (element 3 of the place_data tuple in places)
+ # area could be None so assume 0 if it is (issue #230)
+ place_names = [
+ pname
+ for pname in sorted(
+ [places[p] for p in places if p in place_ids],
+ key=lambda place: place[3] if place[3] is not None else 0,
+ )
+ ]
+
+ self._dbphotos[uuid]["placeNames"] = place_names
+ self._dbphotos[uuid]["reverse_geolocation"] = None # Photos 5
+
+ # build album_titles dictionary
+ for album_id in self._dbalbum_details:
+ title = self._dbalbum_details[album_id]["title"]
+ if title in self._dbalbum_titles:
+ self._dbalbum_titles[title].append(album_id)
+ else:
+ self._dbalbum_titles[title] = [album_id]
+
+ # add volume name to _dbphotos_master
+ for info in self._dbphotos_master.values():
+ # issue 230: have seen bad volumeID values
+ try:
+ info["volume"] = (
+ self._dbvolumes[info["volumeId"]]
+ if info["volumeId"] is not None
+ else None
+ )
+ except KeyError:
+ info["volume"] = None
+
+ # add data on RAW images
+ for info in self._dbphotos.values():
+ if info["has_raw"]:
+ raw_uuid = info["raw_master_uuid"]
+ info["raw_info"] = self._dbphotos_master[raw_uuid]
+ info["UTI_raw"] = self._dbphotos_master[raw_uuid]["UTI"]
+ non_raw_uuid = info["non_raw_master_uuid"]
+ info["raw_pair_info"] = self._dbphotos_master[non_raw_uuid]
+ else:
+ info["raw_info"] = None
+ info["UTI_raw"] = None
+ info["raw_pair_info"] = None
+
+ # done with the database connection
+ conn.close()
+
+ # process faces
+ verbose("Processing face details.")
+ self._process_faceinfo()
+
+ # add faces and keywords to photo data
+ for uuid in self._dbphotos:
+ # keywords
+ if self._dbphotos[uuid]["hasKeywords"] == 1:
+ self._dbphotos[uuid]["keywords"] = self._dbkeywords_uuid[uuid]
+ else:
+ self._dbphotos[uuid]["keywords"] = []
+
+ if uuid in self._dbfaces_uuid:
+ self._dbphotos[uuid]["hasPersons"] = 1
+ self._dbphotos[uuid]["persons"] = self._dbfaces_uuid[uuid]
+ else:
+ self._dbphotos[uuid]["hasPersons"] = 0
+ self._dbphotos[uuid]["persons"] = []
+
+ if uuid in self._dbalbums_uuid:
+ self._dbphotos[uuid]["albums"] = self._dbalbums_uuid[uuid]
+ self._dbphotos[uuid]["hasAlbums"] = 1
+ else:
+ self._dbphotos[uuid]["albums"] = []
+ self._dbphotos[uuid]["hasAlbums"] = 0
+
+ if self._dbphotos[uuid]["volumeId"] is not None:
+ # issue 230: have seen bad volumeID values
+ try:
+ self._dbphotos[uuid]["volume"] = self._dbvolumes[
+ self._dbphotos[uuid]["volumeId"]
+ ]
+ except KeyError:
+ self._dbphotos[uuid]["volume"] = None
+ else:
+ self._dbphotos[uuid]["volume"] = None
+
+ # done processing, dump debug data if requested
+ verbose("Done processing details from Photos library.")
+ if _debug():
+ logging.debug("Faces (_dbfaces_uuid):")
+ logging.debug(pformat(self._dbfaces_uuid))
+
+ logging.debug("Persons (_dbpersons_pk):")
+ logging.debug(pformat(self._dbpersons_pk))
+
+ logging.debug("Keywords by uuid (_dbkeywords_uuid):")
+ logging.debug(pformat(self._dbkeywords_uuid))
+
+ logging.debug("Keywords by keyword (_dbkeywords_keywords):")
+ logging.debug(pformat(self._dbkeywords_keyword))
+
+ logging.debug("Albums by uuid (_dbalbums_uuid):")
+ logging.debug(pformat(self._dbalbums_uuid))
+
+ logging.debug("Albums by album (_dbalbums_albums):")
+ logging.debug(pformat(self._dbalbums_album))
+
+ logging.debug("Album details (_dbalbum_details):")
+ logging.debug(pformat(self._dbalbum_details))
+
+ logging.debug("Album titles (_dbalbum_titles):")
+ logging.debug(pformat(self._dbalbum_titles))
+
+ logging.debug("Volumes (_dbvolumes):")
+ logging.debug(pformat(self._dbvolumes))
+
+ logging.debug("Photos (_dbphotos):")
+ logging.debug(pformat(self._dbphotos))
+
+ logging.debug("Burst Photos (dbphotos_burst:")
+ logging.debug(pformat(self._dbphotos_burst))
+
+ def _build_album_folder_hierarchy_4(self, uuid, folders=None):
+ """ recursively build folder/album hierarchy
+ uuid: parent uuid of the album being processed
+ (parent uuid is a folder in RKFolders)
+ folders: dict holding the folder hierarchy
+ NOTE: This implementation is different than _build_album_folder_hierarchy_5
+ which takes the uuid of the album being processed. Here uuid is the parent uuid
+ of the parent folder album because in Photos <=4, folders are in RKFolders and
+ albums in RKAlbums. In Photos 5, folders are just special albums
+ with kind = _PHOTOS_5_FOLDER_KIND """
+
+ parent_uuid = self._dbfolder_details[uuid]["parentFolderUuid"]
+
+ if parent_uuid is None:
+ return folders
+
+ if parent_uuid == _PHOTOS_4_TOP_LEVEL_ALBUM:
+ if not folders:
+ # this is a top-level folder with no sub-folders
+ folders = {uuid: None}
+ # at top of hierarchy, we're done
+ return folders
+
+ # recurse to keep building
+ if not folders:
+ # first time building
+ folders = {uuid: None}
+ folders = {parent_uuid: folders}
+ folders = self._build_album_folder_hierarchy_4(parent_uuid, folders=folders)
+ return folders
+
+ def _process_database5(self):
+ """ process the Photos database to extract info
+ works on Photos version 5 and version 6
+
+ This is a big hairy 700 line function that should probably be refactored
+ but it works so don't touch it.
+ """
+
+ if _debug():
+ logging.debug(f"_process_database5")
+ verbose = self._verbose
+ verbose(f"Processing database.")
+ (conn, c) = _open_sql_file(self._tmp_db)
+
+ # some of the tables/columns have different names in different versions of Photos
+ photos_ver = get_db_model_version(self._tmp_db)
+ self._photos_ver = photos_ver
+ verbose(f"Database version: {self._db_version}, {photos_ver}.")
+ asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
+ keyword_join = _DB_TABLE_NAMES[photos_ver]["KEYWORD_JOIN"]
+ album_join = _DB_TABLE_NAMES[photos_ver]["ALBUM_JOIN"]
+ album_sort = _DB_TABLE_NAMES[photos_ver]["ALBUM_SORT_ORDER"]
+ import_fok = _DB_TABLE_NAMES[photos_ver]["IMPORT_FOK"]
+ depth_state = _DB_TABLE_NAMES[photos_ver]["DEPTH_STATE"]
+
+ # Look for all combinations of persons and pictures
+ if _debug():
+ logging.debug(f"Getting information about persons")
+
+ # get info to associate persons with photos
+ # then get detected faces in each photo and link to persons
+ verbose("Processing persons in photos.")
+ c.execute(
+ """ SELECT
+ ZPERSON.Z_PK,
+ ZPERSON.ZPERSONUUID,
+ ZPERSON.ZFULLNAME,
+ ZPERSON.ZFACECOUNT,
+ ZPERSON.ZKEYFACE,
+ ZPERSON.ZDISPLAYNAME
+ FROM ZPERSON
+ """
+ )
+
+ # 0 ZPERSON.Z_PK,
+ # 1 ZPERSON.ZPERSONUUID,
+ # 2 ZPERSON.ZFULLNAME,
+ # 3 ZPERSON.ZFACECOUNT,
+ # 4 ZPERSON.ZKEYFACE,
+ # 5 ZPERSON.ZDISPLAYNAME
+
+ for person in c:
+ pk = person[0]
+ fullname = person[2] if person[2] != "" else _UNKNOWN_PERSON
+ self._dbpersons_pk[pk] = {
+ "pk": pk,
+ "uuid": person[1],
+ "fullname": fullname,
+ "facecount": person[3],
+ "keyface": person[4],
+ "displayname": person[5],
+ "photo_uuid": None,
+ "keyface_uuid": None,
+ }
+ try:
+ self._dbpersons_fullname[fullname].append(pk)
+ except KeyError:
+ self._dbpersons_fullname[fullname] = [pk]
+
+ # get info on keyface -- some photos have null keyface so can't do a single query
+ # (at least not with my SQL skills)
+ c.execute(
+ f""" SELECT
+ ZPERSON.Z_PK,
+ ZPERSON.ZKEYFACE,
+ {asset_table}.ZUUID,
+ ZDETECTEDFACE.ZUUID
+ FROM ZPERSON, ZDETECTEDFACE, {asset_table}
+ WHERE ZDETECTEDFACE.Z_PK = ZPERSON.ZKEYFACE AND
+ ZDETECTEDFACE.ZASSET = {asset_table}.Z_PK
+ """
+ )
+
+ # 0 ZPERSON.Z_PK,
+ # 1 ZPERSON.ZKEYFACE,
+ # 2 ZGENERICASSET.ZUUID,
+ # 3 ZDETECTEDFACE.ZUUID
+
+ for person in c:
+ pk = person[0]
+ try:
+ self._dbpersons_pk[pk]["photo_uuid"] = person[2]
+ self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
+ except KeyError:
+ logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
+
+ # get information on detected faces
+ verbose("Processing detected faces in photos.")
+ c.execute(
+ f""" SELECT
+ ZPERSON.Z_PK,
+ {asset_table}.ZUUID
+ FROM ZPERSON, ZDETECTEDFACE, {asset_table}
+ WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND
+ ZDETECTEDFACE.ZASSET = {asset_table}.Z_PK
+ """
+ )
+
+ # 0 ZPERSON.Z_PK,
+ # 1 ZGENERICASSET.ZUUID,
+
+ for face in c:
+ pk = face[0]
+ uuid = face[1]
+ try:
+ self._dbfaces_uuid[uuid].append(pk)
+ except KeyError:
+ self._dbfaces_uuid[uuid] = [pk]
+
+ try:
+ self._dbfaces_pk[pk].append(uuid)
+ except KeyError:
+ self._dbfaces_pk[pk] = [uuid]
+
+ if _debug():
+ logging.debug(f"Finished walking through persons")
+ logging.debug(pformat(self._dbpersons_pk))
+ logging.debug(pformat(self._dbpersons_fullname))
+ logging.debug(pformat(self._dbfaces_pk))
+ logging.debug(pformat(self._dbfaces_uuid))
+
+ # get details about albums
+ verbose("Processing albums.")
+ c.execute(
+ f""" SELECT
+ ZGENERICALBUM.ZUUID,
+ {asset_table}.ZUUID,
+ {album_sort}
+ FROM {asset_table}
+ JOIN Z_26ASSETS ON {album_join} = {asset_table}.Z_PK
+ JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS
+ """
+ )
+
+ # 0 ZGENERICALBUM.ZUUID,
+ # 1 ZGENERICASSET.ZUUID,
+ # 2 Z_26ASSETS.Z_FOK_34ASSETS
+
+ for album in c:
+ # store by uuid in _dbalbums_uuid and by album in _dbalbums_album
+ album_uuid = album[0]
+ photo_uuid = album[1]
+ sort_order = album[2]
+ try:
+ self._dbalbums_uuid[photo_uuid].append(album_uuid)
+ except KeyError:
+ self._dbalbums_uuid[photo_uuid] = [album_uuid]
+
+ try:
+ self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
+ except KeyError:
+ self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]
+
+ # now get additional details about albums
+ c.execute(
+ "SELECT "
+ "ZUUID, " # 0
+ "ZTITLE, " # 1
+ "ZCLOUDLOCALSTATE, " # 2
+ "ZCLOUDOWNERFIRSTNAME, " # 3
+ "ZCLOUDOWNERLASTNAME, " # 4
+ "ZCLOUDOWNERHASHEDPERSONID, " # 5
+ "ZKIND, " # 6
+ "ZPARENTFOLDER, " # 7
+ "Z_PK, " # 8
+ "ZTRASHEDSTATE, " # 9
+ "ZCREATIONDATE, " # 10
+ "ZSTARTDATE, " # 11
+ "ZENDDATE " # 12
+ "FROM ZGENERICALBUM "
+ )
+ for album in c:
+ self._dbalbum_details[album[0]] = {
+ "_uuid": album[0],
+ "title": normalize_unicode(album[1]),
+ "cloudlocalstate": album[2],
+ "cloudownerfirstname": album[3],
+ "cloudownderlastname": album[4],
+ "cloudownerhashedpersonid": album[5],
+ "cloudlibrarystate": None, # Photos 4
+ "cloudidentifier": None, # Photos 4
+ "kind": album[6],
+ "parentfolder": album[7],
+ "pk": album[8],
+ "intrash": False if album[9] == 0 else True,
+ "creation_date": album[10],
+ "start_date": album[11],
+ "end_date": album[12],
+ }
+
+ # add cross-reference by pk to uuid
+ # needed to extract folder hierarchy
+ # in Photos >= 5, folders are special albums
+ self._dbalbums_pk[album[8]] = album[0]
+
+ # get pk of root folder
+ root_uuid = [
+ album
+ for album, details in self._dbalbum_details.items()
+ if details["kind"] == _PHOTOS_5_ROOT_FOLDER_KIND
+ ]
+ if len(root_uuid) != 1:
+ raise ValueError(f"Error finding root folder: {root_uuid}")
+ else:
+ self._folder_root_pk = self._dbalbum_details[root_uuid[0]]["pk"]
+
+ # build _dbalbum_folders which is in form uuid: [list of parent uuids]
+ # TODO: look at this code...it works but I think I album can only be in a single folder
+ # which means there's a code path that will never get executed
+ for album, details in self._dbalbum_details.items():
+ pk_parent = details["parentfolder"]
+ if pk_parent is None:
+ continue
+
+ try:
+ parent = self._dbalbums_pk[pk_parent]
+ except KeyError:
+ raise ValueError(f"Did not find uuid for album {album} pk {pk_parent}")
+
+ try:
+ self._dbalbum_parent_folders[album].append(parent)
+ except KeyError:
+ self._dbalbum_parent_folders[album] = [parent]
+
+ for album, details in self._dbalbum_details.items():
+ # if details["kind"] in [_PHOTOS_5_ALBUM_KIND, _PHOTOS_5_FOLDER_KIND]:
+ if details["kind"] == _PHOTOS_5_ALBUM_KIND:
+ folder_hierarchy = self._build_album_folder_hierarchy_5(album)
+ self._dbalbum_folders[album] = folder_hierarchy
+ elif details["kind"] == _PHOTOS_5_SHARED_ALBUM_KIND:
+ # shared albums can't be in folders
+ self._dbalbum_folders[album] = []
+
+ if _debug():
+ logging.debug(f"Finished walking through albums")
+ logging.debug(pformat(self._dbalbums_album))
+ logging.debug(pformat(self._dbalbums_uuid))
+ logging.debug(pformat(self._dbalbum_details))
+ logging.debug(pformat(self._dbalbum_folders))
+
+ # get details on keywords
+ verbose("Processing keywords.")
+ c.execute(
+ f"""SELECT ZKEYWORD.ZTITLE, {asset_table}.ZUUID
+ FROM {asset_table}
+ JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
+ JOIN Z_1KEYWORDS ON Z_1KEYWORDS.Z_1ASSETATTRIBUTES = ZADDITIONALASSETATTRIBUTES.Z_PK
+ JOIN ZKEYWORD ON ZKEYWORD.Z_PK = {keyword_join} """
+ )
+ for keyword in c:
+ keyword_title = normalize_unicode(keyword[0])
+ if not keyword[1] in self._dbkeywords_uuid:
+ self._dbkeywords_uuid[keyword[1]] = []
+ if not keyword_title in self._dbkeywords_keyword:
+ self._dbkeywords_keyword[keyword_title] = []
+ self._dbkeywords_uuid[keyword[1]].append(keyword[0])
+ self._dbkeywords_keyword[keyword_title].append(keyword[1])
+
+ if _debug():
+ logging.debug(f"Finished walking through keywords")
+ logging.debug(pformat(self._dbkeywords_keyword))
+ logging.debug(pformat(self._dbkeywords_uuid))
+
+ # get details on disk volumes
+ c.execute("SELECT ZUUID, ZNAME from ZFILESYSTEMVOLUME")
+ for vol in c:
+ self._dbvolumes[vol[0]] = vol[1]
+
+ if _debug():
+ logging.debug(f"Finished walking through volumes")
+ logging.debug(self._dbvolumes)
+
+ # get details about photos
+ verbose("Processing photo details.")
+ logging.debug(f"Getting information about photos")
+ c.execute(
+ f"""SELECT {asset_table}.ZUUID,
+ ZADDITIONALASSETATTRIBUTES.ZMASTERFINGERPRINT,
+ ZADDITIONALASSETATTRIBUTES.ZTITLE,
+ ZADDITIONALASSETATTRIBUTES.ZORIGINALFILENAME,
+ {asset_table}.ZMODIFICATIONDATE,
+ {asset_table}.ZDATECREATED,
+ ZADDITIONALASSETATTRIBUTES.ZTIMEZONEOFFSET,
+ ZADDITIONALASSETATTRIBUTES.ZINFERREDTIMEZONEOFFSET,
+ ZADDITIONALASSETATTRIBUTES.ZTIMEZONENAME,
+ {asset_table}.ZHIDDEN,
+ {asset_table}.ZFAVORITE,
+ {asset_table}.ZDIRECTORY,
+ {asset_table}.ZFILENAME,
+ {asset_table}.ZLATITUDE,
+ {asset_table}.ZLONGITUDE,
+ {asset_table}.ZHASADJUSTMENTS,
+ {asset_table}.ZCLOUDBATCHPUBLISHDATE,
+ {asset_table}.ZKIND,
+ {asset_table}.ZUNIFORMTYPEIDENTIFIER,
+ {asset_table}.ZAVALANCHEUUID,
+ {asset_table}.ZAVALANCHEPICKTYPE,
+ {asset_table}.ZKINDSUBTYPE,
+ {asset_table}.ZCUSTOMRENDEREDVALUE,
+ ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE,
+ {asset_table}.ZCLOUDASSETGUID,
+ ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
+ {asset_table}.ZMOMENT,
+ ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
+ {asset_table}.ZTRASHEDSTATE,
+ {asset_table}.ZHEIGHT,
+ {asset_table}.ZWIDTH,
+ {asset_table}.ZORIENTATION,
+ ZADDITIONALASSETATTRIBUTES.ZORIGINALHEIGHT,
+ ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
+ ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
+ ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE,
+ {depth_state},
+ {asset_table}.ZADJUSTMENTTIMESTAMP,
+ {asset_table}.ZVISIBILITYSTATE,
+ {asset_table}.ZTRASHEDDATE,
+ {asset_table}.ZSAVEDASSETTYPE
+ FROM {asset_table}
+ JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
+ ORDER BY {asset_table}.ZUUID """
+ )
+ # Order of results
+ # 0 SELECT ZGENERICASSET.ZUUID,
+ # 1 ZADDITIONALASSETATTRIBUTES.ZMASTERFINGERPRINT,
+ # 2 ZADDITIONALASSETATTRIBUTES.ZTITLE,
+ # 3 ZADDITIONALASSETATTRIBUTES.ZORIGINALFILENAME,
+ # 4 ZGENERICASSET.ZMODIFICATIONDATE,
+ # 5 ZGENERICASSET.ZDATECREATED,
+ # 6 ZADDITIONALASSETATTRIBUTES.ZTIMEZONEOFFSET,
+ # 7 ZADDITIONALASSETATTRIBUTES.ZINFERREDTIMEZONEOFFSET,
+ # 8 ZADDITIONALASSETATTRIBUTES.ZTIMEZONENAME,
+ # 9 ZGENERICASSET.ZHIDDEN,
+ # 10 ZGENERICASSET.ZFAVORITE,
+ # 11 ZGENERICASSET.ZDIRECTORY,
+ # 12 ZGENERICASSET.ZFILENAME,
+ # 13 ZGENERICASSET.ZLATITUDE,
+ # 14 ZGENERICASSET.ZLONGITUDE,
+ # 15 ZGENERICASSET.ZHASADJUSTMENTS
+ # 16 ZCLOUDBATCHPUBLISHDATE -- If not null, indicates a shared photo
+ # 17 ZKIND, -- 0 = photo, 1 = movie
+ # 18 ZUNIFORMTYPEIDENTIFIER -- UTI
+ # 19 ZGENERICASSET.ZAVALANCHEUUID, -- if not NULL, is burst photo
+ # 20 ZGENERICASSET.ZAVALANCHEPICKTYPE -- if not 2, is a selected burst photo
+ # 21 ZGENERICASSET.ZKINDSUBTYPE -- determine if live photos, etc
+ # 22 ZGENERICASSET.ZCUSTOMRENDEREDVALUE -- determine if HDR photo
+ # 23 ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE -- 1 if selfie (front facing camera)
+ # 24 ZGENERICASSET.ZCLOUDASSETGUID -- not null if asset is cloud asset
+ # (e.g. user has "iCloud Photos" checked in Photos preferences)
+ # 25 ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA -- reverse geolocation data
+ # 26 ZGENERICASSET.ZMOMENT -- FK for ZMOMENT.Z_PK
+ # 27 ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE -- 1 if associated RAW image is original else 0
+ # 28 ZGENERICASSET.ZTRASHEDSTATE -- 0 if not in trash, 1 if in trash
+ # 29 ZGENERICASSET.ZHEIGHT,
+ # 30 ZGENERICASSET.ZWIDTH,
+ # 31 ZGENERICASSET.ZORIENTATION,
+ # 32 ZADDITIONALASSETATTRIBUTES.ZORIGINALHEIGHT,
+ # 33 ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
+ # 34 ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
+ # 35 ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
+ # 36 ZGENERICASSET.ZDEPTHSTATES / ZASSET.ZDEPTHTYPE
+ # 37 ZGENERICASSET.ZADJUSTMENTTIMESTAMP -- when was photo edited?
+ # 38 ZGENERICASSET.ZVISIBILITYSTATE -- 0 if visible, 2 if not (e.g. a burst image)
+ # 39 ZGENERICASSET.ZTRASHEDDATE -- date item placed in the trash or null if not in trash
+ # 40 ZGENERICASSET.ZSAVEDASSETTYPE -- how item imported
+
+ for row in c:
+ uuid = row[0]
+ info = {}
+ info["_uuid"] = uuid # stored here for easier debugging
+ info["modelID"] = None
+ info["masterUuid"] = None
+ info["masterFingerprint"] = row[1]
+ info["name"] = normalize_unicode(row[2])
+
+ # There are sometimes negative values for lastmodifieddate in the database
+ # I don't know what these mean but they will raise exception in datetime if
+ # not accounted for
+ info["lastmodifieddate_timestamp"] = row[37]
+ try:
+ info["lastmodifieddate"] = datetime.fromtimestamp(row[37] + TIME_DELTA)
+ except (ValueError, TypeError):
+ info["lastmodifieddate"] = None
+
+ info["imageTimeZoneOffsetSeconds"] = row[6]
+ info["imageDate_timestamp"] = row[5]
+
+ try:
+ imagedate = datetime.fromtimestamp(row[5] + TIME_DELTA)
+ seconds = info["imageTimeZoneOffsetSeconds"] or 0
+ delta = timedelta(seconds=seconds)
+ tz = timezone(delta)
+ info["imageDate"] = imagedate.astimezone(tz=tz)
+ except ValueError:
+ # sometimes imageDate is invalid so use 1 Jan 1970 in UTC as image date
+ imagedate = datetime(1970, 1, 1)
+ tz = timezone(timedelta(0))
+ info["imageDate"] = imagedate.astimezone(tz=tz)
+
+ info["hidden"] = row[9]
+ info["favorite"] = row[10]
+ info["originalFilename"] = row[3]
+ info["filename"] = row[12]
+ info["directory"] = row[11]
+
+ # set latitude and longitude
+ # if both latitude and longitude = -180.0, then they are NULL
+ if row[13] == -180.0 and row[14] == -180.0:
+ info["latitude"] = None
+ info["longitude"] = None
+ else:
+ info["latitude"] = row[13]
+ info["longitude"] = row[14]
+
+ info["hasAdjustments"] = row[15]
+
+ info["cloudbatchpublishdate"] = row[16]
+ info["shared"] = True if row[16] is not None else False
+
+ # these will get filled in later
+ # init to avoid key errors
+ info["extendedDescription"] = None # fill this in later
+ info["localAvailability"] = None
+ info["remoteAvailability"] = None
+ info["isMissing"] = None
+ info["adjustmentUuid"] = None
+ info["adjustmentFormatID"] = None
+
+ # find type
+ if row[17] == 0:
+ info["type"] = _PHOTO_TYPE
+ elif row[17] == 1:
+ info["type"] = _MOVIE_TYPE
+ else:
+ if _debug():
+ logging.debug(f"WARNING: {uuid} found unknown type {row[17]}")
+ info["type"] = None
+
+ info["UTI"] = row[18]
+ info["UTI_original"] = None # filled in later
+
+ # handle burst photos
+ # if burst photo, determine whether or not it's a selected burst photo
+ # in Photos 5, burstUUID is called avalancheUUID
+ info["burstUUID"] = row[19] # avalancheUUID
+ info["burstPickType"] = row[20] # avalanchePickType
+ if row[19] is not None:
+ # it's a burst photo
+ info["burst"] = True
+ burst_uuid = row[19]
+ if burst_uuid not in self._dbphotos_burst:
+ self._dbphotos_burst[burst_uuid] = set()
+ self._dbphotos_burst[burst_uuid].add(uuid)
+ if row[20] != 2 and row[20] != 4:
+ info[
+ "burst_key"
+ ] = True # it's a key photo (selected from the burst)
+ else:
+ info[
+ "burst_key"
+ ] = False # it's a burst photo but not one that's selected
+ else:
+ # not a burst photo
+ info["burst"] = False
+ info["burst_key"] = None
+
+ # Info on sub-type (live photo, panorama, etc)
+ # ZGENERICASSET.ZKINDSUBTYPE
+ # 1 == panorama
+ # 2 == live photo
+ # 10 = screenshot
+ # 100 = shared movie (MP4) ??
+ # 101 = slow-motion video
+ # 102 = Time lapse video
+ info["subtype"] = row[21]
+ info["live_photo"] = True if row[21] == 2 else False
+ info["screenshot"] = True if row[21] == 10 else False
+ info["slow_mo"] = True if row[21] == 101 else False
+ info["time_lapse"] = True if row[21] == 102 else False
+
+ # Handle HDR photos and portraits
+ # ZGENERICASSET.ZCUSTOMRENDEREDVALUE
+ # 3 = HDR photo
+ # 4 = non-HDR version of the photo
+ # 6 = panorama
+ # > 6 = portrait (sometimes, see ZDEPTHSTATE/ZDEPTHTYPE)
+ info["customRenderedValue"] = row[22]
+ info["hdr"] = True if row[22] == 3 else False
+ info["portrait"] = True if row[36] != 0 else False
+
+ # Set panorama from either KindSubType or RenderedValue
+ info["panorama"] = True if row[21] == 1 or row[22] == 6 else False
+
+ # Handle selfies (front facing camera, ZCAMERACAPTUREDEVICE=1)
+ info["selfie"] = True if row[23] == 1 else False
+
+ # Determine if photo is part of cloud library (ZGENERICASSET.ZCLOUDASSETGUID not NULL)
+ # Initialize cloud fields that will filled in later
+ info["cloudAssetGUID"] = row[24]
+ info["cloudLocalState"] = None
+ info["incloud"] = None
+ info["cloudLibraryState"] = None # Photos 4
+ info["cloudStatus"] = None # Photos 4
+ info["cloudAvailable"] = None # Photos 4
+
+ # reverse geolocation info
+ info["reverse_geolocation"] = row[25]
+ info["placeIDs"] = None # Photos 4
+ info["placeNames"] = None # Photos 4
+ info["countryCode"] = None # Photos 4
+
+ # moment info
+ info["momentID"] = row[26]
+
+ # original resource choice (e.g. RAW or jpeg)
+ # for images part of a RAW/jpeg pair,
+ # ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
+ # = 0 if jpeg is selected as "original" in Photos (the default)
+ # = 1 if RAW is selected as "original" in Photos
+ info["original_resource_choice"] = row[27]
+ info["raw_is_original"] = True if row[27] == 1 else False
+
+ # recently deleted items
+ info["intrash"] = True if row[28] == 1 else False
+ info["trasheddate_timestamp"] = row[39]
+ try:
+ info["trasheddate"] = datetime.fromtimestamp(row[39] + TIME_DELTA)
+ except (ValueError, TypeError):
+ info["trasheddate"] = None
+
+ # height/width/orientation
+ info["height"] = row[29]
+ info["width"] = row[30]
+ info["orientation"] = row[31]
+ info["original_height"] = row[32]
+ info["original_width"] = row[33]
+ info["original_orientation"] = row[34]
+ info["original_filesize"] = row[35]
+
+ # visibility state, visible (True) if 0, otherwise not visible (False)
+ # only values I've seen are 0 for visible, 2 for not-visible
+ info["visibility_state"] = row[38]
+ info["visible"] = row[38] == 0
+
+ # ZSAVEDASSETTYPE Values:
+ # 3: imported by copying to Photos library
+ # 4: shared iCloud photo
+ # 6: imported by iCloud (e.g. from iPhone)
+ # 10: referenced file (not copied to Photos library)
+ info["saved_asset_type"] = row[40]
+ info["isreference"] = row[40] == 10
+
+ # initialize import session info which will be filled in later
+ # not every photo has an import session so initialize all records now
+ info["import_session"] = None
+ info["fok_import_session"] = None
+ info["import_uuid"] = None
+
+ # associated RAW image info
+ # will be filled in later
+ info["has_raw"] = False
+ info["raw_data_length"] = None
+ info["UTI_raw"] = None
+ info["datastore_subtype"] = None
+ info["resource_type"] = None
+ info["raw_master_uuid"] = None # Photos 4
+ info["non_raw_master_uuid"] = None # Photos 4
+ info["alt_master_uuid"] = None # Photos 4
+ info["raw_info"] = None # Photos 4
+
+ self._dbphotos[uuid] = info
+
+ # # if row[19] is not None and ((row[20] == 2) or (row[20] == 4)):
+ # # burst photo
+ # if row[19] is not None:
+ # # burst photo, add to _dbphotos_burst
+ # info["burst"] = True
+ # burst_uuid = row[19]
+ # if burst_uuid not in self._dbphotos_burst:
+ # self._dbphotos_burst[burst_uuid] = {}
+ # self._dbphotos_burst[burst_uuid][uuid] = info
+ # else:
+ # info["burst"] = False
+
+ # get info on import sessions
+ # 0 ZGENERICASSET.ZUUID
+ # 1 ZGENERICASSET.ZIMPORTSESSION
+ # 2 ZGENERICASSET.Z_FOK_IMPORTSESSION
+ # 3 ZGENERICALBUM.ZUUID,
+ verbose("Processing import sessions.")
+ c.execute(
+ f"""SELECT
+ {asset_table}.ZUUID,
+ {asset_table}.ZIMPORTSESSION,
+ {import_fok},
+ ZGENERICALBUM.ZUUID
+ FROM
+ {asset_table}
+ JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = {asset_table}.ZIMPORTSESSION
+ """
+ )
+
+ for row in c:
+ uuid = row[0]
+ try:
+ self._dbphotos[uuid]["import_session"] = row[1]
+ self._dbphotos[uuid]["fok_import_session"] = row[2]
+ self._dbphotos[uuid]["import_uuid"] = row[3]
+ except KeyError:
+ logging.debug(f"No info record for uuid {uuid} for import session")
+
+ # Get extended description
+ verbose("Processing additional photo details.")
+ c.execute(
+ f"""SELECT {asset_table}.ZUUID,
+ ZASSETDESCRIPTION.ZLONGDESCRIPTION
+ FROM {asset_table}
+ JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
+ JOIN ZASSETDESCRIPTION ON ZASSETDESCRIPTION.Z_PK = ZADDITIONALASSETATTRIBUTES.ZASSETDESCRIPTION
+ ORDER BY {asset_table}.ZUUID """
+ )
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["extendedDescription"] = normalize_unicode(row[1])
+ else:
+ if _debug():
+ logging.debug(
+ f"WARNING: found description {row[1]} but no photo for {uuid}"
+ )
+
+ # get information about adjusted/edited photos
+ c.execute(
+ f"""SELECT {asset_table}.ZUUID,
+ {asset_table}.ZHASADJUSTMENTS,
+ ZUNMANAGEDADJUSTMENT.ZADJUSTMENTFORMATIDENTIFIER
+ FROM {asset_table}, ZUNMANAGEDADJUSTMENT
+ JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
+ WHERE ZADDITIONALASSETATTRIBUTES.ZUNMANAGEDADJUSTMENT = ZUNMANAGEDADJUSTMENT.Z_PK """
+ )
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["adjustmentFormatID"] = row[2]
+ else:
+ if _debug():
+ logging.debug(
+ f"WARNING: found adjustmentformatidentifier {row[2]} but no photo for uuid {row[0]}"
+ )
+
+ # Find missing photos
+ # TODO: this code is very kludgy and I had to make lots of assumptions
+ # it's probably wrong and needs to be re-worked once I figure out how to reliably
+ # determine if a photo is missing in Photos 5
+
+ # Get info on remote/local availability for photos in shared albums
+ # Also get UTI of original image (zdatastoresubtype = 1)
+ c.execute(
+ f""" SELECT
+ {asset_table}.ZUUID,
+ ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
+ ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
+ ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
+ ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER,
+ ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER
+ FROM {asset_table}
+ JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
+ JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
+ JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
+ WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
+ )
+
+ # Order of results:
+ # 0 {asset_table}.ZUUID,
+ # 1 ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
+ # 2 ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
+ # 3 ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
+ # 4 ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER,
+ # 5 ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER
+
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["localAvailability"] = row[1]
+ self._dbphotos[uuid]["remoteAvailability"] = row[2]
+ if row[3] == 1:
+ self._dbphotos[uuid]["UTI_original"] = row[5]
+
+ if row[1] != 1:
+ self._dbphotos[uuid]["isMissing"] = 1
+ else:
+ self._dbphotos[uuid]["isMissing"] = 0
+
+ # get information on local/remote availability
+ c.execute(
+ f""" SELECT {asset_table}.ZUUID,
+ ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
+ ZINTERNALRESOURCE.ZREMOTEAVAILABILITY
+ FROM {asset_table}
+ JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
+ JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZFINGERPRINT = ZADDITIONALASSETATTRIBUTES.ZMASTERFINGERPRINT """
+ )
+
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["localAvailability"] = row[1]
+ self._dbphotos[uuid]["remoteAvailability"] = row[2]
+
+ if row[1] != 1:
+ self._dbphotos[uuid]["isMissing"] = 1
+ else:
+ self._dbphotos[uuid]["isMissing"] = 0
+
+ # get information about cloud sync state
+ c.execute(
+ f""" SELECT
+ {asset_table}.ZUUID,
+ ZCLOUDMASTER.ZCLOUDLOCALSTATE
+ FROM ZCLOUDMASTER, {asset_table}
+ WHERE {asset_table}.ZMASTER = ZCLOUDMASTER.Z_PK """
+ )
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["cloudLocalState"] = row[1]
+ self._dbphotos[uuid]["incloud"] = True if row[1] == 3 else False
+
+ # get information about associted RAW images
+ # RAW images have ZDATASTORESUBTYPE = 17
+ c.execute(
+ f""" SELECT
+ {asset_table}.ZUUID,
+ ZINTERNALRESOURCE.ZDATALENGTH,
+ ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER,
+ ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
+ ZINTERNALRESOURCE.ZRESOURCETYPE
+ FROM {asset_table}
+ JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
+ JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
+ JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
+ WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
+ """
+ )
+ for row in c:
+ uuid = row[0]
+ if uuid in self._dbphotos:
+ self._dbphotos[uuid]["has_raw"] = True
+ self._dbphotos[uuid]["raw_data_length"] = row[1]
+ self._dbphotos[uuid]["UTI_raw"] = row[2]
+ self._dbphotos[uuid]["datastore_subtype"] = row[3]
+ self._dbphotos[uuid]["resource_type"] = row[4]
+
+ # add faces and keywords to photo data
+ for uuid in self._dbphotos:
+ # keywords
+ if uuid in self._dbkeywords_uuid:
+ self._dbphotos[uuid]["hasKeywords"] = 1
+ self._dbphotos[uuid]["keywords"] = self._dbkeywords_uuid[uuid]
+ else:
+ self._dbphotos[uuid]["hasKeywords"] = 0
+ self._dbphotos[uuid]["keywords"] = []
+
+ if uuid in self._dbfaces_uuid:
+ self._dbphotos[uuid]["hasPersons"] = 1
+ self._dbphotos[uuid]["persons"] = self._dbfaces_uuid[uuid]
+ else:
+ self._dbphotos[uuid]["hasPersons"] = 0
+ self._dbphotos[uuid]["persons"] = []
+
+ if uuid in self._dbalbums_uuid:
+ self._dbphotos[uuid]["albums"] = self._dbalbums_uuid[uuid]
+ self._dbphotos[uuid]["hasAlbums"] = 1
+ else:
+ self._dbphotos[uuid]["albums"] = []
+ self._dbphotos[uuid]["hasAlbums"] = 0
+
+ # build album_titles dictionary
+ for album_id in self._dbalbum_details:
+ title = self._dbalbum_details[album_id]["title"]
+ if title in self._dbalbum_titles:
+ self._dbalbum_titles[title].append(album_id)
+ else:
+ self._dbalbum_titles[title] = [album_id]
+
+ # country codes (only used in Photos <=4)
+ self._db_countries = None
+
+ # close connection and remove temporary files
+ conn.close()
+
+ # process face info
+ verbose("Processing face details.")
+ self._process_faceinfo()
+
+ # process search info
+ verbose("Processing photo labels.")
+ self._process_searchinfo()
+
+ # process exif info
+ verbose("Processing EXIF details.")
+ self._process_exifinfo()
+
+ # process computed scores
+ verbose("Processing computed aesthetic scores.")
+ self._process_scoreinfo()
+
+ # process shared comments/likes
+ verbose("Processing comments and likes for shared photos.")
+ self._process_comments()
+
+ # done processing, dump debug data if requested
+ verbose("Done processing details from Photos library.")
+ if _debug():
+ logging.debug("Faces (_dbfaces_uuid):")
+ logging.debug(pformat(self._dbfaces_uuid))
+
+ logging.debug("Persons (_dbpersons_pk):")
+ logging.debug(pformat(self._dbpersons_pk))
+
+ logging.debug("Keywords by uuid (_dbkeywords_uuid):")
+ logging.debug(pformat(self._dbkeywords_uuid))
+
+ logging.debug("Keywords by keyword (_dbkeywords_keywords):")
+ logging.debug(pformat(self._dbkeywords_keyword))
+
+ logging.debug("Albums by uuid (_dbalbums_uuid):")
+ logging.debug(pformat(self._dbalbums_uuid))
+
+ logging.debug("Albums by album (_dbalbums_albums):")
+ logging.debug(pformat(self._dbalbums_album))
+
+ logging.debug("Album details (_dbalbum_details):")
+ logging.debug(pformat(self._dbalbum_details))
+
+ logging.debug("Album titles (_dbalbum_titles):")
+ logging.debug(pformat(self._dbalbum_titles))
+
+ logging.debug("Album folders (_dbalbum_folders):")
+ logging.debug(pformat(self._dbalbum_folders))
+
+ logging.debug("Album parent folders (_dbalbum_parent_folders):")
+ logging.debug(pformat(self._dbalbum_parent_folders))
+
+ logging.debug("Albums pk (_dbalbums_pk):")
+ logging.debug(pformat(self._dbalbums_pk))
+
+ logging.debug("Volumes (_dbvolumes):")
+ logging.debug(pformat(self._dbvolumes))
+
+ logging.debug("Photos (_dbphotos):")
+ logging.debug(pformat(self._dbphotos))
+
+ logging.debug("Burst Photos (dbphotos_burst:")
+ logging.debug(pformat(self._dbphotos_burst))
+
+ def _build_album_folder_hierarchy_5(self, uuid, folders=None):
+ """ recursively build folder/album hierarchy
+ uuid: uuid of the album/folder being processed
+ folders: dict holding the folder hierarchy """
+
+ # get parent uuid
+ parent = self._dbalbum_details[uuid]["parentfolder"]
+
+ if parent is not None:
+ parent_uuid = self._dbalbums_pk[parent]
+ else:
+ # folder with no parent (e.g. shared iCloud folders)
+ return folders
+
+ if self._db_version > _PHOTOS_4_VERSION and parent == self._folder_root_pk:
+ # at the top of the folder hierarchy, we're done
+ return folders
+
+ # recurse to keep building
+ folders = {parent_uuid: folders}
+ folders = self._build_album_folder_hierarchy_5(parent_uuid, folders=folders)
+ return folders
+
+ def _album_folder_hierarchy_list(self, album_uuid):
+ """ return appropriate album_folder_hierarchy_list for the _db_version """
+ if self._db_version <= _PHOTOS_4_VERSION:
+ return self._album_folder_hierarchy_list_4(album_uuid)
+ else:
+ return self._album_folder_hierarchy_list_5(album_uuid)
+
+ def _album_folder_hierarchy_list_4(self, album_uuid):
+ """ return hierarchical list of folder names album_uuid is contained in
+ the folder list is in form:
+ ["Top level folder", "sub folder 1", "sub folder 2"]
+ returns empty list of album is not in any folders """
+ try:
+ folders = self._dbalbum_folders[album_uuid]
+ except KeyError:
+ logging.debug(f"Caught _dbalbum_folders KeyError for album: {album_uuid}")
+ return []
+
+ def _recurse_folder_hierarchy(folders, hierarchy=[]):
+ """ recursively walk the folders dict to build list of folder hierarchy """
+ if not folders:
+ # empty folder dict (album has no folder hierarchy)
+ return []
+
+ if len(folders) != 1:
+ raise ValueError("Expected only a single key in folders dict")
+
+ folder_uuid = list(folders)[0] # first and only key of dict
+
+ parent_title = self._dbfolder_details[folder_uuid]["name"]
+ hierarchy.append(parent_title)
+
+ folders = folders[folder_uuid]
+ if folders:
+ # still have elements left to recurse
+ hierarchy = _recurse_folder_hierarchy(folders, hierarchy=hierarchy)
+ return hierarchy
+
+ # no elements left to recurse
+ return hierarchy
+
+ hierarchy = _recurse_folder_hierarchy(folders)
+ return hierarchy
+
+ def _album_folder_hierarchy_list_5(self, album_uuid):
+ """ return hierarchical list of folder names album_uuid is contained in
+ the folder list is in form:
+ ["Top level folder", "sub folder 1", "sub folder 2"]
+ returns empty list of album is not in any folders """
+ try:
+ folders = self._dbalbum_folders[album_uuid]
+ except KeyError:
+ logging.debug(f"Caught _dbalbum_folders KeyError for album: {album_uuid}")
+ return []
+
+ def _recurse_folder_hierarchy(folders, hierarchy=[]):
+ """ recursively walk the folders dict to build list of folder hierarchy """
+
+ if not folders:
+ # empty folder dict (album has no folder hierarchy)
+ return []
+
+ if len(folders) != 1:
+ raise ValueError("Expected only a single key in folders dict")
+
+ folder_uuid = list(folders)[0] # first and only key of dict
+ parent_title = self._dbalbum_details[folder_uuid]["title"]
+ hierarchy.append(parent_title)
+
+ folders = folders[folder_uuid]
+ if folders:
+ # still have elements left to recurse
+ hierarchy = _recurse_folder_hierarchy(folders, hierarchy=hierarchy)
+ return hierarchy
+
+ # no elements left to recurse
+ return hierarchy
+
+ hierarchy = _recurse_folder_hierarchy(folders)
+ return hierarchy
+
+ def _album_folder_hierarchy_folderinfo(self, album_uuid):
+ if self._db_version <= _PHOTOS_4_VERSION:
+ return self._album_folder_hierarchy_folderinfo_4(album_uuid)
+ else:
+ return self._album_folder_hierarchy_folderinfo_5(album_uuid)
+
+ def _album_folder_hierarchy_folderinfo_4(self, album_uuid):
+ """ return hierarchical list of FolderInfo objects album_uuid is contained in
+ ["Top level folder", "sub folder 1", "sub folder 2"]
+ returns empty list of album is not in any folders """
+ # title = photosdb._dbalbum_details[album_uuid]["title"]
+ folders = self._dbalbum_folders[album_uuid]
+ # logging.warning(f"uuid = {album_uuid}, folder = {folders}")
+
+ def _recurse_folder_hierarchy(folders, hierarchy=[]):
+ """ recursively walk the folders dict to build list of folder hierarchy """
+ # logging.warning(f"folders={folders},hierarchy = {hierarchy}")
+ if not folders:
+ # empty folder dict (album has no folder hierarchy)
+ return []
+
+ if len(folders) != 1:
+ raise ValueError("Expected only a single key in folders dict")
+
+ folder_uuid = list(folders)[0] # first and only key of dict
+ hierarchy.append(FolderInfo(db=self, uuid=folder_uuid))
+
+ folders = folders[folder_uuid]
+ if folders:
+ # still have elements left to recurse
+ hierarchy = _recurse_folder_hierarchy(folders, hierarchy=hierarchy)
+ return hierarchy
+
+ # no elements left to recurse
+ return hierarchy
+
+ hierarchy = _recurse_folder_hierarchy(folders)
+ # logging.warning(f"hierarchy = {hierarchy}")
+ return hierarchy
+
+ def _album_folder_hierarchy_folderinfo_5(self, album_uuid):
+ """ return hierarchical list of FolderInfo objects album_uuid is contained in
+ ["Top level folder", "sub folder 1", "sub folder 2"]
+ returns empty list of album is not in any folders """
+ # title = photosdb._dbalbum_details[album_uuid]["title"]
+ folders = self._dbalbum_folders[album_uuid]
+
+ def _recurse_folder_hierarchy(folders, hierarchy=[]):
+ """ recursively walk the folders dict to build list of folder hierarchy """
+
+ if not folders:
+ # empty folder dict (album has no folder hierarchy)
+ return []
+
+ if len(folders) != 1:
+ raise ValueError("Expected only a single key in folders dict")
+
+ folder_uuid = list(folders)[0] # first and only key of dict
+ hierarchy.append(FolderInfo(db=self, uuid=folder_uuid))
+
+ folders = folders[folder_uuid]
+ if folders:
+ # still have elements left to recurse
+ hierarchy = _recurse_folder_hierarchy(folders, hierarchy=hierarchy)
+ return hierarchy
+
+ # no elements left to recurse
+ return hierarchy
+
+ hierarchy = _recurse_folder_hierarchy(folders)
+ return hierarchy
+
+ def _get_album_uuids(self, shared=False, import_session=False):
+ """ Return list of album UUIDs found in photos database
+
+ Filters out albums in the trash and any special album types
+
+ Args:
+ shared: boolean; if True, returns shared albums, else normal albums
+ import_session: boolean, if True, returns import session albums, else normal or shared albums
+ Note: flags (shared, import_session) are mutually exclusive
+
+ Raises:
+ ValueError: raised if mutually exclusive flags passed
+
+ Returns: list of album UUIDs
+ """
+ if shared and import_session:
+ raise ValueError(
+ "flags are mutually exclusive: pass zero or one of shared, import_session"
+ )
+
+ if self._db_version <= _PHOTOS_4_VERSION:
+ version4 = True
+ if shared:
+ logging.warning(
+ f"Shared albums not implemented for Photos library version {self._db_version}"
+ )
+ return [] # not implemented for _PHOTOS_4_VERSION
+ elif import_session:
+ logging.warning(
+ f"Import sessions not implemented for Photos library version {self._db_version}"
+ )
+ return [] # not implemented for _PHOTOS_4_VERSION
+ else:
+ album_kind = _PHOTOS_4_ALBUM_KIND
+ else:
+ version4 = False
+ if shared:
+ album_kind = _PHOTOS_5_SHARED_ALBUM_KIND
+ elif import_session:
+ album_kind = _PHOTOS_5_IMPORT_SESSION_ALBUM_KIND
+ else:
+ album_kind = _PHOTOS_5_ALBUM_KIND
+
+ album_list = []
+ # look through _dbalbum_details because _dbalbums_album won't have empty albums it
+ for album, detail in self._dbalbum_details.items():
+ if (
+ detail["kind"] == album_kind
+ and not detail["intrash"]
+ and (
+ (shared and detail["cloudownerhashedpersonid"] is not None)
+ or (not shared and detail["cloudownerhashedpersonid"] is None)
+ )
+ and (
+ not version4
+ # in Photos 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
+ # but should not be listed here; they can be distinguished by looking
+ # for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
+ or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
+ )
+ ):
+ album_list.append(album)
+ return album_list
+
+ def _get_albums(self, shared=False):
+ """ Return list of album titles found in photos database
+ Albums may have duplicate titles -- these will be treated as a single album.
+
+ Filters out albums in the trash and any special album types
+
+ Args:
+ shared: boolean; if True, returns shared albums, else normal albums
+
+ Returns: list of album names
+ """
+
+ album_uuids = self._get_album_uuids(shared=shared)
+ return list({self._dbalbum_details[album]["title"] for album in album_uuids})
+
+[docs] def photos(
+ self,
+ keywords=None,
+ uuid=None,
+ persons=None,
+ albums=None,
+ images=True,
+ movies=True,
+ from_date=None,
+ to_date=None,
+ intrash=False,
+ ):
+ """ Return a list of PhotoInfo objects
+ If called with no args, returns the entire database of photos
+ If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
+ If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
+ If more than one keyword, uuid, persons, albums is passed, they are treated as "OR" criteria
+ e.g. keywords=["wedding","vacation"] returns photos matching either keyword
+ from_date and to_date may be either naive or timezone-aware datetime.datetime objects.
+ If naive, timezone will be assumed to be local timezone.
+
+ Args:
+ keywords: list of keywords to search for
+ uuid: list of UUIDs to search for
+ persons: list of persons to search for
+ albums: list of album names to search for
+ images: if True, returns image files, if False, does not return images; default is True
+ movies: if True, returns movie files, if False, does not return movies; default is True
+ from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
+ to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
+ intrash: if True, returns only images in "Recently deleted items" folder,
+ if False returns only photos that aren't deleted; default is False
+
+ Returns:
+ list of PhotoInfo objects
+ """
+
+ # implementation is a bit kludgy but it works
+ # build a set of each search argument then compute the intersection of the sets
+ # use results to build a list of PhotoInfo objects
+
+ photos_sets = [] # list of photo sets to perform intersection of
+ if intrash:
+ photos_sets.append(
+ {p for p in self._dbphotos if self._dbphotos[p]["intrash"]}
+ )
+ else:
+ photos_sets.append(
+ {p for p in self._dbphotos if not self._dbphotos[p]["intrash"]}
+ )
+
+ if not any([keywords, uuid, persons, albums, from_date, to_date]):
+ # return all the photos, filtering for images and movies
+ # append keys of all photos as a single set to photos_sets
+ photos_sets.append(set(self._dbphotos.keys()))
+ else:
+ if albums:
+ album_set = set()
+ for album in albums:
+ # glob together albums with same name
+ if album in self._dbalbum_titles:
+ title_set = set()
+ for album_id in self._dbalbum_titles[album]:
+ try:
+ # _dbalbums_album value is list of tuples: [(uuid, sort order)]
+ uuid_in_album, _ = zip(*self._dbalbums_album[album_id])
+ title_set.update(uuid_in_album)
+ except KeyError:
+ # an empty album will be in _dbalbum_titles but not _dbalbums_album
+ pass
+ album_set.update(title_set)
+ else:
+ logging.debug(f"Could not find album '{album}' in database")
+ photos_sets.append(album_set)
+
+ if uuid:
+ uuid_set = set()
+ for u in uuid:
+ if u in self._dbphotos:
+ uuid_set.update([u])
+ else:
+ logging.debug(f"Could not find uuid '{u}' in database")
+ photos_sets.append(uuid_set)
+
+ if keywords:
+ keyword_set = set()
+ for keyword in keywords:
+ if keyword in self._dbkeywords_keyword:
+ keyword_set.update(self._dbkeywords_keyword[keyword])
+ else:
+ logging.debug(f"Could not find keyword '{keyword}' in database")
+ photos_sets.append(keyword_set)
+
+ if persons:
+ person_set = set()
+ for person in persons:
+ if person in self._dbpersons_fullname:
+ for pk in self._dbpersons_fullname[person]:
+ try:
+ person_set.update(self._dbfaces_pk[pk])
+ except KeyError:
+ # some persons have zero photos so they won't be in _dbfaces_pk
+ pass
+ else:
+ logging.debug(f"Could not find person '{person}' in database")
+ photos_sets.append(person_set)
+
+ if from_date or to_date: # sourcery off
+ dsel = self._dbphotos
+ if from_date:
+ if not datetime_has_tz(from_date):
+ from_date = datetime_naive_to_local(from_date)
+ dsel = {
+ k: v for k, v in dsel.items() if v["imageDate"] >= from_date
+ }
+ logging.debug(
+ f"Found %i items with from_date {from_date}" % len(dsel)
+ )
+ if to_date:
+ if not datetime_has_tz(to_date):
+ to_date = datetime_naive_to_local(to_date)
+ dsel = {k: v for k, v in dsel.items() if v["imageDate"] <= to_date}
+ logging.debug(f"Found %i items with to_date {to_date}" % len(dsel))
+ photos_sets.append(set(dsel.keys()))
+
+ photoinfo = []
+ if photos_sets: # found some photos
+ # get the intersection of each argument/search criteria
+ for p in set.intersection(*photos_sets):
+ # filter for non-selected burst photos
+ if self._dbphotos[p]["burst"] and not self._dbphotos[p]["burst_key"]:
+ # not a key/selected burst photo, don't include in returned results
+ continue
+
+ # filter for images and/or movies
+ if (images and self._dbphotos[p]["type"] == _PHOTO_TYPE) or (
+ movies and self._dbphotos[p]["type"] == _MOVIE_TYPE
+ ):
+ info = PhotoInfo(db=self, uuid=p, info=self._dbphotos[p])
+ photoinfo.append(info)
+ if _debug:
+ logging.debug(f"photoinfo: {pformat(photoinfo)}")
+
+ return photoinfo
+
+[docs] def get_photo(self, uuid):
+ """ Returns a single photo matching uuid
+
+ Arguments:
+ uuid: the UUID of photo to get
+
+ Returns:
+ PhotoInfo instance for photo with UUID matching uuid or None if no match
+ """
+ try:
+ return PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid])
+ except KeyError:
+ return None
+
+ # TODO: add to docs and test
+[docs] def photos_by_uuid(self, uuids):
+ """ Returns a list of photos with UUID in uuids.
+ Does not generate error if invalid or missing UUID passed.
+ This is faster than using PhotosDB.photos if you have list of UUIDs.
+ Returns photos regardless of intrash state.
+
+ Arguments:
+ uuid: list of UUIDs of photos to get
+
+ Returns:
+ list of PhotoInfo instance for photo with UUID matching uuid or [] if no match
+ """
+ photos = []
+ for uuid in uuids:
+ try:
+ photos.append(PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid]))
+ except KeyError:
+ # ignore missing/invlaid UUID
+ pass
+ return photos
+
+ def __repr__(self):
+ return f"osxphotos.{self.__class__.__name__}(dbfile='{self.db_path}')"
+
+ # compare two PhotosDB objects for equality
+ def __eq__(self, other):
+ if isinstance(other, self.__class__):
+ return self.__dict__ == other.__dict__
+
+ return False
+
+ def __len__(self):
+ """ Returns number of photos in the database
+ Includes recently deleted photos and non-selected burst images
+ """
+ return len(self._dbphotos)
+