* Added constraints, fixed themes for timewarp * Updated progress to use rich_progress * Added --function to timewarp, #676
349 lines
13 KiB
Python
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
|
|
)
|