Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f13ba837f | ||
|
|
dc87194eec | ||
|
|
d32774f495 | ||
|
|
7da02991cf | ||
|
|
6f413c64d7 | ||
|
|
2d7d0b86e0 | ||
|
|
acb6b9e72f | ||
|
|
f1ade92e98 | ||
|
|
a27ce33473 | ||
|
|
2b7d84a4d1 | ||
|
|
92b405a166 | ||
|
|
15d7ad538d | ||
|
|
1f8fd6e929 | ||
|
|
08a9793651 | ||
|
|
2c8fc9789f | ||
|
|
dbededcd0e | ||
|
|
ef799610ae | ||
|
|
8dea41961b | ||
|
|
5799afbdc1 | ||
|
|
9a0fc0db3e | ||
|
|
549170fa36 | ||
|
|
dede640ef3 | ||
|
|
2b3491bdc4 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -4,6 +4,30 @@ 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.13](https://github.com/RhetTbull/osxphotos/compare/v0.22.12...v0.22.13)
|
||||
|
||||
> 8 March 2020
|
||||
|
||||
- Added media type specials, closes #60 [`#60`](https://github.com/RhetTbull/osxphotos/issues/60)
|
||||
- Updated CHANGELOG.md [`08a9793`](https://github.com/RhetTbull/osxphotos/commit/08a9793651481e1984a4482794ffedd48e4367a2)
|
||||
- Updated README.md [`1f8fd6e`](https://github.com/RhetTbull/osxphotos/commit/1f8fd6e929cc0edd3dd2f222416454d26955bf2a)
|
||||
|
||||
#### [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
|
||||
|
||||
101
README.md
101
README.md
@@ -3,13 +3,14 @@
|
||||
[](https://github.com/python/black)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
|
||||
- [OSXPhotos](#osxphotos)
|
||||
* [What is osxphotos?](#what-is-osxphotos)
|
||||
* [Supported operating systems](#supported-operating-systems)
|
||||
* [Installation instructions](#installation-instructions)
|
||||
* [Command Line Usage](#command-line-usage)
|
||||
* [Example uses of the module](#example-uses-of-the-module)
|
||||
* [Module Interface](#module-interface)
|
||||
* [Example uses of the package](#example-uses-of-the-package)
|
||||
* [Package Interface](#package-interface)
|
||||
+ [PhotosDB](#photosdb)
|
||||
+ [PhotoInfo](#photoinfo)
|
||||
+ [Utility Functions](#utility-functions)
|
||||
@@ -18,17 +19,18 @@
|
||||
* [Contributing](#contributing)
|
||||
* [Implementation Notes](#implementation-notes)
|
||||
* [Dependencies](#dependencies)
|
||||
* [Acknowledgements](#acknowledgements)
|
||||
* [Acknowledgements](#acknowledgements)
|
||||
|
||||
|
||||
## What is osxphotos?
|
||||
|
||||
OSXPhotos provides the ability to interact with and query Apple's Photos.app library database on MacOS. Using this module you can query the Photos database for information about the photos stored in a Photos library on your Mac--for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
|
||||
OSXPhotos provides the ability to interact with and query Apple's Photos.app library database on MacOS. Using this package you can query the Photos database for information about the photos stored in a Photos library on your Mac--for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
|
||||
|
||||
## Supported operating systems
|
||||
|
||||
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 / Photos 5.0. Requires python >= 3.6
|
||||
|
||||
This module will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12
|
||||
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12
|
||||
|
||||
|
||||
## Installation instructions
|
||||
@@ -39,7 +41,7 @@ osxmetadata uses setuptools, thus simply run:
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
This module will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
|
||||
This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
|
||||
|
||||
If you only care about the command line tool, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
|
||||
|
||||
@@ -93,9 +95,15 @@ Options:
|
||||
order: 1. last opened library, 2. system
|
||||
library, 3. ~/Pictures/Photos
|
||||
Library.photoslibrary
|
||||
--keyword KEYWORD Search for keyword(s).
|
||||
--person PERSON Search for person(s).
|
||||
--album ALBUM Search for album(s).
|
||||
--keyword KEYWORD Search for keyword KEYWORD. If more than one
|
||||
keyword, treated as "OR", e.g. find photos
|
||||
match any keyword
|
||||
--person PERSON Search for person PERSON. If more than one
|
||||
person, treated as "OR", e.g. find photos
|
||||
match any person
|
||||
--album ALBUM Search for album ALBUM. If more than one
|
||||
album, treated as "OR", e.g. find photos
|
||||
match any album
|
||||
--uuid UUID Search for UUID(s).
|
||||
--title TITLE Search for TITLE in title of photo.
|
||||
--no-title Search for photos with no title.
|
||||
@@ -122,7 +130,26 @@ Options:
|
||||
burst.
|
||||
--live Search for Apple live photos
|
||||
--not-live Search for photos that are not Apple live
|
||||
photos
|
||||
photos.
|
||||
--portrait Search for Apple portrait mode photos.
|
||||
--not-portrait Search for photos that are not Apple
|
||||
portrait mode photos.
|
||||
--screenshot Search for screenshot photos.
|
||||
--not-screenshot Search for photos that are not screenshot
|
||||
photos.
|
||||
--slow-mo Search for slow motion videos.
|
||||
--not-slow-mo Search for photos that are not slow motion
|
||||
videos.
|
||||
--time-lapse Search for time lapse videos.
|
||||
--not-time-lapse Search for photos that are not time lapse
|
||||
videos.
|
||||
--hdr Search for high dynamic range (HDR) photos.
|
||||
--not-hdr Search for photos that are not HDR photos.
|
||||
--selfie Search for selfies (photos taken with front-
|
||||
facing cameras).
|
||||
--not-selfie Search for photos that are not selfies.
|
||||
--panorama Search for panorama photos.
|
||||
--not-panorama Search for photos that are not panoramas.
|
||||
--only-movies Search only for movies (default searches
|
||||
both images and movies).
|
||||
--only-photos Search only for photos/images (default
|
||||
@@ -148,7 +175,9 @@ Options:
|
||||
edited version exists. Edited photo will be
|
||||
named in form of "photoname_edited.ext"
|
||||
--export-bursts If a photo is a burst photo export all
|
||||
associated burst images in the library.
|
||||
associated burst images in the library. Not
|
||||
currently compatible with --download-
|
||||
misssing; see note on --download-missing.
|
||||
--export-live If a photo is a live photo export the
|
||||
associated live video component. Live video
|
||||
will have same name as photo but with .mov
|
||||
@@ -164,7 +193,7 @@ Options:
|
||||
-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
|
||||
Adobe Lightroom, etc.The sidecar file is
|
||||
named in format photoname.xmp
|
||||
--download-missing Attempt to download missing photos from
|
||||
iCloud. The current implementation uses
|
||||
@@ -174,7 +203,15 @@ Options:
|
||||
exist on disk. This will be slow and will
|
||||
require internet connection. This obviously
|
||||
only works if the Photos library is synched
|
||||
to iCloud.
|
||||
to iCloud. Note: --download-missing is not
|
||||
currently compatabile with --export-bursts;
|
||||
only the primary photo will be exported--
|
||||
associated burst images will be skipped.
|
||||
--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.
|
||||
```
|
||||
|
||||
@@ -188,7 +225,7 @@ Example: find all photos with keyword "Kids" and output results to json file nam
|
||||
|
||||
`osxphotos query --keyword Kids --json ~/Pictures/Photos\ Library.photoslibrary >results.json`
|
||||
|
||||
## Example uses of the module
|
||||
## Example uses of the package
|
||||
|
||||
```python
|
||||
import os.path
|
||||
@@ -261,7 +298,7 @@ if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
## Module Interface
|
||||
## Package Interface
|
||||
|
||||
### PhotosDB
|
||||
|
||||
@@ -652,10 +689,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 +720,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:
|
||||
|
||||
@@ -780,17 +837,17 @@ Contributing is easy! if you find bugs or want to suggest additional features/c
|
||||
|
||||
I'll gladly consider pull requests for bug fixes or feature implementations.
|
||||
|
||||
If you have an interesting example that shows usage of this module, submit an issue or pull request and i'll include it or link to it.
|
||||
If you have an interesting example that shows usage of this package, submit an issue or pull request and i'll include it or link to it.
|
||||
|
||||
Testing against "real world" Photos libraries would be especially helpful. If you discover issues in testing against your Photos libraries, please open an issue. I've done extensive testing against my own Photos library but that's a since data point and I'm certain there are issues lurking in various edge cases I haven't discovered yet.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This module works by creating a copy of the sqlite3 database that photos uses to store data about the photos library. the class photosdb then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc. If your library is large, the database can be hundreds of MB in size and the copy then read can take many 10s of seconds to complete. Once copied, the entire database is processed and an in-memory data structure is created meaning all subsequent accesses of the PhotosDB object occur much more quickly.
|
||||
This packge works by creating a copy of the sqlite3 database that photos uses to store data about the photos library. the class photosdb then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc. If your library is large, the database can be hundreds of MB in size and the copy then read can take many 10s of seconds to complete. Once copied, the entire database is processed and an in-memory data structure is created meaning all subsequent accesses of the PhotosDB object occur much more quickly.
|
||||
|
||||
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 funcationality in this module using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
|
||||
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 funcationality in this package using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
|
||||
|
||||
## Dependencies
|
||||
- [PyObjC](https://pythonhosted.org/pyobjc/)
|
||||
@@ -801,5 +858,5 @@ Apple does provide a framework ([PhotoKit](https://developer.apple.com/documenta
|
||||
## Acknowledgements
|
||||
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se
|
||||
|
||||
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this module, I included the entire module (which is published as public domain code) in a private module to prevent ambiguity with other applescript modules on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based modules.
|
||||
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -191,7 +192,45 @@ def query_options(f):
|
||||
o(
|
||||
"--not-live",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not Apple live photos",
|
||||
help="Search for photos that are not Apple live photos.",
|
||||
),
|
||||
o("--portrait", is_flag=True, help="Search for Apple portrait mode photos."),
|
||||
o(
|
||||
"--not-portrait",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not Apple portrait mode photos.",
|
||||
),
|
||||
o("--screenshot", is_flag=True, help="Search for screenshot photos."),
|
||||
o(
|
||||
"--not-screenshot",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not screenshot photos.",
|
||||
),
|
||||
o("--slow-mo", is_flag=True, help="Search for slow motion videos."),
|
||||
o(
|
||||
"--not-slow-mo",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not slow motion videos.",
|
||||
),
|
||||
o("--time-lapse", is_flag=True, help="Search for time lapse videos."),
|
||||
o(
|
||||
"--not-time-lapse",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not time lapse videos.",
|
||||
),
|
||||
o("--hdr", is_flag=True, help="Search for high dynamic range (HDR) photos."),
|
||||
o("--not-hdr", is_flag=True, help="Search for photos that are not HDR photos."),
|
||||
o(
|
||||
"--selfie",
|
||||
is_flag=True,
|
||||
help="Search for selfies (photos taken with front-facing cameras).",
|
||||
),
|
||||
o("--not-selfie", is_flag=True, help="Search for photos that are not selfies."),
|
||||
o("--panorama", is_flag=True, help="Search for panorama photos."),
|
||||
o(
|
||||
"--not-panorama",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not panoramas.",
|
||||
),
|
||||
o(
|
||||
"--only-movies",
|
||||
@@ -507,6 +546,20 @@ def query(
|
||||
not_incloud,
|
||||
from_date,
|
||||
to_date,
|
||||
portrait,
|
||||
not_portrait,
|
||||
screenshot,
|
||||
not_screenshot,
|
||||
slow_mo,
|
||||
not_slow_mo,
|
||||
time_lapse,
|
||||
not_time_lapse,
|
||||
hdr,
|
||||
not_hdr,
|
||||
selfie,
|
||||
not_selfie,
|
||||
panorama,
|
||||
not_panorama,
|
||||
):
|
||||
""" Query the Photos database using 1 or more search options;
|
||||
if more than one option is provided, they are treated as "AND"
|
||||
@@ -537,6 +590,13 @@ def query(
|
||||
(live, not_live),
|
||||
(cloudasset, not_cloudasset),
|
||||
(incloud, not_incloud),
|
||||
(portrait, not_portrait),
|
||||
(screenshot, not_screenshot),
|
||||
(slow_mo, not_slow_mo),
|
||||
(time_lapse, not_time_lapse),
|
||||
(hdr, not_hdr),
|
||||
(selfie, not_selfie),
|
||||
(panorama, not_panorama),
|
||||
]
|
||||
# print help if no non-exclusive term or a double exclusive term is given
|
||||
if not any(nonexclusive + [b ^ n for b, n in exclusive]):
|
||||
@@ -593,6 +653,20 @@ def query(
|
||||
not_incloud=not_incloud,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
portrait=portrait,
|
||||
not_portrait=not_portrait,
|
||||
screenshot=screenshot,
|
||||
not_screenshot=not_screenshot,
|
||||
slow_mo=slow_mo,
|
||||
not_slow_mo=not_slow_mo,
|
||||
time_lapse=time_lapse,
|
||||
not_time_lapse=not_time_lapse,
|
||||
hdr=hdr,
|
||||
not_hdr=not_hdr,
|
||||
selfie=selfie,
|
||||
not_selfie=not_selfie,
|
||||
panorama=panorama,
|
||||
not_panorama=not_panorama,
|
||||
)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
@@ -627,7 +701,8 @@ def query(
|
||||
@click.option(
|
||||
"--export-bursts",
|
||||
is_flag=True,
|
||||
help="If a photo is a burst photo export all associated burst images in the library.",
|
||||
help="If a photo is a burst photo export all associated burst images in the library. "
|
||||
"Not currently compatible with --download-misssing; see note on --download-missing.",
|
||||
)
|
||||
@click.option(
|
||||
"--export-live",
|
||||
@@ -660,7 +735,16 @@ def query(
|
||||
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.",
|
||||
"This obviously only works if the Photos library is synched to iCloud. "
|
||||
"Note: --download-missing is not currently compatabile with --export-bursts; "
|
||||
"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/",
|
||||
)
|
||||
@DB_ARGUMENT
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@@ -707,6 +791,21 @@ def export(
|
||||
not_live,
|
||||
download_missing,
|
||||
dest,
|
||||
exiftool,
|
||||
portrait,
|
||||
not_portrait,
|
||||
screenshot,
|
||||
not_screenshot,
|
||||
slow_mo,
|
||||
not_slow_mo,
|
||||
time_lapse,
|
||||
not_time_lapse,
|
||||
hdr,
|
||||
not_hdr,
|
||||
selfie,
|
||||
not_selfie,
|
||||
panorama,
|
||||
not_panorama,
|
||||
):
|
||||
""" Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -728,11 +827,30 @@ def export(
|
||||
(only_photos, only_movies),
|
||||
(burst, not_burst),
|
||||
(live, not_live),
|
||||
(portrait, not_portrait),
|
||||
(screenshot, not_screenshot),
|
||||
(slow_mo, not_slow_mo),
|
||||
(time_lapse, not_time_lapse),
|
||||
(hdr, not_hdr),
|
||||
(selfie, not_selfie),
|
||||
(panorama, not_panorama),
|
||||
]
|
||||
if any([all(bb) for bb in exclusive]):
|
||||
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
|
||||
@@ -782,6 +900,20 @@ def export(
|
||||
not_incloud=False,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
portrait=portrait,
|
||||
not_portrait=not_portrait,
|
||||
screenshot=screenshot,
|
||||
not_screenshot=not_screenshot,
|
||||
slow_mo=slow_mo,
|
||||
not_slow_mo=not_slow_mo,
|
||||
time_lapse=time_lapse,
|
||||
not_time_lapse=not_time_lapse,
|
||||
hdr=hdr,
|
||||
not_hdr=not_hdr,
|
||||
selfie=selfie,
|
||||
not_selfie=not_selfie,
|
||||
panorama=panorama,
|
||||
not_panorama=not_panorama,
|
||||
)
|
||||
|
||||
if photos:
|
||||
@@ -810,6 +942,7 @@ def export(
|
||||
original_name,
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
)
|
||||
else:
|
||||
for p in photos:
|
||||
@@ -824,6 +957,7 @@ def export(
|
||||
original_name,
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
)
|
||||
if export_path:
|
||||
click.echo(f"Exported {p.filename} to {export_path}")
|
||||
@@ -888,6 +1022,13 @@ def print_photo_info(photos, json=False):
|
||||
"iscloudasset",
|
||||
"incloud",
|
||||
"date_modified",
|
||||
"portrait",
|
||||
"screenshot",
|
||||
"slow_mo",
|
||||
"time_lapse",
|
||||
"hdr",
|
||||
"selfie",
|
||||
"panorama",
|
||||
]
|
||||
)
|
||||
for p in photos:
|
||||
@@ -922,6 +1063,13 @@ def print_photo_info(photos, json=False):
|
||||
p.iscloudasset,
|
||||
p.incloud,
|
||||
date_modified_iso,
|
||||
p.portrait,
|
||||
p.screenshot,
|
||||
p.slow_mo,
|
||||
p.time_lapse,
|
||||
p.hdr,
|
||||
p.selfie,
|
||||
p.panorama,
|
||||
]
|
||||
)
|
||||
for row in dump:
|
||||
@@ -962,6 +1110,20 @@ def _query(
|
||||
not_incloud=None,
|
||||
from_date=None,
|
||||
to_date=None,
|
||||
portrait=None,
|
||||
not_portrait=None,
|
||||
screenshot=None,
|
||||
not_screenshot=None,
|
||||
slow_mo=None,
|
||||
not_slow_mo=None,
|
||||
time_lapse=None,
|
||||
not_time_lapse=None,
|
||||
hdr=None,
|
||||
not_hdr=None,
|
||||
selfie=None,
|
||||
not_selfie=None,
|
||||
panorama=None,
|
||||
not_panorama=None,
|
||||
):
|
||||
""" run a query against PhotosDB to extract the photos based on user supply criteria """
|
||||
""" used by query and export commands """
|
||||
@@ -1054,6 +1216,41 @@ def _query(
|
||||
elif not_live:
|
||||
photos = [p for p in photos if not p.live_photo]
|
||||
|
||||
if portrait:
|
||||
photos = [p for p in photos if p.portrait]
|
||||
elif not_portrait:
|
||||
photos = [p for p in photos if not p.portrait]
|
||||
|
||||
if screenshot:
|
||||
photos = [p for p in photos if p.screenshot]
|
||||
elif not_screenshot:
|
||||
photos = [p for p in photos if not p.screenshot]
|
||||
|
||||
if slow_mo:
|
||||
photos = [p for p in photos if p.slow_mo]
|
||||
elif not_slow_mo:
|
||||
photos = [p for p in photos if not p.slow_mo]
|
||||
|
||||
if time_lapse:
|
||||
photos = [p for p in photos if p.time_lapse]
|
||||
elif not_time_lapse:
|
||||
photos = [p for p in photos if not p.time_lapse]
|
||||
|
||||
if hdr:
|
||||
photos = [p for p in photos if p.hdr]
|
||||
elif not_hdr:
|
||||
photos = [p for p in photos if not p.hdr]
|
||||
|
||||
if selfie:
|
||||
photos = [p for p in photos if p.selfie]
|
||||
elif not_selfie:
|
||||
photos = [p for p in photos if not p.selfie]
|
||||
|
||||
if panorama:
|
||||
photos = [p for p in photos if p.panorama]
|
||||
elif not_panorama:
|
||||
photos = [p for p in photos if not p.panorama]
|
||||
|
||||
if cloudasset:
|
||||
photos = [p for p in photos if p.iscloudasset]
|
||||
elif not_cloudasset:
|
||||
@@ -1078,6 +1275,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 +1288,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 +1338,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 +1357,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.16"
|
||||
|
||||
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:
|
||||
@@ -620,6 +663,7 @@ class PhotoInfo:
|
||||
edited=True,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
)
|
||||
else:
|
||||
# export original version and not edited
|
||||
@@ -632,9 +676,12 @@ class PhotoInfo:
|
||||
edited=False,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
)
|
||||
|
||||
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 +704,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 +751,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 +779,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 +796,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
|
||||
@@ -813,6 +884,13 @@ class PhotoInfo:
|
||||
"iscloudasset": self.iscloudasset,
|
||||
"incloud": self.incloud,
|
||||
"date_modified": date_modified_iso,
|
||||
"portrait": self.portrait,
|
||||
"screenshot": self.screenshot,
|
||||
"slow_mo": self.slow_mo,
|
||||
"time_lapse": self.time_lapse,
|
||||
"hdr": self.hdr,
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
}
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
@@ -852,6 +930,13 @@ class PhotoInfo:
|
||||
"iscloudasset": self.iscloudasset,
|
||||
"incloud": self.incloud,
|
||||
"date_modified": date_modified_iso,
|
||||
"portrait": self.portrait,
|
||||
"screenshot": self.screenshot,
|
||||
"slow_mo": self.slow_mo,
|
||||
"time_lapse": self.time_lapse,
|
||||
"hdr": self.hdr,
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
}
|
||||
return json.dumps(pic)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -299,8 +299,7 @@ def create_path_by_date(dest, dt):
|
||||
# f"""
|
||||
# on openLibrary
|
||||
# tell application "Photos"
|
||||
# activate
|
||||
# open POSIX file "{library_path}"
|
||||
# open POSIX file "{library_path}"
|
||||
# end tell
|
||||
# end openLibrary
|
||||
# """
|
||||
@@ -316,6 +315,7 @@ def _export_photo_uuid_applescript(
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
timeout=120,
|
||||
burst=False,
|
||||
):
|
||||
""" Export photo to dest path using applescript to control Photos
|
||||
If photo is a live photo, exports both the photo and associated .mov file
|
||||
@@ -332,6 +332,7 @@ def _export_photo_uuid_applescript(
|
||||
will raise error if called with both edited and original = True
|
||||
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
|
||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||
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
|
||||
has not been edited. This is due to how Photos Applescript interface works.
|
||||
@@ -342,7 +343,6 @@ def _export_photo_uuid_applescript(
|
||||
"""
|
||||
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
|
||||
tell application "Photos"
|
||||
activate
|
||||
set thePath to thePath
|
||||
set theItem to media item id theUUID
|
||||
set theFilename to filename of theItem
|
||||
@@ -390,6 +390,7 @@ def _export_photo_uuid_applescript(
|
||||
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
||||
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
|
||||
# TemporaryDirectory will cleanup on return
|
||||
filename_stem = pathlib.Path(filename).stem
|
||||
files = glob.glob(os.path.join(tmpdir.name, "*"))
|
||||
exported_paths = []
|
||||
for fname in files:
|
||||
@@ -398,6 +399,10 @@ def _export_photo_uuid_applescript(
|
||||
# it's the .mov part of live photo but not requested, so don't export
|
||||
logging.debug(f"Skipping live photo file {path}")
|
||||
continue
|
||||
if len(files) > 1 and burst and path.stem != filename_stem:
|
||||
# skip any burst photo that's not the one we asked for
|
||||
logging.debug(f"Skipping burst photo file {path}")
|
||||
continue
|
||||
if filestem:
|
||||
# rename the file based on filestem, keeping original extension
|
||||
dest_new = dest / f"{filestem}{path.suffix}"
|
||||
|
||||
2
setup.py
2
setup.py
@@ -50,7 +50,7 @@ setup(
|
||||
url="https://github.com/RhetTbull/",
|
||||
project_urls={"GitHub": "https://github.com/RhetTbull/osxphotos"},
|
||||
download_url="https://github.com/RhetTbull/osxphotos",
|
||||
packages=find_packages(exclude=["tests", "examples"]),
|
||||
packages=find_packages(exclude=["tests", "examples", "utils"]),
|
||||
license="License :: OSI Approved :: MIT License",
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
|
||||
Binary file not shown.
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>441</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.
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-14T06:33:01Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2020-01-30T02:33:23Z</date>
|
||||
<date>2020-03-14T06:33:01Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2020-01-30T04:13:27Z</date>
|
||||
<date>2020-03-14T06:33:01Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-14T06:33:01Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2020-01-30T02:33:23Z</date>
|
||||
<date>2020-03-14T06:33:01Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-14T06:33:01Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-01-30T04:13:27Z</date>
|
||||
<date>2020-03-14T06:33:02Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-01-30T02:33:23Z</date>
|
||||
<date>2020-03-14T06:33:01Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-14T06:33:01Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2020-01-30T02:33:24Z</date>
|
||||
<date>2020-03-14T06:33:01Z</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-14T06:33:02Z</date>
|
||||
<key>LastContactClassificationKey</key>
|
||||
<date>2020-01-30T02:33:26Z</date>
|
||||
<date>2020-03-14T06:33:03Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IncrementalPersonProcessingStage</key>
|
||||
<integer>0</integer>
|
||||
<integer>6</integer>
|
||||
<key>PersonBuilderLastMinimumFaceGroupSizeForCreatingMergeCandidates</key>
|
||||
<integer>15</integer>
|
||||
<key>PersonBuilderMergeCandidatesEnabled</key>
|
||||
|
||||
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