Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d7ad538d | ||
|
|
1f8fd6e929 | ||
|
|
08a9793651 | ||
|
|
2c8fc9789f | ||
|
|
dbededcd0e | ||
|
|
ef799610ae | ||
|
|
8dea41961b | ||
|
|
5799afbdc1 | ||
|
|
9a0fc0db3e | ||
|
|
549170fa36 | ||
|
|
dede640ef3 | ||
|
|
2b3491bdc4 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -4,6 +4,22 @@ 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).
|
||||
|
||||
#### [v0.22.12](https://github.com/RhetTbull/osxphotos/compare/0.22.10...v0.22.12)
|
||||
|
||||
> 7 March 2020
|
||||
|
||||
- Added exiftool [`8dea419`](https://github.com/RhetTbull/osxphotos/commit/8dea41961bad285be7058a68e5f7199e5cfb740e)
|
||||
- Added --exiftool to CLI export [`ef79961`](https://github.com/RhetTbull/osxphotos/commit/ef799610aea67b703a7d056b7eee227534ba78a5)
|
||||
- Updated test library [`9a0fc0d`](https://github.com/RhetTbull/osxphotos/commit/9a0fc0db3e79359610fd0f124a97b03fcf97d8a7)
|
||||
|
||||
#### [0.22.10](https://github.com/RhetTbull/osxphotos/compare/v0.22.9...0.22.10)
|
||||
|
||||
> 8 February 2020
|
||||
|
||||
- Fixed bug in --download-missing to fix issue #64 [`c654e3d`](https://github.com/RhetTbull/osxphotos/commit/c654e3dc61283382b37b6892dab1516ec517143a)
|
||||
- removed commented out code [`69addc3`](https://github.com/RhetTbull/osxphotos/commit/69addc34649f992c6a4a0e0e334754a72530f0ba)
|
||||
- Updated CHANGELOG.md [`1e013b6`](https://github.com/RhetTbull/osxphotos/commit/1e013b6802e49e26ec5a94eb702e841b2eb68395)
|
||||
|
||||
#### [v0.22.9](https://github.com/RhetTbull/osxphotos/compare/v0.22.7...v0.22.9)
|
||||
|
||||
> 1 February 2020
|
||||
|
||||
31
README.md
31
README.md
@@ -175,6 +175,11 @@ Options:
|
||||
require internet connection. This obviously
|
||||
only works if the Photos library is synched
|
||||
to iCloud.
|
||||
--exiftool 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/
|
||||
-h, --help Show this message and exit.
|
||||
```
|
||||
|
||||
@@ -652,10 +657,29 @@ Returns the path to the live video component of a [live photo](#live_photo). If
|
||||
|
||||
**Note**: will also return None if the live video component is missing on disk. It's possible that the original photo may be on disk ([ismissing](#ismissing)==False) but the video component is missing, likely because it has not been downloaded from iCloud.
|
||||
|
||||
#### `portrait`
|
||||
Returns True if photo was taken in iPhone portrait mode, otherwise False.
|
||||
|
||||
#### `hdr`
|
||||
Returns True if photo was taken in High Dynamic Range (HDR) mode, otherwise False.
|
||||
|
||||
#### `selfie`
|
||||
Returns True if photo is a selfie (taken with front-facing camera), otherwise False.
|
||||
|
||||
**Note**: Only implemented for Photos version 3.0+. On Photos version < 3.0, returns None.
|
||||
|
||||
#### `time_lapse`
|
||||
Returns True if photo is a time lapse video, otherwise False.
|
||||
|
||||
#### `panorama`
|
||||
Returns True if photo is a panorama, otherwise False.
|
||||
|
||||
**Note**: The result of `PhotoInfo.panorama` will differ from the "Panoramas" Media Types smart album in that it will also identify panorama photos from older phones that Photos does not recognize as panoramas.
|
||||
|
||||
#### `json()`
|
||||
Returns a JSON representation of all photo info
|
||||
|
||||
#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120,)`
|
||||
#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False)`
|
||||
|
||||
Export photo from the Photos library to another destination on disk.
|
||||
- dest: must be valid destination path as str (or exception raised).
|
||||
@@ -664,10 +688,11 @@ Export photo from the Photos library to another destination on disk.
|
||||
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
|
||||
- live_photo: boolean; if True (default=False), will also export the associted .mov for live photos; exported live photo will be named filename.mov
|
||||
- increment: boolean; if True (default=True), will increment file name until a non-existent name is found
|
||||
- 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 where filename is the stem of the photo name
|
||||
- sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name
|
||||
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name
|
||||
- sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with metadata; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name
|
||||
- use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using.
|
||||
- timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||
- exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path
|
||||
|
||||
The json sidecar file can be used by exiftool to apply the metadata from the json file to the image. For example:
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import osxphotos
|
||||
from ._constants import _EXIF_TOOL_URL, _PHOTOS_5_VERSION
|
||||
from ._version import __version__
|
||||
from .utils import create_path_by_date, _copy_file
|
||||
from .exiftool import get_exiftool_path
|
||||
|
||||
|
||||
def get_photos_db(*db_options):
|
||||
@@ -662,6 +663,13 @@ def query(
|
||||
"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.",
|
||||
)
|
||||
@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/",
|
||||
)
|
||||
@DB_ARGUMENT
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -707,6 +715,7 @@ def export(
|
||||
not_live,
|
||||
download_missing,
|
||||
dest,
|
||||
exiftool,
|
||||
):
|
||||
""" Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -733,6 +742,18 @@ def export(
|
||||
click.echo(cli.commands["export"].get_help(ctx), err=True)
|
||||
return
|
||||
|
||||
# verify exiftool installed an in path
|
||||
if exiftool:
|
||||
try:
|
||||
_ = get_exiftool_path()
|
||||
except FileNotFoundError:
|
||||
click.echo(
|
||||
"Could not find exiftool. Please download and install"
|
||||
" from https://exiftool.org/",
|
||||
err=True,
|
||||
)
|
||||
ctx.exit(2)
|
||||
|
||||
isphoto = ismovie = True # default searches for everything
|
||||
if only_movies:
|
||||
isphoto = False
|
||||
@@ -810,6 +831,7 @@ def export(
|
||||
original_name,
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
)
|
||||
else:
|
||||
for p in photos:
|
||||
@@ -824,6 +846,7 @@ def export(
|
||||
original_name,
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
)
|
||||
if export_path:
|
||||
click.echo(f"Exported {p.filename} to {export_path}")
|
||||
@@ -1078,6 +1101,7 @@ def export_photo(
|
||||
original_name,
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
):
|
||||
""" Helper function for export that does the actual export
|
||||
photo: PhotoInfo object
|
||||
@@ -1090,6 +1114,7 @@ def export_photo(
|
||||
export_live: boolean; also export live video component if photo is a live photo
|
||||
live video will have same name as photo but with .mov extension
|
||||
download_missing: attempt download of missing iCloud photos
|
||||
exiftool: use exiftool to write EXIF metadata directly to exported photo
|
||||
returns destination path of exported photo or None if photo was missing
|
||||
"""
|
||||
|
||||
@@ -1139,6 +1164,7 @@ def export_photo(
|
||||
live_photo=export_live,
|
||||
overwrite=overwrite,
|
||||
use_photos_export=download_missing,
|
||||
exiftool=exiftool,
|
||||
)
|
||||
|
||||
# if export-edited, also export the edited version
|
||||
@@ -1157,6 +1183,7 @@ def export_photo(
|
||||
overwrite=overwrite,
|
||||
edited=True,
|
||||
use_photos_export=download_missing,
|
||||
exiftool=exiftool,
|
||||
)
|
||||
else:
|
||||
click.echo(f"Skipping missing edited photo for {filename}")
|
||||
|
||||
@@ -13,6 +13,9 @@ import os.path
|
||||
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
|
||||
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
|
||||
|
||||
# only version 3 - 4 have RKVersion.selfPortrait
|
||||
_PHOTOS_3_VERSION = "3301"
|
||||
|
||||
# versions later than this have a different database structure
|
||||
_PHOTOS_5_VERSION = "6000"
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.22.10"
|
||||
__version__ = "0.22.13"
|
||||
|
||||
251
osxphotos/exiftool.py
Normal file
251
osxphotos/exiftool.py
Normal file
@@ -0,0 +1,251 @@
|
||||
""" Yet another simple exiftool wrapper
|
||||
I rolled my own for following reasons:
|
||||
1. I wanted something under MIT license (best alternative was licensed under GPL/BSD)
|
||||
2. I wanted singleton behavior so only a single exiftool process was ever running
|
||||
If these aren't important to you, I highly recommend you use Sven Marnach's excellent
|
||||
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
|
||||
from .utils import _debug
|
||||
|
||||
# exiftool -stay_open commands outputs this EOF marker after command is run
|
||||
EXIFTOOL_STAYOPEN_EOF = "{ready}"
|
||||
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
result = subprocess.run(["which", "exiftool"], stdout=subprocess.PIPE)
|
||||
exiftool_path = result.stdout.decode("utf-8")
|
||||
if _debug():
|
||||
logging.debug("exiftool path = %s" % (exiftool_path))
|
||||
if exiftool_path:
|
||||
return exiftool_path.rstrip()
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
"Could not find exiftool. Please download and install from "
|
||||
"https://exiftool.org/"
|
||||
)
|
||||
|
||||
|
||||
class _ExifToolProc:
|
||||
""" Runs exiftool in a subprocess via Popen
|
||||
Creates a singleton object """
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
if not hasattr(cls, "instance") or not cls.instance:
|
||||
cls.instance = super().__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
def __init__(self, exiftool=None):
|
||||
""" construct _ExifToolProc singleton object or return instance of already created object
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
|
||||
|
||||
if hasattr(self, "_process_running") and self._process_running:
|
||||
# already running
|
||||
if exiftool is not None:
|
||||
logging.warning(
|
||||
f"exiftool subprocess already running, "
|
||||
f"ignoring exiftool={exiftool}"
|
||||
)
|
||||
return
|
||||
|
||||
if exiftool:
|
||||
self._exiftool = exiftool
|
||||
else:
|
||||
self._exiftool = get_exiftool_path()
|
||||
|
||||
self._process_running = False
|
||||
self._start_proc()
|
||||
|
||||
@property
|
||||
def process(self):
|
||||
""" return the exiftool subprocess """
|
||||
if self._process_running:
|
||||
return self._process
|
||||
else:
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
""" return process id (PID) of the exiftool process """
|
||||
return self._process.pid
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
""" return path to exiftool process """
|
||||
return self._exiftool
|
||||
|
||||
def _start_proc(self):
|
||||
""" start exiftool in batch mode """
|
||||
|
||||
if self._process_running:
|
||||
logging.warning("exiftool already running: {self._process}")
|
||||
return
|
||||
|
||||
# open exiftool process
|
||||
self._process = subprocess.Popen(
|
||||
[
|
||||
self._exiftool,
|
||||
"-stay_open", # keep process open in batch mode
|
||||
"True", # -stay_open=True, keep process open in batch mode
|
||||
"-@", # read command-line arguments from file
|
||||
"-", # read from stdin
|
||||
"-common_args", # specifies args common to all commands subsequently run
|
||||
"-n", # no print conversion (e.g. print tag values in machine readable format)
|
||||
"-G", # print group name for each tag
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self._process_running = True
|
||||
|
||||
def _stop_proc(self):
|
||||
""" stop the exiftool process if it's running, otherwise, do nothing """
|
||||
if not self._process_running:
|
||||
logging.warning("exiftool process is not running")
|
||||
return
|
||||
|
||||
self._process.stdin.write(b"-stay_open\n")
|
||||
self._process.stdin.write(b"False\n")
|
||||
self._process.stdin.flush()
|
||||
try:
|
||||
self._process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.warning(
|
||||
f"exiftool pid {self._process.pid} did not exit, killing it"
|
||||
)
|
||||
self._process.kill()
|
||||
self._process.communicate()
|
||||
|
||||
del self._process
|
||||
self._process_running = False
|
||||
|
||||
def __del__(self):
|
||||
self._stop_proc()
|
||||
|
||||
|
||||
class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True):
|
||||
""" Return ExifTool object
|
||||
file: path to image file
|
||||
exiftool: path to exiftool, if not specified will look in path
|
||||
overwrite: if True, will overwrite image file without creating backup, default=False """
|
||||
self.file = filepath
|
||||
self.overwrite = overwrite
|
||||
self.data = {}
|
||||
self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
|
||||
self._process = self._exiftoolproc.process
|
||||
self._read_exif()
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
""" Set tag to value(s)
|
||||
if value is None, will delete tag """
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
command = []
|
||||
command.append(f"-{tag}={value}")
|
||||
if self.overwrite:
|
||||
command.append("-overwrite_original")
|
||||
self.run_commands(*command)
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
""" Add one or more value(s) to tag
|
||||
If more than one value is passed, each value will be added to the tag
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("Must pass at least one value")
|
||||
|
||||
command = []
|
||||
for value in values:
|
||||
if value is None:
|
||||
raise ValueError("Can't add None value to tag")
|
||||
command.append(f"-{tag}+={value}")
|
||||
|
||||
if self.overwrite:
|
||||
command.append("-overwrite_original")
|
||||
|
||||
if command:
|
||||
self.run_commands(*command)
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
""" run commands in the exiftool process and return result
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
use no_file=True to run a command without passing the filename """
|
||||
if not hasattr(self, "_process") or not self._process:
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
if not commands:
|
||||
raise TypeError("must provide one or more command to run")
|
||||
|
||||
filename = os.fsencode(self.file) if not no_file else b""
|
||||
command_str = (
|
||||
b"\n".join([c.encode("utf-8") for c in commands])
|
||||
+ b"\n"
|
||||
+ filename
|
||||
+ b"\n"
|
||||
+ b"-execute\n"
|
||||
)
|
||||
|
||||
if _debug():
|
||||
logging.debug(command_str)
|
||||
|
||||
# send the command
|
||||
self._process.stdin.write(command_str)
|
||||
self._process.stdin.flush()
|
||||
|
||||
# read the output
|
||||
output = b""
|
||||
while EXIFTOOL_STAYOPEN_EOF not in str(output):
|
||||
output += self._process.stdout.readline().strip()
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
""" return process id (PID) of the exiftool process """
|
||||
return self._process.pid
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
""" returns exiftool version """
|
||||
ver = self.run_commands("-ver", no_file=True)
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def json(self):
|
||||
""" return JSON dictionary from exiftool as dict """
|
||||
json_str = self.run_commands("-json")
|
||||
if json_str:
|
||||
return json.loads(json_str)
|
||||
else:
|
||||
return None
|
||||
|
||||
def _read_exif(self):
|
||||
""" read exif data from file """
|
||||
json = self.json()
|
||||
self.data = {k: v for k, v in json[0].items()}
|
||||
|
||||
def __str__(self):
|
||||
str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
return str_
|
||||
|
||||
@@ -32,6 +32,7 @@ from .utils import (
|
||||
_get_resource_loc,
|
||||
dd_to_dms_str,
|
||||
)
|
||||
from .exiftool import ExifTool
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
@@ -453,6 +454,41 @@ class PhotoInfo:
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def panorama(self):
|
||||
""" Returns True if photo is a panorama, otherwise False """
|
||||
return self._info["panorama"]
|
||||
|
||||
@property
|
||||
def slow_mo(self):
|
||||
""" Returns True if photo is a slow motion video, otherwise False """
|
||||
return self._info["slow_mo"]
|
||||
|
||||
@property
|
||||
def time_lapse(self):
|
||||
""" Returns True if photo is a time lapse video, otherwise False """
|
||||
return self._info["time_lapse"]
|
||||
|
||||
@property
|
||||
def hdr(self):
|
||||
""" Returns True if photo is an HDR photo, otherwise False """
|
||||
return self._info["hdr"]
|
||||
|
||||
@property
|
||||
def screenshot(self):
|
||||
""" Returns True if photo is an HDR photo, otherwise False """
|
||||
return self._info["screenshot"]
|
||||
|
||||
@property
|
||||
def portrait(self):
|
||||
""" Returns True if photo is a portrait, otherwise False """
|
||||
return self._info["portrait"]
|
||||
|
||||
@property
|
||||
def selfie(self):
|
||||
""" Returns True if photo is a selfie (front facing camera), otherwise False """
|
||||
return self._info["selfie"]
|
||||
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
@@ -465,6 +501,7 @@ class PhotoInfo:
|
||||
sidecar_xmp=False,
|
||||
use_photos_export=False,
|
||||
timeout=120,
|
||||
exiftool=False,
|
||||
):
|
||||
""" export photo
|
||||
dest: must be valid destination path (or exception raised)
|
||||
@@ -481,11 +518,15 @@ class PhotoInfo:
|
||||
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
|
||||
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
|
||||
returns the full path to the exported file """
|
||||
|
||||
# TODO: add this docs:
|
||||
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
|
||||
|
||||
# list of all files exported during this call to export
|
||||
exported_files = []
|
||||
|
||||
# check arguments and get destination path and filename (if provided)
|
||||
if filename and len(filename) > 2:
|
||||
raise TypeError(
|
||||
@@ -586,6 +627,7 @@ class PhotoInfo:
|
||||
|
||||
# copy the file, _copy_file uses ditto to preserve Mac extended attributes
|
||||
_copy_file(src, dest)
|
||||
exported_files.append(str(dest))
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
if live_photo and self.live_photo:
|
||||
@@ -597,6 +639,7 @@ class PhotoInfo:
|
||||
f"Exporting live photo video of {filename} as {live_name.name}"
|
||||
)
|
||||
_copy_file(src_live, str(live_name))
|
||||
exported_files.append(str(live_name))
|
||||
else:
|
||||
logging.warning(f"Skipping missing live movie for {filename}")
|
||||
else:
|
||||
@@ -634,7 +677,9 @@ class PhotoInfo:
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if exported is None:
|
||||
if exported is not None:
|
||||
exported_files.extend(exported)
|
||||
else:
|
||||
logging.warning(f"Error exporting photo {self.uuid} to {dest}")
|
||||
|
||||
if sidecar_json:
|
||||
@@ -657,8 +702,32 @@ class PhotoInfo:
|
||||
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
|
||||
logging.debug(f"export exported_files: {exported_files}")
|
||||
|
||||
# if exiftool, write the metadata
|
||||
if exiftool and exported_files:
|
||||
for exported_file in exported_files:
|
||||
self._write_exif_data(exported_file)
|
||||
|
||||
return str(dest)
|
||||
|
||||
def _write_exif_data(self, filepath):
|
||||
""" write exif data to image file at filepath
|
||||
filepath: full path to the image file """
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Could not find file {filepath}")
|
||||
exiftool = ExifTool(filepath)
|
||||
exif_info = json.loads(self._exiftool_json_sidecar())[0]
|
||||
for exiftag, val in exif_info.items():
|
||||
if type(val) == list:
|
||||
# more than one, set first value the add additional values
|
||||
exiftool.setvalue(exiftag, val.pop(0))
|
||||
if val:
|
||||
# add any remaining items
|
||||
exiftool.addvalues(exiftag, *val)
|
||||
else:
|
||||
exiftool.setvalue(exiftag, val)
|
||||
|
||||
def _exiftool_json_sidecar(self):
|
||||
""" return json string of EXIF details in exiftool sidecar format
|
||||
Does not include all the EXIF fields as those are likely already in the image
|
||||
@@ -680,27 +749,27 @@ class PhotoInfo:
|
||||
|
||||
exif = {}
|
||||
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
|
||||
exif["FileName"] = self.filename
|
||||
exif["File:FileName"] = self.filename
|
||||
|
||||
if self.description:
|
||||
exif["ImageDescription"] = self.description
|
||||
exif["Description"] = self.description
|
||||
exif["EXIF:ImageDescription"] = self.description
|
||||
exif["XMP:Description"] = self.description
|
||||
|
||||
if self.title:
|
||||
exif["Title"] = self.title
|
||||
exif["XMP:Title"] = self.title
|
||||
|
||||
if self.keywords:
|
||||
exif["TagsList"] = exif["Keywords"] = list(self.keywords)
|
||||
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords)
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
exif["Subject"] = list(self.keywords)
|
||||
exif["XMP:Subject"] = list(self.keywords)
|
||||
|
||||
if self.persons:
|
||||
exif["PersonInImage"] = self.persons
|
||||
exif["XMP:PersonInImage"] = self.persons
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
if "Subject" in exif:
|
||||
exif["Subject"].extend(self.persons)
|
||||
if "XMP:Subject" in exif:
|
||||
exif["XMP:Subject"].extend(self.persons)
|
||||
else:
|
||||
exif["Subject"] = self.persons
|
||||
exif["XMP:Subject"] = self.persons
|
||||
|
||||
# if self.favorite():
|
||||
# exif["Rating"] = 5
|
||||
@@ -708,13 +777,13 @@ class PhotoInfo:
|
||||
(lat, lon) = self.location
|
||||
if lat is not None and lon is not None:
|
||||
lat_str, lon_str = dd_to_dms_str(lat, lon)
|
||||
exif["GPSLatitude"] = lat_str
|
||||
exif["GPSLongitude"] = lon_str
|
||||
exif["GPSPosition"] = f"{lat_str}, {lon_str}"
|
||||
exif["EXIF:GPSLatitude"] = lat_str
|
||||
exif["EXIF:GPSLongitude"] = lon_str
|
||||
exif["Composite:GPSPosition"] = f"{lat_str}, {lon_str}"
|
||||
lat_ref = "North" if lat >= 0 else "South"
|
||||
lon_ref = "East" if lon >= 0 else "West"
|
||||
exif["GPSLatitudeRef"] = lat_ref
|
||||
exif["GPSLongitudeRef"] = lon_ref
|
||||
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
||||
exif["EXIF:GPSLongitudeRef"] = lon_ref
|
||||
|
||||
# process date/time and timezone offset
|
||||
date = self.date
|
||||
@@ -725,11 +794,11 @@ class PhotoInfo:
|
||||
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["DateTimeOriginal"] = datetimeoriginal
|
||||
exif["OffsetTimeOriginal"] = offsettime
|
||||
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
||||
exif["EXIF:OffsetTimeOriginal"] = offsettime
|
||||
|
||||
if self.date_modified is not None:
|
||||
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")
|
||||
|
||||
json_str = json.dumps([exif])
|
||||
return json_str
|
||||
|
||||
@@ -18,6 +18,7 @@ from shutil import copyfile
|
||||
from ._constants import (
|
||||
_MOVIE_TYPE,
|
||||
_PHOTO_TYPE,
|
||||
_PHOTOS_3_VERSION,
|
||||
_PHOTOS_5_VERSION,
|
||||
_TESTED_DB_VERSIONS,
|
||||
_TESTED_OS_VERSIONS,
|
||||
@@ -39,6 +40,7 @@ from .utils import (
|
||||
# Or fix the help text to match behavior
|
||||
# TODO: Add test for __str__
|
||||
# TODO: Add special albums and magic albums
|
||||
# TODO: fix "if X not in y" dictionary checks to use try/except EAFP style
|
||||
|
||||
|
||||
class PhotosDB:
|
||||
@@ -521,21 +523,36 @@ class PhotosDB:
|
||||
self._dbvolumes[vol[0]] = vol[1]
|
||||
|
||||
# Get photo details
|
||||
c.execute(
|
||||
""" SELECT RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename,
|
||||
RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating,
|
||||
RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds,
|
||||
RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name,
|
||||
RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden,
|
||||
RKVersion.latitude, RKVersion.longitude,
|
||||
RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI,
|
||||
RKVersion.burstUuid, RKVersion.burstPickType,
|
||||
RKVersion.specialType, RKMaster.modelID
|
||||
FROM RKVersion, RKMaster WHERE RKVersion.isInTrash = 0 AND
|
||||
RKVersion.masterUuid = RKMaster.uuid AND RKVersion.filename NOT LIKE '%.pdf' """
|
||||
)
|
||||
|
||||
# TODO: RKVersion.selfPortrait -- only in Photos 3 and up
|
||||
if self._db_version < _PHOTOS_3_VERSION:
|
||||
# Photos < 3.0 doesn't have RKVersion.selfPortrait (selfie)
|
||||
c.execute(
|
||||
""" SELECT RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename,
|
||||
RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating,
|
||||
RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds,
|
||||
RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name,
|
||||
RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden,
|
||||
RKVersion.latitude, RKVersion.longitude,
|
||||
RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI,
|
||||
RKVersion.burstUuid, RKVersion.burstPickType,
|
||||
RKVersion.specialType, RKMaster.modelID
|
||||
FROM RKVersion, RKMaster WHERE RKVersion.isInTrash = 0 AND
|
||||
RKVersion.masterUuid = RKMaster.uuid AND RKVersion.filename NOT LIKE '%.pdf' """
|
||||
)
|
||||
else:
|
||||
c.execute(
|
||||
""" SELECT RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename,
|
||||
RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating,
|
||||
RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds,
|
||||
RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name,
|
||||
RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden,
|
||||
RKVersion.latitude, RKVersion.longitude,
|
||||
RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI,
|
||||
RKVersion.burstUuid, RKVersion.burstPickType,
|
||||
RKVersion.specialType, RKMaster.modelID,
|
||||
RKVersion.selfPortrait
|
||||
FROM RKVersion, RKMaster WHERE RKVersion.isInTrash = 0 AND
|
||||
RKVersion.masterUuid = RKMaster.uuid AND RKVersion.filename NOT LIKE '%.pdf' """
|
||||
)
|
||||
|
||||
# order of results
|
||||
# 0 RKVersion.uuid
|
||||
@@ -565,8 +582,7 @@ class PhotosDB:
|
||||
# 24 RKVersion.burstPickType
|
||||
# 25 RKVersion.specialType
|
||||
# 26 RKMaster.modelID
|
||||
|
||||
# 27 RKVersion.selfPortrait -- 1 if selfie (not yet implemented)
|
||||
# 27 RKVersion.selfPortrait -- 1 if selfie, Photos >= 3, not present for Photos < 3
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -670,9 +686,11 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["screenshot"] = True if row[25] == 6 else False
|
||||
self._dbphotos[uuid]["portrait"] = True if row[25] == 9 else False
|
||||
|
||||
# TODO: Handle selfies (front facing camera, RKVersion.selfPortrait == 1)
|
||||
# self._dbphotos[uuid]["selfie"] = True if row[27] == 1 else False
|
||||
self._dbphotos[uuid]["selfie"] = None
|
||||
# selfies (front facing camera, RKVersion.selfPortrait == 1)
|
||||
if self._db_version >= _PHOTOS_3_VERSION:
|
||||
self._dbphotos[uuid]["selfie"] = True if row[27] == 1 else False
|
||||
else:
|
||||
self._dbphotos[uuid]["selfie"] = None
|
||||
|
||||
# Init cloud details that will be filled in later if cloud asset
|
||||
self._dbphotos[uuid]["cloudAssetGUID"] = None # Photos 5
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>1309</integer>
|
||||
<integer>1134</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,24 +3,24 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BackgroundHighlightCollection</key>
|
||||
<date>2020-01-30T02:33:23Z</date>
|
||||
<date>2020-03-07T16:11:53Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2020-01-30T02:33:23Z</date>
|
||||
<date>2020-03-07T16:11:53Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2020-01-30T04:13:27Z</date>
|
||||
<date>2020-03-07T18:15:22Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-07T16:11:53Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2020-01-30T02:33:23Z</date>
|
||||
<date>2020-03-07T16:11:52Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-07T08:11:00Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-01-30T04:13:27Z</date>
|
||||
<date>2020-03-07T18:15:23Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-01-30T02:33:23Z</date>
|
||||
<date>2020-03-07T08:10:59Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-07T18:15:22Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-07T08:11:00Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>FaceIDModelLastGenerationKey</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-07T08:11:01Z</date>
|
||||
<key>LastContactClassificationKey</key>
|
||||
<date>2020-01-30T02:33:26Z</date>
|
||||
<date>2020-03-07T08:11:02Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
178
tests/test_exiftool.py
Normal file
178
tests/test_exiftool.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import pytest
|
||||
from osxphotos.exiftool import get_exiftool_path
|
||||
|
||||
TEST_FILE_ONE_KEYWORD = "tests/test-images/wedding.jpg"
|
||||
TEST_FILE_MULTI_KEYWORD = "tests/test-images/Tulips.jpg"
|
||||
TEST_MULTI_KEYWORDS = [
|
||||
"Top Shot",
|
||||
"flowers",
|
||||
"flower",
|
||||
"design",
|
||||
"Stock Photography",
|
||||
"vibrant",
|
||||
"plastic",
|
||||
"Digital Nomad",
|
||||
"close up",
|
||||
"stock photo",
|
||||
"outdoor",
|
||||
"wedding",
|
||||
"Reiseblogger",
|
||||
"fake",
|
||||
"colorful",
|
||||
"Indoor",
|
||||
"display",
|
||||
"photography",
|
||||
]
|
||||
|
||||
try:
|
||||
exiftool = get_exiftool_path()
|
||||
except:
|
||||
exiftool = None
|
||||
|
||||
if exiftool is None:
|
||||
pytest.skip("could not find exiftool in path", allow_module_level=True)
|
||||
|
||||
|
||||
def test_get_exiftool_path():
|
||||
import osxphotos.exiftool
|
||||
|
||||
exiftool = osxphotos.exiftool.get_exiftool_path()
|
||||
assert exiftool is not None
|
||||
|
||||
|
||||
def test_version():
|
||||
import osxphotos.exiftool
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
|
||||
assert exif.version is not None
|
||||
assert isinstance(exif.version, str)
|
||||
|
||||
|
||||
def test_read():
|
||||
import osxphotos.exiftool
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
|
||||
assert exif.data["File:MIMEType"] == "image/jpeg"
|
||||
assert exif.data["EXIF:ISO"] == 160
|
||||
assert exif.data["IPTC:Keywords"] == "wedding"
|
||||
|
||||
|
||||
def test_setvalue_1():
|
||||
# test setting a tag value
|
||||
import os.path
|
||||
import tempfile
|
||||
from osxphotos.utils import _copy_file
|
||||
import osxphotos.exiftool
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
|
||||
_copy_file(TEST_FILE_ONE_KEYWORD, tempfile)
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(tempfile)
|
||||
exif.setvalue("IPTC:Keywords", "test")
|
||||
exif._read_exif()
|
||||
assert exif.data["IPTC:Keywords"] == "test"
|
||||
|
||||
|
||||
def test_clear_value():
|
||||
# test clearing a tag value
|
||||
import os.path
|
||||
import tempfile
|
||||
from osxphotos.utils import _copy_file
|
||||
import osxphotos.exiftool
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
|
||||
_copy_file(TEST_FILE_ONE_KEYWORD, tempfile)
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(tempfile)
|
||||
assert "IPTC:Keywords" in exif.data
|
||||
|
||||
exif.setvalue("IPTC:Keywords", None)
|
||||
exif._read_exif()
|
||||
assert "IPTC:Keywords" not in exif.data
|
||||
|
||||
|
||||
def test_addvalues_1():
|
||||
# test setting a tag value
|
||||
import os.path
|
||||
import tempfile
|
||||
from osxphotos.utils import _copy_file
|
||||
import osxphotos.exiftool
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_ONE_KEYWORD))
|
||||
_copy_file(TEST_FILE_ONE_KEYWORD, tempfile)
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(tempfile)
|
||||
exif.addvalues("IPTC:Keywords", "test")
|
||||
exif._read_exif()
|
||||
assert sorted(exif.data["IPTC:Keywords"]) == sorted(["wedding", "test"])
|
||||
|
||||
|
||||
def test_addvalues_2():
|
||||
# test setting a tag value where multiple values already exist
|
||||
import os.path
|
||||
import tempfile
|
||||
from osxphotos.utils import _copy_file
|
||||
import osxphotos.exiftool
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_MULTI_KEYWORD))
|
||||
_copy_file(TEST_FILE_MULTI_KEYWORD, tempfile)
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(tempfile)
|
||||
assert sorted(exif.data["IPTC:Keywords"]) == sorted(TEST_MULTI_KEYWORDS)
|
||||
exif.addvalues("IPTC:Keywords", "test")
|
||||
exif._read_exif()
|
||||
assert "IPTC:Keywords" in exif.data
|
||||
test_multi = TEST_MULTI_KEYWORDS.copy()
|
||||
test_multi.append("test")
|
||||
assert sorted(exif.data["IPTC:Keywords"]) == sorted(test_multi)
|
||||
|
||||
|
||||
def test_singleton():
|
||||
import osxphotos.exiftool
|
||||
|
||||
exif1 = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
|
||||
exif2 = osxphotos.exiftool.ExifTool(TEST_FILE_MULTI_KEYWORD)
|
||||
|
||||
assert exif1._process.pid == exif2._process.pid
|
||||
|
||||
|
||||
def test_pid():
|
||||
import osxphotos.exiftool
|
||||
|
||||
exif1 = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
|
||||
assert exif1.pid == exif1._process.pid
|
||||
|
||||
|
||||
def test_exiftoolproc_process():
|
||||
import osxphotos.exiftool
|
||||
|
||||
exif1 = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
|
||||
assert exif1._exiftoolproc.process is not None
|
||||
|
||||
|
||||
def test_exiftoolproc_exiftool():
|
||||
import osxphotos.exiftool
|
||||
|
||||
exif1 = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
|
||||
assert exif1._exiftoolproc.exiftool == osxphotos.exiftool.get_exiftool_path()
|
||||
|
||||
|
||||
def test_json():
|
||||
import osxphotos.exiftool
|
||||
import json
|
||||
|
||||
exif1 = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
|
||||
json1 = exif1.json()
|
||||
assert json1[0]["XMP:TagsList"] == "wedding"
|
||||
|
||||
|
||||
def test_str():
|
||||
import osxphotos.exiftool
|
||||
|
||||
exif1 = osxphotos.exiftool.ExifTool(TEST_FILE_ONE_KEYWORD)
|
||||
assert "file: " in str(exif1)
|
||||
assert "exiftool: " in str(exif1)
|
||||
@@ -446,29 +446,38 @@ def test_exiftool_json_sidecar():
|
||||
|
||||
json_expected = json.loads(
|
||||
"""
|
||||
[{"FileName": "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg",
|
||||
"Title": "St. James\'s Park",
|
||||
"TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"GPSLatitude": "51 deg 30\' 12.86\\" N",
|
||||
"GPSLongitude": "0 deg 7\' 54.50\\" W",
|
||||
"GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
|
||||
"GPSLatitudeRef": "North", "GPSLongitudeRef": "West",
|
||||
"DateTimeOriginal": "2018:10:13 09:18:12",
|
||||
"OffsetTimeOriginal": "-04:00",
|
||||
"ModifyDate": "2019:12:08 14:06:44"}] """
|
||||
)
|
||||
[{"File:FileName": "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg",
|
||||
"XMP:Title": "St. James\'s Park",
|
||||
"XMP:TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"IPTC:Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
|
||||
"EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
|
||||
"Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
|
||||
"EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
|
||||
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"EXIF:ModifyDate": "2019:12:08 14:06:44",
|
||||
"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos"
|
||||
}] """
|
||||
)[0]
|
||||
|
||||
json_got = photos[0]._exiftool_json_sidecar()
|
||||
json_got = json.loads(json_got)
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
# some gymnastics to account for different sort order in different pythons
|
||||
for item in zip(sorted(json_got[0].items()), sorted(json_expected[0].items())):
|
||||
if type(item[0][1]) in (list, tuple):
|
||||
assert sorted(item[0][1]) == sorted(item[1][1])
|
||||
# some gymnastics to account for different sort order in different pythons
|
||||
for k, v in json_got.items():
|
||||
if type(v) in (list, tuple):
|
||||
assert sorted(json_expected[k]) == sorted(v)
|
||||
else:
|
||||
assert item[0][1] == item[1][1]
|
||||
assert json_expected[k] == v
|
||||
|
||||
for k, v in json_expected.items():
|
||||
if type(v) in (list, tuple):
|
||||
assert sorted(json_got[k]) == sorted(v)
|
||||
else:
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_xmp_sidecar():
|
||||
|
||||
@@ -390,30 +390,37 @@ def test_exiftool_json_sidecar():
|
||||
|
||||
json_expected = json.loads(
|
||||
"""
|
||||
[{"FileName": "St James Park.jpg",
|
||||
"Title": "St. James\'s Park",
|
||||
"TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"GPSLatitude": "51 deg 30\' 12.86\\" N",
|
||||
"GPSLongitude": "0 deg 7\' 54.50\\" W",
|
||||
"GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
|
||||
"GPSLatitudeRef": "North", "GPSLongitudeRef": "West",
|
||||
"DateTimeOriginal": "2018:10:13 09:18:12",
|
||||
"OffsetTimeOriginal": "-04:00",
|
||||
"ModifyDate": "2019:12:01 11:43:45"}]
|
||||
"""
|
||||
)
|
||||
[{"File:FileName": "St James Park.jpg",
|
||||
"XMP:Title": "St. James\'s Park",
|
||||
"XMP:TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"IPTC:Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
|
||||
"EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
|
||||
"Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
|
||||
"EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
|
||||
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
"EXIF:ModifyDate": "2019:12:01 11:43:45",
|
||||
"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos"
|
||||
}] """
|
||||
)[0]
|
||||
|
||||
json_got = photos[0]._exiftool_json_sidecar()
|
||||
json_got = json.loads(json_got)
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
# some gymnastics to account for different sort order in different pythons
|
||||
for item in zip(sorted(json_got[0].items()), sorted(json_expected[0].items())):
|
||||
if type(item[0][1]) in (list, tuple):
|
||||
assert sorted(item[0][1]) == sorted(item[1][1])
|
||||
for k, v in json_got.items():
|
||||
if type(v) in (list, tuple):
|
||||
assert sorted(json_expected[k]) == sorted(v)
|
||||
else:
|
||||
assert item[0][1] == item[1][1]
|
||||
assert json_expected[k] == v
|
||||
|
||||
for k, v in json_expected.items():
|
||||
if type(v) in (list, tuple):
|
||||
assert sorted(json_got[k]) == sorted(v)
|
||||
else:
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_xmp_sidecar():
|
||||
|
||||
94
tests/test_specials_catalina_10_15_1.py
Normal file
94
tests/test_specials_catalina_10_15_1.py
Normal file
@@ -0,0 +1,94 @@
|
||||
# Test cloud photos
|
||||
|
||||
import pytest
|
||||
|
||||
PHOTOS_DB_CLOUD = "./tests/Test-Cloud-10.15.1.photoslibrary/database/photos.db"
|
||||
|
||||
UUID_DICT = {
|
||||
"portrait": "7CDA5F84-AA16-4D28-9AA6-A49E1DF8A332",
|
||||
"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15",
|
||||
"selfie": "080525C4-1F05-48E5-A3F4-0C53127BB39C",
|
||||
"time_lapse": "4614086E-C797-4876-B3B9-3057E8D757C9",
|
||||
"panorama": "1C1C8F1F-826B-4A24-B1CB-56628946A834",
|
||||
"no_specials": "C2BBC7A4-5333-46EE-BAF0-093E72111B39",
|
||||
}
|
||||
|
||||
|
||||
def test_portrait():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["portrait"]])
|
||||
|
||||
assert photos[0].portrait
|
||||
assert not photos[0].hdr
|
||||
assert not photos[0].selfie
|
||||
assert not photos[0].time_lapse
|
||||
assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].portrait
|
||||
|
||||
|
||||
def test_hdr():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["hdr"]])
|
||||
|
||||
assert photos[0].hdr
|
||||
assert not photos[0].portrait
|
||||
assert not photos[0].selfie
|
||||
assert not photos[0].time_lapse
|
||||
assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].hdr
|
||||
|
||||
|
||||
def test_selfie():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["selfie"]])
|
||||
|
||||
assert photos[0].selfie
|
||||
assert not photos[0].portrait
|
||||
assert not photos[0].hdr
|
||||
assert not photos[0].time_lapse
|
||||
assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].selfie
|
||||
|
||||
|
||||
def test_time_lapse():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["time_lapse"]], movies=True)
|
||||
|
||||
assert photos[0].time_lapse
|
||||
assert not photos[0].portrait
|
||||
assert not photos[0].hdr
|
||||
assert not photos[0].selfie
|
||||
assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].time_lapse
|
||||
|
||||
|
||||
def test_panorama():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["panorama"]])
|
||||
|
||||
assert photos[0].panorama
|
||||
assert not photos[0].portrait
|
||||
assert not photos[0].selfie
|
||||
assert not photos[0].time_lapse
|
||||
assert not photos[0].hdr
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].panorama
|
||||
96
tests/test_specials_mojave_10_14_6.py
Normal file
96
tests/test_specials_mojave_10_14_6.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# Test cloud photos
|
||||
|
||||
import pytest
|
||||
|
||||
PHOTOS_DB_CLOUD = "./tests/Test-Cloud-10.14.6.photoslibrary/database/photos.db"
|
||||
|
||||
UUID_DICT = {
|
||||
# "portrait": "7CDA5F84-AA16-4D28-9AA6-A49E1DF8A332",
|
||||
"hdr": "UIgouj2cQqyKJnB2bCHrSg",
|
||||
"selfie": "NsO5Yg8qSPGBGiVxsCd5Kw",
|
||||
"time_lapse": "pKAWFwtlQYuR962KEaonPA",
|
||||
# "panorama": "1C1C8F1F-826B-4A24-B1CB-56628946A834",
|
||||
"no_specials": "%PgMNP%xRTWTJF+oOyZbXQ",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="don't have portrait photo in the 10.14.6yy database")
|
||||
def test_portrait():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["portrait"]])
|
||||
|
||||
assert photos[0].portrait
|
||||
assert not photos[0].hdr
|
||||
assert not photos[0].selfie
|
||||
assert not photos[0].time_lapse
|
||||
assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].portrait
|
||||
|
||||
|
||||
def test_hdr():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["hdr"]])
|
||||
|
||||
assert photos[0].hdr
|
||||
assert not photos[0].portrait
|
||||
assert not photos[0].selfie
|
||||
assert not photos[0].time_lapse
|
||||
assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].hdr
|
||||
|
||||
|
||||
def test_selfie():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["selfie"]])
|
||||
|
||||
assert photos[0].selfie
|
||||
assert not photos[0].portrait
|
||||
assert not photos[0].hdr
|
||||
assert not photos[0].time_lapse
|
||||
assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].selfie
|
||||
|
||||
|
||||
def test_time_lapse():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["time_lapse"]], movies=True)
|
||||
|
||||
assert photos[0].time_lapse
|
||||
assert not photos[0].portrait
|
||||
assert not photos[0].hdr
|
||||
assert not photos[0].selfie
|
||||
assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].time_lapse
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="no panorama in 10.14.6 database")
|
||||
def test_panorama():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["panorama"]])
|
||||
|
||||
assert photos[0].panorama
|
||||
assert not photos[0].portrait
|
||||
assert not photos[0].selfie
|
||||
assert not photos[0].time_lapse
|
||||
assert not photos[0].hdr
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].panorama
|
||||
87
tests/test_specials_sierra_10_12.py
Normal file
87
tests/test_specials_sierra_10_12.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# Test cloud photos
|
||||
|
||||
import pytest
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.12.6.photoslibrary/database/photos.db"
|
||||
|
||||
UUID_DICT = {"no_specials": "Pj99JmYjQkeezdY2OFuSaw"}
|
||||
|
||||
|
||||
def test_portrait():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||
# photos = photosdb.photos(uuid=[UUID_DICT["portrait"]])
|
||||
|
||||
# assert photos[0].portrait
|
||||
# assert not photos[0].hdr
|
||||
# assert not photos[0].selfie
|
||||
# assert not photos[0].time_lapse
|
||||
# assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].portrait
|
||||
|
||||
|
||||
def test_hdr():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||
# photos = photosdb.photos(uuid=[UUID_DICT["hdr"]])
|
||||
|
||||
# assert photos[0].hdr
|
||||
# assert not photos[0].portrait
|
||||
# assert not photos[0].selfie
|
||||
# assert not photos[0].time_lapse
|
||||
# assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].hdr
|
||||
|
||||
|
||||
def test_selfie():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||
# photos = photosdb.photos(uuid=[UUID_DICT["selfie"]])
|
||||
|
||||
# assert photos[0].selfie
|
||||
# assert not photos[0].portrait
|
||||
# assert not photos[0].hdr
|
||||
# assert not photos[0].time_lapse
|
||||
# assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert photos[0].selfie is None
|
||||
|
||||
|
||||
def test_time_lapse():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||
# photos = photosdb.photos(uuid=[UUID_DICT["time_lapse"]], movies=True)
|
||||
|
||||
# assert photos[0].time_lapse
|
||||
# assert not photos[0].portrait
|
||||
# assert not photos[0].hdr
|
||||
# assert not photos[0].selfie
|
||||
# assert not photos[0].panorama
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].time_lapse
|
||||
|
||||
|
||||
def test_panorama():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||
# photos = photosdb.photos(uuid=[UUID_DICT["panorama"]])
|
||||
|
||||
# assert photos[0].panorama
|
||||
# assert not photos[0].portrait
|
||||
# assert not photos[0].selfie
|
||||
# assert not photos[0].time_lapse
|
||||
# assert not photos[0].hdr
|
||||
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_specials"]])
|
||||
assert not photos[0].panorama
|
||||
Reference in New Issue
Block a user