Skip to content

Commit

Permalink
Add date to ExifInfo
Browse files Browse the repository at this point in the history
  • Loading branch information
RhetTbull committed Aug 10, 2024
1 parent 9a07c29 commit c987b1f
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 73 deletions.
84 changes: 62 additions & 22 deletions osxphotos/exifinfo.py
Original file line number Diff line number Diff line change
@@ -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
48 changes: 3 additions & 45 deletions osxphotos/photoinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions osxphotos/photos_datetime.py
Original file line number Diff line number Diff line change
@@ -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)
122 changes: 116 additions & 6 deletions tests/test_exif_info.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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():
Expand All @@ -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]
39 changes: 39 additions & 0 deletions tests/test_photos_datetime.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit c987b1f

Please sign in to comment.