Skip to content

Instantly share code, notes, and snippets.

@oPromessa
Created May 15, 2025 19:21
Show Gist options
  • Save oPromessa/6daf2a39d5bc73c4e123e557dc3c341f to your computer and use it in GitHub Desktop.
Save oPromessa/6daf2a39d5bc73c4e123e557dc3c341f to your computer and use it in GitHub Desktop.
Mac Photos: identify all pics with mismatched Timezone vs GPS Location and prepare commands to fix it: osxphotos timewarp and osxphotos push-exif. See https://github.com/RhetTbull/osxphotos/issues/1788
#!/usr/bin/env python3
"""This script checks and fixes timezone inconsistencies in photos
managed by the Photos app on macOS.
It uses the `osxphotos` library to access photo metadata and the
`timezonefinder` library to determine timezones based on GPS coordinates.
Use with Python 3.11 or later.
Required Python libraries: `pip install osxphotos pytz timezonefinder rich`
To run the command on all photos within album "2014-fix", generate
`osxphotos timearwp command` and save the output to a CSV file:
`osxphotos run check_fix_timezone.py --album 2014-fix --fix-timezone --output 2014-fix.csv`
"""
from __future__ import annotations
import csv
import inspect
import re
import sys
from dataclasses import dataclass
from datetime import datetime
import click
import osxphotos
import pytz
from osxphotos import PhotoInfo
from osxphotos.cli import echo_error, query_command, verbose
from osxphotos.cli.verbose import get_verbose_console
from osxphotos.exifutils import get_exif_date_time_offset
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
TextColumn,
TimeElapsedColumn,
TimeRemainingColumn,
)
from timezonefinder import TimezoneFinder
def validate_fname(ctx, param, value):
"""Used with click.option.
Validate the filename for the --output and --output-push-exif options.
This function checks if the filename starts with '--' and raises
a BadParameter exception if it does.
"""
try:
if isinstance(value, click.utils.LazyFile) and value.name.startswith("--"):
raise click.BadParameter(
"Filename starts with '--'. Make sure you're not mixing options: "
f"'{value}'. "
)
else:
return value
except ValueError as exc:
raise click.BadParameter(
"Filename starts with '--'. Make sure you're not mixing options: "
f"'{value}'. "
) from exc
class MustHaveOption(click.Option):
"""Used with click.option.
Custom Click class to enforce that an option must be used with another list of options.
"""
def __init__(self, *args, **kwargs):
"""Initialize the MustHaveOption with a list of required options."""
self.must_have = set(kwargs.pop("must_have", []))
help_text = kwargs.get("help", "")
if self.must_have:
required_str = ", ".join(self.must_have)
kwargs["help"] = help_text + (
f" NOTE: This argument must be used with the following options: [{required_str}]."
)
super().__init__(*args, **kwargs)
def handle_parse_result(self, ctx, opts, args):
"""Check if the required options are present when this option is used."""
if self.name in opts and not self.must_have.intersection(opts):
raise click.UsageError(
f"Illegal usage: `{self.name}` must be used with at least one "
"of the following options: "
f"`{', '.join(self.must_have)}`."
)
return super().handle_parse_result(ctx, opts, args)
@dataclass
class TimezoneRecord:
"""Data class to hold timezone information."""
tzname: str | None
tzoffset: str | None
is_dst: bool
@dataclass
class EXIFRecord:
"""Data class to hold EXIF information."""
exif_datetime: str | None
exif_tzoffset: str | None
exif_latitude: str | None
exif_longitude: str | None
photo_exif_same_location: bool
photo_exif_result_cmp: str
@dataclass
class ValidatePhotoRecord:
"""Data class to hold validated photo information."""
photo_tzname: str | None
from_gps_tzname: str | None
from_gps_tzoffset: str | None
from_gps_is_dst: bool
status: str | None
def ident(deduct: int = 0) -> str:
"""Generates indentation based on the depth of the function in the call stack."""
depth = (
len(inspect.stack()) - max(deduct, 0) - 1
) # Subtract 1 to exclude the current function
return " " * depth
def f_name(caller_level: int = 0) -> str:
"""Get the name of the current function in the call stack.
### Example usage
- for current func name, f_name(0) or f_name().
- for name of caller of current func, f_name(1).
- for name of caller of caller of current func, f_name(2), etc.
"""
return sys._getframe(caller_level + 1).f_code.co_name
def seconds_to_hours_minutes(seconds: int) -> str:
"""Convert seconds into hours:minutes format with a + or - sign.
### Args:
* seconds: The number of seconds to convert.
### Returns:
* String: in the format '+HH:MM' or '-HH:MM'.
"""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=4)
sign = "+" if seconds >= 0 else "-"
seconds = abs(seconds)
hours = seconds // 3600
minutes = (seconds % 3600) // 60
return f"{sign}{hours:02}:{minutes:02}"
def is_offset_compatible_with_timezone(
offset: int, ref_timezone: str, date_str: str
) -> bool:
"""Check if the GMT offset is compatible with the reference timezone for a given date.
### Example usage
```
gmtoffset = go with +-NNNN format. Instead of "GMT+0100"
ref_timezone = "Europe/Paris"
date_str = "2023:07:22 10:10:10"
print(f"Does '{ref_timezone}' matches {gmtoffset}? {'Yes' if
is_offset_compatible_with_timezone(gmtoffset, ref_timezone,
date_str) else 'No'}")
```
"""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}] ", level=3)
# Convert the date string to a datetime object
try:
now = datetime.strptime(date_str[:19], "%Y:%m:%d %H:%M:%S")
except ValueError as err:
echo_error(f"{err=} [{date_str}]")
return False
try:
# Get the reference timezone object
ref_tz = pytz.timezone(ref_timezone)
except (
pytz.exceptions.AmbiguousTimeError,
pytz.exceptions.NonExistentTimeError,
pytz.exceptions.UnknownTimeZoneError,
) as err:
echo_error(f"{err=} [{ref_timezone}]")
return False
# Check if the reference timezone's offset matches the given offset
verbose(
f"\\[v4] {ident(6)}- \\[{f_name()}] "
f"\t{now.astimezone(ref_tz).utcoffset()=} ==?? {offset=}"
f"\n\t{type(now.astimezone(ref_tz).utcoffset())=} ==?? {type(offset)=}"
f"\n\t{(now.astimezone(ref_tz).utcoffset().total_seconds() == offset)=}",
level=4,
)
result = now.astimezone(ref_tz).utcoffset().total_seconds() == offset
verbose(
f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{result=}",
level=3,
)
return result
def get_timezone_info_from_gps(lat, lon, date_str, tf=None) -> TimezoneRecord:
"""Retrieve timezone information based on GPS coordinates and date.
### Returns a TimezoneRecord type:
* tzname: Timezone name (e.g., 'Europe/Lisbon')
* tzoffset: Offset including DST in '+/-NNNNN' format
* is_dst:True if DST is in effect"""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=3)
tzname = is_dst = tzoffset = None
value_to_return = TimezoneRecord(
tzname=tzname,
tzoffset=tzoffset,
is_dst=is_dst,
)
if any(value in [None, "None"] for value in [lat, lon]):
return value_to_return
# Convert the date string to a datetime object
try:
date = datetime.strptime(date_str[:19], "%Y:%m:%d %H:%M:%S")
except ValueError as err:
echo_error(f"{err=} [{date_str}]")
return value_to_return
# Find the timezone based on latitude and longitude
if tf is None:
tf = TimezoneFinder()
try:
if (tzname := tf.timezone_at(lat=lat, lng=lon)) is None:
echo_error("Timezone not found for the given coordinates.")
return value_to_return
except Exception as err:
echo_error(f"{err=} [timezone.at({lat}, {lon})]")
return value_to_return
try:
# Get the timezone object
timezone = pytz.timezone(tzname)
# Check if DST is applied
is_dst = bool(timezone.dst(date))
# Get the UTC offset including DST. Type: datetime.timedelta
tzoffset = int(timezone.utcoffset(date).total_seconds())
except (
pytz.exceptions.AmbiguousTimeError,
pytz.exceptions.NonExistentTimeError,
) as err:
echo_error(f"{err=} [{date}]")
return value_to_return
value_to_return.tzname = tzname
value_to_return.tzoffset = tzoffset
value_to_return.is_dst = is_dst
verbose(
f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{value_to_return=}",
level=3,
)
return value_to_return
def get_exif_info(photo: osxphotos.PhotoInfo) -> EXIFRecord:
"""
Extracts EXIF information from a photo object (including
datetime, offset, latitude, longitude) and compares with photo's location.
### Args:
- photo (object) A photo object containing EXIF data and metadata.
### Returns EXIFRecord:
- exif_datetime (str or None): The EXIF datetime in the format
"%Y:%m:%d %H:%M:%S.%f%z", or None if unavailable.
- exif_offset: EXIF timezone offset in format: +-HHMM
- exif_latitude (float or None): The latitude from the EXIF data,
- exif_longitude (float or None): The longitude from the EXIF data,
- photo_exif_same_location: Photo and EXIF location are the same or EXIF is None.
- photo_exif_result_cmp: Type of comparison result.
### Notes:
- The function relies on helper functions `get_exif_date_time_offset`
and `compare_photo_and_exif_location` to process EXIF data.
- If EXIF data is unavailable, all returned values will be None or
False, and an error message will be logged.
"""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=3)
exif_info = EXIFRecord(
exif_datetime=None,
exif_tzoffset=None,
exif_latitude=None,
exif_longitude=None,
photo_exif_same_location=False,
photo_exif_result_cmp="",
)
if photo.exiftool is not None and photo.exiftool.asdict() is not None:
exif_dt_offset = get_exif_date_time_offset(photo.exiftool.asdict())
exif_info.exif_datetime = (
exif_dt_offset.datetime.strftime("%Y:%m:%d %H:%M:%S.%f%z")
if exif_dt_offset.datetime
else None
)
exif_info.exif_tzoffset = exif_dt_offset.offset_seconds
(
_,
_,
exif_info.exif_latitude,
exif_info.exif_longitude,
exif_info.photo_exif_same_location,
exif_info.photo_exif_result_cmp,
) = compare_photo_and_exif_location(photo, photo.exiftool.asdict())
else:
echo_error(f"[error]No EXIF data available for path {photo.uuid=}[/error]")
verbose(f"\\[v3] {ident(6)}< \\[{f_name()}] {exif_info=}", level=3)
return exif_info
def fmt_(
type_str: str,
fmt: str,
val1: str,
val2: str,
val3: str,
) -> str:
"""
Format the given values with the specified type into the desired output format.
Args:
type_str (str): The type string to include in the output.
fmt (str): The format to apply to val(s).
val1 (str): The first val to format.
val2 (str): The second val to format.
val3 (str): The third val to format.
Returns:
str: The formatted string.
"""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=4)
type_str = f"{type_str:^8}" # Center the type string in an 8-character space
fmt_in = fmt_out = ""
if fmt:
fmt_in = f"[{fmt}]"
fmt_out = f"[/{fmt}]"
formatted_val1 = f"{fmt_in}{val1:>36}{fmt_out}"
formatted_val2 = f"{fmt_in}{val2:<36}{fmt_out}"
formatted_val3 = f"{fmt_in}{val3:<36}{fmt_out}"
fmt_str = (
f"{formatted_val1} = {type_str} = "
f"{formatted_val2} = {type_str} = "
f"{formatted_val3}\r\n"
)
verbose(f"\\[v3] {ident(6)}< \\[{f_name()}] {len(fmt_str)=}", level=4)
return fmt_str
def validate_photo_timezone(
photo: PhotoInfo,
exif_info: EXIFRecord,
tf=None,
) -> ValidatePhotoRecord:
"""
Validate if a photo's timezone is aligned with its GPS position.
### Returns a ValidatedPhotoRecord:
- photo_tzname: Photos based
- from_gps_tzname: GPS Location based
- from_gps_tzoffset: GPS Location based
- from_gps_is_dst: GPS Location based
- status:
- NotAvail - tzoffset (Location based) may not be determined
- Ok - Photos timezone == tzoffset (location based)
- NOk - Photos timezone != tzoffset (location based)
"""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=3)
validated_return = ValidatePhotoRecord(
photo_tzname=None,
from_gps_tzname=None,
from_gps_tzoffset=None,
from_gps_is_dst=False,
status=None,
)
tz_from_gps_position = get_timezone_info_from_gps(
photo.latitude,
photo.longitude,
photo.date.strftime("%Y:%m:%d %H:%M:%S"),
tf=TimezoneFinder() if tf is None else tf,
)
tz_from_exif_position = get_timezone_info_from_gps(
exif_info.exif_latitude,
exif_info.exif_longitude,
photo.date.strftime(
"%Y:%m:%d %H:%M:%S"
), # exif_info.exif_datetime may be None !!!
tf=TimezoneFinder() if tf is None else tf,
)
if any(value in [None, "None"] for value in vars(tz_from_gps_position).values()):
# Failed to determine tzname/tzoffset for Photo based on Photo GPS Location
status = "NotAvail"
elif is_offset_compatible_with_timezone(
photo.tzoffset,
tz_from_gps_position.tzname,
photo.date.strftime("%Y:%m:%d %H:%M:%S"),
):
if (
photo.tzname == tz_from_gps_position.tzname
and photo.tzoffset == tz_from_gps_position.tzoffset
):
# Photos timezone and offset match Photos GPS based timezone and offset
status = "Ok"
else:
# Photos timezone or offset does not match Photos GPS based timezone of tzoffset
status = "NOk"
else:
# Photos timezone does not match Photos GPS based tzoffset
status = "NOk"
verbose(
"", # Workaround for weird first argument line that gets 1 character offset
fmt_(
"diff",
"",
"photo",
"tz_from_gps_position",
"tz_from_exif_position \\[exif_info]",
),
fmt_("UUID", "uuid", f"{photo.uuid}", f"{photo.uuid}", f"{photo.uuid}"),
fmt_(
"file",
"filename",
f"{photo.original_filename}",
f"{photo.original_filename}",
f"{photo.original_filename}",
),
f"{'':>36} = {'path':^8} = [filepath]{photo.path}[/filepath]\r\n",
fmt_(
"tzname",
"tz",
f"{photo.tzname}",
f"{tz_from_gps_position.tzname}",
f"{tz_from_exif_position.tzname}",
),
fmt_(
"tzoffset",
"tz",
f"{photo.tzoffset}",
f"{tz_from_gps_position.tzoffset}",
f"{tz_from_exif_position.tzoffset} [{exif_info.exif_tzoffset}]",
),
fmt_(
"gmt",
"tz",
f"{convert_utc_offset_to_gmt_format(photo.tzoffset)}",
f"{convert_utc_offset_to_gmt_format(tz_from_gps_position.tzoffset)}",
f"{convert_utc_offset_to_gmt_format(tz_from_exif_position.tzoffset)}"
f" [{convert_utc_offset_to_gmt_format(exif_info.exif_tzoffset)}]",
),
fmt_(
"is_dst",
"tz",
"--",
f"{tz_from_gps_position.is_dst}",
f"{tz_from_exif_position.is_dst}",
),
fmt_(
"date_std",
"time",
f"{photo.date.strftime('%Y-%m-%d %H:%M:%S.%f%z')}",
"--",
f"{exif_info.exif_datetime}",
),
fmt_(
"same_loc",
"count",
f"{exif_info.photo_exif_same_location}",
f"{str(photo.latitude) + ', ' + str(photo.longitude)}",
f"{str(exif_info.exif_latitude) + ', ' + (str(exif_info.exif_longitude))}",
),
fmt_(
"status",
"no_change" if status == "Ok" else "change",
f"{status}",
f"{status}",
f"{exif_info.photo_exif_result_cmp}",
),
level=1,
)
validated_return.photo_tzname = photo.tzname
validated_return.from_gps_tzname = tz_from_gps_position.tzname
validated_return.from_gps_tzoffset = tz_from_gps_position.tzoffset
validated_return.from_gps_is_dst = tz_from_gps_position.is_dst
validated_return.status = status
verbose(
f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{validated_return=}",
level=3,
)
return validated_return
def convert_utc_offset_to_gmt_format(offset_seconds):
"""Convert UTC offset in seconds to GMT[+-]HHMM format."""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=3)
if offset_seconds is None:
return ""
hours = abs(offset_seconds) // 3600
minutes = (abs(offset_seconds) % 3600) // 60
sign = "+" if offset_seconds >= 0 else "-"
result = f"GMT{sign}{hours:02}{minutes:02}"
verbose(
f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{result=}",
level=3,
)
return result
def compare_photo_and_exif_location(
photo: osxphotos.PhotoInfo, file_data: dict
) -> tuple:
"""
Compare the location between Photos metadata and EXIF data for a single photo.
### Args:
* photo: osxphotos.PhotoInfo object containing photo metadata.
* file_data: Dictionary containing EXIF data.
### Returns:
* Tuple containing:
- photo_latitude: Latitude from Photos metadata.
- photo_longitude: Longitude from Photos metadata.
- exif_latitude: Latitude from EXIF data.
- exif_longitude: Longitude from EXIF data.
- bool: True if the latitude and longitude from Photos and EXIF data
are approximately equal (including if both are None).
- compare: Photo _vs_ EXIF Location: 'BothNone', 'OnlyPhoto', 'OnlyEXIF',
'BothSame', 'Differ'
"""
def approx(a: float | None, b: float | None, epsilon: float = 0.00001) -> bool:
"""Return True if a and b are approximately equal"""
if a is None and b is None:
return True
elif a is None or b is None:
return False
return abs(a - b) < epsilon
def are_none(a: float | None, b: float | None) -> bool:
"""Return True if a and b are None"""
return a is None and b is None
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=3)
photo_latitude = photo.latitude
photo_longitude = photo.longitude
exif_latitude = exif_longitude = None
exif_latitude_ref = exif_longitude_ref = None
if photo.isphoto:
exif_latitude = file_data.get("EXIF:GPSLatitude")
exif_longitude = file_data.get("EXIF:GPSLongitude")
exif_latitude_ref = file_data.get("EXIF:GPSLatitudeRef")
exif_longitude_ref = file_data.get("EXIF:GPSLongitudeRef")
elif photo.ismovie:
exif_coordinates = file_data.get("QuickTime:GPSCoordinates")
exif_latitude, exif_longitude = (
[float(x) for x in exif_coordinates.split()[:2]]
if exif_coordinates
else [None, None]
)
exif_latitude_ref = None
exif_longitude_ref = None
if exif_latitude and exif_latitude_ref == "S":
exif_latitude = -exif_latitude
if exif_longitude and exif_longitude_ref == "W":
exif_longitude = -exif_longitude
if are_none(photo_latitude, photo_longitude) and are_none(
exif_latitude, exif_longitude
):
result_cmp = "BothNone"
elif are_none(photo_latitude, photo_longitude):
result_cmp = "OnlyEXIF"
elif are_none(exif_latitude, exif_longitude):
result_cmp = "OnlyPhoto"
elif approx(photo_latitude, exif_latitude) and approx(
photo_longitude, exif_longitude
):
result_cmp = "BothSame"
else:
result_cmp = "BothDiffer"
result = (
photo_latitude,
photo_longitude,
exif_latitude,
exif_longitude,
approx(photo_latitude, exif_latitude)
and approx(photo_longitude, exif_longitude),
result_cmp,
)
verbose(
f"\\[v3] {ident(6)}< \\[{f_name()}] {result=}",
level=3,
)
return result
def determine_timezone_format(timezone: str) -> str:
"""
Determine the format of a given timezone string.
### Returns:
- 'GMT' : GMT+-HHMM
- 'Offset' : +-HH:MM
- 'Named Timezone' : Region/Country
- 'Invalid' : Invalid timezone string
"""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=3)
if not timezone:
result = "Unknown"
verbose(f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{result=}", level=3)
return result
# Check for GMT format (e.g., GMT+2, GMT-05:00)
gmt_pattern = re.compile(r"^GMT([+-]\d{1,2}(:?\d{2})?)?$")
if gmt_pattern.match(timezone):
result = "GMT"
verbose(f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{result=}", level=3)
return result
# Check for UTC offset format (e.g., +02:00, -05:00)
utc_offset_pattern = re.compile(r"^[+-]\d{2}:\d{2}$")
if utc_offset_pattern.match(timezone):
result = "Offset"
verbose(f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{result=}", level=3)
return result
# Check for named timezone (e.g., Europe/Lisbon, America/New_York)
result = "Invalid"
try:
pytz.timezone(timezone)
result = "Named Timezone"
except pytz.UnknownTimeZoneError:
pass
finally:
verbose(
f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{result=}",
level=3,
)
return result
def fix_photo_timezone(
photo: osxphotos.PhotoInfo,
gps_position: ValidatePhotoRecord,
exif_info: EXIFRecord,
match_time: bool = False,
force_fix: bool = False,
):
"""
Generate osxphotos timewarp command to fix timezone issues based on GPS and EXIF data.
"""
verbose(f"\\[v3] {ident(6)}> \\[{f_name()}]", level=3)
fix_timezone_cmd = ""
# Only fix if Photo.Location == EXIF.Location OR EXIF.Location is None
# except if --force-fix-timezone is set
if exif_info.photo_exif_result_cmp in ["BothSame", "OnlyPhoto"] or (
exif_info.photo_exif_result_cmp == "BothDiffer" and force_fix
):
# If Photos Timezone based on Photos Location format is 'Named Timezone'
if exif_info.photo_exif_result_cmp == "BothDiffer":
verbose(
f"[warning]{photo.original_filename} ({photo.path}): "
"Photos and EXIF Locations differ. "
f"--force-fix-timezone is set: Using Photos Location to set timezone. "
"See/Run push-exif.[/warning]",
level=2,
)
if determine_timezone_format(gps_position.from_gps_tzname) == "Named Timezone":
# Only fix Timezone when Photos pic GPS based Timezone is valid and
# in 'Named Timezone' format.
if (
photo.tzname != gps_position.from_gps_tzname
or photo.tzoffset != gps_position.from_gps_tzoffset
):
time_delta = (
photo.tzoffset
- gps_position.from_gps_tzoffset
# + (3600 if gps_position.from_gps_is_dst else 0)
)
if match_time:
time_adjust_option = "--match-time"
else:
time_adjust_option = f"--time-delta '{time_delta} seconds'"
fix_timezone_cmd = (
f"osxphotos timewarp --uuid {photo.uuid} "
f"--timezone '{gps_position.from_gps_tzname}' "
f"{time_adjust_option} "
f"--force --verbose"
)
verbose(
"",
fmt_(
"fixtz",
"change",
f"--timezone {gps_position.from_gps_tzname}",
f"{gps_position.from_gps_tzname}",
"",
),
fmt_(
"match",
"change",
f"{time_adjust_option}",
f"{str(time_delta) + ' [' + seconds_to_hours_minutes(time_delta)}]",
"",
),
level=1,
)
verbose(
f"\\[v3] {ident(6)}- \\[{f_name()}] "
f"{gps_position.from_gps_tzname=}",
level=3,
)
else:
verbose(
f"[warning]{photo.original_filename} ({photo.path}): "
"Photos GPS based Location is not in 'Named Timezone' format. "
"Not setting timezone.",
level=1,
)
elif exif_info.photo_exif_result_cmp == "BothDiffer":
echo_error(
f"[error]{photo.original_filename} ({photo.path}): "
"Photos and EXIF Locations differ. "
f"--force-fix-timezone is not set: Not setting timezone. "
f"Review Photos and EXIF Location or use --force-fix-timezone.[/error]"
)
elif exif_info.photo_exif_result_cmp == "BothNone":
pass
elif exif_info.photo_exif_result_cmp == "OnlyEXIF":
echo_error(
f"[error]{photo.original_filename} ({photo.path}): "
"Only EXIF Location available. Not setting timezone. "
"Review Photos and EXIF Location.[/error]"
)
verbose(f"\\[v2] {fix_timezone_cmd=}", level=2)
verbose(
f"\\[v3] {ident(6)}< \\[{f_name()}] " f"{fix_timezone_cmd=}",
level=3,
)
return fix_timezone_cmd
@query_command
@click.option(
"--fix-timezone",
is_flag=True,
help="Provide parameters for osxphotos --timewarp to adjust "
"timezone based on GPS Location while keeping timestamp.",
)
@click.option(
"--match-time",
is_flag=True,
cls=MustHaveOption,
must_have=[
"fix_timezone",
],
help="When used with --fix-timezone: uses --match-time option instead "
"of --time-delta to the 'osxphotos timewarp' command.",
)
@click.option(
"--force-fix-timezone",
is_flag=True,
cls=MustHaveOption,
must_have=[
"fix_timezone",
"push_exif",
],
help="When used with --fix-timezone: forces generating timewarp command "
"even if EXIF GPS Location exists and does not match Photos GPS Location. "
"When used with --push-exif: forces generating --push-exif command "
"even if EXIF GPS Location exists and does not match Photos GPS Location. ",
)
@click.option(
"--push-exif",
is_flag=True,
help="Generates --output-push-exif file to use with command "
"`osxphotos push-exif datetime,location "
"--ignore-date-modified --push-edited --verbose "
"--uuid-from-file push_exif_uuid.csv` --force` "
"to write photo metadata (datetime and location) to original files. "
"See --output-push-exif.",
)
@click.option(
"--output-push-exif",
type=click.File("w"),
cls=MustHaveOption,
must_have=[
"push_exif",
],
default="push_exif_uuid.csv",
callback=validate_fname,
help="When used with --push-exif: output CSV file to save UUIDs "
"of photos processed with push-exif. "
"If not provided, defaults to 'push_exif_uuid.csv'.",
)
@click.option(
"--output",
type=click.File("w"),
default=sys.stdout,
callback=validate_fname,
help="Output CSV file or stdout (if not provided).",
)
@click.option(
"--no-progress",
is_flag=True,
help="Disable progress bar updates.",
)
def process_photos_and_fix_timezones(
photos: list[osxphotos.PhotoInfo],
fix_timezone,
match_time,
force_fix_timezone,
push_exif,
output,
no_progress,
output_push_exif,
**kwargs,
):
"""
Process photos, check for timezone inconsistencies, and optionally
fix them. The script generates a detailed CSV report with information
about each photo's timezone, GPS data, and potential fixes.
1. Processes photos from the Photos app. (use osxphotos query options).
The following osxphotos' options are available as well:
--verbose, --timestamp, --theme, --db, --debug (hidden, won't show in help).
2. Checks GPS position, timezone, and UTC offset information from
Photos. Based on GPS Position: determine correct Timezone,
Offset and DST with TimeZoneFinder.
3. See status field possible results in function "validate_photo_timezone".
4. Compares EXIF data with Photos metadata.
5. Identifies Timezone based Photos Location when EXIF GPS Position
is non-existent or the same as Photos Location.
6. Optionally generates commands to fix timezone issues using `osxphotos timewarp`.
7. Outputs a CSV report with all the details.
To run the command on all photos within album "2014-fix", generate
`osxphotos timewarp command` and save the output to a CSV file:
`osxphotos run example_fix_timezone.py --album 2014-fix --fix-timezone --output 2014-fix.csv`
"""
verbose(
f"\\[v2] {ident(6)}- \\[{f_name()}] "
f"Args: {fix_timezone=}, {match_time=}, {force_fix_timezone=}, "
f"{push_exif=}, {output_push_exif=}, "
f"{output=}, {no_progress=}",
level=2,
)
num_photos = len(photos)
verbose(
"\\[v1] Processing photos and fixing timezones: "
f"Found: {num_photos} photo(s).",
level=1,
)
fieldnames = [
"uuid",
"filename",
"isphoto",
"ismovie",
"uti",
"hidden",
"ismissing",
"date",
"date_std",
"epoch",
"photo_datemodified",
"path",
"latitude",
"longitude",
"photo_tzname",
"photo_tzoffset",
"photo_converted_tzoffset",
"gps_tzname",
"gps_tzoffset",
"gps_converted_tzoffset",
"gps_is_dst",
"exif_datetime",
"exif_offset",
"exif_latitude",
"exif_longitude",
"photo_exif_same_location",
"status",
"loc_cmp",
"fix_timezone",
"push_exif",
]
csv_writer = csv.DictWriter(output, fieldnames=fieldnames, quoting=csv.QUOTE_ALL)
csv_writer.writeheader()
# push-exif output CSV file format and initialize list to store UUIDs
push_exif_fieldnames = [
"uuid",
]
push_exif_uuids = []
# Load TimezoneFinder only once as it is slow to initialize
tf = TimezoneFinder()
# Initialize progress bar if not disabled
progress = None
task = None
if not no_progress:
progress = Progress(
TextColumn("Processing {task.total} photos:"),
TextColumn("•"),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
BarColumn(),
MofNCompleteColumn(),
TextColumn("•"),
TimeElapsedColumn(),
TextColumn("•"),
TimeRemainingColumn(),
TextColumn("•"),
TextColumn("Current item: {task.fields[current_item]}"),
console=get_verbose_console(),
auto_refresh=True,
)
task = progress.add_task(
"Processing photos", total=num_photos, current_item="..."
)
progress.start()
# Process photos
for idx, photo in enumerate(photos, start=1):
if progress:
progress.update(task, advance=1, current_item=photo.original_filename)
verbose(
f"\\[v2] Processing photo #{idx:>06}: {photo.original_filename}", level=2
)
# Required to set exif_info.photo_exif_same_location
# for fix_photo_tmezone to ONLY operate if EXIF GPS is the same as Photo's
exif_info = get_exif_info(photo)
from_gps_position = validate_photo_timezone(
photo,
exif_info,
tf=tf,
)
fix_timezone_cmd = ""
if fix_timezone and from_gps_position.status == "Ok":
verbose(
"",
fmt_(
"fixtz",
"no_change",
"",
"Ok",
"",
),
level=1,
)
elif fix_timezone:
fix_timezone_cmd = fix_photo_timezone(
photo,
from_gps_position,
exif_info,
match_time,
force_fix_timezone,
)
push_exif_cmd = ""
if push_exif and (
exif_info.photo_exif_result_cmp == "OnlyPhoto"
or (exif_info.photo_exif_result_cmp == "BothDiffer" and force_fix_timezone)
):
push_exif_cmd = f"datetime,location --uuid {photo.uuid}"
# Add UUID to the list to use for push-exif with --uuid-from-file
push_exif_uuids.append(photo.uuid)
verbose(
f"\\[v3] {ident(6)}< \\[{f_name()}] \\[{len(push_exif_uuids)=}]",
level=3,
)
verbose(
"",
fmt_(
"pushexif",
"change",
"",
"",
f"datetime,location --uuid {photo.uuid}",
),
level=1,
)
elif push_exif and exif_info.photo_exif_result_cmp == "BothSame":
verbose(
"",
fmt_(
"pushexif",
"no_change",
"",
"Ok",
"",
),
level=1,
)
elif push_exif and exif_info.photo_exif_result_cmp == "OnlyEXIF":
verbose(
"",
fmt_(
"pushexif",
"change",
"",
"OnlyEXIF",
"",
),
level=1,
)
verbose(f"\\[v2] {push_exif_cmd=}", level=2)
csv_writer.writerow(
{
"uuid": photo.uuid,
"filename": photo.filename,
"isphoto": photo.isphoto,
"ismovie": photo.ismovie,
"uti": photo.uti,
"hidden": photo.hidden,
"ismissing": photo.ismissing,
"date": photo.date,
"date_std": photo.date.strftime("%Y-%m-%d %H:%M:%S.%f%z"),
"epoch": photo.date.timestamp(),
"photo_datemodified": photo.date_modified,
"path": photo.path,
"latitude": photo.latitude,
"longitude": photo.longitude,
"photo_tzname": photo.tzname,
"photo_tzoffset": photo.tzoffset,
"photo_converted_tzoffset": convert_utc_offset_to_gmt_format(
photo.tzoffset
),
"gps_tzname": from_gps_position.from_gps_tzname,
"gps_tzoffset": from_gps_position.from_gps_tzoffset,
"gps_converted_tzoffset": convert_utc_offset_to_gmt_format(
from_gps_position.from_gps_tzoffset
),
"gps_is_dst": from_gps_position.from_gps_is_dst,
"exif_datetime": exif_info.exif_datetime,
"exif_offset": str(exif_info.exif_tzoffset),
"exif_latitude": str(exif_info.exif_latitude),
"exif_longitude": str(exif_info.exif_longitude),
"photo_exif_same_location": exif_info.photo_exif_same_location,
"status": from_gps_position.status,
"loc_cmp": exif_info.photo_exif_result_cmp,
"fix_timezone": fix_timezone_cmd,
"push_exif": push_exif_cmd,
}
)
# Stop progress bar if it was started
if progress:
progress.stop()
# Write push-exif UUIDs to the output CSV file
if push_exif:
try:
# csv.QUOTE_NONE is required to avoid quoting the UUIDs
push_writer = csv.DictWriter(
output_push_exif,
fieldnames=push_exif_fieldnames,
quoting=csv.QUOTE_NONE,
)
push_writer.writeheader()
if output_push_exif is not None and len(push_exif_uuids) > 0:
for uuid in push_exif_uuids:
push_writer.writerow(
{
"uuid": uuid,
}
)
else:
verbose(
f"\\[v1] No photos to write to '{output_push_exif.name}'",
level=1,
)
except IOError as e:
echo_error(f"[error]Failed to write to {output_push_exif.name}: {e}")
verbose(f"\\[v3] {ident(6)}< \\[{f_name()}]", level=3)
echo_error("[green]Done![/green]")
if __name__ == "__main__":
echo_error("[blue]Starting...[/blue]")
process_photos_and_fix_timezones() # pylint: disable=E1120
echo_error("[blue]Finished![/blue]")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment