diff --git a/osxphotos/exifinfo.py b/osxphotos/exifinfo.py index 83d5f8b87..b1454b67b 100644 --- a/osxphotos/exifinfo.py +++ b/osxphotos/exifinfo.py @@ -1,30 +1,70 @@ """ ExifInfo class to expose EXIF info from the library """ +from __future__ import annotations + +import datetime from dataclasses import dataclass +from typing import Any +from osxphotos.photos_datetime import photos_datetime -__all__ = ["ExifInfo"] +__all__ = ["ExifInfo", "exifinfo_factory"] @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 + """Original EXIF info associated with a photo from the Photos library""" + + flash_fired: bool | None = None + iso: int | None = None + metering_mode: int | None = None + sample_rate: int | None = None + track_format: int | None = None + white_balance: int | None = None + aperture: float | None = None + bit_rate: float | None = None + duration: float | None = None + exposure_bias: float | None = None + focal_length: float | None = None + fps: float | None = None + latitude: float | None = None + longitude: float | None = None + shutter_speed: float | None = None + camera_make: str | None = None + camera_model: str | None = None + codec: str | None = None + lens_model: str | None = None + date: datetime.datetime | None = None + tzoffset: int | None = None + tzname: str | None = None + +def exifinfo_factory(data: dict[str, Any] | None) -> ExifInfo: + """Create an ExifInfo object from a dictionary of EXIF data""" + if data is None: + return ExifInfo() + + exif_info = ExifInfo( + iso=data["ZISO"], + flash_fired=True if data["ZFLASHFIRED"] == 1 else False, + metering_mode=data["ZMETERINGMODE"], + sample_rate=data["ZSAMPLERATE"], + track_format=data["ZTRACKFORMAT"], + white_balance=data["ZWHITEBALANCE"], + aperture=data["ZAPERTURE"], + bit_rate=data["ZBITRATE"], + duration=data["ZDURATION"], + exposure_bias=data["ZEXPOSUREBIAS"], + focal_length=data["ZFOCALLENGTH"], + fps=data["ZFPS"], + latitude=data["ZLATITUDE"], + longitude=data["ZLONGITUDE"], + shutter_speed=data["ZSHUTTERSPEED"], + camera_make=data["ZCAMERAMAKE"], + camera_model=data["ZCAMERAMODEL"], + codec=data["ZCODEC"], + lens_model=data["ZLENSMODEL"], + # ZDATECREATED, ZTIMEZONEOFFSET, ZTIMEZONENAME added in Ventura / Photos 8 so may not be present + date=photos_datetime(data.get("ZDATECREATED"), data.get("ZTIMEZONEOFFSET"), False), + tzoffset=data.get("ZTIMEZONEOFFSET"), + tzname=data.get("ZTIMEZONENAME"), + ) + return exif_info diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index f8a3b108a..3b2e03ba6 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -55,7 +55,7 @@ from .albuminfo import AlbumInfo, ImportInfo, ProjectInfo from .bookmark import resolve_bookmark_path from .commentinfo import CommentInfo, LikeInfo -from .exifinfo import ExifInfo +from .exifinfo import ExifInfo, exifinfo_factory from .exiftool import ExifToolCaching, get_exiftool_path from .exportoptions import ExportOptions from .momentinfo import MomentInfo @@ -1604,52 +1604,10 @@ def exif_info(self) -> ExifInfo | 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"], - ) + return exifinfo_factory(exif) except KeyError: logger.debug(f"Could not find exif record for uuid {self.uuid}") - exif_info = ExifInfo( - iso=None, - flash_fired=None, - metering_mode=None, - sample_rate=None, - track_format=None, - white_balance=None, - aperture=None, - bit_rate=None, - duration=None, - exposure_bias=None, - focal_length=None, - fps=None, - latitude=None, - longitude=None, - shutter_speed=None, - camera_make=None, - camera_model=None, - codec=None, - lens_model=None, - ) - - return exif_info + return exifinfo_factory(None) @property def exiftool(self) -> ExifToolCaching | None: diff --git a/osxphotos/photos_datetime.py b/osxphotos/photos_datetime.py new file mode 100644 index 000000000..a4982e480 --- /dev/null +++ b/osxphotos/photos_datetime.py @@ -0,0 +1,42 @@ +""" Utilities for working with date/time values in the Photos library """ + +from __future__ import annotations + +import datetime + +# Time delta: add this to Photos times to get unix time +# Apple Epoch is Jan 1, 2001 +TIME_DELTA = (datetime.datetime(2001, 1, 1, 0, 0) - datetime.datetime(1970, 1, 1, 0, 0)).total_seconds() + +# Default datetime for when we can't determine the date +DEFAULT_DATETIME = datetime.datetime(1970, 1, 1).astimezone(tz=datetime.timezone.utc) + +__all__ = ["photos_datetime"] + + +def photos_datetime( + timestamp: float | None, tzoffset: int, default: bool = False +) -> datetime.datetime | None: + """Convert a timestamp from the Photos database to a timezone aware datetime""" + dt: datetime.datetime | None = None + + if timestamp is None: + if default: + return DEFAULT_DATETIME + else: + return None + + try: + dt = datetime.datetime.fromtimestamp(timestamp + TIME_DELTA) + except (ValueError, TypeError): + dt = None + + if dt is None: + if default: + return DEFAULT_DATETIME + else: + return None + + delta = datetime.timedelta(seconds=tzoffset) + tz = datetime.timezone(delta) + return dt.astimezone(tz=tz) diff --git a/tests/test_exif_info.py b/tests/test_exif_info.py index c91f91300..aef240767 100644 --- a/tests/test_exif_info.py +++ b/tests/test_exif_info.py @@ -1,13 +1,17 @@ """ Test ExifInfo """ +import datetime + import pytest +import osxphotos from osxphotos.exifinfo import ExifInfo PHOTOS_DB_5 = "tests/Test-Cloud-10.15.1.photoslibrary" PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary" +PHOTOS_DB_8 = "tests/Test-13.0.0.photoslibrary" -EXIF_DICT = { +EXIF_DICT_5 = { "D11D25FF-5F31-47D2-ABA9-58418878DC15": ExifInfo( flash_fired=False, iso=50, @@ -73,19 +77,118 @@ ), } +EXIF_DICT_8 = { + "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": ExifInfo( + flash_fired=True, + iso=160, + metering_mode=3, + sample_rate=None, + track_format=None, + white_balance=0, + aperture=2.2, + bit_rate=None, + duration=None, + exposure_bias=None, + focal_length=100.0, + fps=None, + latitude=None, + longitude=None, + shutter_speed=0.001, + camera_make="NIKON CORPORATION", + camera_model="NIKON D810", + codec=None, + lens_model="100.0 mm f/2.0", + date=datetime.datetime( + 2019, + 4, + 15, + 14, + 40, + 24, + 860000, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)), + ), + tzoffset=-14400, + tzname="America/New_York", + ), + "8E1D7BC9-9321-44F9-8CFB-4083F6B9232A": ExifInfo( + flash_fired=False, + iso=200, + metering_mode=5, + sample_rate=None, + track_format=None, + white_balance=0, + aperture=2.8, + bit_rate=None, + duration=None, + exposure_bias=0.0, + focal_length=6.1, + fps=None, + latitude=None, + longitude=None, + shutter_speed=0.03333333333333333, + camera_make="Canon", + camera_model="Canon PowerShot G10", + codec=None, + lens_model="6.1-30.5 mm", + date=None, + tzoffset=None, + tzname=None, + ), + "A92D9C26-3A50-4197-9388-CB5F7DB9FA91": ExifInfo( + flash_fired=False, + iso=80, + metering_mode=5, + sample_rate=None, + track_format=None, + white_balance=0, + aperture=4.0, + bit_rate=None, + duration=None, + exposure_bias=0.0, + focal_length=12.07, + fps=None, + latitude=None, + longitude=None, + shutter_speed=0.0015625, + camera_make="Canon", + camera_model="Canon PowerShot G10", + codec=None, + lens_model="6.1-30.5 mm", + date=datetime.datetime( + 2020, + 4, + 15, + 10, + 25, + 51, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)), + ), + tzoffset=-14400, + tzname="America/New_York", + ), +} + @pytest.fixture -def photosdb(): +def photosdb5() -> osxphotos.PhotosDB: import osxphotos return osxphotos.PhotosDB(dbfile=PHOTOS_DB_5) -def test_exif_info_v5(photosdb): +@pytest.fixture +def photosdb8() -> osxphotos.PhotosDB: + import osxphotos + + return osxphotos.PhotosDB(dbfile=PHOTOS_DB_8) + + +def test_exif_info_v5(photosdb5: osxphotos.PhotosDB): """test exif_info""" - for uuid in EXIF_DICT: - photo = photosdb.photos(uuid=[uuid], movies=True)[0] - assert photo.exif_info == EXIF_DICT[uuid] + for uuid in EXIF_DICT_5: + photo = photosdb5.photos(uuid=[uuid], movies=True)[0] + assert photo.exif_info == EXIF_DICT_5[uuid] def test_exif_info_v4(): @@ -95,3 +198,10 @@ def test_exif_info_v4(): photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_4) for photo in photosdb.photos(): assert photo.exif_info is None + + +def test_exif_info_v8(photosdb8: osxphotos.PhotosDB): + """test exif_info""" + for uuid in EXIF_DICT_8: + photo = photosdb8.photos(uuid=[uuid], movies=True)[0] + assert photo.exif_info == EXIF_DICT_8[uuid] diff --git a/tests/test_photos_datetime.py b/tests/test_photos_datetime.py new file mode 100644 index 000000000..6fad4fab4 --- /dev/null +++ b/tests/test_photos_datetime.py @@ -0,0 +1,39 @@ +"""Test photos_datetime.py utilities""" + +from __future__ import annotations + +import datetime + +import pytest + +from osxphotos.photos_datetime import photos_datetime + +# data is timestamp, tzoffset, expected datetime +TEST_DATA = [ + (608405423, -25200, False, "2020-04-12 10:30:23-07:00"), + (123456789012345, -25200, False, None), + (123456789012345, -25200, True, "1970-01-01 06:00:00+00:00"), + (714316684, 34200, False, "2023-08-21 22:48:04+09:30"), + (583964641, -14400, False, "2019-07-04 16:24:01-04:00"), + (561129492.501, -14400, False, "2018-10-13 09:18:12.501000-04:00"), + (715411622, -14400, False, "2023-09-03 01:27:02-04:00"), + (622244186.719, -25200, False, "2020-09-19 14:36:26.719000-07:00"), + (608664351, -25200, False, "2020-04-15 10:25:51-07:00"), + (714316684, -14400, False, "2023-08-21 09:18:04-04:00"), + (608758101, -25200, False, "2020-04-16 12:28:21-07:00"), + (714316684, -14400, False, "2023-08-21 09:18:04-04:00"), + (608751778, -25200, False, "2020-04-16 10:42:58-07:00"), + (559856149.063, -14400, False, "2018-09-28 15:35:49.063000-04:00"), + (559856399.008, -14400, False, "2018-09-28 15:39:59.008000-04:00"), + (744897809.3687729, -18000, False, "2024-08-09 07:03:29.368773-05:00"), +] + + +@pytest.mark.parametrize("timestamp, tzoffset, default, expected", TEST_DATA) +def test_photos_datetime( + timestamp: float, tzoffset: int, default: bool, expected: str | None +): + """Test photos_datetime""" + dt = photos_datetime(timestamp, tzoffset, default) + dt_expected = datetime.datetime.fromisoformat(expected) if expected else None + assert dt == dt_expected