export all photos to ~/Desktop/export group in folders by date created

find all photos with keyword “Kids” and output results to json file named results.json:

find all photos with keyword "Kids" and output results to json file named results.json:

export photos to file structure based on 4-digit year and full name of month of photo’s creation date:

export photos to file structure based on 4-digit year and full name of month of photo's creation date:

export photos to file structure based on 4-digit year of photo’s creation date and add keywords for media type and labels (labels are only awailable on Photos 5 and higher):

export photos to file structure based on 4-digit year of photo's creation date and add keywords for media type and labels (labels are only awailable on Photos 5 and higher):

export default library using ‘country name/year’ as output directory (but use “NoCountry/year” if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput

export default library using 'country name/year' as output directory (but use "NoCountry/year" if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput

osxphotos-albums command line option; --db <Photos database path>

osxphotos-export command line option; --db <Photos database path>

osxphotos-export command line option; --jpeg-quality <jpeg_quality>

osxphotos-export command line option; --exiftool-path <EXIFTOOL_PATH>

osxphotos-export command line option; --exiftool-option <OPTION>

osxphotos-export command line option; --keyword-template <TEMPLATE>

osxphotos-export command line option; --description-template <TEMPLATE>

osxphotos-export command line option; --finder-tag-template <TEMPLATE>

osxphotos-export command line option; --xattr-template <ATTRIBUTE TEMPLATE>

osxphotos-export command line option; --original-suffix <SUFFIX>

osxphotos-export command line option; --report <path to export report>

osxphotos-export command line option; --exportdb <EXPORTDB_FILE>

osxphotos-export command line option; --load-config <config file path>

osxphotos-export command line option; --save-config <config file path>

osxphotos-keywords command line option; --db <Photos database path>

osxphotos-labels command line option; --db <Photos database path>

osxphotos-persons command line option; --db <Photos database path>

osxphotos-places command line option; --db <Photos database path>

osxphotos-query command line option; --db <Photos database path>

intrusive_object_presence (osxphotos.PhotoInfo.ScoreInfo attribute)

pleasant_post_processing (osxphotos.PhotoInfo.ScoreInfo attribute)

sharply_focused_subject (osxphotos.PhotoInfo.ScoreInfo attribute)

"""
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

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)

        # 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")

            logging.debug(f"library = {library_path}, masters = {masters_path}")

            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)
                try:
            else:

    @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
            ]

    @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()
                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 """
            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:
                detail["title"]
                for detail in self._dbalbum_details.values()
            ]
        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
            self._album_info = [
                AlbumInfo(db=self, uuid=album)
                for album in self._get_album_uuids(shared=False)
            ]

    @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
            self._album_info_shared = [
                for album in self._get_album_uuids(shared=True)
            ]

    @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
            self._albums = self._get_albums(shared=False)

    @property
    def albums_shared(self):
        """ return list of shared albums found in photos database

        try:
            return self._albums_shared
            self._albums_shared = self._get_albums(shared=True)

    @property
    def import_info(self):
        """ return list of ImportInfo objects for each import session in the database """
        try:
            return self._import_info
            self._import_info = [
                ImportInfo(db=self, uuid=album)
                for album in self._get_album_uuids(import_session=True)
            ]

    @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

    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

            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)
                self._dbpersons_fullname[fullname] = [pk]

        # get info on key face
            """ SELECT
                RKPerson.representativeFaceId,
                RKVersion.uuid,
                RKFace.uuid
                FROM RKPerson, RKFace, RKVersion
                WHERE 
                RKFace.modelId = RKPerson.representativeFaceId AND
                RKVersion.modelId = RKFace.ImageModelId
            """
        )

        # 1     RKPerson.representativeFaceId
        # 2     RKVersion.uuid,
        # 3     RKFace.uuid

            try:
                self._dbpersons_pk[pk]["photo_uuid"] = person[2]
                self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
                logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")

        # get information on detected faces
        verbose("Processing detected faces in photos.")
            """ SELECT 
                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)
                self._dbfaces_uuid[uuid] = [pk]

            try:
                self._dbfaces_pk[pk].append(uuid)
                self._dbfaces_pk[pk] = [uuid]

            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.")
            """ SELECT 
                RKAlbum.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)
                self._dbalbums_uuid[photo_uuid] = [album_uuid]

            try:
                self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
                self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]

        # now get additional details about albums
            """ 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

            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
            """ SELECT 
                uuid, 
                modelId, 
                name, 
                isMagic, 
                folderType, 
                parentFolderUuid, 
                folderPath
                FROM RKFolder """
        )

        # 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
            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] = {}

            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.")
            """ 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:
                    RKVersion.specialType, RKMaster.modelID,
                    RKVersion.selfPortrait,
                    RKVersion.momentUuid,
                    RKMaster.originalFileSize,
            )

        # 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)

                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]["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)
                # 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]["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:
                    ] = 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.")
            """ 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

            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
            """ SELECT RKVersion.uuid, RKVersion.adjustmentUuid, RKModelResource.modelId,
                RKModelResource.resourceTag, RKModelResource.UTI, RKVersion.specialType,
                RKModelResource.attachedModelType, RKModelResource.resourceType
                JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId """
        )

        # 1     RKVersion.adjustmentUuid
        # 2     RKModelResource.modelId
        # 3     RKModelResource.resourceTag
        # 4     RKModelResource.UTI
        # 5     RKVersion.specialType
        # 6     RKModelResource.attachedModelType
        # 7     RKModelResource.resourceType

            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
            """ SELECT RKVersion.uuid, 
                RKVersion.adjustmentUuid, 
                RKAdjustmentData.originator, 
                RKAdjustmentData.format 
                FROM RKVersion, RKAdjustmentData 
                WHERE RKVersion.adjustmentUuid = RKAdjustmentData.uuid """
        )

                self._dbphotos[uuid]["adjustmentFormatID"] = row[3]

        # get details to find path of live photos
            """ SELECT 
                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'
              """
        )

        # 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?
                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
            """ SELECT 
                RKMaster.cloudLibraryState,
                RKCloudResource.available, 
                RKCloudResource.status
                FROM RKCloudResource
                INNER JOIN RKMaster ON RKMaster.fingerprint = RKCloudResource.fingerprint
                INNER JOIN RKVersion ON RKVersion.masterUuid = RKMaster.uuid """
        )

        # 0  RKVersion.uuid,
        # 1  RKMaster.cloudLibraryState,
        # 2  RKCloudResource.available,
        # 3  RKCloudResource.status

                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 "
        places = {p[0]: p for p in place_data}
        self._db_places = places

            # 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
                )
                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
            # 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"]
                    ]
                    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.")
            logging.debug("Faces (_dbfaces_uuid):")

            logging.debug("Persons (_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("Albums by album (_dbalbums_albums):")

            logging.debug("Album details (_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

        # 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)

    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.
        """

            logging.debug(f"_process_database5")
        verbose(f"Processing database.")

        # 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
            logging.debug(f"Getting information about persons")

            """ 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

            fullname = person[2] if person[2] != "" else _UNKNOWN_PERSON
                "pk": pk,
                "keyface": person[4],
                "displayname": person[5],
            }
            try:

        # get info on keyface -- some photos have null keyface so can't do a single query
        # (at least not with my SQL skills)
            f""" SELECT
                {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

            try:

                {asset_table}.ZUUID
                WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND
            """
        )

        # 1     ZGENERICASSET.ZUUID,

            try:

            try:

        # get details about albums
            f""" SELECT 
                ZGENERICALBUM.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,
        # 2     Z_26ASSETS.Z_FOK_34ASSETS

            try:

            try:

            "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 "
        )
                "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
            pk_parent = details["parentfolder"]
            if pk_parent is None:
                continue

            try:
                parent = self._dbalbums_pk[pk_parent]
                raise ValueError(f"Did not find uuid for album {album} pk {pk_parent}")

            try:
                self._dbalbum_parent_folders[album].append(parent)
                self._dbalbum_parent_folders[album] = [parent]

            # 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)
            elif details["kind"] == _PHOTOS_5_SHARED_ALBUM_KIND:
                # shared albums can't be in folders
                self._dbalbum_folders[album] = []

        # get details on keywords
            f"""SELECT ZKEYWORD.ZTITLE, {asset_table}.ZUUID
                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} """
        )
            keyword_title = normalize_unicode(keyword[0])
            if not keyword_title in self._dbkeywords_keyword:
                self._dbkeywords_keyword[keyword_title] = []
            self._dbkeywords_keyword[keyword_title].append(keyword[1])

            logging.debug(f"Finished walking through keywords")

        # get details on disk volumes
        c.execute("SELECT ZUUID, ZNAME from ZFILESYSTEMVOLUME")

            logging.debug(f"Finished walking through volumes")
            logging.debug(self._dbvolumes)

        # get details about photos
        logging.debug(f"Getting information about photos")
            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
                ORDER BY {asset_table}.ZUUID  """
        )
        # 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

            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])

            info["lastmodifieddate_timestamp"] = row[37]
            try:
                info["lastmodifieddate"] = datetime.fromtimestamp(row[37] + TIME_DELTA)
                info["lastmodifieddate"] = None

            info["imageTimeZoneOffsetSeconds"] = row[6]
            info["imageDate_timestamp"] = row[5]

            try:
                seconds = info["imageTimeZoneOffsetSeconds"] or 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:
                    logging.debug(f"WARNING: {uuid} found unknown type {row[17]}")
                info["type"] = None

            info["UTI"] = row[18]
            info["UTI_original"] = None  # filled in later

            # in Photos 5, burstUUID is called avalancheUUID
            info["burstUUID"] = row[19]  # avalancheUUID
            info["burstPickType"] = row[20]  # avalanchePickType
            if row[19] is not None:
                info["burst"] = True
                burst_uuid = row[19]
                if row[20] != 2 and row[20] != 4:
                    info[
                else:
                    info[
            else:
                info["burst"] = False
                info["burst_key"] = None

            # Info on sub-type (live photo, panorama, etc)
            # ZGENERICASSET.ZKINDSUBTYPE
            # 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]

            # for images part of a RAW/jpeg pair,
            info["original_resource_choice"] = row[27]
            info["raw_is_original"] = True if row[27] == 1 else False

            info["intrash"] = True if row[28] == 1 else False
            info["trasheddate_timestamp"] = row[39]
            try:
                info["trasheddate"] = datetime.fromtimestamp(row[39] + TIME_DELTA)
                info["trasheddate"] = None

            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

            # 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.")
            f"""SELECT
                {asset_table}.ZIMPORTSESSION,
                {import_fok},
                ZGENERICALBUM.ZUUID
                FROM
                {asset_table}
                JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = {asset_table}.ZIMPORTSESSION
            """
        )

            try:
                self._dbphotos[uuid]["import_session"] = row[1]
                self._dbphotos[uuid]["fok_import_session"] = row[2]
                self._dbphotos[uuid]["import_uuid"] = row[3]
                logging.debug(f"No info record for uuid {uuid} for import session")

        # Get extended description
                ZASSETDESCRIPTION.ZLONGDESCRIPTION 
                JOIN ZASSETDESCRIPTION ON ZASSETDESCRIPTION.Z_PK = ZADDITIONALASSETATTRIBUTES.ZASSETDESCRIPTION 
                ORDER BY {asset_table}.ZUUID """
        )
                self._dbphotos[uuid]["extendedDescription"] = normalize_unicode(row[1])
            else:
                    logging.debug(
                        f"WARNING: found description {row[1]} but no photo for {uuid}"
                    )

        # get information about adjusted/edited photos
                ZUNMANAGEDADJUSTMENT.ZADJUSTMENTFORMATIDENTIFIER 
                FROM {asset_table}, ZUNMANAGEDADJUSTMENT 
                WHERE ZADDITIONALASSETATTRIBUTES.ZUNMANAGEDADJUSTMENT = ZUNMANAGEDADJUSTMENT.Z_PK """
        )
                self._dbphotos[uuid]["adjustmentFormatID"] = row[2]
            else:
                        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)
                {asset_table}.ZUUID, 
                ZINTERNALRESOURCE.ZLOCALAVAILABILITY, 
                ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
                ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
                ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER,
                ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER
                FROM {asset_table}
                JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET 
                JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER 
                WHERE  ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
        )

        # 0 {asset_table}.ZUUID,
        # 1 ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
        # 2 ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
        # 3 ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
        # 4 ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER,
        # 5 ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER

                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
            f""" SELECT {asset_table}.ZUUID,
                ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
                ZINTERNALRESOURCE.ZREMOTEAVAILABILITY
                JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
                JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZFINGERPRINT = ZADDITIONALASSETATTRIBUTES.ZMASTERFINGERPRINT """
        )

                else:

        # get information about cloud sync state
                ZCLOUDMASTER.ZCLOUDLOCALSTATE
                FROM ZCLOUDMASTER, {asset_table}
                WHERE {asset_table}.ZMASTER = ZCLOUDMASTER.Z_PK """
        )
                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
                ZINTERNALRESOURCE.ZDATALENGTH, 
                ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER,
                ZINTERNALRESOURCE.ZRESOURCETYPE
                JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
                JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK =  ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
                WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
        """
        )
                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]

            # keywords
            if uuid in self._dbkeywords_uuid:
                self._dbphotos[uuid]["hasKeywords"] = 1
            else:
                self._dbphotos[uuid]["hasKeywords"] = 0

            else:

            else:

            else:

        # country codes (only used in Photos <=4)
        self._db_countries = None

        # close connection and remove temporary files

        # process face info

        # 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()

            logging.debug("Album folders (_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))

    def _build_album_folder_hierarchy_5(self, uuid, folders=None):
            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)

        if self._db_version > _PHOTOS_4_VERSION and parent == self._folder_root_pk:
            # at the top of the folder hierarchy, we're done

        folders = self._build_album_folder_hierarchy_5(parent_uuid, folders=folders)

    def _album_folder_hierarchy_list(self, album_uuid):
        """ return appropriate album_folder_hierarchy_list for the _db_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 """
                # 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):
        try:
            return []

                return []

            parent_title = self._dbalbum_details[folder_uuid]["title"]

    def _album_folder_hierarchy_folderinfo(self, album_uuid):
            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
        # title = photosdb._dbalbum_details[album_uuid]["title"]
        folders = self._dbalbum_folders[album_uuid]
        # logging.warning(f"uuid = {album_uuid}, folder = {folders}")

            # logging.warning(f"folders={folders},hierarchy = {hierarchy}")
                return []

            hierarchy.append(FolderInfo(db=self, uuid=folder_uuid))

        # logging.warning(f"hierarchy = {hierarchy}")

    def _album_folder_hierarchy_folderinfo_5(self, album_uuid):

                return []

    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"
            )

            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:
                    f"Import sessions not implemented for Photos library version {self._db_version}"
                )
            else:
                album_kind = _PHOTOS_4_ALBUM_KIND
        else:
            version4 = False
            if shared:
                album_kind = _PHOTOS_5_SHARED_ALBUM_KIND
                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.
        
        Args:
        
        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})

    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:
                {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])
                                # 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
                    }
                        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

    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])
            return None

    # TODO: add to docs and test
    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.

            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]))
                # 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)

"""
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 os
from datetime import timedelta, timezone

import yaml

    _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

class PhotoInfo:
    """
    Info about a specific photo, contains all the details about the photo
    including keywords, persons, albums, uuid, path, etc.
    """

    from ._photoinfo_searchinfo import (
        search_info,
        search_info_normalized,
        labels,
        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 (
        ):
            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:

        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:

    @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
            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:
                            self._db._masters_path,
                            self._info["raw_pair_info"]["imagePath"],
                        )
                else:
                    vol = self._info["volume"]
                            "/Volumes", vol, self._info["imagePath"]
                        )
                    else:
                            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 self._info["directory"].startswith("/"):
                    self._info["directory"], self._info["filename"]
                )
            else:
                    self._db._masters_path,
                )
            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
                self._path_edited = self._path_edited_4()
            else:
                self._path_edited = self._path_edited_5()

    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
            )

                    f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
                )
        else:

        # 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:

        photopath = None
            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
                    library, "resources", "media", "version", folder_id, "00", filename
                )

                    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
                        f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
                    )
            else:
                    f"{self.uuid} hasAdjustments but edit_resource_id is None"
                )
        else:

    @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:
                    "/Volumes", vol, self._info["raw_info"]["imagePath"]
                )
            else:
                    self._db._masters_path, self._info["raw_info"]["imagePath"]
                )
                    f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
                )
        else:
            filestem = pathlib.Path(self._info["filename"]).stem
            raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])

                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}")
            else:
                photopath = os.path.join(filepath, raw_file[0])
                        f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
                    )

    @property
    def description(self):
        """ long / extended description of picture """
        return self._info["extendedDescription"]

    @property
        """ list of persons in picture """
        return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]]

    @property
        """ list of PersonInfo objects for person in picture """
        try:
            return self._personinfo
            self._personinfo = [
                PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]
            ]

    @property
    def face_info(self):
        """ list of FaceInfo objects for faces in picture """
        try:
            return self._faceinfo
            try:
                faces = self._db._db_faceinfo_uuid[self._uuid]
                self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
                # no faces
                self._faceinfo = []

    @property
        """ list of albums picture is contained in """
        try:
            album_uuids = self._get_album_uuids()
            self._albums = list(
                {self._db._dbalbum_details[album]["title"] for album in album_uuids}
            )

    @property
        """ list of AlbumInfo objects representing albums the photos is contained in """
        try:
                AlbumInfo(db=self._db, uuid=album) for album in album_uuids
            ]

    @property
        """ ImportInfo object representing import session for the photo or None if no import session """
        try:
            self._import_info = (
                ImportInfo(db=self._db, uuid=self._info["import_uuid"])
                if self._info["import_uuid"] is not None
                else None
            )

    @property
        """ 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:
            return trasheddate.astimezone(tz=tz)
        else:

    @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"""
            return self._info["shared"]
        else:

    @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 (
        ):
            # 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
        """
        if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
        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:

    @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
        """
            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"""

            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}")
                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:
        else:
            # Photos 5
                filename = pathlib.Path(self.path)
                photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
                photopath = str(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?
                        f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
                    )
            else:

    @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):
        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

            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:
                if self._info["reverse_geolocation"]:
                    self._place = PlaceInfo5(self._info["reverse_geolocation"])
                else:

    @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"]

    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

        Returns: list of album UUIDs
        """
            album_kind = [_PHOTOS_4_ALBUM_KIND]
        else:
            album_kind = [_PHOTOS_5_SHARED_ALBUM_KIND, _PHOTOS_5_ALBUM_KIND]

        for album in self._info["albums"]:
            detail = self._db._dbalbum_details[album]
            if (
                detail["kind"] in album_kind
                and (
                    # in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
                )
            ):

        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)

    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,
            "date": self.date,
            "labels": self.labels,
            "folders": folders,
            "faces": faces,
            "uti_original": self.uti_original,
            "isreference": self.isreference,
            "date_modified": self.date_modified,
            "israw": self.israw,
            "raw_original": self.raw_original,
            "place": place,
            "comments": comments,
            "likes": likes,
            "search_info": search_info,
        }

    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)

        """ 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)
            return (
                self._db.db_path == other._db.db_path
                and self.uuid == other.uuid
                and self._info == other._info
            )

    def __ne__(self, other):
        """ Compare two PhotoInfo objects for inequality """
        return not self.__eq__(other)

""" PhotoInfo methods to expose EXIF info from the library """

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}")
            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 os
import re
from collections import namedtuple  # pylint: disable=syntax-error

import photoscript
from mako.template import Template

# from .._applescript import AppleScript
    _MAX_IPTC_KEYWORD_LEN,
    _OSXPHOTOS_NONE_SENTINEL,
    _TEMPLATE_DIR,
    _XMP_TEMPLATE_NAME,
    _XMP_TEMPLATE_NAME_BETA,
    SIDECAR_EXIFTOOL,
    SIDECAR_JSON,
    SIDECAR_XMP,
)
from ..datetime_utils import datetime_tz_to_utc
from ..exiftool import ExifTool
from ..export_db import ExportDBNoOp
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

        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,
    raw_photo=False,
    export_as_hardlink=False,
    overwrite=False,
    increment=True,
    sidecar_json=False,
    sidecar_exiftool=False,
    sidecar_xmp=False,
    use_photos_export=False,
    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
    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,
    sidecar=0,
    sidecar_drop_ext=False,
    update=False,
    ignore_signature=False,
    export_db=None,
    fileutil=FileUtil,
    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
                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.
    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_EXIFTOOL: if set will write a json sidecar with data in format readable by exiftool
             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')
    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
    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:
                    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
                        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:
                    raw_path,
                    raw_name,
                    update,
                    exiftool,
                    convert_to_jpeg,
                    jpeg_quality=jpeg_quality,
                )
    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
                try:
                except PhotoKitFetchFailed:
                        )
                if photo:
                        try:
                                dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
                            )
                            )
                    else:
            else:
                try:
                        original=True,
                        edited=False,
                    )
        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:
            tag_groups=False,
        )
            (
                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(
            extension=dest.suffix[1:] if dest.suffix else None,
        )
            (
                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,
                        flags=exiftool_flags,
                    )
                    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,
                    ),
                )
                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:
            verbose(f"Writing metadata with exiftool for {exported_file}")
                warning_, error_ = self._write_exif_data(
                    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(
                ),
            )
            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,
    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")

    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)

            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
                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)
                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):
    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,
    flags=None,
):
    """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(
        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,
    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
        description_template: (list of strings); list of template strings to render for the description
        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:
                    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:
                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)
                pass
    return keywords

def _get_exif_persons(self):
    """ returns list of persons found in the file's exif metadata """
    persons = []
    if exif:
        try:
            p = exifdict["XMP:PersonInImage"]
            if p and type(p) != list:
                p = [p]
            p = [str(p_) for p_ in p]
            persons.extend(p)
            pass
    return persons

def _exiftool_json_sidecar(
    self,
    tag_groups=True,
):

    Args:
        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

        EXIF:ImageDescription
        XMP:Title
        XMP:TagsList
        XMP:Subject (set to keywords + person)
        IPTC:DigitalCreationDate
    """
    exif = self._exiftool_dict(
        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,
    extension=None,
):
    """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
    extension: which extension to use for SidecarForExtension property
    """

    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

        )[0]
    else:
        description = self.description if self.description is not None else ""

    # TODO: keyword handling in this and _exiftool_json_sidecar is
    # good candidate for pulling out in a function

            )
                )

            keyword
            for keyword in rendered_keywords
        ]

            long_str
        ]
            )

    # remove duplicates
    # sorted mainly to make testing the XMP file easier
        keyword_list = sorted(list(set(keyword_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 """

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
    """

        logging.debug(f"score not implemented for this database version")

    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
                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,
            )

""" 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
"""

    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
    """

    # memoize SearchInfo object
    try:
        return self._search_info
        self._search_info = SearchInfo(self)

@property
def search_info_normalized(self):
    """ returns SearchInfo object for photo that produces normalized results
    """

    try:
        return self._search_info_normalized
        self._search_info_normalized = SearchInfo(self, normalized=True)

@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 
    """
        return []

    return self.search_info.labels

@property
def labels_normalized(self):
    """ returns normalized list of labels applied to photo by Photos image categorization
    """
        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]
            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

        """ return dict of search info """
        return {
            "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 []