Created
May 15, 2025 19:21
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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