216 lines
7.3 KiB
Python
216 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
|
|
from subprocess import check_output, CalledProcessError
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
import json
|
|
from argparse import ArgumentParser
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from os import cpu_count
|
|
from time import sleep
|
|
|
|
EXT_GROUPS = {
|
|
"quicktime": {".mp4", ".mov", ".heic", ".cr3"},
|
|
"exif": {".jpg", ".jpeg", ".cr2"},
|
|
}
|
|
DATETIME_KEYS = [
|
|
("Composite", "SubSecDateTimeOriginal"),
|
|
("Composite", "SubSecCreateDate"),
|
|
('ExifIFD', 'DateTimeOriginal'),
|
|
('ExifIFD', 'CreateDate'),
|
|
('XMP-xmp', 'CreateDate'),
|
|
('Keys', 'CreationDate'),
|
|
('QuickTime', 'CreateDate'),
|
|
('XMP-photoshop', 'DateCreated'),
|
|
]
|
|
|
|
def run(command):
|
|
return check_output(command, text=True).strip()
|
|
|
|
|
|
def mdls_timestamp(file):
|
|
for i in range(5): # retry a few times in case of transient mdls failures
|
|
try:
|
|
output = run(('mdls', '-raw', '-name', 'kMDItemContentCreationDate', file))
|
|
except CalledProcessError as e:
|
|
print(f"{file}: Error running mdls (attempt {i+1}/5): {e}")
|
|
continue
|
|
|
|
try:
|
|
return datetime.strptime(output, "%Y-%m-%d %H:%M:%S %z")
|
|
except ValueError as e:
|
|
print(f"{file}: Error parsing mdls output (attempt {i+1}/5): {e}")
|
|
continue
|
|
|
|
sleep(1)
|
|
|
|
raise RuntimeError(f"Failed to get mdls timestamp for {file} after 5 attempts")
|
|
|
|
|
|
def exiftool_data(file):
|
|
try:
|
|
output = run((
|
|
'exiftool',
|
|
'-j', # json
|
|
'-a', # unknown tags
|
|
'-u', # unknown values
|
|
'-g1', # group by category
|
|
'-time:all', # all time tags
|
|
'-api', 'QuickTimeUTC=1', # use UTC for QuickTime timestamps
|
|
'-d', '%Y-%m-%dT%H:%M:%S%z',
|
|
file,
|
|
))
|
|
except CalledProcessError as e:
|
|
print(f"Error running exiftool: {e}")
|
|
return None
|
|
else:
|
|
return json.loads(output)[0]
|
|
|
|
def exiftool_timestamp(file):
|
|
data = exiftool_data(file)
|
|
for category, key in DATETIME_KEYS:
|
|
try:
|
|
value = data[category][key]
|
|
return category, key, datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z')
|
|
except (TypeError, KeyError, ValueError) as e:
|
|
continue
|
|
print(f"⚠️ {file}: No timestamp found in exiftool: " + json.dumps(data, indent=2))
|
|
return None, None, None
|
|
|
|
|
|
def photo_has_embedded_timestamp(file):
|
|
mdls_ts = mdls_timestamp(file)
|
|
category, key, exiftool_ts = exiftool_timestamp(file)
|
|
|
|
if not exiftool_ts:
|
|
print(f"⚠️ {file}: No timestamp found in exiftool")
|
|
return False
|
|
|
|
# normalize timezone for comparison
|
|
exiftool_ts = exiftool_ts.astimezone(mdls_ts.tzinfo)
|
|
delta = abs(mdls_ts - exiftool_ts)
|
|
|
|
if delta < timedelta(hours=1): # allow for small differences
|
|
print(f"✅ {file}: {mdls_ts.isoformat()} (#{category}:{key})")
|
|
return True
|
|
else:
|
|
print(f"⚠️ {file}: {mdls_ts.isoformat()} != {exiftool_ts} (Δ={delta})")
|
|
return False
|
|
|
|
|
|
def photos_without_embedded_timestamps(directory):
|
|
executor = ThreadPoolExecutor(max_workers=cpu_count()//2)
|
|
try:
|
|
futures = {
|
|
executor.submit(photo_has_embedded_timestamp, file): file
|
|
for file in directory.iterdir()
|
|
if file.is_file()
|
|
if file.suffix.lower() not in {".aae"}
|
|
if not file.name.startswith('.')
|
|
}
|
|
|
|
for future in as_completed(futures):
|
|
file = futures[future]
|
|
has_ts = future.result() # raises immediately on first failed future
|
|
|
|
if has_ts:
|
|
file.rename(file.parent / 'ok' / file.name)
|
|
else:
|
|
yield file
|
|
|
|
except Exception:
|
|
executor.shutdown(wait=False, cancel_futures=True)
|
|
raise
|
|
else:
|
|
executor.shutdown(wait=True)
|
|
|
|
|
|
def exiftool_write(file, assignments):
|
|
print(f"🔵 {file}: Writing -- {assignments}")
|
|
return run((
|
|
"exiftool", "-overwrite_original",
|
|
"-api", "QuickTimeUTC=1",
|
|
*[
|
|
f"-{group}:{tag}={value}"
|
|
for group, tag, value in assignments
|
|
],
|
|
str(file),
|
|
))
|
|
|
|
|
|
def add_missing_timestamp(file):
|
|
data = exiftool_data(file)
|
|
mdls_ts = mdls_timestamp(file)
|
|
|
|
offset = mdls_ts.strftime("%z")
|
|
offset = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
|
|
|
|
exif_ts = mdls_ts.strftime("%Y:%m:%d %H:%M:%S")
|
|
qt_ts = mdls_ts.strftime("%Y:%m:%d %H:%M:%S")
|
|
qt_ts_tz = f"{qt_ts}{offset}"
|
|
ext = file.suffix.lower()
|
|
|
|
try:
|
|
if ext in {".heic"}:
|
|
exiftool_write(file, [
|
|
("ExifIFD", "DateTimeOriginal", qt_ts),
|
|
("ExifIFD", "CreateDate", qt_ts),
|
|
("ExifIFD", "OffsetTime", offset),
|
|
("ExifIFD", "OffsetTimeOriginal", offset),
|
|
("ExifIFD", "OffsetTimeDigitized", offset),
|
|
("QuickTime", "CreateDate", qt_ts_tz),
|
|
("Keys", "CreationDate", qt_ts_tz),
|
|
("XMP-xmp", "CreateDate", qt_ts_tz),
|
|
])
|
|
elif "QuickTime" in data or ext in {".mp4", ".mov", ".heic", ".cr3"}:
|
|
exiftool_write(file, [
|
|
("QuickTime", "CreateDate", qt_ts_tz),
|
|
("Keys", "CreationDate", qt_ts_tz),
|
|
])
|
|
elif "ExifIFD" in data or ext in {".jpg", ".jpeg", ".cr2", ".webp"}:
|
|
exiftool_write(file, [
|
|
("ExifIFD", "DateTimeOriginal", exif_ts),
|
|
("ExifIFD", "CreateDate", exif_ts),
|
|
("IFD0", "ModifyDate", exif_ts),
|
|
("ExifIFD", "OffsetTime", offset),
|
|
("ExifIFD", "OffsetTimeOriginal", offset),
|
|
("ExifIFD", "OffsetTimeDigitized", offset),
|
|
])
|
|
elif ext in {".png", ".gif", ".avif"}:
|
|
exiftool_write(file, [
|
|
("XMP-xmp", "CreateDate", qt_ts_tz),
|
|
("XMP-photoshop", "DateCreated", exif_ts),
|
|
])
|
|
else:
|
|
print(f"❌ {file}: unsupported type, skipped")
|
|
return
|
|
|
|
if photo_has_embedded_timestamp(file):
|
|
print(f"✅ {file}: Timestamp successfully added: {mdls_ts.isoformat()}")
|
|
file.rename(file.parent / 'processed' / file.name)
|
|
return
|
|
else:
|
|
category, key, exiftool_ts = exiftool_timestamp(file)
|
|
print(f"❌ {file}: Timestamp still wrong/missing after write '{category}:{key}:{exiftool_ts}': #{json.dumps(data, indent=4)}")
|
|
return
|
|
except CalledProcessError as e:
|
|
print(f"❌ {file}: Failed to write timestamp: {e}")
|
|
return
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = ArgumentParser(description="Print timestamps of photos in the current directory.")
|
|
parser.add_argument("-d", "--directory", help="Directory to scan for photos")
|
|
args = parser.parse_args()
|
|
|
|
directory = Path(args.directory)
|
|
(directory/'ok').mkdir(exist_ok=True)
|
|
(directory/'processed').mkdir(exist_ok=True)
|
|
|
|
_photos_without_embedded_timestamps = list(photos_without_embedded_timestamps(directory))
|
|
print(f"{len(_photos_without_embedded_timestamps)} photos without embedded timestamps found.")
|
|
print("Press Enter to add missing timestamps...")
|
|
input()
|
|
|
|
for file in _photos_without_embedded_timestamps:
|
|
add_missing_timestamp(file)
|