Compare commits

...

7 Commits

Author SHA1 Message Date
Rhet Turnbull
69cd236712 Merge branch 'master' of github.com:RhetTbull/osxphotos 2020-12-05 07:19:18 -08:00
Rhet Turnbull
4cce9d4939 Implement fix for issue #282, QuickTime metadata 2020-12-05 07:18:49 -08:00
Rhet Turnbull
cfb07cbfaf Implement fix for issue #282, QuickTime metadata 2020-12-05 07:17:26 -08:00
Rhet Turnbull
1eff6bae9e Updated README.md 2020-12-01 21:19:23 -08:00
Rhet Turnbull
435da2a5dd Updated CHANGELOG.md 2020-11-29 18:43:45 -08:00
Rhet Turnbull
ed3a9711dc Removed --use-photokit authorization check, issue 278 2020-11-29 18:26:55 -08:00
Rhet Turnbull
1bc0926948 Updated CHANGELOG.md 2020-11-29 18:26:00 -08:00
47 changed files with 1714 additions and 371 deletions

View File

@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.37.3](https://github.com/RhetTbull/osxphotos/compare/v0.37.2...v0.37.3)
> 30 November 2020
- Removed --use-photokit authorization check, issue 278 [`ed3a971`](https://github.com/RhetTbull/osxphotos/commit/ed3a9711dc0805aed1aacc30e01eeb9c1077d9e1)
#### [v0.37.2](https://github.com/RhetTbull/osxphotos/compare/v0.37.1...v0.37.2)
> 29 November 2020
- Catch errors in export_photo [`d9dcf09`](https://github.com/RhetTbull/osxphotos/commit/d9dcf0917a541725d1e472e7f918733e4e2613d0)
- Added --missing to export, see issue #277 [`25eacc7`](https://github.com/RhetTbull/osxphotos/commit/25eacc7caddd6721232b3f77a02532fcd35f7836)
#### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1) #### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1)
> 28 November 2020 > 28 November 2020

View File

@@ -2206,8 +2206,6 @@ This package works by creating a copy of the sqlite3 database that photos uses t
If apple changes the database format this will likely break. If apple changes the database format this will likely break.
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the functionality in this package using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons) and Apple's System Integrity Protection (SIP) made the interface unreliable. If you'd like to experiment with the PhotoKit interface, here's some sample [code](https://gist.github.com/RhetTbull/41cc85e5bdeb30f761147ce32fba5c94). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
For additional details about how osxphotos is implemented or if you would like to extend the code, see the [wiki](https://github.com/RhetTbull/osxphotos/wiki). For additional details about how osxphotos is implemented or if you would like to extend the code, see the [wiki](https://github.com/RhetTbull/osxphotos/wiki).
## Dependencies ## Dependencies

View File

@@ -59,13 +59,13 @@ def normalize_unicode(value):
def get_photos_db(*db_options): def get_photos_db(*db_options):
""" Return path to photos db, select first non-None db_options """Return path to photos db, select first non-None db_options
If no db_options are non-None, try to find library to use in If no db_options are non-None, try to find library to use in
the following order: the following order:
- last library opened - last library opened
- system library - system library
- ~/Pictures/Photos Library.photoslibrary - ~/Pictures/Photos Library.photoslibrary
- failing above, returns None - failing above, returns None
""" """
if db_options: if db_options:
for db in db_options: for db in db_options:
@@ -919,8 +919,8 @@ def list_libraries(ctx, cli_obj, json_):
def _list_libraries(json_=False, error=True): def _list_libraries(json_=False, error=True):
""" Print list of Photos libraries found on the system. """Print list of Photos libraries found on the system.
If json_ == True, print output as JSON (default = False) """ If json_ == True, print output as JSON (default = False)"""
photo_libs = osxphotos.utils.list_photo_libraries() photo_libs = osxphotos.utils.list_photo_libraries()
sys_lib = osxphotos.utils.get_system_library_path() sys_lib = osxphotos.utils.get_system_library_path()
@@ -1053,9 +1053,9 @@ def query(
has_likes, has_likes,
no_likes, no_likes,
): ):
""" Query the Photos database using 1 or more search options; """Query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND" if more than one option is provided, they are treated as "AND"
(e.g. search for photos matching all options). (e.g. search for photos matching all options).
""" """
# if no query terms, show help and return # if no query terms, show help and return
@@ -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",
@@ -1519,16 +1531,16 @@ def export(
use_photokit, use_photokit,
report, report,
): ):
""" Export photos from the Photos database. """Export photos from the Photos database.
Export path DEST is required. Export path DEST is required.
Optionally, query the Photos database using 1 or more search options; Optionally, query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND" if more than one option is provided, they are treated as "AND"
(e.g. search for photos matching all options). (e.g. search for photos matching all options).
If no query options are provided, all photos will be exported. If no query options are provided, all photos will be exported.
By default, all versions of all photos will be exported including edited By default, all versions of all photos will be exported including edited
versions, live photo movies, burst photos, and associated raw images. versions, live photo movies, burst photos, and associated raw images.
See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options
to modify this behavior. to modify this behavior.
""" """
global VERBOSE global VERBOSE
@@ -1587,17 +1599,17 @@ def export(
) )
raise click.Abort() raise click.Abort()
if use_photokit and not check_photokit_authorization(): # if use_photokit and not check_photokit_authorization():
click.echo( # click.echo(
"Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access." # "Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
) # )
request_photokit_authorization() # request_photokit_authorization()
click.confirm("Have you granted access?") # click.confirm("Have you granted access?")
if not check_photokit_authorization(): # if not check_photokit_authorization():
click.echo( # click.echo(
"Failed to get access to the Photos library which is needed with `--use-photokit`." # "Failed to get access to the Photos library which is needed with `--use-photokit`."
) # )
return # return
# initialize export flags # initialize export flags
# by default, will export all versions of photos unless skip flag is set # by default, will export all versions of photos unless skip flag is set
@@ -2107,10 +2119,10 @@ def _query(
has_likes=False, has_likes=False,
no_likes=False, no_likes=False,
): ):
""" run a query against PhotosDB to extract the photos based on user supply criteria """run a query against PhotosDB to extract the photos based on user supply criteria
used by query and export commands used by query and export commands
arguments must be passed in same order as query and export arguments must be passed in same order as query and export
if either is modified, need to ensure all three functions are updated """ if either is modified, need to ensure all three functions are updated"""
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose) photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose)
if deleted or deleted_only: if deleted or deleted_only:
@@ -2342,7 +2354,7 @@ def get_photos_by_attribute(photos, attribute, values, ignore_case):
"""Search for photos based on values being in PhotoInfo.attribute """Search for photos based on values being in PhotoInfo.attribute
Args: Args:
photos: a list of PhotoInfo objects photos: a list of PhotoInfo objects
attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc) attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc)
values: list of values to search in property values: list of values to search in property
ignore_case: ignore case when searching ignore_case: ignore case when searching
@@ -2401,7 +2413,7 @@ def export_photo(
ignore_date_modified=False, ignore_date_modified=False,
use_photokit=False, use_photokit=False,
): ):
""" Helper function for export that does the actual export """Helper function for export that does the actual export
Args: Args:
photo: PhotoInfo object photo: PhotoInfo object
@@ -2437,7 +2449,7 @@ def export_photo(
Returns: Returns:
list of path(s) of exported photo or None if photo was missing list of path(s) of exported photo or None if photo was missing
Raises: Raises:
ValueError on invalid filename_template ValueError on invalid filename_template
""" """
@@ -2708,16 +2720,16 @@ def export_photo(
def get_filenames_from_template(photo, filename_template, original_name): def get_filenames_from_template(photo, filename_template, original_name):
""" get list of export filenames for a photo """get list of export filenames for a photo
Args: Args:
photo: a PhotoInfo instance photo: a PhotoInfo instance
filename_template: a PhotoTemplate template string, may be None filename_template: a PhotoTemplate template string, may be None
original_name: boolean; if True, use photo's original filename instead of current filename original_name: boolean; if True, use photo's original filename instead of current filename
Returns: Returns:
list of filenames list of filenames
Raises: Raises:
click.BadOptionUsage if template is invalid click.BadOptionUsage if template is invalid
""" """
@@ -2744,7 +2756,7 @@ def get_filenames_from_template(photo, filename_template, original_name):
def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run): def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
""" get list of directories to export a photo into, creates directories if they don't exist """get list of directories to export a photo into, creates directories if they don't exist
Args: Args:
photo: a PhotoInstance object photo: a PhotoInstance object
@@ -2792,8 +2804,8 @@ def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
def find_files_in_branch(pathname, filename): def find_files_in_branch(pathname, filename):
""" Search a directory branch to find file(s) named filename """Search a directory branch to find file(s) named filename
The branch searched includes all folders below pathname and The branch searched includes all folders below pathname and
the parent tree of pathname but not pathname itself. the parent tree of pathname but not pathname itself.
e.g. find filename in children folders and parent folders e.g. find filename in children folders and parent folders
@@ -2801,7 +2813,7 @@ def find_files_in_branch(pathname, filename):
Args: Args:
pathname: str, full path of directory to search pathname: str, full path of directory to search
filename: str, filename to search for filename: str, filename to search for
Returns: list of full paths to any matching files Returns: list of full paths to any matching files
""" """
@@ -2829,16 +2841,16 @@ def find_files_in_branch(pathname, filename):
def load_uuid_from_file(filename): def load_uuid_from_file(filename):
""" Load UUIDs from file. Does not validate UUIDs. """Load UUIDs from file. Does not validate UUIDs.
Format is 1 UUID per line, any line beginning with # is ignored. Format is 1 UUID per line, any line beginning with # is ignored.
Whitespace is stripped. Whitespace is stripped.
Arguments: Arguments:
filename: file name of the file containing UUIDs filename: file name of the file containing UUIDs
Returns: Returns:
list of UUIDs or empty list of no UUIDs in file list of UUIDs or empty list of no UUIDs in file
Raises: Raises:
FileNotFoundError if file does not exist FileNotFoundError if file does not exist
""" """

View File

@@ -1,4 +1,4 @@
""" version info """ """ version info """
__version__ = "0.37.2" __version__ = "0.37.4"

View File

@@ -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)

View File

@@ -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
@@ -82,27 +83,27 @@ def _export_photo_uuid_applescript(
burst=False, burst=False,
dry_run=False, dry_run=False,
): ):
""" Export photo to dest path using applescript to control Photos """Export photo to dest path using applescript to control Photos
If photo is a live photo, exports both the photo and associated .mov file If photo is a live photo, exports both the photo and associated .mov file
uuid: UUID of photo to export uuid: UUID of photo to export
dest: destination path to export to dest: destination path to export to
filestem: (string) if provided, exported filename will be named stem.ext filestem: (string) if provided, exported filename will be named stem.ext
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc) where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
If not provided, file will be named with whatever name Photos uses If not provided, file will be named with whatever name Photos uses
If filestem.ext exists, it wil be overwritten If filestem.ext exists, it wil be overwritten
original: (boolean) if True, export original image; default = True original: (boolean) if True, export original image; default = True
edited: (boolean) if True, export edited photo; default = False edited: (boolean) if True, export edited photo; default = False
If photo not edited and edited=True, will still export the original image If photo not edited and edited=True, will still export the original image
caller must verify image has been edited caller must verify image has been edited
*Note*: must be called with either edited or original but not both, *Note*: must be called with either edited or original but not both,
will raise error if called with both edited and original = True will raise error if called with both edited and original = True
live_photo: (boolean) if True, export associated .mov live photo; default = False live_photo: (boolean) if True, export associated .mov live photo; default = False
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
burst: (boolean) set to True if file is a burst image to avoid Photos export error burst: (boolean) set to True if file is a burst image to avoid Photos export error
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
Returns: list of paths to exported file(s) or None if export failed Returns: list of paths to exported file(s) or None if export failed
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
has not been edited. This is due to how Photos Applescript interface works. has not been edited. This is due to how Photos Applescript interface works.
""" """
# setup the applescript to do the export # setup the applescript to do the export
@@ -191,10 +192,10 @@ def _export_photo_uuid_applescript(
# _check_export_suffix is not a class method, don't import this into PhotoInfo # _check_export_suffix is not a class method, don't import this into PhotoInfo
def _check_export_suffix(src, dest, edited): def _check_export_suffix(src, dest, edited):
"""Helper function for exporting photos to check file extensions of destination path. """Helper function for exporting photos to check file extensions of destination path.
Checks that dst file extension is appropriate for the src. Checks that dst file extension is appropriate for the src.
If edited=True, will use src file extension of ".jpeg" if None provided for src. If edited=True, will use src file extension of ".jpeg" if None provided for src.
Args: Args:
src: path to source file or None. src: path to source file or None.
dest: path to destination file. dest: path to destination file.
@@ -249,43 +250,43 @@ def export(
keyword_template=None, keyword_template=None,
description_template=None, description_template=None,
): ):
""" export photo """export photo
dest: must be valid destination path (or exception raised) dest: must be valid destination path (or exception raised)
filename: (optional): name of exported picture; if not provided, will use current filename filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct. **NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg. For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is, If you provide an extension different than what the actual file is,
export will print a warning but will export the photo using the export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored). silently ignored).
e.g. to get the extension of the edited photo, e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version) (or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.json sidecar filename will be dest/filename.json
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
returns list of full paths to the exported files returns list of full paths to the exported files
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description description_template: string; optional template string that will be rendered for use as photo description
returns: list of photos exported returns: list of photos exported
""" """
# Implementation note: calls export2 to actually do the work # Implementation note: calls export2 to actually do the work
@@ -344,56 +345,56 @@ def export2(
use_photokit=False, use_photokit=False,
verbose=None, verbose=None,
): ):
""" export photo, like export but with update and dry_run options """export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised dest: must be valid destination path or exception raised
filename: (optional): name of exported picture; if not provided, will use current filename filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct. **NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg. For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is, If you provide an extension different than what the actual file is,
will export the photo using the incorrect file extension (unless use_photos_export is true, will export the photo using the incorrect file extension (unless use_photos_export is true,
in which case export will use the extension provided by Photos upon export. in which case export will use the extension provided by Photos upon export.
e.g. to get the extension of the edited photo, e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version) (or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.json sidecar filename will be dest/filename.json
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description description_template: string; optional template string that will be rendered for use as photo description
update: (boolean, default=False); if True export will run in update mode, that is, it will update: (boolean, default=False); if True export will run in update mode, that is, it will
not export the photo if the current version already exists in the destination not export the photo if the current version already exists in the destination
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
for getting/setting data related to exported files to compare update state for getting/setting data related to exported files to compare update state
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
dry_run: (boolean, default=False); set to True to run in "dry run" mode dry_run: (boolean, default=False); set to True to run in "dry run" mode
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression. jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output. verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths where each field is a list of file paths
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db, Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp) and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
""" """
# NOTE: This function is very complex and does a lot of things. # NOTE: This function is very complex and does a lot of things.
# Don't modify this code if you don't fully understand everything it does. # Don't modify this code if you don't fully understand everything it does.
@@ -956,12 +957,12 @@ def _export_photo(
edited=False, edited=False,
jpeg_quality=1.0, jpeg_quality=1.0,
): ):
""" Helper function for export() """Helper function for export()
Does the actual copy or hardlink taking the appropriate Does the actual copy or hardlink taking the appropriate
action depending on update, overwrite, export_as_hardlink action depending on update, overwrite, export_as_hardlink
Assumes destination is the right destination (e.g. UUID matches) Assumes destination is the right destination (e.g. UUID matches)
sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido
Args: Args:
src: src path (string) src: src path (string)
dest: dest path (pathlib.Path) dest: dest path (pathlib.Path)
@@ -1125,10 +1126,10 @@ def _write_exif_data(
description_template=None, description_template=None,
ignore_date_modified=False, ignore_date_modified=False,
): ):
""" write exif data to image file at filepath """write exif data to image file at filepath
Args: Args:
filepath: full path to the image file filepath: full path to the image file
use_albums_as_keywords: treat album names as keywords use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords keyword_template: (list of strings); list of template strings to render as keywords
@@ -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:
@@ -1163,7 +1162,7 @@ def _exiftool_dict(
description_template=None, description_template=None,
ignore_date_modified=False, ignore_date_modified=False,
): ):
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool. """Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image. Does not include all the EXIF fields as those are likely already in the image.
Args: Args:
@@ -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:
exif["EXIF:GPSLatitude"] = lat if self.isphoto:
exif["EXIF:GPSLongitude"] = lon exif["EXIF:GPSLatitude"] = lat
lat_ref = "N" if lat >= 0 else "S" exif["EXIF:GPSLongitude"] = lon
lon_ref = "E" if lon >= 0 else "W" lat_ref = "N" if lat >= 0 else "S"
exif["EXIF:GPSLatitudeRef"] = lat_ref lon_ref = "E" if lon >= 0 else "W"
exif["EXIF:GPSLongitudeRef"] = lon_ref exif["EXIF:GPSLatitudeRef"] = lat_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,30 +1295,45 @@ 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
# exiftool expects format to "2015:01:18 12:00:00" if self.isphoto:
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S") date = self.date
exif["EXIF:DateTimeOriginal"] = datetimeoriginal # exiftool expects format to "2015:01:18 12:00:00"
exif["EXIF:CreateDate"] = datetimeoriginal datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
offsettime = date.strftime("%z") exif["EXIF:DateTimeOriginal"] = datetimeoriginal
# find timezone offset in format "-04:00" exif["EXIF:CreateDate"] = datetimeoriginal
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["EXIF:OffsetTimeOriginal"] = offsettime
dateoriginal = date.strftime("%Y:%m:%d") offsettime = date.strftime("%z")
exif["IPTC:DateCreated"] = dateoriginal # find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["EXIF:OffsetTimeOriginal"] = offsettime
timeoriginal = date.strftime(f"%H:%M:%S{offsettime}") dateoriginal = date.strftime("%Y:%m:%d")
exif["IPTC:TimeCreated"] = timeoriginal exif["IPTC:DateCreated"] = dateoriginal
if self.date_modified is not None and not ignore_date_modified: timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S") exif["IPTC:TimeCreated"] = timeoriginal
else:
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S") if self.date_modified is not None and not ignore_date_modified:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
else:
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
@@ -1325,7 +1346,7 @@ def _exiftool_json_sidecar(
description_template=None, description_template=None,
ignore_date_modified=False, ignore_date_modified=False,
): ):
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool. """Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image. Does not include all the EXIF fields as those are likely already in the image.
Args: Args:
@@ -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,
@@ -1372,11 +1397,11 @@ def _xmp_sidecar(
description_template=None, description_template=None,
extension=None, extension=None,
): ):
""" returns string for XMP sidecar """returns string for XMP sidecar
use_albums_as_keywords: treat album names as keywords use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords keyword_template: (list of strings); list of template strings to render as keywords
description_template: string; optional template string that will be rendered for use as photo description """ description_template: string; optional template string that will be rendered for use as photo description"""
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)) xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
@@ -1461,8 +1486,8 @@ def _xmp_sidecar(
def _write_sidecar(self, filename, sidecar_str): def _write_sidecar(self, filename, sidecar_str):
""" write sidecar_str to filename """write sidecar_str to filename
used for exporting sidecar info """ used for exporting sidecar info"""
if not (filename or sidecar_str): if not (filename or sidecar_str):
raise ( raise (
ValueError( ValueError(

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

File diff suppressed because it is too large Load Diff

View 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"],

View File

@@ -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)

View File

@@ -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"],

View File

@@ -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"],