When I export photos or videos from Photos.app, I want the file’s Created Date (Created Date in Finder) to be the time the photo or video was taken. Alas, this is not the way Photos works, and setting the date turned out to be more challenging than expected.

Try this: choose a photo in the Photos.app, then “File” > “Export...” > “Export Unmodified Originals...” to a folder. Then in Finder, right click the file “Get Info...”. I think the photo’s Created Date is the time that it was downloaded from iCloud, because sometimes, you’ll find this date is neither the time the photo was taken, nor the time the export was performed!

JPEG / HEIF Photo EXIF Metadata

Photo EXIF metadata in Photos.app

I know the Date Time Digitized is stored as EXIF metadata in the .JPEG or .HEIC file. This date can be see in Preview.app, via Tools > Show Inspector, under the More Info > Exif tab. I know I can retrieve this using ExifTool by Phil Harvey but I was curious if I could stick doing the same with CLI tools built-in to macOS.

So a bit of Googling later, I discovered:

  1. macOS Spotlight indexes EXIF metadata from .JPEG, .JPG or .HEIC photos, and mdls -n can be used to read specific metadata fields.
  2. The files Creation Dates can be overwritten with SetFile -d (note that the uppercase letters) with the date formatted as mm/dd/[yy]yy [hh:mm:[:ss] [AM | PM]] (quoted verbatim from the man SetFile page, but mm is ambiguous)
  3. The Spotlight indexes EXIF Date Time Digitized as kMDItemContentCreationDate, but the display format is yyyy/mm/dd HH:MM:SS +ZZZZ where +ZZZZ is the +/- timezone offset.
  4. To convert between these date formats, just use the built-in date -f.

This works:

file_name="photo.jpeg"
mdls_date=$(mdls -rnkMDItemContentCreationDate "$file_name")
creation_date=$(date -f"%F %T %z" -j "$mdls_date" "+%D %T %z")
SetFile -d "$creation_date" "$file_name"

mdls is very fast, since it simply reads the Spotlight index. However, Spotlight may take a while to index new files, so mdls may return an error before then.

Alternatively, it is possible to call mdimport, to build the index in the first place. The mdimport -td3 will initiate a re-read of metadata, without actually updating the index:

file_name="photo.jpeg"
mdimport_date=$(mdimport -td3 "$file_name" | sed -nE 's/.*kMDItemContentCreationDate = "([^"]*)";/\1/p')
creation_date=$(date -f"%F %T %z" -j "$mdimport_date" "+%D %T %z")
SetFile -d "$creation_date" "$file_name"

XMP Sidecar Metadata

Export with XMP metadata sidecar from Photos.aspp

In the Photos.app, checking Export IPTC as XMP when performing File > Export > **Export Unmodified Original will export Photo or Video metadata as an .XMP sidecar along with the photo.

The XML-formatted XMP sidecar has the created date stored as <photoshop:DateCreated>, but this time, the date is formatted as yyyy-mm-ddTHH:MM:SS+ZZ:ZZ where T is a fixed string and +ZZ:ZZ is the +/- timezone offset with a colon! Surprise surprise, is not a standard that date -f can convert natively!

So:

file_name="photo.jpg"
xmp_file="${file_name%.*}.xmp"
xmp_date=$(sed -nE "s/^.*DateCreated.([^\<]*)..photoshop:DateCreated./\1/p" "$xmp_file")
creation_date=$(date -f"%Y-%m-%dT%T%z" -j "${xmp_date%:*}${xmp_date##*:}" "+%D %T %z")
SetFile -d "$creation_date" "$file_name"

QuickTime Video Metadata

QuickTime .MOV files are a different story. At time of writing, Spotlight fails to read the correct kMDItemContentCreationDate metadata. From my testing, it is always the same as kMDItemFSCreationDate, so I needed an alternative to mdls.

Further Googling landed me on an answer to “Getting metadata for MOV video” on Stack Overflow, where Multimedia Mike provided Python code to read MOV metadata.

Some testing revealed that the code only reads the QuickTime Movie Header Atoms, specifically, Creation time and Modification time in the mvhd section. Both are 32-bit integers that are supposed to be date times in UTC... but they aren’t! Somehow they are in my home timezone instead of the timezone of the shooting location. Lots of others complain about this, like this unresolved this Apple community complaint, “Incorrect time for .MOV video files”.

It turns out iPhones do store date with timezones, but they are in the Quicktime Metadata Atom meta section instead! The key is com.apple.quicktime.creationdate and the value is formatted as yyyy-mm-hhTHH:MM:SS+ZZZZ. It is this data that QuickTime Player.app > Window > Show Movie Inspector displays as the creation date.

Ultimately, I had to implement Python code to read the meta section instead of and in addition to, the mvhd section (with thanks to what I learnt from Multimedia Mike). Sigh... See Apple’s QuickTime File Format Specification, especially:

My Python Code

So now, I have 4 complications:

  • If I elect to export the XMP sidecar, I can retrieve the creation date reliably. Alas, I don’t prefer sidecars, as they will take up a lot of storage and/or need cleanup.
  • I can use mdls to retrieve the EXIF date digitized for photos only, trusting that Spotlight does its work,
  • But I must parse MOV metadata myself, because Spotlight does not do its work properly. I implement the bare minimum to meet my very specific requirements and assume every data element is where I expect it to be, e.g. data types are not validated, offsets are hard coded, there is minimum error handling, etc.
  • I must handle timezones since the Creation Date should be the local time at the location when the photo/video was shot (not the time in my home country or UTC). However, Spotlight metadata is returned as UTC (+0000) via mdls. Somehow converting this to my computer timezone gives me the correct local time at the destination, so I am guessing Spotlight is doing some conversion already, confusing me to no end. Note that in any case, timezones are IGNORED when calling SetFile.

At this point I am already tired of writing, so without any further explanation, may I preset set_creation.py:

#!/usr/bin/env python3
# set_creation.py v0.1 (c) 2023, C.Y. Wong, myByways.com
import sys, os, subprocess, getopt, re
from datetime import datetime
import struct

DRY_RUN = IGNORE_XMP = RECURSE_DIRS = False
LOCAL_TZ = datetime.now().astimezone().tzinfo
PHOTO_EXT = tuple(['.jpg', '.jpeg', '.heic'])
VIDEO_EXT = '.mov'
XMP_REGEX = re.compile(r'.*photoshop:DateCreated>(.*)<\/photoshop:DateCreated.*', re.IGNORECASE)
SETFILE_COUNT = 0

def set_file(file, date):
    date = date.strftime('%m/%d/%Y %H:%M:%S')
    if not DRY_RUN:
        subprocess.run(['SetFile', '-d', date, file], check=True, stdout=subprocess.PIPE)
        global SETFILE_COUNT
        SETFILE_COUNT += 1
    #else:
    #   print(f'{" "*18}\033[95mSetFile -d "{date}" "{file}"\033[0m') 

def get_xmp_date(file):
    name, ext = os.path.splitext(file)
    name = name + '.xmp'
    if not os.path.isfile(name):
        return None
    try:
        with open(name, 'r') as fp:
            content = fp.read()
    except:
        return None
    if (result := XMP_REGEX.search(content)):
        result = result.group(1)
        result = result[:-3]+result[-2:] if ':' == result[-3] else result
        return datetime.strptime(result, '%Y-%m-%dT%H:%M:%S%z')

def get_photo_date(file):
    process = subprocess.run(['mdls', '-rnkMDItemContentCreationDate', file], check=True, stdout=subprocess.PIPE)
    result = process.stdout.decode()
    if result[4] != '-':
        raise ValueError('Invalid date format or metadata not available yet')
    return datetime.strptime(result, '%Y-%m-%d %H:%M:%S %z').astimezone(LOCAL_TZ)

def get_video_date_moov(fp, length):
    EPOCH_ADJUSTER = 2082844800 # January 1, 1904
    creation = modification = None
    pos = fp.tell()
    length = fp.read(4)
    if fp.read(4) == b'mvhd':
        fp.seek(4, 1)
        creation = struct.unpack('>I', fp.read(4))[0] - EPOCH_ADJUSTER
        creation = datetime.fromtimestamp(creation)
        modification = struct.unpack('>I', fp.read(4))[0] - EPOCH_ADJUSTER
        modification = datetime.fromtimestamp(modification)
    fp.seek(pos, 0)
    return creation, modification

def get_video_date_meta(fp, length):
    pos = fp.tell()
    fp.seek(16, 1)
    if fp.read(4) == b'mdta':
        found = i = 0
        fp.seek(26, 1)
        count = struct.unpack('>I', fp.read(4))[0]
        while i < count:
            i += 1
            length = struct.unpack('>I', fp.read(4))[0]
            if fp.read(length - 4) == b'mdtacom.apple.quicktime.creationdate':
                found = i
        fp.seek(4, 1)
        if fp.read(4) == b'ilst' and found > 0:
            i = 0
            while i < count:
                length = struct.unpack('>I', fp.read(4))[0]
                if struct.unpack('>I', fp.read(4))[0] == found:
                    fp.seek(16, 1)
                    creation = fp.read(length - 16)
                    if (n := creation.find(0)) > -1:
                        creation = creation[0:n]
                    return datetime.strptime(creation.decode(), '%Y-%m-%dT%H:%M:%S%z')
                fp.seek(length - 8,1)
    fp.seek(pos, 0)
    return None

def get_video_date(filename):
    creation = modification = None
    with open(filename, 'rb') as fp:
        while (length := fp.read(4)) != b'':
            length = struct.unpack('>I', length)[0]
            section = fp.read(4)
            if section == b'moov':
                creation, modification = get_video_date_moov(fp, length)
            elif section == b'meta':
                metadata = get_video_date_meta(fp, length)
                if metadata:
                    return metadata, 'meta'
            else:
                fp.seek(length - 8, 1)
    return creation, 'mvhd'

def process_photo(file):
    try:
        date = None
        if not IGNORE_XMP and (date := get_xmp_date(file)):
            print(f'[\033[94m{file.rjust(15, " ")}\033[0m] xmp  creation date [\033[92m{date.replace(tzinfo=None)}\033[0m]')
            set_file(file, date)
        if not date:
            date = get_photo_date(file)
            print(f'[\033[94m{file.rjust(15, " ")}\033[0m] exif creation date [\033[92m{date.replace(tzinfo=None)}\033[0m]')
            set_file(file, date)
    except ValueError as e:
        print(f'\033[91m ==> ERROR running mdls: {e}\033[0m')
    except subprocess.CalledProcessError as e:
        print(f'\033[91m ==> ERROR running SetFile: {e.output.decode().split(chr(10), 1)[0]}\033[0m')

def process_video(file):
    try:
        date = None
        if not IGNORE_XMP and (date := get_xmp_date(file)):
            print(f'[\033[94m{file.rjust(15, " ")}\033[0m] xmp  creation date [\033[92m{date.replace(tzinfo=None)}\033[0m]')
            set_file(file, date)
        if not date:
            date, section = get_video_date(file)
            print(f'[\033[94m{file.rjust(15, " ")}\033[0m] {section} creation date [\033[92m{date.replace(tzinfo=None)}\033[0m]')
            set_file(file, date)
    except RuntimeError as e:
        print(f'\033[91m ==> ERROR reading video metadata: {e}\033[0m')
    except subprocess.CalledProcessError as e:
        print(f'\033[91m ==> ERROR running SetFile: {e.output.decode().split(chr(10), 1)[0]}\033[0m')

def process_files(files):
    folders = []
    for file in files:
        if os.path.isfile(file):
            if file.lower().endswith(PHOTO_EXT):
                process_photo(file)
            if file.lower().endswith(VIDEO_EXT):
                process_video(file)
        elif RECURSE_DIRS and os.path.isdir(file):
            folders.append(file)
    for folder in folders:
        process_folders(folder)

def process_folders(path):
    cwd = os.getcwd()
    os.chdir(path)
    if len(files := os.listdir()):
        print(f'\033[1mProcessing folder [\033[94m{cwd}/{path}\033[0m\033[1m]\033[0m')
        process_files(files)
    os.chdir(cwd)

def usage():
    print(f'''Set filesystem Date Created of JPEG/HEIC/MOV files to the creation date
found in .XMP sidecar, JPEG/HEIC filesystem metadata (mdls) or MOV metadata

Usage: {sys.argv[0]} [-drx] [file...]
 -d  Dry run (do not run SetFile)
 -r  Recurisvely process files in sub-directories
 -x  Ignore .XMP sidecar''')
    sys.exit(1)

def on_abort(signum, frame):
    if SETFILE_COUNT:
        print(f'\033[91m Stopped by user after processing {SETFILE_COUNT} file{"s" if SETFILE_COUNT > 1 else ""} successfully.\033[0m')
    else:
        print('\033[91m Stopped by user.\033[0m')
    sys.exit(2)

try:
    opts, arguments = getopt.getopt(sys.argv[1:], 'drx')
    for opt, args in opts:
        if opt == '-d':
            DRY_RUN = True
        if opt == '-r':
            RECURSE_DIRS = True
        if opt == '-x':
            IGNORE_XMP = True
except getopt.GetoptError:
    usage()

signal.signal(signal.SIGINT, on_abort)
if len(arguments) == 1 and os.path.isdir(arguments[0]):
    process_folders(arguments[0])
else:
    process_files(arguments if arguments else os.listdir())
if SETFILE_COUNT:
    print(f'\033[91mProcessed {SETFILE_COUNT} file{"s" if SETFILE_COUNT > 1 else ""} successfully.\033[0m')