diff --git a/osxphotos/cli/exportdb.py b/osxphotos/cli/exportdb.py index d14939e2..10fc5ae7 100644 --- a/osxphotos/cli/exportdb.py +++ b/osxphotos/cli/exportdb.py @@ -59,12 +59,14 @@ @click.option( "--check-signatures", is_flag=True, - help="Check signatures for all exported photos in the database to find signatures that don't match.", + help="Check signatures for all exported photos in the database to find signatures that don't match. " + "See also option --export-dir.", ) @click.option( "--update-signatures", is_flag=True, - help="Update signatures for all exported photos in the database to match on-disk signatures.", + help="Update signatures for all exported photos in the database to match on-disk signatures. " + "See also option --export-dir.", ) @click.option( "--touch-file", @@ -268,6 +270,7 @@ def exportdb( info, last_run, last_export_dir, + migrate_photos_library, upgrade, repair, report, @@ -323,47 +326,63 @@ def exportdb( ) sys.exit(1) - try: - ExportDB(export_db, export_dir, create) - except Exception as e: - rich_echo_error(f"[error]Error: {e}[/error]") - sys.exit(1) + if not dry_run: + try: + ExportDB(export_db, export_dir, create) + except Exception as e: + rich_echo_error(f"[error]Error: {e}[/error]") + sys.exit(1) + else: + rich_echo(f"Created export database [filepath]{export_db}[/]") + sys.exit(0) else: - rich_echo(f"Created export database [filepath]{export_db}[/]") + rich_echo(f"[Dryrun] Created export database [filepath]{export_db}[/]") sys.exit(0) if check: - errors = sqlite_check_integrity(export_db) - if not errors: - rich_echo(f"Ok: [filepath]{export_db}[/]") - sys.exit(0) + if not dry_run: + errors = sqlite_check_integrity(export_db) + if not errors: + rich_echo(f"Ok: [filepath]{export_db}[/]") + sys.exit(0) + else: + rich_echo_error(f"[error]Errors: [filepath]{export_db}[/][/error]") + for error in errors: + rich_echo_error(error) + sys.exit(1) else: - rich_echo_error(f"[error]Errors: [filepath]{export_db}[/][/error]") - for error in errors: - rich_echo_error(error) - sys.exit(1) + rich_echo(f"[Dryrun] Check [filepath]{export_db}[/]") + sys.exit(0) if repair: - try: - sqlite_repair_db(export_db) - except Exception as e: - rich_echo_error(f"[error]Error: {e}[/error]") - sys.exit(1) + if not dry_run: + try: + sqlite_repair_db(export_db) + except Exception as e: + rich_echo_error(f"[error]Error: {e}[/error]") + sys.exit(1) + else: + rich_echo(f"Ok: [filepath]{export_db}[/]") + sys.exit(0) else: - rich_echo(f"Ok: [filepath]{export_db}[/]") + rich_echo(f"[Dryrun] Repair [filepath]{export_db}[/]") sys.exit(0) if vacuum: - try: - start_size = pathlib.Path(export_db).stat().st_size - export_db_vacuum(export_db) - except Exception as e: - rich_echo_error(f"[error]Error: {e}[/error]") - sys.exit(1) + if not dry_run: + try: + start_size = pathlib.Path(export_db).stat().st_size + export_db_vacuum(export_db) + except Exception as e: + rich_echo_error(f"[error]Error: {e}[/error]") + sys.exit(1) + else: + rich_echo( + f"Vacuumed {export_db}! [num]{start_size}[/] bytes -> [num]{pathlib.Path(export_db).stat().st_size}[/] bytes" + ) + sys.exit(0) else: - rich_echo( - f"Vacuumed {export_db}! [num]{start_size}[/] bytes -> [num]{pathlib.Path(export_db).stat().st_size}[/] bytes" - ) + rich_echo(f"[Dryrun] Vacuum [filepath]{export_db}[/]") sys.exit(0) if update_signatures: @@ -417,13 +436,17 @@ def exportdb( sys.exit(1) if save_config: - try: - export_db_save_config_to_file(export_db, save_config) - except Exception as e: - rich_echo_error(f"[error]Error: {e}[/error]") - sys.exit(1) + if not dry_run: + try: + export_db_save_config_to_file(export_db, save_config) + except Exception as e: + rich_echo_error(f"[error]Error: {e}[/error]") + sys.exit(1) + else: + rich_echo(f"Saved configuration to [filepath]{save_config}") + sys.exit(0) else: - rich_echo(f"Saved configuration to [filepath]{save_config}") + rich_echo(f"[Dryrun] Saved configuration to [filepath]{save_config}") sys.exit(0) if check_signatures: @@ -561,10 +584,11 @@ def exportdb( exportdb = ExportDB(export_db, export_dir) for uuid in delete_uuid: rich_echo(f"Deleting uuid [uuid]{uuid}[/] from database.") - count = exportdb.delete_data_for_uuid(uuid) - rich_echo( - f"Deleted [num]{count}[/] {pluralize(count, 'record', 'records')}." - ) + if not dry_run: + count = exportdb.delete_data_for_uuid(uuid) + rich_echo( + f"Deleted [num]{count}[/] {pluralize(count, 'record', 'records')}." + ) sys.exit(0) if delete_file: @@ -572,10 +596,11 @@ def exportdb( exportdb = ExportDB(export_db, export_dir) for filepath in delete_file: rich_echo(f"Deleting file [filepath]{filepath}[/] from database.") - count = exportdb.delete_data_for_filepath(filepath) - rich_echo( - f"Deleted [num]{count}[/] {pluralize(count, 'record', 'records')}." - ) + if not dry_run: + count = exportdb.delete_data_for_filepath(filepath) + rich_echo( + f"Deleted [num]{count}[/] {pluralize(count, 'record', 'records')}." + ) sys.exit(0) if report: @@ -599,28 +624,40 @@ def exportdb( sys.exit(0) if upgrade: - exportdb = ExportDB(export_db, export_dir) - if upgraded := exportdb.was_upgraded: - rich_echo( - f"Upgraded export database [filepath]{export_db}[/] from version [num]{upgraded[0]}[/] to [num]{upgraded[1]}[/]" - ) + if not dry_run: + exportdb = ExportDB(export_db, export_dir) + if upgraded := exportdb.was_upgraded: + rich_echo( + f"Upgraded export database [filepath]{export_db}[/] from version [num]{upgraded[0]}[/] to [num]{upgraded[1]}[/]" + ) + else: + rich_echo( + f"Export database [filepath]{export_db}[/] is already at latest version [num]{OSXPHOTOS_EXPORTDB_VERSION}[/]" + ) else: + # Does OSXPHOTOS_EXPORTDB_VERSION reflect the actual exportdb file version? rich_echo( - f"Export database [filepath]{export_db}[/] is already at latest version [num]{OSXPHOTOS_EXPORTDB_VERSION}[/]" + f"[Dryrun] Upgrading database [filepath]{export_db}[/]" ) sys.exit(0) if sql: - exportdb = ExportDB(export_db, export_dir) - try: - c = exportdb._conn.cursor() - results = c.execute(sql) - except Exception as e: - rich_echo_error(f"[error]Error: {e}[/error]") - sys.exit(1) + if not dry_run: + exportdb = ExportDB(export_db, export_dir) + try: + c = exportdb._conn.cursor() + results = c.execute(sql) + except Exception as e: + rich_echo_error(f"[error]Error: {e}[/error]") + sys.exit(1) + else: + for row in results: + print(row) + sys.exit(0) else: - for row in results: - print(row) + rich_echo( + f"[Dryrun] SQL_STATEMENT: [filepath]{sql}[/]" + ) sys.exit(0) if migrate_photos_library: diff --git a/osxphotos/cli/push_exif.py b/osxphotos/cli/push_exif.py index a9c6c3cc..d3700307 100644 --- a/osxphotos/cli/push_exif.py +++ b/osxphotos/cli/push_exif.py @@ -635,10 +635,16 @@ def compare_location(photo: PhotoInfo, file_data: dict[str, Any]) -> str: """Compare location between Photos and original file for a single photo""" photo_latitude = photo.latitude photo_longitude = photo.longitude - 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") + 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_longitude and exif_longitude_ref == "W": exif_longitude = -exif_longitude diff --git a/osxphotos/cli/timewarp.py b/osxphotos/cli/timewarp.py index 54197153..8de24549 100644 --- a/osxphotos/cli/timewarp.py +++ b/osxphotos/cli/timewarp.py @@ -449,6 +449,9 @@ def timewarp( "must be specified." ) + if inspect and compare_exif: + raise click.UsageError("--inspect and --compare-exif are mutually exclusive.") + if date and date_delta: raise click.UsageError("--date and --date-delta are mutually exclusive.") @@ -620,11 +623,7 @@ def timewarp( ) for photo in photos: set_crash_data("photo", f"{photo.uuid} {photo.filename}") - diff_results = ( - photocomp.compare_exif_no_markup(photo) - if plain - else photocomp.compare_exif_with_markup(photo) - ) + diff_results = photocomp.timewarp_compare_exif(photo, plain) if not plain: filename = ( diff --git a/osxphotos/compare_exif.py b/osxphotos/compare_exif.py index 76d55b67..20d38856 100644 --- a/osxphotos/compare_exif.py +++ b/osxphotos/compare_exif.py @@ -87,79 +87,45 @@ def compare_exif(self, photo: Photo) -> List[str]: return [photos_date_str, photos_tz_str, exif_date, exif_offset] - def compare_exif_with_markup(self, photo: Photo) -> ExifDiff: + def timewarp_compare_exif(self, photo: Photo, plain: bool = False) -> ExifDiff: """Compare date/time/timezone in Photos to the exif data and return an ExifDiff named tuple; - adds rich markup to strings to show differences + optionally adds rich markup to strings to show differences. Args: photo (Photo): Photo object to compare - """ + plain (bool): Flag to determine if plain (True) or markup (False) should be applied + """ + def compare_values(photo_value: str, exif_value: str) -> tuple: + """Compare two values and return them with or without markup. + + Affects nonlocal variable diff (from timewarp_compare_exif) with result. + """ + + nonlocal diff + if photo_value != exif_value: + diff = True + if not plain: + return change(photo_value), change(exif_value) + else: + if not plain: + return no_change(photo_value), no_change(exif_value) + return photo_value, exif_value + + # Get values from comparison function photos_date, photos_tz, exif_date, exif_tz = self.compare_exif(photo) diff = False - photos_date, photos_time = photos_date.split(" ", 1) - try: - exif_date, exif_time = exif_date.split(" ", 1) - except ValueError: - exif_date = exif_date - exif_time = "" - - if photos_date != exif_date: - photos_date = change(photos_date) - exif_date = change(exif_date) - diff = True - else: - photos_date = no_change(photos_date) - exif_date = no_change(exif_date) - - if photos_time != exif_time: - photos_time = change(photos_time) - exif_time = change(exif_time) - diff = True - else: - photos_time = no_change(photos_time) - exif_time = no_change(exif_time) - - if photos_tz != exif_tz: - photos_tz = change(photos_tz) - exif_tz = change(exif_tz) - diff = True - else: - photos_tz = no_change(photos_tz) - exif_tz = no_change(exif_tz) - - return ExifDiff( - diff, - photos_date, - photos_time, - photos_tz, - exif_date, - exif_time, - exif_tz, - ) - - def compare_exif_no_markup(self, photo: Photo) -> ExifDiff: - """Compare date/time/timezone in Photos to the exif data and return an ExifDiff named tuple; - Args: - photo (Photo): Photo object to compare - """ - photos_date, photos_tz, exif_date, exif_tz = self.compare_exif(photo) - diff = False + # Split date and time photos_date, photos_time = photos_date.split(" ", 1) try: exif_date, exif_time = exif_date.split(" ", 1) except ValueError: - exif_date = exif_date - exif_time = "" - - if photos_date != exif_date: - diff = True - - if photos_time != exif_time: - diff = True + exif_time = "" # Handle missing time in exif_date - if photos_tz != exif_tz: - diff = True + # Compare dates, times, and timezones + photos_date, exif_date = compare_values(photos_date, exif_date) + photos_time, exif_time = compare_values(photos_time, exif_time) + photos_tz, exif_tz = compare_values(photos_tz, exif_tz) return ExifDiff( diff, @@ -170,3 +136,4 @@ def compare_exif_no_markup(self, photo: Photo) -> ExifDiff: exif_time, exif_tz, ) + \ No newline at end of file diff --git a/osxphotos/exif_datetime_updater.py b/osxphotos/exif_datetime_updater.py index 949c46cd..dba57e67 100644 --- a/osxphotos/exif_datetime_updater.py +++ b/osxphotos/exif_datetime_updater.py @@ -178,6 +178,8 @@ def update_photos_from_exif( ) return None + print(f"{dtinfo=} {type(dtinfo)=} {dtinfo.offset_seconds=}") + if dtinfo.offset_seconds is not None: # update timezone then update date/time timezone = Timezone(dtinfo.offset_seconds) diff --git a/osxphotos/exifutils.py b/osxphotos/exifutils.py index b91c0a52..d049d834 100644 --- a/osxphotos/exifutils.py +++ b/osxphotos/exifutils.py @@ -51,16 +51,28 @@ def get_exif_date_time_offset( # set to True if no date/time in EXIF and the FileModifyDate is used used_file_modify_date = False + # determine file type + mime_type = exif.get('File:MIMEType', 'image') + isphoto = mime_type.startswith("image") + ismovie = mime_type.startswith("video") + # search these fields in this order for date/time/timezone - time_fields = [ + # Prioritize QuickTime:ContentCreateDate over EXIF:DateTimeOriginal for videos + start_time_fields = [ "Composite:DateTimeCreated", "Composite:SubSecDateTimeOriginal", "Composite:SubSecCreateDate", + ] + photo_time_fields = [ "EXIF:DateTimeOriginal", "EXIF:CreateDate", + ] + movie_time_fields = [ "QuickTime:ContentCreateDate", "QuickTime:CreationDate", "QuickTime:CreateDate", + ] + end_time_fields = [ "IPTC:DateCreated", "XMP-exif:DateTimeOriginal", "XMP-xmp:DateCreated", @@ -75,11 +87,24 @@ def get_exif_date_time_offset( "ContentCreateDate", "CreationDate", ] + if isphoto: + time_fields = start_time_fields + photo_time_fields + movie_time_fields + end_time_fields + elif ismovie: + time_fields = start_time_fields + movie_time_fields + photo_time_fields + end_time_fields + else: + time_fields = start_time_fields + photo_time_fields + movie_time_fields + end_time_fields + if use_file_modify_date: time_fields.extend(["File:FileModifyDate", "FileModifyDate"]) + print(f"{isphoto=} {ismovie=}") + for dt_str in time_fields: dt = exif.get(dt_str) + print(f"{dt_str=} {dt=}") + # Some old mp4 may return ContentCreationDate as YYYY (eg. 2014) which + # is converted to int causing re.match(pattern, dt) to fail. + dt = str(dt) if isinstance(dt, int) else dt if dt and dt_str in {"IPTC:DateCreated", "DateCreated"}: # also need time time_ = exif.get("IPTC:TimeCreated") or exif.get("TimeCreated") @@ -97,6 +122,9 @@ def get_exif_date_time_offset( # try to get offset from EXIF:OffsetTimeOriginal offset = exif.get("EXIF:OffsetTimeOriginal") or exif.get("OffsetTimeOriginal") + + print(f"{dt=} for {dt_str=} and {offset=}") + if dt and offset is None: # see if offset set in the dt string for pattern in ( @@ -119,8 +147,15 @@ def get_exif_date_time_offset( dt = f"{matched.group(1)} 00:00:00" default_time = True + if offset is not None: + # make sure we have offset + if not re.match(r"([+-]\d{2}:\d{2})", offset): + offset = None + offset_seconds = exif_offset_to_seconds(offset) if offset is not None else None + print(f"{offset_seconds=}") + if dt: if offset is not None: # drop offset from dt string and add it back on in datetime %z format @@ -137,11 +172,16 @@ def get_exif_date_time_offset( # 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) + + print(f"Converted dt: {dt=} for {type(dt)=}") + except ValueError: dt = None # format offset in form +/-hhmm offset_str = offset.replace(":", "") if offset else "" + + print(f"FINAL {dt=}, {offset_seconds=}, {offset_str=}, {default_time=}, {used_file_modify_date=}") return ExifDateTime( dt, offset_seconds, offset_str, default_time, used_file_modify_date )