From 98e417023ec5bd8292b25040d0844f3706645950 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 16 Aug 2020 22:57:33 -0700 Subject: [PATCH] Added ImportInfo for Photos 5+ --- README.md | 36 +++++ osxphotos/_constants.py | 10 ++ osxphotos/_version.py | 2 +- osxphotos/albuminfo.py | 150 +++++++++++++++--- osxphotos/datetime_utils.py | 10 +- osxphotos/photoinfo/photoinfo.py | 26 ++- osxphotos/photosdb/photosdb.py | 119 +++++++++++--- tests/test_albums_folders_catalina_10_15_4.py | 45 +++++- tests/test_catalina_10_15_6.py | 53 +++++++ 9 files changed, 394 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 392d1a35..f8624009 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ + [PhotoInfo](#photoinfo) + [ExifInfo](#exifinfo) + [AlbumInfo](#albuminfo) + + [ImportInfo](#importinfo) + [FolderInfo](#folderinfo) + [PlaceInfo](#placeinfo) + [ScoreInfo](#scoreinfo) @@ -767,6 +768,10 @@ Returns list of shared album names found in photos database (e.g. albums shared **Note**: *Only valid for Photos 5 / MacOS 10.15*; on Photos <= 4, prints warning and returns empty list. +#### `import_info` + +Returns a list of [ImportInfo](#importinfo) objects representing the import sessions for the database. + #### `folder_info` ```python # assumes photosdb is a PhotosDB object (see above) @@ -1053,6 +1058,9 @@ Returns a list of albums the photo is contained in. See also [album_info](#album #### `album_info` Returns a list of [AlbumInfo](#AlbumInfo) objects representing the albums the photo is contained in. See also [albums](#albums). +#### `import_info` +Returns an [ImportInfo](#importinfo) object representing the import session associated with the photo or `None` if there is no associated import session. + #### `persons` Returns a list of the names of the persons in the photo @@ -1378,6 +1386,15 @@ Returns the title or name of the album. #### `photos` Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album sorted in the same order as in Photos. (e.g. if photos were manually sorted in the Photos albums, photos returned by `photos` will be in same order as they appear in the Photos album) +#### `creation_date` +Returns the creation date as a timezone aware datetime.datetime object of the album. + +#### `start_date` +Returns the date of earliest photo in the album as a timezone aware datetime.datetime object. + +#### `end_date` +Returns the date of latest photo in the album as a timezone aware datetime.datetime object. + #### `folder_list` Returns a hierarchical list of [FolderInfo](#FolderInfo) objects representing the folders the album is contained in. For example, if album "AlbumInFolder" is in SubFolder2 of Folder1 as illustrated below, would return a list of `FolderInfo` objects representing ["Folder1", "SubFolder2"] @@ -1403,6 +1420,25 @@ Photos Library #### `parent` Returns a [FolderInfo](#FolderInfo) object representing the albums parent folder or `None` if album is not a in a folder. +### ImportInfo +PhotosDB.import_info returns a list of ImportInfo objects. Each ImportInfo object represents an import session in the library. PhotoInfo.import_info returns a single ImportInfo object representing the import session for the photo (or `None` if no associated import session). + +**Note**: Photos 5+ only. Not implemented for Photos version <= 4. + +#### `uuid` +Returns the universally unique identifier (uuid) of the import session. This is how Photos keeps track of individual objects within the database. + +#### `photos` +Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album sorted in the same order as in Photos. (e.g. if photos were manually sorted in the Photos albums, photos returned by `photos` will be in same order as they appear in the Photos album) + +#### `creation_date` +Returns the creation date as a timezone aware datetime.datetime object of the import session. + +#### `start_date` +Returns the start date as a timezone aware datetime.datetime object for when the import session bega. + +#### `end_date` +Returns the end date as a timezone aware datetime.datetime object for when the import session completed. ### FolderInfo PhotosDB.folder_info returns a list of FolderInfo objects representing the top level folders in the library. Each FolderInfo object represents a single folder in the Photos library. diff --git a/osxphotos/_constants.py b/osxphotos/_constants.py index 8b19b79b..93184dc9 100644 --- a/osxphotos/_constants.py +++ b/osxphotos/_constants.py @@ -3,6 +3,11 @@ Constants used by osxphotos """ import os.path +from datetime import datetime + +# Time delta: add this to Photos times to get unix time +# Apple Epoch is Jan 1, 2001 +TIME_DELTA = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds() # which Photos library database versions have been tested # Photos 2.0 (10.12.6) == 2622 @@ -36,11 +41,15 @@ _DB_TABLE_NAMES = { "ASSET": "ZGENERICASSET", "KEYWORD_JOIN": "Z_1KEYWORDS.Z_37KEYWORDS", "ALBUM_JOIN": "Z_26ASSETS.Z_34ASSETS", + "ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_34ASSETS", + "IMPORT_FOK": "ZGENERICASSET.Z_FOK_IMPORTSESSION", }, 6: { "ASSET": "ZASSET", "KEYWORD_JOIN": "Z_1KEYWORDS.Z_36KEYWORDS", "ALBUM_JOIN": "Z_26ASSETS.Z_3ASSETS", + "ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_3ASSETS", + "IMPORT_FOK": "null", }, } @@ -71,6 +80,7 @@ _PHOTOS_5_ALBUM_KIND = 2 # normal user album _PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album _PHOTOS_5_FOLDER_KIND = 4000 # user folder _PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder +_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND = 1506 # import session _PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass _PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums" diff --git a/osxphotos/_version.py b/osxphotos/_version.py index c6b18673..4dd6f572 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.32.0" +__version__ = "0.33.0" diff --git a/osxphotos/albuminfo.py b/osxphotos/albuminfo.py index def724e2..3084938f 100644 --- a/osxphotos/albuminfo.py +++ b/osxphotos/albuminfo.py @@ -10,7 +10,7 @@ Represents a single Folder in the Photos library and provides access to the fold PhotosDB.folders() returns a list of FolderInfo objects """ -import logging +from datetime import datetime, timedelta, timezone from ._constants import ( _PHOTOS_4_ALBUM_KIND, @@ -18,11 +18,34 @@ from ._constants import ( _PHOTOS_4_VERSION, _PHOTOS_5_ALBUM_KIND, _PHOTOS_5_FOLDER_KIND, + TIME_DELTA, ) +from .datetime_utils import get_local_tz -class AlbumInfo: +def sort_list_by_keys(values, sort_keys): + """ Sorts list values by a second list sort_keys + e.g. given ["a","c","b"], [1, 3, 2], returns ["a", "b", "c"] + + Args: + values: a list of values to be sorted + sort_keys: a list of keys to sort values by + + Returns: + list of values, sorted by sort_keys + + Raises: + ValueError: raised if len(values) != len(sort_keys) """ + if len(values) != len(sort_keys): + return ValueError("values and sort_keys must have same length") + + return list(zip(*sorted(zip(sort_keys, values))))[1] + + +class AlbumInfoBaseClass: + """ + Base class for AlbumInfo, ImportInfo Info about a specific Album, contains all the details about the album including folders, photos, etc. """ @@ -31,33 +54,107 @@ class AlbumInfo: self._uuid = uuid self._db = db self._title = self._db._dbalbum_details[uuid]["title"] - - @property - def title(self): - """ return title / name of album """ - return self._title + self._creation_date_timestamp = self._db._dbalbum_details[uuid]["creation_date"] + self._start_date_timestamp = self._db._dbalbum_details[uuid]["start_date"] + self._end_date_timestamp = self._db._dbalbum_details[uuid]["end_date"] + self._local_tz = get_local_tz() @property def uuid(self): """ return uuid of album """ return self._uuid + @property + def creation_date(self): + """ return creation date of album """ + try: + return self._creation_date + except AttributeError: + try: + self._creation_date = ( + datetime.fromtimestamp( + self._creation_date_timestamp + TIME_DELTA + ).astimezone(tz=self._local_tz) + if self._creation_date_timestamp + else datetime(1970, 1, 1, 0, 0, 0).astimezone( + tz=timezone(timedelta(0)) + ) + ) + except ValueError: + self._creation_date = datetime(1970, 1, 1, 0, 0, 0).astimezone( + tz=timezone(timedelta(0)) + ) + return self._creation_date + + @property + def start_date(self): + """ For Albums, return start date (earliest image) of album or None for albums with no images + For Import Sessions, return start date of import session (when import began) """ + try: + return self._start_date + except AttributeError: + try: + self._start_date = ( + datetime.fromtimestamp( + self._start_date_timestamp + TIME_DELTA + ).astimezone(tz=self._local_tz) + if self._start_date_timestamp + else None + ) + except ValueError: + self._start_date = None + return self._start_date + + @property + def end_date(self): + """ For Albums, return end date (most recent image) of album or None for albums with no images + For Import Sessions, return end date of import sessions (when import was completed) """ + try: + return self._end_date + except AttributeError: + try: + self._end_date = ( + datetime.fromtimestamp( + self._end_date_timestamp + TIME_DELTA + ).astimezone(tz=self._local_tz) + if self._end_date_timestamp + else None + ) + except ValueError: + self._end_date = None + return self._end_date + @property def photos(self): - """ return list of photos contained in album """ + return [] + + def __len__(self): + """ return number of photos contained in album """ + return len(self.photos) + + +class AlbumInfo(AlbumInfoBaseClass): + """ + Base class for AlbumInfo, ImportInfo + Info about a specific Album, contains all the details about the album + including folders, photos, etc. + """ + + @property + def title(self): + """ return title / name of album """ + return self._title + + @property + def photos(self): + """ return list of photos contained in album sorted in same sort order as Photos """ try: return self._photos except AttributeError: if self.uuid in self._db._dbalbums_album: uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid]) - self._photos = self._db.photos(uuid=uuid) - # PhotosDB.photos does not preserve order when passing in list of uuids - # so need to build photo list one a time - # sort uuids by sort order - sorted_uuid = sorted(zip(sort_order, uuid)) - self._photos = [ - self._db.photos(uuid=[uuid])[0] for _, uuid in sorted_uuid - ] + sorted_uuid = sort_list_by_keys(uuid, sort_order) + self._photos = self._db.photos_by_uuid(sorted_uuid) else: self._photos = [] return self._photos @@ -110,9 +207,24 @@ class AlbumInfo: ) return self._parent - def __len__(self): - """ return number of photos contained in album """ - return len(self.photos) + +class ImportInfo(AlbumInfoBaseClass): + @property + def photos(self): + """ return list of photos contained in import session """ + try: + return self._photos + except AttributeError: + uuid_list, sort_order = zip( + *[ + (uuid, self._db._dbphotos[uuid]["fok_import_session"]) + for uuid in self._db._dbphotos + if self._db._dbphotos[uuid]["import_uuid"] == self.uuid + ] + ) + sorted_uuid = sort_list_by_keys(uuid_list, sort_order) + self._photos = self._db.photos_by_uuid(sorted_uuid) + return self._photos class FolderInfo: diff --git a/osxphotos/datetime_utils.py b/osxphotos/datetime_utils.py index 57267715..3ba6d0c4 100644 --- a/osxphotos/datetime_utils.py +++ b/osxphotos/datetime_utils.py @@ -20,8 +20,7 @@ def datetime_remove_tz(dt): if type(dt) != datetime.datetime: raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}") - dt_new = dt.replace(tzinfo=None) - return dt_new + return dt.replace(tzinfo=None) def datetime_has_tz(dt): @@ -32,9 +31,7 @@ def datetime_has_tz(dt): if type(dt) != datetime.datetime: raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}") - if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None: - return True - return False + return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None def datetime_naive_to_local(dt): @@ -53,5 +50,4 @@ def datetime_naive_to_local(dt): f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tizinfo.utcoffset(dt)}" ) - dt_local = dt.replace(tzinfo=get_local_tz()) - return dt_local + return dt.replace(tzinfo=get_local_tz()) diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py index d2b9707a..c71f524f 100644 --- a/osxphotos/photoinfo/photoinfo.py +++ b/osxphotos/photoinfo/photoinfo.py @@ -5,16 +5,12 @@ PhotosDB.photos() returns a list of PhotoInfo objects """ import dataclasses -import glob import json import logging import os import os.path import pathlib -import subprocess -import sys from datetime import timedelta, timezone -from pprint import pformat import yaml @@ -25,10 +21,11 @@ from .._constants import ( _PHOTOS_4_ROOT_FOLDER, _PHOTOS_4_VERSION, _PHOTOS_5_ALBUM_KIND, + _PHOTOS_5_IMPORT_SESSION_ALBUM_KIND, _PHOTOS_5_SHARED_ALBUM_KIND, _PHOTOS_5_SHARED_PHOTO_PATH, ) -from ..albuminfo import AlbumInfo +from ..albuminfo import AlbumInfo, ImportInfo from ..personinfo import FaceInfo, PersonInfo from ..phototemplate import PhotoTemplate from ..placeinfo import PlaceInfo4, PlaceInfo5 @@ -88,7 +85,7 @@ class PhotoInfo: 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 @@ -357,7 +354,7 @@ class PhotoInfo: except AttributeError: try: faces = self._db._db_faceinfo_uuid[self._uuid] - self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces] + self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces] except KeyError: # no faces self._faceinfo = [] @@ -387,6 +384,19 @@ class PhotoInfo: ] return self._album_info + @property + def import_info(self): + """ ImportInfo object representing import session for the photo or None if no import session """ + try: + return self._import_info + except AttributeError: + self._import_info = ( + ImportInfo(db=self._db, uuid=self._info["import_uuid"]) + if self._info["import_uuid"] is not None + else None + ) + return self._import_info + @property def keywords(self): """ list of keywords for picture """ @@ -745,7 +755,7 @@ class PhotoInfo: """ Return list of album UUIDs this photo is found in Filters out albums in the trash and any special album types - + Returns: list of album UUIDs """ if self._db._db_version <= _PHOTOS_4_VERSION: diff --git a/osxphotos/photosdb/photosdb.py b/osxphotos/photosdb/photosdb.py index 1d3a417d..ffa47eb9 100644 --- a/osxphotos/photosdb/photosdb.py +++ b/osxphotos/photosdb/photosdb.py @@ -8,7 +8,6 @@ import os import os.path import pathlib import platform -import sqlite3 import sys import tempfile from datetime import datetime, timedelta, timezone @@ -26,15 +25,15 @@ from .._constants import ( _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, - _PHOTOS_5_VERSION, - _TESTED_DB_VERSIONS, _TESTED_OS_VERSIONS, _UNKNOWN_PERSON, + TIME_DELTA, ) from .._version import __version__ -from ..albuminfo import AlbumInfo, FolderInfo +from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo from ..datetime_utils import datetime_has_tz, datetime_naive_to_local from ..personinfo import PersonInfo from ..photoinfo import PhotoInfo @@ -46,7 +45,7 @@ from ..utils import ( _open_sql_file, get_last_library_path, ) -from .photosdb_utils import get_db_version, get_db_model_version +from .photosdb_utils import get_db_model_version, get_db_version # TODO: Add test for imageTimeZoneOffsetSeconds = None # TODO: Add test for __str__ @@ -485,6 +484,18 @@ class PhotosDB: self._albums_shared = self._get_albums(shared=True) return self._albums_shared + @property + def import_info(self): + """ return list of ImportInfo objects for each import session in the database """ + try: + return self._import_info + except AttributeError: + self._import_info = [ + ImportInfo(db=self, uuid=album) + for album in self._get_album_uuids(import_session=True) + ] + return self._import_info + @property def db_version(self): """ return the database version as stored in LiGlobals table """ @@ -514,6 +525,7 @@ class PhotosDB: """ 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) @@ -536,9 +548,6 @@ class PhotosDB: """ process the Photos database to extract info works on Photos version <= 4.0 """ - # Epoch is Jan 1, 2001 - td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds() - (conn, c) = _open_sql_file(self._tmp_db) # get info to associate persons with photos @@ -685,7 +694,8 @@ class PhotosDB: isInTrash, folderUuid, albumType, - albumSubclass + albumSubclass, + createDate FROM RKAlbum """ ) @@ -698,6 +708,7 @@ class PhotosDB: # 5: folderUuid # 6: albumType # 7: albumSubclass -- if 3, normal user album + # 8: createDate for album in c: self._dbalbum_details[album[0]] = { @@ -715,6 +726,9 @@ class PhotosDB: "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 @@ -920,7 +934,7 @@ class PhotosDB: # not accounted for try: self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp( - row[4] + td + row[4] + TIME_DELTA ) except ValueError: self._dbphotos[uuid]["lastmodifieddate"] = None @@ -930,7 +944,7 @@ class PhotosDB: self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9] try: - imagedate = datetime.fromtimestamp(row[5] + td) + imagedate = datetime.fromtimestamp(row[5] + TIME_DELTA) seconds = self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] or 0 delta = timedelta(seconds=seconds) tz = timezone(delta) @@ -1066,6 +1080,11 @@ class PhotosDB: self._dbphotos[uuid]["original_orientation"] = row[38] self._dbphotos[uuid]["original_filesize"] = row[39] + # 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 c.execute( """ SELECT @@ -1419,16 +1438,16 @@ class PhotosDB: if _debug(): logging.debug(f"_process_database5") - # Epoch is Jan 1, 2001 - td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds() + (conn, c) = _open_sql_file(self._tmp_db) + # some of the tables/columns have different names in different versions of Photos photos_ver = get_db_model_version(self._tmp_db) self._photos_ver = photos_ver 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"] - - (conn, c) = _open_sql_file(self._tmp_db) + album_sort = _DB_TABLE_NAMES[photos_ver]["ALBUM_SORT_ORDER"] + import_fok = _DB_TABLE_NAMES[photos_ver]["IMPORT_FOK"] # Look for all combinations of persons and pictures if _debug(): @@ -1539,7 +1558,7 @@ class PhotosDB: f""" SELECT ZGENERICALBUM.ZUUID, {asset_table}.ZUUID, - {album_join} + {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 @@ -1577,7 +1596,10 @@ class PhotosDB: "ZKIND, " # 6 "ZPARENTFOLDER, " # 7 "Z_PK, " # 8 - "ZTRASHEDSTATE " # 9 + "ZTRASHEDSTATE, " # 9 + "ZCREATIONDATE, " # 10 + "ZSTARTDATE, " # 11 + "ZENDDATE " # 12 "FROM ZGENERICALBUM " ) for album in c: @@ -1594,6 +1616,9 @@ class PhotosDB: "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 @@ -1771,7 +1796,7 @@ class PhotosDB: # I don't know what these mean but they will raise exception in datetime if # not accounted for try: - info["lastmodifieddate"] = datetime.fromtimestamp(row[4] + td) + info["lastmodifieddate"] = datetime.fromtimestamp(row[4] + TIME_DELTA) except ValueError: info["lastmodifieddate"] = None except TypeError: @@ -1780,7 +1805,7 @@ class PhotosDB: info["imageTimeZoneOffsetSeconds"] = row[6] try: - imagedate = datetime.fromtimestamp(row[5] + td) + imagedate = datetime.fromtimestamp(row[5] + TIME_DELTA) seconds = info["imageTimeZoneOffsetSeconds"] or 0 delta = timedelta(seconds=seconds) tz = timezone(delta) @@ -1925,6 +1950,12 @@ class PhotosDB: info["original_orientation"] = row[34] info["original_filesize"] = row[35] + # initialize import session info which will be filled in later + # not every photo has an import session so initialize all records now + info["import_session"] = None + info["fok_import_session"] = None + info["import_uuid"] = None + # associated RAW image info # will be filled in later info["has_raw"] = False @@ -1951,6 +1982,32 @@ class PhotosDB: # else: # info["burst"] = False + # get info on import sessions + # 0 ZGENERICASSET.ZUUID + # 1 ZGENERICASSET.ZIMPORTSESSION + # 2 ZGENERICASSET.Z_FOK_IMPORTSESSION + # 3 ZGENERICALBUM.ZUUID, + c.execute( + f"""SELECT + {asset_table}.ZUUID, + {asset_table}.ZIMPORTSESSION, + {import_fok}, + ZGENERICALBUM.ZUUID + FROM + {asset_table} + JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = {asset_table}.ZIMPORTSESSION + """ + ) + + for row in c: + uuid = row[0] + try: + self._dbphotos[uuid]["import_session"] = row[1] + self._dbphotos[uuid]["fok_import_session"] = row[2] + self._dbphotos[uuid]["import_uuid"] = row[3] + except KeyError: + logging.debug(f"No info record for uuid {uuid} for import session") + # Get extended description c.execute( f"""SELECT {asset_table}.ZUUID, @@ -2362,16 +2419,26 @@ class PhotosDB: hierarchy = _recurse_folder_hierarchy(folders) return hierarchy - def _get_album_uuids(self, shared=False): + def _get_album_uuids(self, shared=False, import_session=False): """ Return list of album UUIDs found in photos database Filters out albums in the trash and any special album types Args: shared: boolean; if True, returns shared albums, else normal albums + import_session: boolean, if True, returns import session albums, else normal or shared albums + Note: flags (shared, import_session) are mutually exclusive + Raises: + ValueError: raised if mutually exclusive flags passed + Returns: list of album UUIDs """ + if shared and import_session: + raise ValueError( + "flags are mutually exclusive: pass zero or one of shared, import_session" + ) + if self._db_version <= _PHOTOS_4_VERSION: version4 = True if shared: @@ -2379,11 +2446,21 @@ class PhotosDB: f"Shared albums not implemented for Photos library version {self._db_version}" ) return [] # not implemented for _PHOTOS_4_VERSION + elif import_session: + logging.warning( + f"Import sessions not implemented for Photos library version {self._db_version}" + ) + return [] # not implemented for _PHOTOS_4_VERSION else: album_kind = _PHOTOS_4_ALBUM_KIND else: version4 = False - album_kind = _PHOTOS_5_SHARED_ALBUM_KIND if shared else _PHOTOS_5_ALBUM_KIND + if shared: + album_kind = _PHOTOS_5_SHARED_ALBUM_KIND + elif import_session: + album_kind = _PHOTOS_5_IMPORT_SESSION_ALBUM_KIND + else: + album_kind = _PHOTOS_5_ALBUM_KIND album_list = [] # look through _dbalbum_details because _dbalbums_album won't have empty albums it diff --git a/tests/test_albums_folders_catalina_10_15_4.py b/tests/test_albums_folders_catalina_10_15_4.py index cf0e732f..c09217c8 100644 --- a/tests/test_albums_folders_catalina_10_15_4.py +++ b/tests/test_albums_folders_catalina_10_15_4.py @@ -55,7 +55,10 @@ ALBUM_PHOTO_UUID_DICT = { ], } -UUID_DICT = {"two_albums": "F12384F6-CD17-4151-ACBA-AE0E3688539E"} +UUID_DICT = { + "two_albums": "F12384F6-CD17-4151-ACBA-AE0E3688539E", + "album_dates": "0C514A98-7B77-4E4F-801B-364B7B65EAFA", +} def test_folders_1(): @@ -228,6 +231,46 @@ def test_albums_photos(): assert photo.uuid in ALBUM_PHOTO_UUID_DICT[album.title] +def test_album_dates(): + """ Test album date methods """ + import datetime + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + + album = [a for a in photosdb.album_info if a.uuid == UUID_DICT["album_dates"]][0] + assert album.creation_date == datetime.datetime( + 2019, + 7, + 27, + 6, + 19, + 13, + 706262, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + assert album.start_date == datetime.datetime( + 2018, + 9, + 28, + 12, + 35, + 49, + 63000, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + assert album.end_date == datetime.datetime( + 2018, + 9, + 28, + 13, + 9, + 33, + 22000, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + + def test_photoinfo_albums(): """ Test PhotoInfo.albums """ import osxphotos diff --git a/tests/test_catalina_10_15_6.py b/tests/test_catalina_10_15_6.py index 422de38f..ac449672 100644 --- a/tests/test_catalina_10_15_6.py +++ b/tests/test_catalina_10_15_6.py @@ -74,6 +74,7 @@ UUID_DICT = { "intrash": "71E3E212-00EB-430D-8A63-5E294B268554", "not_intrash": "DC99FBDD-7A52-4100-A5BB-344131646C30", "intrash_person_keywords": "6FD38366-3BF2-407D-81FE-7153EB6125B6", + "import_session": "8846E3E6-8AC8-4857-8448-E3D025784410", } UUID_PUMPKIN_FARM = [ @@ -1069,3 +1070,55 @@ def test_date_modified_invalid(): assert len(photos) == 1 p = photos[0] assert p.date_modified is None + + +def test_import_session_count(): + """ Test PhotosDB.import_session """ + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + + import_sessions = photosdb.import_info + assert len(import_sessions) == 10 + + +def test_import_session_photo(): + """ Test photo.import_session """ + import datetime + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photo = photosdb.get_photo(UUID_DICT["import_session"]) + import_session = photo.import_info + assert import_session.creation_date == datetime.datetime( + 2020, + 6, + 6, + 7, + 15, + 24, + 729811, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + assert import_session.start_date == datetime.datetime( + 2020, + 6, + 6, + 7, + 15, + 24, + 725564, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + assert import_session.end_date == datetime.datetime( + 2020, + 6, + 6, + 7, + 15, + 24, + 725564, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + assert len(import_session.photos) == 1 +