Implement fix for issue #282, QuickTime metadata
@@ -1267,6 +1267,79 @@ def query(
|
|||||||
"Note: this does not skip raw photos if the raw photo does not have an associated jpeg image "
|
"Note: this does not skip raw photos if the raw photo does not have an associated jpeg image "
|
||||||
"(e.g. the raw file was imported to Photos without a jpeg preview).",
|
"(e.g. the raw file was imported to Photos without a jpeg preview).",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--current-name",
|
||||||
|
is_flag=True,
|
||||||
|
help="Use photo's current filename instead of original filename for export. "
|
||||||
|
"Note: Starting with Photos 5, all photos are renamed upon import. By default, "
|
||||||
|
"photos are exported with the the original name they had before import.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--convert-to-jpeg",
|
||||||
|
is_flag=True,
|
||||||
|
help="Convert all non-jpeg images (e.g. raw, HEIC, PNG, etc) "
|
||||||
|
"to JPEG upon export. Only works if your Mac has a GPU.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--jpeg-quality",
|
||||||
|
type=click.FloatRange(0.0, 1.0),
|
||||||
|
default=1.0,
|
||||||
|
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
|
||||||
|
"A value of 1.0 specifies best quality, "
|
||||||
|
"a value of 0.0 specifies maximum compression. "
|
||||||
|
"Defaults to 1.0.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--download-missing",
|
||||||
|
is_flag=True,
|
||||||
|
help="Attempt to download missing photos from iCloud. The current implementation uses Applescript "
|
||||||
|
"to interact with Photos to export the photo which will force Photos to download from iCloud if "
|
||||||
|
"the photo does not exist on disk. This will be slow and will require internet connection. "
|
||||||
|
"This obviously only works if the Photos library is synched to iCloud. "
|
||||||
|
"Note: --download-missing does not currently export all burst images; "
|
||||||
|
"only the primary photo will be exported--associated burst images will be skipped.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--sidecar",
|
||||||
|
default=None,
|
||||||
|
multiple=True,
|
||||||
|
metavar="FORMAT",
|
||||||
|
type=click.Choice(["xmp", "json"], case_sensitive=False),
|
||||||
|
help="Create sidecar for each photo exported; valid FORMAT values: xmp, json; "
|
||||||
|
f"--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) "
|
||||||
|
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
|
||||||
|
'"exiftool -j=photoname.jpg.json photoname.jpg" '
|
||||||
|
"The sidecar file is named in format photoname.ext.json "
|
||||||
|
"--sidecar xmp: create XMP sidecar used by Adobe Lightroom, etc."
|
||||||
|
"The sidecar file is named in format photoname.ext.xmp"
|
||||||
|
"The XMP sidecar exports the following tags: Description, Title, Keywords/Tags, "
|
||||||
|
"Subject (set to Keywords + PersonInImage), PersonInImage, CreateDate, ModifyDate, "
|
||||||
|
"GPSLongitude. "
|
||||||
|
"For a list of tags exported in the JSON sidecar, see --exiftool.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--exiftool",
|
||||||
|
is_flag=True,
|
||||||
|
help="Use exiftool to write metadata directly to exported photos. "
|
||||||
|
"To use this option, exiftool must be installed and in the path. "
|
||||||
|
"exiftool may be installed from https://exiftool.org/. "
|
||||||
|
"Cannot be used with --export-as-hardlink. Writes the following metadata: "
|
||||||
|
"EXIF:ImageDescription, XMP:Description (see also --description-template); "
|
||||||
|
"XMP:Title; XMP:TagsList, IPTC:Keywords (see also --keyword-template, --person-keyword, --album-keyword); "
|
||||||
|
"XMP:Subject (set to keywords + person in image to mirror Photos' behavior); "
|
||||||
|
"XMP:PersonInImage; EXIF:GPSLatitudeRef; EXIF:GPSLongitudeRef; EXIF:GPSLatitude; EXIF:GPSLongitude; "
|
||||||
|
"EXIF:GPSPosition; EXIF:DateTimeOriginal; EXIF:OffsetTimeOriginal; "
|
||||||
|
"EXIF:ModifyDate (see --ignore-date-modified); IPTC:DateCreated; IPTC:TimeCreated; "
|
||||||
|
"(video files only): QuickTime:CreationDate (UTC); QuickTime:ModifyDate (UTC) (see also --ignore-date-modified); "
|
||||||
|
"QuickTime:GPSCoordinates; UserData:GPSCoordinates.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ignore-date-modified",
|
||||||
|
is_flag=True,
|
||||||
|
help="If used with --exiftool or --sidecar, will ignore the photo "
|
||||||
|
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
|
||||||
|
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--person-keyword",
|
"--person-keyword",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
@@ -1303,67 +1376,6 @@ def query(
|
|||||||
'--description-template "{descr} exported with osxphotos on {today.date}" '
|
'--description-template "{descr} exported with osxphotos on {today.date}" '
|
||||||
"See Templating System below.",
|
"See Templating System below.",
|
||||||
)
|
)
|
||||||
@click.option(
|
|
||||||
"--current-name",
|
|
||||||
is_flag=True,
|
|
||||||
help="Use photo's current filename instead of original filename for export. "
|
|
||||||
"Note: Starting with Photos 5, all photos are renamed upon import. By default, "
|
|
||||||
"photos are exported with the the original name they had before import.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--convert-to-jpeg",
|
|
||||||
is_flag=True,
|
|
||||||
help="Convert all non-jpeg images (e.g. raw, HEIC, PNG, etc) "
|
|
||||||
"to JPEG upon export. Only works if your Mac has a GPU.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--jpeg-quality",
|
|
||||||
type=click.FloatRange(0.0, 1.0),
|
|
||||||
default=1.0,
|
|
||||||
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
|
|
||||||
"A value of 1.0 specifies best quality, "
|
|
||||||
"a value of 0.0 specifies maximum compression. "
|
|
||||||
"Defaults to 1.0.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--sidecar",
|
|
||||||
default=None,
|
|
||||||
multiple=True,
|
|
||||||
metavar="FORMAT",
|
|
||||||
type=click.Choice(["xmp", "json"], case_sensitive=False),
|
|
||||||
help="Create sidecar for each photo exported; valid FORMAT values: xmp, json; "
|
|
||||||
f"--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) "
|
|
||||||
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
|
|
||||||
'"exiftool -j=photoname.json photoname.jpg" '
|
|
||||||
"The sidecar file is named in format photoname.json "
|
|
||||||
"--sidecar xmp: create XMP sidecar used by Adobe Lightroom, etc."
|
|
||||||
"The sidecar file is named in format photoname.xmp",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--download-missing",
|
|
||||||
is_flag=True,
|
|
||||||
help="Attempt to download missing photos from iCloud. The current implementation uses Applescript "
|
|
||||||
"to interact with Photos to export the photo which will force Photos to download from iCloud if "
|
|
||||||
"the photo does not exist on disk. This will be slow and will require internet connection. "
|
|
||||||
"This obviously only works if the Photos library is synched to iCloud. "
|
|
||||||
"Note: --download-missing does not currently export all burst images; "
|
|
||||||
"only the primary photo will be exported--associated burst images will be skipped.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--exiftool",
|
|
||||||
is_flag=True,
|
|
||||||
help="Use exiftool to write metadata directly to exported photos. "
|
|
||||||
"To use this option, exiftool must be installed and in the path. "
|
|
||||||
"exiftool may be installed from https://exiftool.org/. "
|
|
||||||
"Cannot be used with --export-as-hardlink.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--ignore-date-modified",
|
|
||||||
is_flag=True,
|
|
||||||
help="If used with --exiftool or --sidecar, will ignore the photo "
|
|
||||||
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
|
|
||||||
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
|
|
||||||
)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--directory",
|
"--directory",
|
||||||
metavar="DIRECTORY",
|
metavar="DIRECTORY",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.37.3"
|
__version__ = "0.37.4"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
""" datetime utilities """
|
""" datetime.datetime helper functions for converting to/from UTC """
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
def get_local_tz(dt):
|
def get_local_tz(dt):
|
||||||
""" return local timezone as datetime.timezone tzinfo for dt
|
""" Return local timezone as datetime.timezone tzinfo for dt
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dt: datetime.datetime
|
dt: datetime.datetime
|
||||||
@@ -21,21 +21,18 @@ def get_local_tz(dt):
|
|||||||
raise ValueError("dt must be naive datetime.datetime object")
|
raise ValueError("dt must be naive datetime.datetime object")
|
||||||
|
|
||||||
|
|
||||||
def datetime_remove_tz(dt):
|
|
||||||
""" remove timezone from a datetime.datetime object
|
|
||||||
dt: datetime.datetime object with tzinfo
|
|
||||||
returns: dt without any timezone info (naive datetime object) """
|
|
||||||
|
|
||||||
if type(dt) != datetime.datetime:
|
|
||||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
|
||||||
|
|
||||||
return dt.replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
def datetime_has_tz(dt):
|
def datetime_has_tz(dt):
|
||||||
""" return True if datetime dt has tzinfo else False
|
""" Return True if datetime dt has tzinfo else False
|
||||||
|
|
||||||
|
Args:
|
||||||
dt: datetime.datetime
|
dt: datetime.datetime
|
||||||
returns True if dt is timezone aware, else False """
|
|
||||||
|
Returns:
|
||||||
|
True if dt is timezone aware, else False
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
"""
|
||||||
|
|
||||||
if type(dt) != datetime.datetime:
|
if type(dt) != datetime.datetime:
|
||||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
@@ -43,11 +40,90 @@ def datetime_has_tz(dt):
|
|||||||
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
||||||
|
|
||||||
|
|
||||||
def datetime_naive_to_local(dt):
|
def datetime_tz_to_utc(dt):
|
||||||
""" convert naive (timezone unaware) datetime.datetime
|
""" Convert datetime.datetime object with timezone to UTC timezone
|
||||||
to aware timezone in local timezone
|
|
||||||
|
Args:
|
||||||
|
dt: datetime.datetime object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime.datetime in UTC timezone
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not datetime.datetime object
|
||||||
|
ValueError if dt does not have timeone information
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(dt) != datetime.datetime:
|
||||||
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
|
|
||||||
|
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||||
|
return dt.replace(tzinfo=dt.tzinfo).astimezone(tz=datetime.timezone.utc)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"dt does not have timezone info")
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_remove_tz(dt):
|
||||||
|
""" Remove timezone from a datetime.datetime object
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime.datetime object with tzinfo
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dt without any timezone info (naive datetime object)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(dt) != datetime.datetime:
|
||||||
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
|
|
||||||
|
return dt.replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_naive_to_utc(dt):
|
||||||
|
""" Convert naive (timezone unaware) datetime.datetime
|
||||||
|
to aware timezone in UTC timezone
|
||||||
|
|
||||||
|
Args:
|
||||||
dt: datetime.datetime without timezone
|
dt: datetime.datetime without timezone
|
||||||
returns: datetime.datetime with local timezone """
|
|
||||||
|
Returns:
|
||||||
|
datetime.datetime with UTC timezone
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
ValueError if dt is not a naive/timezone unaware object
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(dt) != datetime.datetime:
|
||||||
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
|
|
||||||
|
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||||
|
# has timezone info
|
||||||
|
raise ValueError(
|
||||||
|
"dt must be naive/timezone unaware: "
|
||||||
|
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tzinfo.utcoffset(dt)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return dt.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_naive_to_local(dt):
|
||||||
|
""" Convert naive (timezone unaware) datetime.datetime
|
||||||
|
to aware timezone in local timezone
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime.datetime without timezone
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime.datetime with local timezone
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
ValueError if dt is not a naive/timezone unaware object
|
||||||
|
"""
|
||||||
|
|
||||||
if type(dt) != datetime.datetime:
|
if type(dt) != datetime.datetime:
|
||||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
@@ -60,3 +136,26 @@ def datetime_naive_to_local(dt):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return dt.replace(tzinfo=get_local_tz(dt))
|
return dt.replace(tzinfo=get_local_tz(dt))
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_utc_to_local(dt):
|
||||||
|
""" Convert datetime.datetime object in UTC timezone to local timezone
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime.datetime object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime.datetime in local timezone
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
ValueError if dt is not in UTC timezone
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(dt) != datetime.datetime:
|
||||||
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
|
|
||||||
|
if dt.tzinfo is not datetime.timezone.utc:
|
||||||
|
raise ValueError(f"{dt} must be in UTC timezone: timezone = {dt.tzinfo}")
|
||||||
|
|
||||||
|
return dt.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from .._constants import (
|
|||||||
_UNKNOWN_PERSON,
|
_UNKNOWN_PERSON,
|
||||||
_XMP_TEMPLATE_NAME,
|
_XMP_TEMPLATE_NAME,
|
||||||
)
|
)
|
||||||
|
from ..datetime_utils import datetime_tz_to_utc
|
||||||
from ..exiftool import ExifTool
|
from ..exiftool import ExifTool
|
||||||
from ..export_db import ExportDBNoOp
|
from ..export_db import ExportDBNoOp
|
||||||
from ..fileutil import FileUtil
|
from ..fileutil import FileUtil
|
||||||
@@ -1146,9 +1147,7 @@ def _write_exif_data(
|
|||||||
|
|
||||||
with ExifTool(filepath) as exiftool:
|
with ExifTool(filepath) as exiftool:
|
||||||
for exiftag, val in exif_info.items():
|
for exiftag, val in exif_info.items():
|
||||||
if exiftag == "_CreatedBy":
|
if type(val) == list:
|
||||||
continue
|
|
||||||
elif type(val) == list:
|
|
||||||
for v in val:
|
for v in val:
|
||||||
exiftool.setvalue(exiftag, v)
|
exiftool.setvalue(exiftag, v)
|
||||||
else:
|
else:
|
||||||
@@ -1176,12 +1175,12 @@ def _exiftool_dict(
|
|||||||
Returns: dict with exiftool tags / values
|
Returns: dict with exiftool tags / values
|
||||||
|
|
||||||
Exports the following:
|
Exports the following:
|
||||||
EXIF:ImageDescription
|
EXIF:ImageDescription (may include template)
|
||||||
XMP:Description (may include template)
|
XMP:Description (may include template)
|
||||||
XMP:Title
|
XMP:Title
|
||||||
XMP:TagsList
|
XMP:TagsList (may include album name, person name, or template)
|
||||||
IPTC:Keywords (may include album name, person name, or template)
|
IPTC:Keywords (may include album name, person name, or template)
|
||||||
XMP:Subject
|
XMP:Subject (set to keywords + persons)
|
||||||
XMP:PersonInImage
|
XMP:PersonInImage
|
||||||
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
||||||
EXIF:GPSLatitude, EXIF:GPSLongitude
|
EXIF:GPSLatitude, EXIF:GPSLongitude
|
||||||
@@ -1191,10 +1190,13 @@ def _exiftool_dict(
|
|||||||
EXIF:ModifyDate
|
EXIF:ModifyDate
|
||||||
IPTC:DateCreated
|
IPTC:DateCreated
|
||||||
IPTC:TimeCreated
|
IPTC:TimeCreated
|
||||||
|
QuickTime:CreationDate (UTC)
|
||||||
|
QuickTime:ModifyDate (UTC)
|
||||||
|
QuickTime:GPSCoordinates
|
||||||
|
UserData:GPSCoordinates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
exif = {}
|
exif = {}
|
||||||
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
|
|
||||||
if description_template is not None:
|
if description_template is not None:
|
||||||
description = self.render_template(
|
description = self.render_template(
|
||||||
description_template, expand_inplace=True, inplace_sep=", "
|
description_template, expand_inplace=True, inplace_sep=", "
|
||||||
@@ -1272,12 +1274,16 @@ def _exiftool_dict(
|
|||||||
|
|
||||||
(lat, lon) = self.location
|
(lat, lon) = self.location
|
||||||
if lat is not None and lon is not None:
|
if lat is not None and lon is not None:
|
||||||
|
if self.isphoto:
|
||||||
exif["EXIF:GPSLatitude"] = lat
|
exif["EXIF:GPSLatitude"] = lat
|
||||||
exif["EXIF:GPSLongitude"] = lon
|
exif["EXIF:GPSLongitude"] = lon
|
||||||
lat_ref = "N" if lat >= 0 else "S"
|
lat_ref = "N" if lat >= 0 else "S"
|
||||||
lon_ref = "E" if lon >= 0 else "W"
|
lon_ref = "E" if lon >= 0 else "W"
|
||||||
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
||||||
exif["EXIF:GPSLongitudeRef"] = lon_ref
|
exif["EXIF:GPSLongitudeRef"] = lon_ref
|
||||||
|
elif self.ismovie:
|
||||||
|
exif["Keys:GPSCoordinates"] = f"{lat} {lon}"
|
||||||
|
exif["UserData:GPSCoordinates"] = f"{lat} {lon}"
|
||||||
|
|
||||||
# process date/time and timezone offset
|
# process date/time and timezone offset
|
||||||
# Photos exports the following fields and sets modify date to creation date
|
# Photos exports the following fields and sets modify date to creation date
|
||||||
@@ -1289,10 +1295,12 @@ def _exiftool_dict(
|
|||||||
#
|
#
|
||||||
# This code deviates from Photos in one regard:
|
# This code deviates from Photos in one regard:
|
||||||
# if photo has modification date, use it otherwise use creation date
|
# if photo has modification date, use it otherwise use creation date
|
||||||
date = self.date
|
|
||||||
|
|
||||||
|
if self.isphoto:
|
||||||
|
date = self.date
|
||||||
# exiftool expects format to "2015:01:18 12:00:00"
|
# exiftool expects format to "2015:01:18 12:00:00"
|
||||||
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
|
||||||
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
||||||
exif["EXIF:CreateDate"] = datetimeoriginal
|
exif["EXIF:CreateDate"] = datetimeoriginal
|
||||||
|
|
||||||
@@ -1313,6 +1321,19 @@ def _exiftool_dict(
|
|||||||
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
|
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
|
||||||
else:
|
else:
|
||||||
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
|
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
elif self.ismovie:
|
||||||
|
# QuickTime spec specifies times in UTC
|
||||||
|
# reference: https://exiftool.org/TagNames/QuickTime.html#Keys
|
||||||
|
date_utc = datetime_tz_to_utc(self.date)
|
||||||
|
creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
exif["QuickTime:CreationDate"] = creationdate
|
||||||
|
exif["QuickTime:CreateDate"] = creationdate
|
||||||
|
if self.date_modified is not None and not ignore_date_modified:
|
||||||
|
exif["QuickTime:ModifyDate"] = datetime_tz_to_utc(
|
||||||
|
self.date_modified
|
||||||
|
).strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
else:
|
||||||
|
exif["QuickTime:ModifyDate"] = creationdate
|
||||||
|
|
||||||
return exif
|
return exif
|
||||||
|
|
||||||
@@ -1343,7 +1364,7 @@ def _exiftool_json_sidecar(
|
|||||||
XMP:Title
|
XMP:Title
|
||||||
XMP:TagsList
|
XMP:TagsList
|
||||||
IPTC:Keywords (may include album name, person name, or template)
|
IPTC:Keywords (may include album name, person name, or template)
|
||||||
XMP:Subject
|
XMP:Subject (set to keywords + person)
|
||||||
XMP:PersonInImage
|
XMP:PersonInImage
|
||||||
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
||||||
EXIF:GPSLatitude, EXIF:GPSLongitude
|
EXIF:GPSLatitude, EXIF:GPSLongitude
|
||||||
@@ -1353,6 +1374,10 @@ def _exiftool_json_sidecar(
|
|||||||
EXIF:ModifyDate
|
EXIF:ModifyDate
|
||||||
IPTC:DigitalCreationDate
|
IPTC:DigitalCreationDate
|
||||||
IPTC:DateCreated
|
IPTC:DateCreated
|
||||||
|
QuickTime:CreationDate (UTC)
|
||||||
|
QuickTime:ModifyDate (UTC)
|
||||||
|
QuickTime:GPSCoordinates
|
||||||
|
UserData:GPSCoordinates
|
||||||
"""
|
"""
|
||||||
exif = self._exiftool_dict(
|
exif = self._exiftool_dict(
|
||||||
use_albums_as_keywords=use_albums_as_keywords,
|
use_albums_as_keywords=use_albums_as_keywords,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>hostuuid</key>
|
<key>hostuuid</key>
|
||||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||||
<key>pid</key>
|
<key>pid</key>
|
||||||
<integer>1797</integer>
|
<integer>464</integer>
|
||||||
<key>processname</key>
|
<key>processname</key>
|
||||||
<string>photolibraryd</string>
|
<string>photolibraryd</string>
|
||||||
<key>uid</key>
|
<key>uid</key>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 74 KiB |
1133
tests/test_catalina_10_15_7.py
Normal file
@@ -336,6 +336,31 @@ CLI_EXIFTOOL = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CLI_EXIFTOOL_QUICKTIME = {
|
||||||
|
"35329C57-B963-48D6-BB75-6AFF9370CBBC": {
|
||||||
|
"File:FileName": "Jellyfish.MOV",
|
||||||
|
"XMP:Description": "Jellyfish Video",
|
||||||
|
"XMP:Title": "Jellyfish",
|
||||||
|
"XMP:TagsList": "Travel",
|
||||||
|
"XMP:Subject": "Travel",
|
||||||
|
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
|
||||||
|
"QuickTime:CreationDate": "2020:01:05 22:13:13",
|
||||||
|
"QuickTime:CreateDate": "2020:01:05 22:13:13",
|
||||||
|
"QuickTime:ModifyDate": "2020:01:05 22:13:13",
|
||||||
|
},
|
||||||
|
"2CE332F2-D578-4769-AEFA-7631BB77AA41": {
|
||||||
|
"File:FileName": "Jellyfish.mp4",
|
||||||
|
"XMP:Description": "Jellyfish Video",
|
||||||
|
"XMP:Title": "Jellyfish",
|
||||||
|
"XMP:TagsList": "Travel",
|
||||||
|
"XMP:Subject": "Travel",
|
||||||
|
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
|
||||||
|
"QuickTime:CreationDate": "2020:12:05 05:21:52",
|
||||||
|
"QuickTime:CreateDate": "2020:12:05 05:21:52",
|
||||||
|
"QuickTime:ModifyDate": "2020:12:05 05:21:52",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
|
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
|
||||||
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
|
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
|
||||||
"File:FileName": "wedding.jpg",
|
"File:FileName": "wedding.jpg",
|
||||||
@@ -995,6 +1020,46 @@ def test_export_exiftool_ignore_date_modified():
|
|||||||
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
|
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||||
|
def test_export_exiftool_quicktime():
|
||||||
|
""" test --exiftol correctly writes QuickTime tags """
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
from osxphotos.__main__ import export
|
||||||
|
from osxphotos.exiftool import ExifTool
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
for uuid in CLI_EXIFTOOL_QUICKTIME:
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[
|
||||||
|
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--exiftool",
|
||||||
|
"--uuid",
|
||||||
|
f"{uuid}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
files = glob.glob("*")
|
||||||
|
assert sorted(files) == sorted(
|
||||||
|
[CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
exif = ExifTool(CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]).asdict()
|
||||||
|
for key in CLI_EXIFTOOL_QUICKTIME[uuid]:
|
||||||
|
assert exif[key] == CLI_EXIFTOOL_QUICKTIME[uuid][key]
|
||||||
|
|
||||||
|
# clean up exported files to avoid name conflicts
|
||||||
|
for filename in files:
|
||||||
|
os.unlink(filename)
|
||||||
|
|
||||||
|
|
||||||
def test_export_edited_suffix():
|
def test_export_edited_suffix():
|
||||||
""" test export with --edited-suffix """
|
""" test export with --edited-suffix """
|
||||||
import glob
|
import glob
|
||||||
@@ -2859,8 +2924,7 @@ def test_export_sidecar_keyword_template():
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Girl holding pumpkin",
|
||||||
"EXIF:ImageDescription": "Girl holding pumpkin",
|
|
||||||
"XMP:Description": "Girl holding pumpkin",
|
"XMP:Description": "Girl holding pumpkin",
|
||||||
"XMP:Title": "I found one!",
|
"XMP:Title": "I found one!",
|
||||||
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
|
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
|
||||||
|
|||||||
@@ -1,90 +1,96 @@
|
|||||||
""" test datetime_utils """
|
from datetime import date, timezone
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from osxphotos.datetime_utils import *
|
||||||
|
|
||||||
|
|
||||||
def test_get_local_tz():
|
def test_get_local_tz():
|
||||||
""" test get_local_tz during time with no DST """
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
from osxphotos.datetime_utils import get_local_tz
|
|
||||||
|
|
||||||
os.environ["TZ"] = "US/Pacific"
|
os.environ["TZ"] = "US/Pacific"
|
||||||
time.tzset()
|
|
||||||
|
|
||||||
dt = datetime.datetime(2018, 12, 31, 0, 0, 0)
|
dt = datetime.datetime(2020, 9, 1, 21, 10, 00)
|
||||||
local_tz = get_local_tz(dt)
|
tz = get_local_tz(dt)
|
||||||
assert local_tz == datetime.timezone(
|
assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
datetime.timedelta(days=-1, seconds=57600), "PST"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
dt = datetime.datetime(2020, 12, 1, 21, 10, 00)
|
||||||
def test_get_local_tz_dst():
|
tz = get_local_tz(dt)
|
||||||
""" test get_local_tz during time with DST """
|
assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-28800))
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
from osxphotos.datetime_utils import get_local_tz
|
|
||||||
|
|
||||||
os.environ["TZ"] = "US/Pacific"
|
|
||||||
time.tzset()
|
|
||||||
|
|
||||||
dt = datetime.datetime(2018, 6, 30, 0, 0, 0)
|
|
||||||
local_tz = get_local_tz(dt)
|
|
||||||
assert local_tz == datetime.timezone(
|
|
||||||
datetime.timedelta(days=-1, seconds=61200), "PDT"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_datetime_remove_tz():
|
|
||||||
""" test datetime_remove_tz """
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from osxphotos.datetime_utils import datetime_remove_tz
|
|
||||||
|
|
||||||
dt = datetime.datetime(
|
|
||||||
2018,
|
|
||||||
12,
|
|
||||||
31,
|
|
||||||
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600), "PST"),
|
|
||||||
)
|
|
||||||
dt_no_tz = datetime_remove_tz(dt)
|
|
||||||
assert dt_no_tz.tzinfo is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_datetime_has_tz():
|
def test_datetime_has_tz():
|
||||||
""" test datetime_has_tz """
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from osxphotos.datetime_utils import datetime_has_tz
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-28800))
|
||||||
|
dt = datetime.datetime(2020, 9, 1, 21, 10, 00, tzinfo=tz)
|
||||||
dt = datetime.datetime(
|
|
||||||
2018,
|
|
||||||
12,
|
|
||||||
31,
|
|
||||||
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600), "PST"),
|
|
||||||
)
|
|
||||||
assert datetime_has_tz(dt)
|
assert datetime_has_tz(dt)
|
||||||
|
|
||||||
dt_notz = datetime.datetime(2018, 12, 31)
|
dt = datetime.datetime(2020, 9, 1, 21, 10, 00)
|
||||||
assert not datetime_has_tz(dt_notz)
|
assert not datetime_has_tz(dt)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_tz_to_utc():
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
|
dt = datetime.datetime(2020, 9, 1, 22, 6, 0, tzinfo=tz)
|
||||||
|
utc = datetime_tz_to_utc(dt)
|
||||||
|
assert utc == datetime.datetime(2020, 9, 2, 5, 6, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_remove_tz():
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["TZ"] = "US/Pacific"
|
||||||
|
|
||||||
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
|
dt = datetime.datetime(2020, 9, 1, 22, 6, 0, tzinfo=tz)
|
||||||
|
dt = datetime_remove_tz(dt)
|
||||||
|
assert dt == datetime.datetime(2020, 9, 1, 22, 6, 0)
|
||||||
|
assert not datetime_has_tz(dt)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_naive_to_utc():
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
dt = datetime.datetime(2020, 9, 1, 12, 0, 0)
|
||||||
|
utc = datetime_naive_to_utc(dt)
|
||||||
|
assert utc == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def test_datetime_naive_to_local():
|
def test_datetime_naive_to_local():
|
||||||
""" test datetime_naive_to_local """
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
from osxphotos.datetime_utils import datetime_naive_to_local
|
|
||||||
|
|
||||||
os.environ["TZ"] = "US/Pacific"
|
os.environ["TZ"] = "US/Pacific"
|
||||||
time.tzset()
|
|
||||||
|
|
||||||
dt = datetime.datetime(2018, 6, 30, 0, 0, 0)
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
dt_local = datetime_naive_to_local(dt)
|
dt = datetime.datetime(2020, 9, 1, 12, 0, 0)
|
||||||
assert dt_local.tzinfo == datetime.timezone(
|
utc = datetime_naive_to_local(dt)
|
||||||
datetime.timedelta(days=-1, seconds=61200), "PDT"
|
assert utc == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=tz)
|
||||||
)
|
|
||||||
|
|
||||||
|
def test_datetime_utc_to_local():
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["TZ"] = "US/Pacific"
|
||||||
|
|
||||||
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
|
utc = datetime.datetime(2020, 9, 1, 19, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
dt = datetime_utc_to_local(utc)
|
||||||
|
assert dt == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=tz)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_utc_to_local_2():
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["TZ"] = "CEST"
|
||||||
|
|
||||||
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=7200))
|
||||||
|
utc = datetime.datetime(2020, 9, 1, 19, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
dt = datetime_utc_to_local(utc)
|
||||||
|
assert dt == datetime.datetime(2020, 9, 1, 21, 0, 0, tzinfo=tz)
|
||||||
@@ -68,8 +68,7 @@ XMP_JPG_FILENAME = "Pumkins1.jpg"
|
|||||||
|
|
||||||
EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
|
EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
|
||||||
EXIF_JSON_EXPECTED = """
|
EXIF_JSON_EXPECTED = """
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
"EXIF:ImageDescription": "Bride Wedding day",
|
|
||||||
"XMP:Description": "Bride Wedding day",
|
"XMP:Description": "Bride Wedding day",
|
||||||
"XMP:TagsList": ["wedding"],
|
"XMP:TagsList": ["wedding"],
|
||||||
"IPTC:Keywords": ["wedding"],
|
"IPTC:Keywords": ["wedding"],
|
||||||
@@ -84,8 +83,7 @@ EXIF_JSON_EXPECTED = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
|
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
"EXIF:ImageDescription": "Bride Wedding day",
|
|
||||||
"XMP:Description": "Bride Wedding day",
|
"XMP:Description": "Bride Wedding day",
|
||||||
"XMP:TagsList": ["wedding"],
|
"XMP:TagsList": ["wedding"],
|
||||||
"IPTC:Keywords": ["wedding"],
|
"IPTC:Keywords": ["wedding"],
|
||||||
@@ -544,8 +542,7 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
"EXIF:ImageDescription": "Bride Wedding day",
|
|
||||||
"XMP:Description": "Bride Wedding day",
|
"XMP:Description": "Bride Wedding day",
|
||||||
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||||
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||||
@@ -594,8 +591,7 @@ def test_exiftool_json_sidecar_keyword_template():
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
"EXIF:ImageDescription": "Bride Wedding day",
|
|
||||||
"XMP:Description": "Bride Wedding day",
|
"XMP:Description": "Bride Wedding day",
|
||||||
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||||
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||||
@@ -655,8 +651,7 @@ def test_exiftool_json_sidecar_use_persons_keyword():
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Girls with pumpkins",
|
||||||
"EXIF:ImageDescription": "Girls with pumpkins",
|
|
||||||
"XMP:Description": "Girls with pumpkins",
|
"XMP:Description": "Girls with pumpkins",
|
||||||
"XMP:Title": "Can we carry this?",
|
"XMP:Title": "Can we carry this?",
|
||||||
"XMP:TagsList": ["Kids", "Suzy", "Katie"],
|
"XMP:TagsList": ["Kids", "Suzy", "Katie"],
|
||||||
@@ -698,8 +693,7 @@ def test_exiftool_json_sidecar_use_albums_keyword():
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Girls with pumpkins",
|
||||||
"EXIF:ImageDescription": "Girls with pumpkins",
|
|
||||||
"XMP:Description": "Girls with pumpkins",
|
"XMP:Description": "Girls with pumpkins",
|
||||||
"XMP:Title": "Can we carry this?",
|
"XMP:Title": "Can we carry this?",
|
||||||
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
|
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ UUID_DICT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EXIF_JSON_EXPECTED = """
|
EXIF_JSON_EXPECTED = """
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"XMP:Title": "St. James\'s Park",
|
||||||
"XMP:Title": "St. James\'s Park",
|
|
||||||
"XMP:TagsList": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
"XMP:TagsList": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
||||||
"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
||||||
"XMP:Subject": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
"XMP:Subject": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
||||||
|
|||||||