Files
osxphotos/osxphotos/exif_datetime_updater.py
Rhet Turnbull 70d3247e8c Feature timewarp function (#678)
* Added constraints, fixed themes for timewarp

* Updated progress to use rich_progress

* Added --function to timewarp, #676
2022-05-01 22:22:01 -07:00

349 lines
13 KiB
Python

"""Use exiftool to update exif data in photos """
import datetime
import re
from collections import namedtuple
from typing import Callable, Dict, Optional, Tuple
from photoscript import Photo
from .datetime_utils import (
datetime_has_tz,
datetime_naive_to_local,
datetime_remove_tz,
datetime_to_new_tz,
datetime_tz_to_utc,
datetime_utc_to_local,
)
from .exiftool import ExifTool
from .photosdb import PhotosDB
from .phototz import PhotoTimeZone, PhotoTimeZoneUpdater
from .timezones import Timezone, format_offset_time
from .utils import noop
__all__ = ["ExifDateTime", "ExifDateTimeUpdater"]
# date/time/timezone extracted from regex as a timezone aware datetime.datetime object
# default_time is True if the time is not specified in the exif otherwise False (and if True, set to 00:00:00)
# default_offset is True if timezone offset is not specified in the exif otherwise False (and if True, set to +00:00)
# used_file_modify_date is True if the date/time is not specified in the exif and the FileModifyDate is used instead
ExifDateTime = namedtuple(
"ExifDateTime",
[
"datetime",
"offset_seconds",
"offset_str",
"default_time",
"used_file_modify_date",
],
)
def exif_offset_to_seconds(offset: str) -> int:
"""Convert timezone offset from UTC in exiftool format (+/-hh:mm) to seconds"""
sign = 1 if offset[0] == "+" else -1
hours, minutes = offset[1:].split(":")
return sign * (int(hours) * 3600 + int(minutes) * 60)
class ExifDateTimeUpdater:
"""Update exif data in photos"""
def __init__(
self,
library_path: Optional[str] = None,
verbose: Optional[Callable] = None,
exiftool_path: Optional[str] = None,
plain=False,
):
self.library_path = library_path
self.db = PhotosDB(self.library_path)
self.verbose = verbose or noop
self.exiftool_path = exiftool_path
self.tzinfo = PhotoTimeZone(library_path=self.library_path)
self.plain = plain
def filename_color(self, filename: str) -> str:
"""Colorize filename for display in verbose output"""
return filename if self.plain else f"[filename]{filename}[/filename]"
def uuid_color(self, uuid: str) -> str:
"""Colorize uuid for display in verbose output"""
return uuid if self.plain else f"[uuid]{uuid}[/uuid]"
def update_exif_from_photos(self, photo: Photo) -> Tuple[str, str]:
"""Update EXIF data in photo to match the date/time/timezone in Photos library
Args:
photo: photoscript.Photo object to act on
"""
# photo is the photoscript.Photo object passed in
# _photo is the osxphotos.PhotoInfo object for the same photo
# Need _photo to get the photo's path
_photo = self.db.get_photo(photo.uuid)
if not _photo:
raise ValueError(f"Photo {photo.uuid} not found")
if not _photo.path:
self.verbose(
"Skipping EXIF update for missing photo "
f"[filename]{_photo.original_filename}[/filename] ([uuid]{_photo.uuid}[/uuid])"
)
return "", ""
self.verbose(
"Updating EXIF data for "
f"[filename]{photo.filename}[/filename] ([uuid]{photo.uuid}[/uuid])"
)
photo_date = datetime_naive_to_local(photo.date)
timezone_offset = self.tzinfo.get_timezone(photo)[0]
photo_date = datetime_to_new_tz(photo_date, timezone_offset)
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = photo_date.strftime("%Y:%m:%d %H:%M:%S")
# exiftool expects format of "-04:00"
offset = format_offset_time(timezone_offset)
# process date/time and timezone offset
# Photos exports the following fields and sets modify date to creation date
# [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
# [Keys] CreationDate : 2020:12:10 22:10:10-08:00
exif = {}
if _photo.isphoto:
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:CreateDate"] = datetimeoriginal
dateoriginal = photo_date.strftime("%Y:%m:%d")
exif["IPTC:DateCreated"] = dateoriginal
timeoriginal = photo_date.strftime(f"%H:%M:%S{offset}")
exif["IPTC:TimeCreated"] = timeoriginal
exif["EXIF:OffsetTimeOriginal"] = offset
elif _photo.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
creationdate = f"{datetimeoriginal}{offset}"
exif["QuickTime:CreationDate"] = creationdate
# need to convert to UTC then back to formatted string
tzdate = datetime.datetime.strptime(creationdate, "%Y:%m:%d %H:%M:%S%z")
utcdate = datetime_tz_to_utc(tzdate)
createdate = utcdate.strftime("%Y:%m:%d %H:%M:%S")
exif["QuickTime:CreateDate"] = createdate
self.verbose(
f"Writing EXIF data with exiftool to {self.filename_color(_photo.path)}"
)
with ExifTool(filepath=_photo.path, exiftool=self.exiftool_path) as exiftool:
for tag, val in exif.items():
if type(val) == list:
for v in val:
exiftool.setvalue(tag, v)
else:
exiftool.setvalue(tag, val)
return exiftool.warning, exiftool.error
def update_photos_from_exif(
self, photo: Photo, use_file_modify_date: bool = False
) -> None:
"""Update date/time/timezone in Photos library to match the data in EXIF
Args:
photo: photoscript.Photo object to act on
use_file_modify_date: if True, use the file modify date if there's no date/time in the exif data
"""
# photo is the photoscript.Photo object passed in
# _photo is the osxphotos.PhotoInfo object for the same photo
# Need _photo to get the photo's path
_photo = self.db.get_photo(photo.uuid)
if not _photo:
raise ValueError(f"Photo {photo.uuid} not found")
if not _photo.path:
self.verbose(
"Skipping EXIF update for missing photo "
f"[filename]{_photo.original_filename}[/filename] ([uuid]{_photo.uuid}[/uuid])"
)
return None
self.verbose(
"Updating Photos from EXIF data for "
f"[filename]{photo.filename}[/filename] ([uuid]{photo.uuid}[/uuid])"
)
dtinfo = self.get_date_time_offset_from_exif(
_photo.path, use_file_modify_date=use_file_modify_date
)
if dtinfo.used_file_modify_date:
self.verbose(
"EXIF date/time missing, using file modify date/time for "
f"[filename]{photo.filename}[/filename] ([uuid]{photo.uuid}[/uuid])"
)
if not dtinfo.datetime and not dtinfo.offset_seconds:
self.verbose(
"Skipping update for missing EXIF data in photo "
f"[filename]{photo.filename}[/filename] ([uuid]{photo.uuid}[/uuid])"
)
return None
if dtinfo.offset_seconds:
# update timezone then update date/time
timezone = Timezone(dtinfo.offset_seconds)
tzupdater = PhotoTimeZoneUpdater(
library_path=self.library_path, timezone=timezone
)
tzupdater.update_photo(photo)
self.verbose(
"Updated timezone offset for photo "
f"[filename]{photo.filename}[/filename] ([uuid]{photo.uuid}[/uuid]): [tz]{timezone}[/tz]"
)
if dtinfo.datetime:
if datetime_has_tz(dtinfo.datetime):
# convert datetime to naive local time for setting in photos
local_datetime = datetime_remove_tz(
datetime_utc_to_local(datetime_tz_to_utc(dtinfo.datetime))
)
else:
local_datetime = dtinfo.datetime
# update date/time
photo.date = local_datetime
self.verbose(
"Updated date/time for photo "
f"[filename]{photo.filename}[/filename] ([uuid]{photo.uuid}[/uuid]): [time]{local_datetime}[/time]"
)
return None
def get_date_time_offset_from_exif(
self, photo_path: str, use_file_modify_date: bool = False
) -> ExifDateTime:
"""Get date/time/timezone from EXIF data for a photo
Args:
photo_path: path to photo to get EXIF data from
use_file_modify_date: if True, use the file modify date if there's no date/time in the exif data
Returns:
ExifDateTime named tuple
"""
exiftool = ExifTool(filepath=photo_path, exiftool=self.exiftool_path)
exif = exiftool.asdict()
return get_exif_date_time_offset(
exif, use_file_modify_date=use_file_modify_date
)
def get_photo_path(self, photo: Photo) -> Optional[str]:
"""Get the path to a photo
Args:
photo: photoscript.Photo object to act on
Returns:
str: path to photo or None if not found
"""
_photo = self.db.get_photo(photo.uuid)
return _photo.path if _photo else None
def get_exif_date_time_offset(
exif: Dict, use_file_modify_date: bool = False
) -> ExifDateTime:
"""Get datetime/offset from an exif dict as returned by osxphotos.exiftool.ExifTool.asdict()
Args:
exif: dict of exif data
use_file_modify_date: if True, use the file modify date if there's no date/time in the exif data
"""
# set to True if no time is found
default_time = False
# set to True if no date/time in EXIF and the FileModifyDate is used
used_file_modify_date = False
# search these fields in this order for date/time/timezone
time_fields = [
"EXIF:DateTimeOriginal",
"EXIF:CreateDate",
"QuickTime:CreationDate",
"QuickTime:CreateDate",
"IPTC:DateCreated",
"XMP-exif:DateTimeOriginal",
"XMP-xmp:CreateDate",
]
if use_file_modify_date:
time_fields.append("File:FileModifyDate")
for dt_str in time_fields:
dt = exif.get(dt_str)
if dt and dt_str == "IPTC:DateCreated":
# also need time
time_ = exif.get("IPTC:TimeCreated")
if not time_:
time_ = "00:00:00"
default_time = True
dt = f"{dt} {time_}"
if dt:
used_file_modify_date = dt_str == "File:FileModifyDate"
break
else:
# no date/time found
dt = None
# try to get offset from EXIF:OffsetTimeOriginal
offset = exif.get("EXIF:OffsetTimeOriginal")
if dt and not offset:
# see if offset set in the dt string
matched = re.match(r"\d{4}:\d{2}:\d{2}\s\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2})", dt)
offset = matched.group(1) if matched else None
if dt:
# make sure we have time
matched = re.match(r"\d{4}:\d{2}:\d{2}\s(\d{2}:\d{2}:\d{2})", dt)
if not matched:
if matched := re.match(r"^(\d{4}:\d{2}:\d{2})", dt):
# set time to 00:00:00
dt = f"{matched.group(1)} 00:00:00"
default_time = True
offset_seconds = exif_offset_to_seconds(offset) if offset else None
if dt:
if offset:
# drop offset from dt string and add it back on in datetime %z format
dt = re.sub(r"[+-]\d{2}:\d{2}$", "", dt)
offset = offset.replace(":", "")
dt = f"{dt}{offset}"
dt_format = "%Y:%m:%d %H:%M:%S%z"
else:
dt_format = "%Y:%m:%d %H:%M:%S"
# convert to datetime
# some files can have bad date/time data, (e.g. #24, Date/Time Original = 0000:00:00 00:00:00)
try:
dt = datetime.datetime.strptime(dt, dt_format)
except ValueError:
dt = None
# format offset in form +/-hhmm
offset_str = offset.replace(":", "") if offset else ""
return ExifDateTime(
dt, offset_seconds, offset_str, default_time, used_file_modify_date
)