Compare commits

...

41 Commits

Author SHA1 Message Date
Rhet Turnbull
6acf9acd63 Alpha support for MacOS Big Sur/10.16, see issue #187 2020-08-09 10:59:05 -07:00
Rhet Turnbull
d0ec8620c7 Added py37 2020-08-08 22:04:36 -07:00
Rhet Turnbull
10156e34b5 Updated requirements.txt 2020-08-08 22:03:12 -07:00
Rhet Turnbull
a714ae0af0 Dropped py36 due to datetime.fromisoformat 2020-08-08 21:59:38 -07:00
Rhet Turnbull
fc416ea0b7 Fixed from_date and to_date to be timezone aware, closes #193 2020-08-08 21:03:34 -07:00
Rhet Turnbull
2628c1f2d2 Cleaned up test images 2020-08-08 08:22:28 -07:00
Rhet Turnbull
e482c3915a Added test for valid XMP file, closes #197 2020-08-01 07:58:00 -07:00
Rhet Turnbull
6baeae7ddd Test library updates 2020-08-01 06:55:51 -07:00
Rhet Turnbull
bea770b322 Added write_uuid_to_file.applescript to utils 2020-08-01 06:55:23 -07:00
Rhet Turnbull
840e9937be Added --uuid-from-file to CLI 2020-07-31 19:02:52 -07:00
Rhet Turnbull
002fce8e93 Updated README.md 2020-07-28 22:58:12 -07:00
Rhet Turnbull
ef32b1e9bc Test library updates 2020-07-27 15:31:02 -07:00
Rhet Turnbull
6f29cda99f Initial FaceInfo support for Issue #21 2020-07-27 06:20:04 -07:00
Rhet Turnbull
9fc4f76219 Updated Github Actions to run on PR 2020-07-24 19:03:01 -07:00
Rhet Turnbull
65b84ad345 Updated CHANGELOG.md 2020-07-23 07:20:30 -07:00
Rhet Turnbull
cf4dca10c0 Version bump for bug fix 2020-07-23 07:14:15 -07:00
Rhet Turnbull
27040d1604 Revert "Merge pull request #191 from RhetTbull/revert-190-Fix133"
This reverts commit b7f4b739de, reversing
changes made to da551036f9.
2020-07-23 07:04:42 -07:00
Rhet Turnbull
b91a9828fa Merge pull request #192 from PabloKohan/Fix133
Fix findfiles not to fail on missing/invalid dir
2020-07-23 06:55:47 -07:00
Pablo 'merKur' Kohan
8c10b61e90 Fix findfiles not to fail on missing/invalid dir
Was failing on --dry-run and tests.
Added unit-test.
2020-07-23 15:16:40 +03:00
Rhet Turnbull
b7f4b739de Merge pull request #191 from RhetTbull/revert-190-Fix133
Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)"
2020-07-22 22:18:19 -07:00
Rhet Turnbull
f8e62d8f5e Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" 2020-07-22 22:13:39 -07:00
Rhet Turnbull
da551036f9 Merge pull request #190 from PabloKohan/Fix133
Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)
2020-07-22 21:59:44 -07:00
Pablo 'merKur' Kohan
d52b387a29 Fix FileExistsError when filename differs only in case and export-as-hardlink
When exporting with --export-as-hardlink (and without --overwrite), an
exception is thrown in os.link (FileExistsError: [Errno 17] File exists)

This can happen if filenames differ only in case (on a case-insensitive
filesystem), so the similar filename is not incremented.

This fix uses `findfiles` (to glob in a case-insensitive way) instead of `glob`.
2020-07-22 22:20:48 +03:00
Rhet Turnbull
927e25911e Updated CHANGELOG.md 2020-07-18 16:21:50 -07:00
Rhet Turnbull
6688d1ff64 Updated dependencies, now supports py36, py37, py38 2020-07-18 07:42:52 -07:00
Rhet Turnbull
3526881ec8 Update README.md 2020-07-18 06:54:29 -07:00
Rhet Turnbull
3f19276c5c Implemented PersonInfo, closes #181 2020-07-17 22:06:37 -07:00
Rhet Turnbull
091e7b8f2e Updated CHANGELOG.md 2020-07-06 10:41:18 -07:00
Rhet Turnbull
1ef518cc3e Bug fix for empty albums 2020-07-06 10:35:54 -07:00
Rhet Turnbull
a934b692ab Updated CHANGELOG.md 2020-07-06 10:16:18 -07:00
Rhet Turnbull
9d820a0557 AlbumInfo.photos now returns photos in album sort order 2020-07-06 10:06:11 -07:00
Rhet Turnbull
fcff8ec5f8 Refactored person processing to enable implementation of #181 2020-07-06 00:10:22 -07:00
Rhet Turnbull
dfcbfa725a Updated CHANGELOG.md 2020-07-04 10:17:25 -07:00
Rhet Turnbull
df75a05645 Bug fix for keywords, persons in deleted photos 2020-07-04 09:54:43 -07:00
Rhet Turnbull
80f5989e2c Updated CHANGELOG.md 2020-07-03 12:31:18 -07:00
Rhet Turnbull
8c3af0a4e4 Added height, width, orientation, filesize to json, str) 2020-07-03 12:28:26 -07:00
Rhet Turnbull
4523224276 Updated CHANGELOG.md 2020-07-03 12:04:20 -07:00
Rhet Turnbull
541c390b7b Added height, width, orientation, filesize, closes #163 2020-07-03 11:24:59 -07:00
Rhet Turnbull
6ab0ad7e86 Added GPS location to XMP sidecar, closes #175 2020-07-03 09:04:23 -07:00
Rhet Turnbull
e5755c6144 Updated CHANGELOG.md 2020-06-28 21:54:36 -07:00
Rhet Turnbull
7806e05673 Updated README.md 2020-06-28 21:53:50 -07:00
660 changed files with 7692 additions and 533 deletions

View File

@@ -1,6 +1,6 @@
name: Python package
name: Tests
on: [push]
on: [push, pull_request]
jobs:
build:
@@ -9,7 +9,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
python-version: [3.8]
python-version: [3.7, 3.8]
steps:
- uses: actions/checkout@v1

View File

@@ -4,6 +4,79 @@ 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.30.13](https://github.com/RhetTbull/osxphotos/compare/v0.30.12...v0.30.13)
> 23 July 2020
- This reverts commit b7f4b739de978991def8ae2dca0f4e4b2881f56d, reversing [`#191`](https://github.com/RhetTbull/osxphotos/pull/191)
- Fix findfiles not to fail on missing/invalid dir [`#192`](https://github.com/RhetTbull/osxphotos/pull/192)
- Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" [`#191`](https://github.com/RhetTbull/osxphotos/pull/191)
- Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133) [`#190`](https://github.com/RhetTbull/osxphotos/pull/190)
- Fix findfiles not to fail on missing/invalid dir [`8c10b61`](https://github.com/RhetTbull/osxphotos/commit/8c10b61e90abbcfdff472bad4bb760558c7b850c)
- Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" [`f8e62d8`](https://github.com/RhetTbull/osxphotos/commit/f8e62d8f5ed26814f02383426237fd4c99a7ad04)
- Fix FileExistsError when filename differs only in case and export-as-hardlink [`d52b387`](https://github.com/RhetTbull/osxphotos/commit/d52b387a294e68ebf0580a202ea70b97205560ef)
- Version bump for bug fix [`cf4dca1`](https://github.com/RhetTbull/osxphotos/commit/cf4dca10c02d5f3f6132ab1572a698379b667e48)
#### [v0.30.12](https://github.com/RhetTbull/osxphotos/compare/v0.30.10...v0.30.12)
> 18 July 2020
- Implemented PersonInfo, closes #181 [`#181`](https://github.com/RhetTbull/osxphotos/issues/181)
- Updated dependencies, now supports py36, py37, py38 [`6688d1f`](https://github.com/RhetTbull/osxphotos/commit/6688d1ff6491f2e7e155946b265ef8b5d8929441)
- Update README.md [`3526881`](https://github.com/RhetTbull/osxphotos/commit/3526881ec872cc009b0d8936f366afcfff166d42)
#### [v0.30.10](https://github.com/RhetTbull/osxphotos/compare/v0.30.9...v0.30.10)
> 6 July 2020
- Bug fix for empty albums [`1ef518c`](https://github.com/RhetTbull/osxphotos/commit/1ef518cc3e9efbe9d4c16aa3d36c6dc6db86798e)
#### [v0.30.9](https://github.com/RhetTbull/osxphotos/compare/v0.30.7...v0.30.9)
> 6 July 2020
- Refactored person processing to enable implementation of #181 [`fcff8ec`](https://github.com/RhetTbull/osxphotos/commit/fcff8ec5f8286b28e7d8559b40b5808a7b59cc15)
- AlbumInfo.photos now returns photos in album sort order [`9d820a0`](https://github.com/RhetTbull/osxphotos/commit/9d820a0557944340d0c664a6c3497d138c6100d5)
#### [v0.30.7](https://github.com/RhetTbull/osxphotos/compare/v0.30.6...v0.30.7)
> 4 July 2020
- Bug fix for keywords, persons in deleted photos [`df75a05`](https://github.com/RhetTbull/osxphotos/commit/df75a05645a88b31daa411f960d99ade71efc908)
#### [v0.30.6](https://github.com/RhetTbull/osxphotos/compare/v0.30.5...v0.30.6)
> 3 July 2020
- Added height, width, orientation, filesize to json, str) [`8c3af0a`](https://github.com/RhetTbull/osxphotos/commit/8c3af0a4e4e49d9bbb33e809973d958334e44dca)
#### [v0.30.5](https://github.com/RhetTbull/osxphotos/compare/v0.30.4...v0.30.5)
> 3 July 2020
- Added height, width, orientation, filesize, closes #163 [`#163`](https://github.com/RhetTbull/osxphotos/issues/163)
#### [v0.30.4](https://github.com/RhetTbull/osxphotos/compare/v0.30.3...v0.30.4)
> 3 July 2020
- Added GPS location to XMP sidecar, closes #175 [`#175`](https://github.com/RhetTbull/osxphotos/issues/175)
- Updated README.md [`7806e05`](https://github.com/RhetTbull/osxphotos/commit/7806e05673775ded231e65f53f3a1d5095a4b4e1)
#### [v0.30.3](https://github.com/RhetTbull/osxphotos/compare/v0.30.2...v0.30.3)
> 29 June 2020
- Added --description-template to CLI, closes #166 [`#166`](https://github.com/RhetTbull/osxphotos/issues/166)
- Added expand_inplace to PhotoTemplate.render [`ff03287`](https://github.com/RhetTbull/osxphotos/commit/ff0328785f3ea14b1c8ae2b7d1a9b07e8aef0777)
- Updated README.md [`5950707`](https://github.com/RhetTbull/osxphotos/commit/59507077bafe39a17bc23babe6d6c52e1f502a53)
#### [v0.30.2](https://github.com/RhetTbull/osxphotos/compare/v0.30.1...v0.30.2)
> 28 June 2020
- Added --deleted, --deleted-only to CLI, closes #179 [`#179`](https://github.com/RhetTbull/osxphotos/issues/179)
#### [v0.30.1](https://github.com/RhetTbull/osxphotos/compare/v0.30.0...v0.30.1)
> 27 June 2020

198
README.md
View File

@@ -18,6 +18,8 @@
+ [FolderInfo](#folderinfo)
+ [PlaceInfo](#placeinfo)
+ [ScoreInfo](#scoreinfo)
+ [PersonInfo](#personinfo)
+ [FaceInfo](#faceinfo)
+ [Template Substitutions](#template-substitutions)
+ [Utility Functions](#utility-functions)
* [Examples](#examples)
@@ -35,9 +37,11 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
## 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 - 10.15.5 / Photos 5.0.
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 - 10.15.6 / Photos 5.0.
Requires python >= 3.8. You can probably get this to run with Python 3.6 or 3.7 (see notes [below](#Installation-instructions)) but only 3.8+ is officially supported.
Alpha support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0.
Requires python >= 3.7.
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.
@@ -48,11 +52,7 @@ OSXPhotos uses setuptools, thus simply run:
python3 setup.py install
If you're using python 3.6 or 3.7, you'll need to do this first to get around an issue with bpylist2:
pip install -r requirements.txt
You can also install directly from [pypi](https://pypi.org/) but you must use python >= 3.8 to avoid an error with bpylist2. The package currently works fine with python 3.6 or 3.7 but I know of no way to get `pip` to install the right dependencies.
You can also install directly from [pypi](https://pypi.org/):
pip install osxphotos
@@ -140,6 +140,9 @@ Options:
searches top level folders (e.g. does not
look at subfolders)
--uuid UUID Search for photos with UUID(s).
--uuid-from-file FILE Search for photos with UUID(s) loaded from
FILE. Format is a single UUID per line.
Lines preceeded with # are ignored.
--title TITLE Search for TITLE in title of photo.
--no-title Search for photos with no title.
--description DESC Search for DESC in description of photo.
@@ -253,6 +256,16 @@ Options:
--keyword-template "{folder_album}"
--keyword-template "{created.year}" See
Templating System below.
--description-template TEMPLATE
For use with --exiftool, --sidecar; specify
a template string to use as description in
the form '{name,DEFAULT}' This is the same
format as --directory. For example, if you
wanted to append 'exported with osxphotos on
[today's date]' to the description, you
could specify --description-template
"{descr} exported with osxphotos on
{today.date}" See Templating System below.
--current-name Use photo's current filename instead of
original filename for export. Note:
Starting with Photos 5, all photos are
@@ -780,7 +793,15 @@ Returns a list names of top level folder names in the database.
persons = photosdb.persons
```
Returns a list of the persons (faces) found in the Photos library
Returns a list of the person names (faces) found in the Photos library. **Note**: It is of course possible to have more than one person with the same name, e.g. "Maria Smith", in the database. `persons` assumes these are the same person and will list only one person named "Maria Smith". If you need more information about persons in the database, see [person_info](#dbpersoninfo).
#### <a name="dbpersoninfo">`person_info`</a>
```python
# assumes photosdb is a PhotosDB object (see above)
person_info = photosdb.person_info
```
Returns a list of [PersonInfo](#personinfo) objects representing persons who appear in photos in the database.
#### `keywords_as_dict`
```python
@@ -796,7 +817,8 @@ Returns a dictionary of keywords found in the Photos library where key is the ke
persons_dict = photosdb.persons_as_dict
```
Returns a dictionary of persons (faces) found in the Photos library where key is the person name and value is the count of how many times that person appears in the library (ie. how many photos are tagged with the person). Resulting dictionary is in reverse sorted order (e.g. person who appears in the most photos is listed first).
Returns a dictionary of persons (faces) found in the Photos library where key is the person name and value is the count of how many times that person appears in the library (ie. how many photos are tagged with the person). Resulting dictionary is in reverse sorted order (e.g. person who appears in the most photos is listed first). **Note**: It is of course possible to have more than one person with the same name, e.g. "Maria Smith", in the database. `persons_as_dict` assumes these are the same person and will list only one person named "Maria Smith". If you need more information about persons in the database, see [person_info](#dbpersoninfo).
#### `albums_as_dict`
```python
@@ -882,8 +904,7 @@ for row in results:
conn.close()
```
#### ` photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)`
#### <A name="photos">`photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)`</a>
```python
# assumes photosdb is a PhotosDB object (see above)
@@ -919,6 +940,8 @@ photos = photosdb.photos(
- ```to_date```: datetime.datetime; if provided, finds photos where creation date <= to_date; default is None
- ```intrash```: if True, finds only photos in the "Recently Deleted" or trash folder, if False does not find any photos in the trash; default is False
See also [get_photo()](#getphoto) which is much faster for retrieving a single photo.
If more than one of (keywords, uuid, persons, albums,from_date, to_date) is provided, they are treated as "and" criteria. E.g.
Finds all photos with (keyword = "wedding" or "birthday") and (persons = "Juan Rodriguez")
@@ -993,6 +1016,9 @@ For example, in my library, Photos says I have 19,386 photos and 474 movies. Ho
>>>
```
#### <a name="getphoto">`get_photo(uuid)`</A>
Returns a single PhotoInfo instance for photo with UUID matching `uuid` or None if no photo is found matching `uuid`. If you know the UUID of a photo, `get_photo()` is much faster than `photos`. See also [photos()](#photos).
### PhotoInfo
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library.
@@ -1030,6 +1056,12 @@ Returns a list of [AlbumInfo](#AlbumInfo) objects representing the albums the ph
#### `persons`
Returns a list of the names of the persons in the photo
#### <a name="photopersoninfo">`person_info`</a>
Returns a list of [PersonInfo](#personinfo) objects representing persons in the photo. Each PersonInfo object is associated with one or more FaceInfo objects.
#### <a name="photofaceinfo">`face_info`</a>
Returns a list of [FaceInfo](#faceinfo) objects representing faces in the photo. Each face is associated with the a PersonInfo object.
#### `path`
Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see [hasadjustments](#hasadjustments)). If the file is missing on disk, path=`None` (see [ismissing](#ismissing)).
@@ -1038,6 +1070,27 @@ Returns the absolute path to the edited photo on disk as a string. If the photo
**Note**: will also return None if the edited photo is missing on disk.
#### `height`
Returns height of the photo in pixels. If image has been edited, returns height of the edited image, otherwise returns height of the original image. See also [original_height](#original_height).
#### `width`
Returns width of the photo in pixels. If image has been edited, returns width of the edited image, otherwise returns width of the original image. See also [original_width](#original_width).
#### `orientation`
Returns EXIF orientation value of the photo as integer. If image has been edited, returns orientation of the edited image, otherwise returns orientation of the original image. See also [original_orientation](#original_orientation).
#### `original_height`
Returns height of the original photo in pixels. See also [height](#height).
#### `original_width`
Returns width of the original photo in pixels. See also [width](#width).
#### `original_orientation`
Returns EXIF orientation value of the original photo as integer. See also [orientation](#orientation).
#### `original_filesize`
Returns size of the original photo in bytes as integer.
#### `ismissing`
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted and user hasn't enabled "Copy items to the Photos library" in Photos preferences. **Note**: this status is computed based on data in the Photos library and `ismissing` does not verify if the photo is actually missing. See also [path](#path).
@@ -1322,8 +1375,8 @@ Returns the universally unique identifier (uuid) of the album. This is how Phot
#### `title`
Returns the title or name of the album.
#### `photos`
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album.
#### <a name="albumphotos">`photos`</a>
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album sorted in the same order as in Photos. (e.g. if photos were manually sorted in the Photos albums, photos returned by `photos` will be in same order as they appear in the Photos album)
#### `folder_list`
Returns a hierarchical list of [FolderInfo](#FolderInfo) objects representing the folders the album is contained in. For example, if album "AlbumInFolder" is in SubFolder2 of Folder1 as illustrated below, would return a list of `FolderInfo` objects representing ["Folder1", "SubFolder2"]
@@ -1497,6 +1550,118 @@ Example: find your "best" photo of food
>>> best_food_photo = sorted([p for p in photos if "food" in p.labels_normalized], key=lambda p: p.score.overall, reverse=True)[0]
```
### PersonInfo
[PhotosDB.person_info](#dbpersoninfo) and [PhotoInfo.person_info](#photopersoninfo) return a list of PersonInfo objects represents persons in the database and in a photo, respectively. The PersonInfo class has the following properties and methods.
#### `name`
Returns the full name of the person represented in the photo. For example, "Maria Smith".
#### `display_name`
Returns the display name of the person represented in the photo. For example, "Maria".
#### `uuid`
Returns the UUID of the person as stored in the Photos library database.
#### `keyphoto`
Returns a PhotoInfo instance for the photo designated as the key photo for the person. This is the Photos uses to display the person's face thumbnail in Photos' "People" view.
#### `facecount`
Returns a count of how many times this person appears in images in the database.
#### <a name="personphotos">`photos`</a>
Returns a list of PhotoInfo objects representing all photos the person appears in.
#### <a name="personfaceinfo">`face_info`</a>
Returns a list of [FaceInfo](#faceinfo) objects associated with this person sorted by quality score. Highest quality face is result[0] and lowest quality face is result[n].
#### `json()`
Returns a json string representation of the PersonInfo instance.
### FaceInfo
[PhotoInfo.face_info](#photofaceinfo) return a list of FaceInfo objects representing detected faces in a photo. The FaceInfo class has the following properties and methods.
#### `uuid`
UUID of the face.
#### `name`
Full name of the person represented by the face or None if person hasn't been given a name in Photos. This is a shortcut for `FaceInfo.person_info.name`.
#### `asset_uuid`
UUID of the photo this face is associated with.
#### `person_info`
[PersonInfo](#personinfo) object associated with this face.
#### `photo`
[PhotoInfo](#photoinfo) object representing the photo that contains this face.
#### `face_rect()`
Returns list of x, y coordinates as tuples `[(x0, y0), (x1, y1)]` representing the corners of rectangular region that contains the face. Coordinates are in same format and [reference frame](https://pillow.readthedocs.io/en/stable/handbook/concepts.html#coordinate-system) as used by [Pillow](https://pypi.org/project/Pillow/) imaging library. **Note**: face_rect() and all other properties/methods that return coordinates refer to the *current version* of the image. E.g. if the image has been edited ([`PhotoInfo.hasadjustments`](#hasadjustments)), these refer to [`PhotoInfo.path_edited`](#pathedited). If the image has no adjustments, these coordinates refer to the original photo ([`PhotoInfo.path`](#path)).
#### `center`
Coordinates as (x, y) tuple for the center of the detected face.
#### `mouth`
Coordinates as (x, y) tuple for the mouth of the detected face.
#### `left_eye`
Coordinates as (x, y) tuple for the left eye of the detected face.
#### `right_eye`
Coordinates as (x, y) tuple for the right eye of the detected face.
#### `size_pixels`
Diameter of detected face region in pixels.
#### `roll_pitch_yaw()`
Roll, pitch, and yaw of face region in radians. Returns a tuple of (roll, pitch, yaw)
#### roll
Roll of face region in radians.
#### pitch
Pitch of face region in radians.
#### yaw
Yaw of face region in radians.
#### `Additional properties`
The following additional properties are also available but are not yet fully documented.
- `center_x`: x coordinate of center of face in Photos' internal reference frame
- `center_y`: y coordinate of center of face in Photos' internal reference frame
- `mouth_x`: x coordinate of mouth in Photos' internal reference frame
- `mouth_y`: y coordinate of mouth in Photos' internal reference frame
- `left_eye_x`: x coordinate of left eye in Photos' internal reference frame
- `left_eye_y`: y coordinate of left eye in Photos' internal reference frame
- `right_eye_x`: x coordinate of right eye in Photos' internal reference frame
- `right_eye_y`: y coordinate of right eye in Photos' internal reference frame
- `size`: size of face region in Photos' internal reference frame
- `quality`: quality measure of detected face
- `source_width`: width in pixels of photo
- `source_height`: height in pixels of photo
- `has_smile`:
- `left_eye_closed`:
- `right_eye_closed`:
- `manual`:
- `face_type`:
- `age_type`:
- `bald_type`:
- `eye_makeup_type`:
- `eye_state`:
- `facial_hair_type`:
- `gender_type`:
- `glasses_type`:
- `hair_color_type`:
- `lip_makeup_type`:
- `smile_type`:
#### `asdict()`
Returns a dictionary representation of the FaceInfo instance.
#### `json()`
Returns a JSON representation of the FaceInfo instance.
### Template Substitutions
The following substitutions are availabe for use with `PhotoInfo.render_template()`
@@ -1661,10 +1826,11 @@ Testing against "real world" Photos libraries would be especially helpful. If y
## Known Bugs
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 400 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 600 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
- RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101) Note: Beta version of fix for this bug is implemented in the current version of osxphotos.
- The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75)
- Face coordinates (mouth, left eye, right eye) may not be correct for images where the head is tilted. See [Issue #196](https://github.com/RhetTbull/osxphotos/issues/196).
- RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101). Note: Beta version of fix for this bug is implemented in the current version of osxphotos.
- The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75).
## Implementation Notes

View File

@@ -79,7 +79,7 @@ def export(export_path, default_album, library_path, edited):
exported = p.export(dest_dir, filename)
click.echo(f"Exported {filename} to {exported}")
else:
click.echo(f"Skipping missing photo: {p.original_filename} in album {album}")
click.echo(f"Skipping missing photo: {p.original_filename}")
if __name__ == "__main__":

83
examples/export_faces.py Normal file
View File

@@ -0,0 +1,83 @@
""" Export all photos that contain a detected face and draw rectangles around each face
photos with no persons/detected faces will not be export
This shows how to use the FaceInfo class and is useful for validating that FaceInfo is
correctly handling faces.
To use this, you'll need to install Pillow:
python3 -m pip install Pillow
"""
import os
import click
from PIL import Image, ImageDraw
import osxphotos
@click.command()
@click.argument("export-path", type=click.Path(exists=True))
@click.option(
"--uuid",
metavar="UUID",
help="Limit export to optional UUID(s)",
required=False,
multiple=True,
)
@click.option(
"--library-path",
metavar="PATH",
help="Path to Photos library, default to last used library",
default=None,
)
def export(export_path, library_path, uuid):
""" export photos to export_path and draw faces """
library_path = os.path.expanduser(library_path) if library_path else None
if library_path is not None:
photosdb = osxphotos.PhotosDB(library_path)
else:
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos(uuid=uuid) if uuid else photosdb.photos(movies=False)
for p in photos:
if p.person_info and not p.ismissing:
# has persons and not missing
if "heic" in p.filename.lower():
print(f"skipping heic image {p.filename}")
continue
print(f"exporting photo {p.original_filename}, uuid = {p.uuid}")
export = p.export(export_path, p.original_filename, edited=p.hasadjustments)
if export:
im = Image.open(export[0])
draw = ImageDraw.Draw(im)
for face in p.face_info:
coords = face.face_rect()
draw.rectangle(coords, width=3)
draw.ellipse(get_circle_points(face.center, 3), width=1)
draw.text(face.mouth, "M", fill=(255, 255, 255, 255))
draw.text(face.left_eye, "L", fill=(255, 255, 255, 255))
draw.text(face.right_eye, "R", fill=(255, 255, 255, 255))
im.save(export[0])
else:
print(f"no photos exported for {p.uuid}")
def get_circle_points(xy, radius):
""" Returns tuples of (x0, y0), (x1, y1) for a circle centered at x, y with radius
Arguments:
xy: tuple of x, y coordinates
radius: radius of circle to draw
Returns:
[(x0, y0), (x1, y1)] for bounding box of circle centered at x, y
"""
x, y = xy
x0, y0 = x - radius, y - radius
x1, y1 = x + radius, y + radius
return [(x0, y0), (x1, y1)]
if __name__ == "__main__":
export() # pylint: disable=no-value-for-parameter

View File

@@ -58,5 +58,6 @@ if __name__ == "__main__":
print("getting photos")
tic = time.perf_counter()
photos = photosdb.photos(images=True, movies=True)
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
toc = time.perf_counter()
print(f"found {len(photos)} photos in {toc-tic} seconds")

View File

@@ -77,6 +77,20 @@ def get_photos_db(*db_options):
return None
class DateTimeISO8601(click.ParamType):
name = "DATETIME"
def convert(self, value, param, ctx):
try:
return datetime.datetime.fromisoformat(value)
except:
self.fail(
f"Invalid value for --{param.name}: invalid datetime format {value}. "
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
)
# Click CLI object & context settings
class CLI_Obj:
def __init__(self, db=None, json=False, debug=False):
@@ -298,6 +312,15 @@ def query_options(f):
multiple=True,
help="Search for photos with UUID(s).",
),
o(
"--uuid-from-file",
metavar="FILE",
default=None,
multiple=False,
help="Search for photos with UUID(s) loaded from FILE. "
"Format is a single UUID per line. Lines preceeded with # are ignored.",
type=click.Path(exists=True),
),
o(
"--title",
metavar="TITLE",
@@ -445,13 +468,13 @@ def query_options(f):
),
o(
"--from-date",
help="Search by start item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ).",
type=click.DateTime(),
help="Search by start item date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601).",
type=DateTimeISO8601(),
),
o(
"--to-date",
help="Search by end item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ).",
type=click.DateTime(),
help="Search by end item date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601).",
type=DateTimeISO8601(),
),
]
for o in options[::-1]:
@@ -520,10 +543,14 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid):
print("_dbkeywords_uuid:")
pprint.pprint(photosdb._dbkeywords_uuid)
elif attr == "persons":
print("_dbfaces_person:")
pprint.pprint(photosdb._dbfaces_person)
print("_dbfaces_uuid:")
pprint.pprint(photosdb._dbfaces_uuid)
print("_dbfaces_pk:")
pprint.pprint(photosdb._dbfaces_pk)
print("_dbpersons_pk:")
pprint.pprint(photosdb._dbpersons_pk)
print("_dbpersons_fullname:")
pprint.pprint(photosdb._dbpersons_fullname)
elif attr == "photos":
if uuid:
for uuid_ in uuid:
@@ -887,6 +914,7 @@ def query(
album,
folder,
uuid,
uuid_from_file,
title,
no_title,
description,
@@ -950,6 +978,7 @@ def query(
album,
folder,
uuid,
uuid_from_file,
edited,
external_edit,
uti,
@@ -994,6 +1023,12 @@ def query(
if only_photos:
ismovie = False
# load UUIDs if necessary and append to any uuids passed with --uuid
if uuid_from_file:
uuid_list = list(uuid) # Click option is a tuple
uuid_list.extend(load_uuid_from_file(uuid_from_file))
uuid = tuple(uuid_list)
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
@@ -1243,6 +1278,7 @@ def export(
album,
folder,
uuid,
uuid_from_file,
title,
no_title,
description,
@@ -1325,7 +1361,7 @@ def export(
VERBOSE = True if verbose_ else False
if not os.path.isdir(dest):
sys.exit("DEST must be valid path")
sys.exit(f"DEST {dest} must be valid path")
# sanity check input args
exclusive = [
@@ -1377,6 +1413,12 @@ def export(
if only_photos:
ismovie = False
# load UUIDs if necessary and append to any uuids passed with --uuid
if uuid_from_file:
uuid_list = list(uuid) # Click option is a tuple
uuid_list.extend(load_uuid_from_file(uuid_from_file))
uuid = tuple(uuid_list)
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
@@ -2087,6 +2129,12 @@ def export_photo(
)
return ExportResults([], [], [], [], [])
results_exported = []
results_new = []
results_updated = []
results_skipped = []
results_exif_updated = []
filenames = get_filenames_from_template(photo, filename_template, original_name)
for filename in filenames:
verbose(f"Exporting {photo.filename} as {filename}")
@@ -2109,11 +2157,6 @@ def export_photo(
)
# export the photo to each path in dest_paths
results_exported = []
results_new = []
results_updated = []
results_skipped = []
results_exif_updated = []
for dest_path in dest_paths:
export_results = photo.export2(
dest_path,
@@ -2335,5 +2378,32 @@ def find_files_in_branch(pathname, filename):
return files
def load_uuid_from_file(filename):
""" Load UUIDs from file. Does not validate UUIDs.
Format is 1 UUID per line, any line beginning with # is ignored.
Whitespace is stripped.
Arguments:
filename: file name of the file containing UUIDs
Returns:
list of UUIDs or empty list of no UUIDs in file
Raises:
FileNotFoundError if file does not exist
"""
if not pathlib.Path(filename).is_file():
raise FileNotFoundError(f"Could not find file {filename}")
uuid = []
with open(filename, "r") as uuid_file:
for line in uuid_file:
line = line.strip()
if len(line) and line[0] != "#":
uuid.append(line)
return uuid
if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter

View File

@@ -10,18 +10,42 @@ import os.path
# Photos 4.0 (10.14.5) == 4016
# Photos 4.0 (10.14.6) == 4025
# Photos 5.0 (10.15.0) == 6000
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
# database model versions (applies to Photos 5, Photos 6)
# these come from PLModelVersion key in binary plist in Z_METADATA.Z_PLIST
# Photos 5 (10.15.1) == 13537
# Photos 5 (10.15.4, 10.15.5, 10.15.6) == 13703
# Photos 6 (10.16.0 Beta) == 14104
_TEST_MODEL_VERSIONS = ["13537", "13703", "14104"]
# only version 3 - 4 have RKVersion.selfPortrait
_PHOTOS_3_VERSION = "3301"
# versions 5.0 and later have a different database structure
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.5
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.6
# Ranges for model version by Photos version
_PHOTOS_5_MODEL_VERSION = [13000, 13999]
_PHOTOS_6_MODEL_VERSION = [14000, 14999]
# some table names differ between Photos 5 and Photos 6
_DB_TABLE_NAMES = {
5: {
"ASSET": "ZGENERICASSET",
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_37KEYWORDS",
"ALBUM_JOIN": "Z_26ASSETS.Z_34ASSETS",
},
6: {
"ASSET": "ZASSET",
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_36KEYWORDS",
"ALBUM_JOIN": "Z_26ASSETS.Z_3ASSETS",
},
}
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
_TESTED_OS_VERSIONS = ["12", "13", "14", "15", "16"]
# Photos 5 has persons who are empty string if unidentified face
_UNKNOWN_PERSON = "_UNKNOWN_"

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.30.3"
__version__ = "0.32.0"

View File

@@ -48,8 +48,18 @@ class AlbumInfo:
try:
return self._photos
except AttributeError:
uuid = self._db._dbalbums_album[self._uuid]
self._photos = self._db.photos(uuid=uuid)
if self.uuid in self._db._dbalbums_album:
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
self._photos = self._db.photos(uuid=uuid)
# PhotosDB.photos does not preserve order when passing in list of uuids
# so need to build photo list one a time
# sort uuids by sort order
sorted_uuid = sorted(zip(sort_order, uuid))
self._photos = [
self._db.photos(uuid=[uuid])[0] for _, uuid in sorted_uuid
]
else:
self._photos = []
return self._photos
@property

View File

@@ -0,0 +1,57 @@
""" datetime utilities """
import datetime
def get_local_tz():
""" return local timezone as datetime.timezone tzinfo """
local_tz = (
datetime.datetime.now(datetime.timezone(datetime.timedelta(0)))
.astimezone()
.tzinfo
)
return local_tz
def datetime_remove_tz(dt):
""" remove timezone from a datetime.datetime object
dt: datetime.datetime object with tzinfo
returns: dt without any timezone info (naive datetime object) """
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
dt_new = dt.replace(tzinfo=None)
return dt_new
def datetime_has_tz(dt):
""" return True if datetime dt has tzinfo else False
dt: datetime.datetime
returns True if dt is timezone aware, else False """
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
return True
return False
def datetime_naive_to_local(dt):
""" convert naive (timezone unaware) datetime.datetime
to aware timezone in local timezone
dt: datetime.datetime without timezone
returns: datetime.datetime with local timezone """
if type(dt) != datetime.datetime:
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
# has timezone info
raise ValueError(
"dt must be naive/timezone unaware: "
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tizinfo.utcoffset(dt)}"
)
dt_local = dt.replace(tzinfo=get_local_tz())
return dt_local

408
osxphotos/personinfo.py Normal file
View File

@@ -0,0 +1,408 @@
""" PhotoInfo and FaceInfo classes to expose info about persons and faces in the Photos library """
import json
import logging
import math
class PersonInfo:
""" Info about a person in the Photos library
"""
def __init__(self, db=None, pk=None):
""" Creates a new PersonInfo instance
Arguments:
db: instance of PhotosDB object
pk: primary key value of person to initialize PersonInfo with
Returns:
PersonInfo instance
"""
self._db = db
self._pk = pk
person = self._db._dbpersons_pk[pk]
self.uuid = person["uuid"]
self.name = person["fullname"]
self.display_name = person["displayname"]
self.keyface = person["keyface"]
self.facecount = person["facecount"]
@property
def keyphoto(self):
try:
return self._keyphoto
except AttributeError:
person = self._db._dbpersons_pk[self._pk]
if person["photo_uuid"]:
try:
key_photo = self._db.get_photo(person["photo_uuid"])
except IndexError:
key_photo = None
else:
key_photo = None
self._keyphoto = key_photo
return self._keyphoto
@property
def photos(self):
""" Returns list of PhotoInfo objects associated with this person """
return self._db.photos_by_uuid(self._db._dbfaces_pk[self._pk])
@property
def face_info(self):
""" Returns a list of FaceInfo objects associated with this person sorted by quality score
Highest quality face is result[0] and lowest quality face is result[n]
"""
try:
faces = self._db._db_faceinfo_person[self._pk]
return sorted(
[FaceInfo(db=self._db, pk=face) for face in faces],
key=lambda face: face.quality,
reverse=True,
)
except KeyError:
# no faces
return []
def json(self):
""" Returns JSON representation of class instance """
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
person = {
"uuid": self.uuid,
"name": self.name,
"displayname": self.display_name,
"keyface": self.keyface,
"facecount": self.facecount,
"keyphoto": keyphoto,
}
return json.dumps(person)
def __str__(self):
return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})"
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
return all(
getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"]
)
def __ne__(self, other):
return not self.__eq__(other)
class FaceInfo:
""" Info about a face in the Photos library
"""
def __init__(self, db=None, pk=None):
""" Creates a new FaceInfo instance
Arguments:
db: instance of PhotosDB object
pk: primary key value of face to init the object with
Returns:
FaceInfo instance
"""
self._db = db
self._pk = pk
face = self._db._db_faceinfo_pk[pk]
self._info = face
self.uuid = face["uuid"]
self.name = face["fullname"]
self.asset_uuid = face["asset_uuid"]
self._person_pk = face["person"]
self.center_x = face["centerx"]
self.center_y = face["centery"]
self.mouth_x = face["mouthx"]
self.mouth_y = face["mouthy"]
self.left_eye_x = face["lefteyex"]
self.left_eye_y = face["lefteyey"]
self.right_eye_x = face["righteyex"]
self.right_eye_y = face["righteyey"]
self.size = face["size"]
self.quality = face["quality"]
self.source_width = face["sourcewidth"]
self.source_height = face["sourceheight"]
self.has_smile = face["has_smile"]
self.left_eye_closed = face["left_eye_closed"]
self.right_eye_closed = face["right_eye_closed"]
self.manual = face["manual"]
self.face_type = face["facetype"]
self.age_type = face["agetype"]
self.bald_type = face["baldtype"]
self.eye_makeup_type = face["eyemakeuptype"]
self.eye_state = face["eyestate"]
self.facial_hair_type = face["facialhairtype"]
self.gender_type = face["gendertype"]
self.glasses_type = face["glassestype"]
self.hair_color_type = face["haircolortype"]
self.intrash = face["intrash"]
self.lip_makeup_type = face["lipmakeuptype"]
self.smile_type = face["smiletype"]
@property
def center(self):
""" Coordinates, in PIL format, for center of face
Returns:
tuple of coordinates in form (x, y)
"""
return self._make_point((self.center_x, self.center_y))
@property
def size_pixels(self):
""" Size of face in pixels (centered around center_x, center_y)
Returns:
size, in int pixels, of a circle drawn around the center of the face
"""
photo = self.photo
size_reference = photo.width if photo.width > photo.height else photo.height
return self.size * size_reference
@property
def mouth(self):
""" Coordinates, in PIL format, for mouth position
Returns:
tuple of coordinates in form (x, y)
"""
return self._make_point_with_rotation((self.mouth_x, self.mouth_y))
@property
def left_eye(self):
""" Coordinates, in PIL format, for left eye position
Returns:
tuple of coordinates in form (x, y)
"""
return self._make_point_with_rotation((self.left_eye_x, self.left_eye_y))
@property
def right_eye(self):
""" Coordinates, in PIL format, for right eye position
Returns:
tuple of coordinates in form (x, y)
"""
return self._make_point_with_rotation((self.right_eye_x, self.right_eye_y))
@property
def person_info(self):
""" PersonInfo instance for person associated with this face """
try:
return self._person
except AttributeError:
self._person = PersonInfo(db=self._db, pk=self._person_pk)
return self._person
@property
def photo(self):
""" PhotoInfo instance associated with this face """
try:
return self._photo
except AttributeError:
self._photo = self._db.get_photo(self.asset_uuid)
if self._photo is None:
logging.warning(f"Could not get photo for uuid: {self.asset_uuid}")
return self._photo
def face_rect(self):
""" Get face rectangle coordinates for current version of the associated image
If image has been edited, rectangle applies to edited version, otherwise original version
Coordinates in format and reference frame used by PIL
Returns:
list [(x0, x1), (y0, y1)] of coordinates in reference frame used by PIL
"""
photo = self.photo
size_reference = photo.width if photo.width > photo.height else photo.height
radius = (self.size / 2) * size_reference
x, y = self._make_point((self.center_x, self.center_y))
x0, y0 = x - radius, y - radius
x1, y1 = x + radius, y + radius
return [(x0, y0), (x1, y1)]
def roll_pitch_yaw(self):
""" Roll, pitch, yaw of face in radians as tuple """
info = self._info
roll = 0 if info["roll"] is None else info["roll"]
pitch = 0 if info["pitch"] is None else info["pitch"]
yaw = 0 if info["yaw"] is None else info["yaw"]
return (roll, pitch, yaw)
@property
def roll(self):
""" Return roll angle in radians of the face region """
roll, _, _ = self.roll_pitch_yaw()
return roll
@property
def pitch(self):
""" Return pitch angle in radians of the face region """
_, pitch, _ = self.roll_pitch_yaw()
return pitch
@property
def yaw(self):
""" Return yaw angle in radians of the face region """
_, _, yaw = self.roll_pitch_yaw()
return yaw
def _make_point(self, xy):
""" Translate an (x, y) tuple based on image orientation
and convert to image coordinates
Arguments:
xy: tuple of (x, y) coordinates for point to translate
in format used by Photos (percent of height/width)
Returns:
(x, y) tuple of translated coordinates in pixels in PIL format/reference frame
"""
# Reference: https://github.com/neilpa/phace/blob/7594776480505d0c389688a42099c94ac5d34f3f/cmd/phace/draw.go#L79-L94
orientation = self.photo.orientation
x, y = xy
dx = self.photo.width
dy = self.photo.height
if orientation in [1, 2]:
y = 1.0 - y
elif orientation in [3, 4]:
x = 1.0 - x
elif orientation in [5, 6]:
x, y = 1.0 - y, 1.0 - x
dx, dy = dy, dx
elif orientation in [7, 8]:
x, y = y, x
dx, dy = dy, dx
else:
logging.warning(f"Unhandled orientation: {orientation}")
return (int(x * dx), int(y * dy))
def _make_point_with_rotation(self, xy):
""" Translate an (x, y) tuple based on image orientation and rotation
and convert to image coordinates
Arguments:
xy: tuple of (x, y) coordinates for point to translate
in format used by Photos (percent of height/width)
Returns:
(x, y) tuple of translated coordinates in pixels in PIL format/reference frame
"""
# convert to image coordinates
x, y = self._make_point(xy)
# rotate about center
xmid, ymid = self.center
roll, _, _ = self.roll_pitch_yaw()
xr, yr = rotate_image_point(x, y, xmid, ymid, roll)
return (int(xr), int(yr))
def asdict(self):
""" Returns dict representation of class instance """
roll, pitch, yaw = self.roll_pitch_yaw()
return {
"_pk": self._pk,
"uuid": self.uuid,
"name": self.name,
"asset_uuid": self.asset_uuid,
"_person_pk": self._person_pk,
"center_x": self.center_x,
"center_y": self.center_y,
"center": self.center,
"mouth_x": self.mouth_x,
"mouth_y": self.mouth_y,
"mouth": self.mouth,
"left_eye_x": self.left_eye_x,
"left_eye_y": self.left_eye_y,
"left_eye": self.left_eye,
"right_eye_x": self.right_eye_x,
"right_eye_y": self.right_eye_y,
"right_eye": self.right_eye,
"size": self.size,
"face_rect": self.face_rect(),
"roll": roll,
"pitch": pitch,
"yaw": yaw,
"quality": self.quality,
"source_width": self.source_width,
"source_height": self.source_height,
"has_smile": self.has_smile,
"left_eye_closed": self.left_eye_closed,
"right_eye_closed": self.right_eye_closed,
"manual": self.manual,
"face_type": self.face_type,
"age_type": self.age_type,
"bald_type": self.bald_type,
"eye_makeup_type": self.eye_makeup_type,
"eye_state": self.eye_state,
"facial_hair_type": self.facial_hair_type,
"gender_type": self.gender_type,
"glasses_type": self.glasses_type,
"hair_color_type": self.hair_color_type,
"intrash": self.intrash,
"lip_makeup_type": self.lip_makeup_type,
"smile_type": self.smile_type,
}
def json(self):
""" Return JSON representation of FaceInfo instance """
return json.dumps(self.asdict())
def __str__(self):
return f"FaceInfo(uuid={self.uuid}, center_x={self.center_x}, center_y = {self.center_y}, size={self.size}, person={self.name}, asset_uuid={self.asset_uuid})"
def __repr__(self):
return f"FaceInfo(db={self._db}, pk={self._pk})"
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
return all(
getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"]
)
def __ne__(self, other):
return not self.__eq__(other)
def rotate_image_point(x, y, xmid, ymid, angle):
""" rotate image point about xm, ym by angle in radians
Arguments:
x: x coordinate of point to rotate
y: y coordinate of point to rotate
xmid: x coordinate of center point to rotate about
ymid: y coordinate of center point to rotate about
angle: angle in radians about which to coordinate,
counter-clockwise is positive
Returns:
tuple of rotated points (xr, yr)
"""
# translate point relative to the mid point
x = x - xmid
y = y - ymid
# rotate by angle and translate back
# the photo coordinate system is downwards y is positive so
# need to adjust the rotation accordingly
cos_angle = math.cos(angle)
sin_angle = math.sin(angle)
xr = x * cos_angle + y * sin_angle + xmid
yr = -x * sin_angle + y * cos_angle + ymid
return (xr, yr)

View File

@@ -5,6 +5,7 @@ import os
from ..exiftool import ExifTool, get_exiftool_path
@property
def exiftool(self):
""" Returns an ExifTool object for the photo
@@ -26,8 +27,9 @@ def exiftool(self):
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None
logging.warning(f"exiftool not in path; download and install from https://exiftool.org/")
logging.warning(
f"exiftool not in path; download and install from https://exiftool.org/"
)
self._exiftool = exiftool
return self._exiftool

View File

@@ -34,7 +34,7 @@ from .._constants import (
from .._export_db import ExportDBNoOp
from ..exiftool import ExifTool
from ..fileutil import FileUtil
from ..utils import dd_to_dms_str
from ..utils import dd_to_dms_str, findfiles
ExportResults = namedtuple(
"ExportResults", ["exported", "new", "updated", "skipped", "exif_updated"]
@@ -428,11 +428,10 @@ def export2(
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
if not update and increment and not overwrite:
count = 1
glob_str = str(dest.parent / f"{dest.stem}*")
dest_files = glob.glob(glob_str)
dest_files = [pathlib.Path(f).stem for f in dest_files]
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest.stem
while dest_new in dest_files:
while dest_new.lower() in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
@@ -1103,7 +1102,6 @@ def _exiftool_json_sidecar(
lat_str, lon_str = dd_to_dms_str(lat, lon)
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["EXIF:GPSLatitudeRef"] = lat_ref

View File

@@ -29,6 +29,7 @@ from .._constants import (
_PHOTOS_5_SHARED_PHOTO_PATH,
)
from ..albuminfo import AlbumInfo
from ..personinfo import FaceInfo, PersonInfo
from ..phototemplate import PhotoTemplate
from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
@@ -86,12 +87,8 @@ class PhotoInfo:
@property
def date(self):
""" image creation date as timezone aware datetime object """
imagedate = self._info["imageDate"]
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return imagedate.astimezone(tz=tz)
return self._info["imageDate"]
@property
def date_modified(self):
""" image modification date as timezone aware datetime object
@@ -339,7 +336,32 @@ class PhotoInfo:
@property
def persons(self):
""" list of persons in picture """
return self._info["persons"]
return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]]
@property
def person_info(self):
""" list of PersonInfo objects for person in picture """
try:
return self._personinfo
except AttributeError:
self._personinfo = [
PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]
]
return self._personinfo
@property
def face_info(self):
""" list of FaceInfo objects for faces in picture """
try:
return self._faceinfo
except AttributeError:
try:
faces = self._db._db_faceinfo_uuid[self._uuid]
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
except KeyError:
# no faces
self._faceinfo = []
return self._faceinfo
@property
def albums(self):
@@ -642,6 +664,41 @@ class PhotoInfo:
otherwise returns False """
return self._info["raw_is_original"]
@property
def height(self):
""" returns height of the current photo version in pixels """
return self._info["height"]
@property
def width(self):
""" returns width of the current photo version in pixels """
return self._info["width"]
@property
def orientation(self):
""" returns EXIF orientation of the current photo version as int """
return self._info["orientation"]
@property
def original_height(self):
""" returns height of the original photo version in pixels """
return self._info["original_height"]
@property
def original_width(self):
""" returns width of the original photo version in pixels """
return self._info["original_width"]
@property
def original_orientation(self):
""" returns EXIF orientation of the original photo version as int """
return self._info["original_orientation"]
@property
def original_filesize(self):
""" returns filesize of original photo in bytes as int """
return self._info["original_filesize"]
def render_template(
self,
template_str,
@@ -771,6 +828,13 @@ class PhotoInfo:
"exif": exif,
"score": score,
"intrash": self.intrash,
"height": self.height,
"width": self.width,
"orientation": self.orientation,
"original_height": self.original_height,
"original_width": self.original_width,
"original_orientation": self.original_orientation,
"original_filesize": self.original_filesize,
}
return yaml.dump(info, sort_keys=False)
@@ -831,6 +895,13 @@ class PhotoInfo:
"exif": exif,
"score": score,
"intrash": self.intrash,
"height": self.height,
"width": self.width,
"orientation": self.orientation,
"original_height": self.original_height,
"original_width": self.original_width,
"original_orientation": self.original_orientation,
"original_filesize": self.original_filesize,
}
return json.dumps(pic)

View File

@@ -4,3 +4,4 @@ Processes a Photos.app library database to extract information about photos
"""
from .photosdb import PhotosDB
from .photosdb_utils import get_db_version, get_db_model_version, get_model_version

View File

@@ -3,9 +3,9 @@
import logging
from .._constants import _PHOTOS_4_VERSION
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
from ..utils import _db_is_locked, _debug, _open_sql_file
from .photosdb_utils import get_db_version
def _process_exifinfo(self):
""" load the exif data from the database
@@ -34,15 +34,17 @@ def _process_exifinfo_5(photosdb):
photosdb: PhotosDB instance """
db = photosdb._tmp_db
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
(conn, cursor) = _open_sql_file(db)
result = conn.execute(
"""
SELECT ZGENERICASSET.ZUUID, ZEXTENDEDATTRIBUTES.*
FROM ZGENERICASSET
f"""
SELECT {asset_table}.ZUUID, ZEXTENDEDATTRIBUTES.*
FROM {asset_table}
JOIN ZEXTENDEDATTRIBUTES
ON ZEXTENDEDATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
ON ZEXTENDEDATTRIBUTES.ZASSET = {asset_table}.Z_PK
"""
)
@@ -54,3 +56,5 @@ def _process_exifinfo_5(photosdb):
if uuid in photosdb._db_exifinfo_uuid:
logging.warning(f"duplicate exifinfo record found for uuid {uuid}")
photosdb._db_exifinfo_uuid[uuid] = record
conn.close()

View File

@@ -0,0 +1,331 @@
""" Methods for PhotosDB to add Photos face info
"""
import logging
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
from ..utils import _open_sql_file
from .photosdb_utils import get_db_version
"""
This module should be imported in the class defintion of PhotosDB in photosdb.py
Do not import this module directly
This module adds the following method to PhotosDB:
_process_faceinfo: process photo face info
The following data structures are added to PhotosDB
self._db_faceinfo_pk: {pk: {faceinfo}}
self._db_faceinfo_uuid: {photo uuid: [face pk]}
self._db_faceinfo_person: {person_pk: [face_pk]}
"""
def _process_faceinfo(self):
""" Process face information
"""
self._db_faceinfo_pk = {}
self._db_faceinfo_uuid = {}
self._db_faceinfo_person = {}
if self._db_version <= _PHOTOS_4_VERSION:
_process_faceinfo_4(self)
else:
_process_faceinfo_5(self)
def _process_faceinfo_4(photosdb):
""" Process face information for Photos 4 databases
Args:
photosdb: an OSXPhotosDB instance
"""
db = photosdb._tmp_db
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
"""
SELECT
RKFace.modelId,
RKVersion.uuid,
RKFace.uuid,
RKPerson.name,
RKFace.isInTrash,
RKFace.personId,
RKFace.imageModelId,
RKFace.sourceWidth,
RKFace.sourceHeight,
RKFace.centerX,
RKFace.centerY,
RKFace.size,
RKFace.leftEyeX,
RKFace.leftEyeY,
RKFace.rightEyeX,
RKFace.rightEyeY,
RKFace.mouthX,
RKFace.mouthY,
RKFace.hidden,
RKFace.manual,
RKFace.hasSmile,
RKFace.isLeftEyeClosed,
RKFace.isRightEyeClosed,
RKFace.poseRoll,
RKFace.poseYaw,
RKFace.posePitch,
RKFace.faceType,
RKFace.qualityMeasure
FROM
RKFace
JOIN RKPerson on RKPerson.modelId = RKFace.personId
JOIN RKVersion on RKVersion.modelId = RKFace.imageModelId
"""
)
# 0 RKFace.modelId,
# 1 RKVersion.uuid,
# 2 RKFace.uuid,
# 3 RKPerson.name,
# 4 RKFace.isInTrash,
# 5 RKFace.personId,
# 6 RKFace.imageModelId,
# 7 RKFace.sourceWidth,
# 8 RKFace.sourceHeight,
# 9 RKFace.centerX,
# 10 RKFace.centerY,
# 11 RKFace.size,
# 12 RKFace.leftEyeX,
# 13 RKFace.leftEyeY,
# 14 RKFace.rightEyeX,
# 15 RKFace.rightEyeY,
# 16 RKFace.mouthX,
# 17 RKFace.mouthY,
# 18 RKFace.hidden,
# 19 RKFace.manual,
# 20 RKFace.hasSmile,
# 21 RKFace.isLeftEyeClosed,
# 22 RKFace.isRightEyeClosed,
# 23 RKFace.poseRoll,
# 24 RKFace.poseYaw,
# 25 RKFace.posePitch,
# 26 RKFace.faceType,
# 27 RKFace.qualityMeasure
for row in result:
modelid = row[0]
asset_uuid = row[1]
person_id = row[5]
face = {}
face["pk"] = modelid
face["asset_uuid"] = asset_uuid
face["uuid"] = row[2]
face["person"] = person_id
face["fullname"] = row[3]
face["sourcewidth"] = row[7]
face["sourceheight"] = row[8]
face["centerx"] = row[9]
face["centery"] = row[10]
face["size"] = row[11]
face["lefteyex"] = row[12]
face["lefteyey"] = row[13]
face["righteyex"] = row[14]
face["righteyey"] = row[15]
face["mouthx"] = row[16]
face["mouthy"] = row[17]
face["hidden"] = row[18]
face["manual"] = row[19]
face["has_smile"] = row[20]
face["left_eye_closed"] = row[21]
face["right_eye_closed"] = row[22]
face["roll"] = row[23]
face["yaw"] = row[24]
face["pitch"] = row[25]
face["facetype"] = row[26]
face["quality"] = row[27]
# Photos 5 only
face["agetype"] = None
face["baldtype"] = None
face["eyemakeuptype"] = None
face["eyestate"] = None
face["facialhairtype"] = None
face["gendertype"] = None
face["glassestype"] = None
face["haircolortype"] = None
face["intrash"] = None
face["lipmakeuptype"] = None
face["smiletype"] = None
photosdb._db_faceinfo_pk[modelid] = face
try:
photosdb._db_faceinfo_uuid[asset_uuid].append(modelid)
except KeyError:
photosdb._db_faceinfo_uuid[asset_uuid] = [modelid]
try:
photosdb._db_faceinfo_person[person_id].append(modelid)
except KeyError:
photosdb._db_faceinfo_person[person_id] = [modelid]
conn.close()
def _process_faceinfo_5(photosdb):
""" Process face information for Photos 5 databases
Args:
photosdb: an OSXPhotosDB instance
"""
db = photosdb._tmp_db
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
f"""
SELECT
ZDETECTEDFACE.Z_PK,
{asset_table}.ZUUID,
ZDETECTEDFACE.ZUUID,
ZDETECTEDFACE.ZPERSON,
ZPERSON.ZFULLNAME,
ZDETECTEDFACE.ZAGETYPE,
ZDETECTEDFACE.ZBALDTYPE,
ZDETECTEDFACE.ZEYEMAKEUPTYPE,
ZDETECTEDFACE.ZEYESSTATE,
ZDETECTEDFACE.ZFACIALHAIRTYPE,
ZDETECTEDFACE.ZGENDERTYPE,
ZDETECTEDFACE.ZGLASSESTYPE,
ZDETECTEDFACE.ZHAIRCOLORTYPE,
ZDETECTEDFACE.ZHASSMILE,
ZDETECTEDFACE.ZHIDDEN,
ZDETECTEDFACE.ZISINTRASH,
ZDETECTEDFACE.ZISLEFTEYECLOSED,
ZDETECTEDFACE.ZISRIGHTEYECLOSED,
ZDETECTEDFACE.ZLIPMAKEUPTYPE,
ZDETECTEDFACE.ZMANUAL,
ZDETECTEDFACE.ZQUALITYMEASURE,
ZDETECTEDFACE.ZSMILETYPE,
ZDETECTEDFACE.ZSOURCEHEIGHT,
ZDETECTEDFACE.ZSOURCEWIDTH,
ZDETECTEDFACE.ZBLURSCORE,
ZDETECTEDFACE.ZCENTERX,
ZDETECTEDFACE.ZCENTERY,
ZDETECTEDFACE.ZLEFTEYEX,
ZDETECTEDFACE.ZLEFTEYEY,
ZDETECTEDFACE.ZMOUTHX,
ZDETECTEDFACE.ZMOUTHY,
ZDETECTEDFACE.ZPOSEYAW,
ZDETECTEDFACE.ZQUALITY,
ZDETECTEDFACE.ZRIGHTEYEX,
ZDETECTEDFACE.ZRIGHTEYEY,
ZDETECTEDFACE.ZROLL,
ZDETECTEDFACE.ZSIZE,
ZDETECTEDFACE.ZYAW,
ZDETECTEDFACE.ZMASTERIDENTIFIER
FROM ZDETECTEDFACE
JOIN {asset_table} ON {asset_table}.Z_PK = ZDETECTEDFACE.ZASSET
JOIN ZPERSON ON ZPERSON.Z_PK = ZDETECTEDFACE.ZPERSON;
"""
)
# 0 ZDETECTEDFACE.Z_PK
# 1 ZGENERICASSET.ZUUID,
# 2 ZDETECTEDFACE.ZUUID,
# 3 ZDETECTEDFACE.ZPERSON,
# 4 ZPERSON.ZFULLNAME,
# 5 ZDETECTEDFACE.ZAGETYPE,
# 6 ZDETECTEDFACE.ZBALDTYPE,
# 7 ZDETECTEDFACE.ZEYEMAKEUPTYPE,
# 8 ZDETECTEDFACE.ZEYESSTATE,
# 9 ZDETECTEDFACE.ZFACIALHAIRTYPE,
# 10 ZDETECTEDFACE.ZGENDERTYPE,
# 11 ZDETECTEDFACE.ZGLASSESTYPE,
# 12 ZDETECTEDFACE.ZHAIRCOLORTYPE,
# 13 ZDETECTEDFACE.ZHASSMILE,
# 14 ZDETECTEDFACE.ZHIDDEN,
# 15 ZDETECTEDFACE.ZISINTRASH,
# 16 ZDETECTEDFACE.ZISLEFTEYECLOSED,
# 17 ZDETECTEDFACE.ZISRIGHTEYECLOSED,
# 18 ZDETECTEDFACE.ZLIPMAKEUPTYPE,
# 19 ZDETECTEDFACE.ZMANUAL,
# 20 ZDETECTEDFACE.ZQUALITYMEASURE,
# 21 ZDETECTEDFACE.ZSMILETYPE,
# 22 ZDETECTEDFACE.ZSOURCEHEIGHT,
# 23 ZDETECTEDFACE.ZSOURCEWIDTH,
# 24 ZDETECTEDFACE.ZBLURSCORE,
# 25 ZDETECTEDFACE.ZCENTERX,
# 26 ZDETECTEDFACE.ZCENTERY,
# 27 ZDETECTEDFACE.ZLEFTEYEX,
# 28 ZDETECTEDFACE.ZLEFTEYEY,
# 29 ZDETECTEDFACE.ZMOUTHX,
# 30 ZDETECTEDFACE.ZMOUTHY,
# 31 ZDETECTEDFACE.ZPOSEYAW,
# 32 ZDETECTEDFACE.ZQUALITY,
# 33 ZDETECTEDFACE.ZRIGHTEYEX,
# 34 ZDETECTEDFACE.ZRIGHTEYEY,
# 35 ZDETECTEDFACE.ZROLL,
# 36 ZDETECTEDFACE.ZSIZE,
# 37 ZDETECTEDFACE.ZYAW,
# 38 ZDETECTEDFACE.ZMASTERIDENTIFIER
for row in result:
pk = row[0]
asset_uuid = row[1]
person_pk = row[3]
face = {}
face["pk"] = pk
face["asset_uuid"] = asset_uuid
face["uuid"] = row[2]
face["person"] = person_pk
face["fullname"] = row[4]
face["agetype"] = row[5]
face["baldtype"] = row[6]
face["eyemakeuptype"] = row[7]
face["eyestate"] = row[8]
face["facialhairtype"] = row[9]
face["gendertype"] = row[10]
face["glassestype"] = row[11]
face["haircolortype"] = row[12]
face["has_smile"] = row[13]
face["hidden"] = row[14]
face["intrash"] = row[15]
face["left_eye_closed"] = row[16]
face["right_eye_closed"] = row[17]
face["lipmakeuptype"] = row[18]
face["manual"] = row[19]
face["smiletype"] = row[21]
face["sourceheight"] = row[22]
face["sourcewidth"] = row[23]
face["facetype"] = None # Photos 4 only
face["centerx"] = row[25]
face["centery"] = row[26]
face["lefteyex"] = row[27]
face["lefteyey"] = row[28]
face["mouthx"] = row[29]
face["mouthy"] = row[30]
face["quality"] = row[32]
face["righteyex"] = row[33]
face["righteyey"] = row[34]
face["roll"] = row[35]
face["size"] = row[36]
face["yaw"] = row[37]
face["pitch"] = 0.0 # not defined in Photos 5
photosdb._db_faceinfo_pk[pk] = face
try:
photosdb._db_faceinfo_uuid[asset_uuid].append(pk)
except KeyError:
photosdb._db_faceinfo_uuid[asset_uuid] = [pk]
try:
photosdb._db_faceinfo_person[person_pk].append(pk)
except KeyError:
photosdb._db_faceinfo_person[person_pk] = [pk]
conn.close()

View File

@@ -4,8 +4,9 @@
import logging
from .._constants import _PHOTOS_4_VERSION
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
from ..utils import _open_sql_file
from .photosdb_utils import get_db_version
"""
This module should be imported in the class defintion of PhotosDB in photosdb.py
@@ -45,16 +46,18 @@ def _process_scoreinfo_5(photosdb):
db = photosdb._tmp_db
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
"""
f"""
SELECT
ZGENERICASSET.ZUUID,
ZGENERICASSET.ZOVERALLAESTHETICSCORE,
ZGENERICASSET.ZCURATIONSCORE,
ZGENERICASSET.ZPROMOTIONSCORE,
ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE,
{asset_table}.ZUUID,
{asset_table}.ZOVERALLAESTHETICSCORE,
{asset_table}.ZCURATIONSCORE,
{asset_table}.ZPROMOTIONSCORE,
{asset_table}.ZHIGHLIGHTVISIBILITYSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE,
@@ -78,8 +81,8 @@ def _process_scoreinfo_5(photosdb):
ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE
FROM ZGENERICASSET
JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
FROM {asset_table}
JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
"""
)
@@ -143,3 +146,5 @@ def _process_scoreinfo_5(photosdb):
scores["well_framed_subject"] = row[26]
scores["well_timed_shot"] = row[27]
photosdb._db_scoreinfo_uuid[uuid] = scores
conn.close()

View File

@@ -147,7 +147,8 @@ def _process_searchinfo(self):
"_db_searchinfo_labels_normalized: \n"
+ pformat(self._db_searchinfo_labels_normalized)
)
conn.close()
@property
def labels(self):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
""" utility functions used by PhotosDB """
import logging
import plistlib
from .._constants import (
_PHOTOS_5_MODEL_VERSION,
_PHOTOS_6_MODEL_VERSION,
_TESTED_DB_VERSIONS,
)
from ..utils import _open_sql_file
def get_db_version(db_file):
""" Gets the Photos DB version from LiGlobals table
Args:
db_file: path to photos.db database file containing LiGlobals table
Returns: version as str
"""
version = None
(conn, c) = _open_sql_file(db_file)
# get database version
c.execute("SELECT value from LiGlobals where LiGlobals.keyPath is 'libraryVersion'")
version = c.fetchone()[0]
conn.close()
if version not in _TESTED_DB_VERSIONS:
print(
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
+ f" You have database version={version} which has not been tested"
)
return version
def get_model_version(db_file):
""" Returns the database model version from Z_METADATA
Args:
db_file: path to Photos.sqlite database file containing Z_METADATA table
Returns: model version as str
"""
version = None
(conn, c) = _open_sql_file(db_file)
# get database version
c.execute("SELECT MAX(Z_VERSION), Z_PLIST FROM Z_METADATA")
results = c.fetchone()
conn.close()
plist = plistlib.loads(results[1])
return plist["PLModelVersion"]
def get_db_model_version(db_file):
""" Returns Photos version based on model version found in db_file
Args:
db_file: path to Photos.sqlite file
Returns: int of major Photos version number (e.g. 5 or 6).
If unknown model version found, logs warning and returns most current Photos version.
"""
model_ver = get_model_version(db_file)
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
db_ver = 5
elif _PHOTOS_6_MODEL_VERSION[0] <= model_ver <= _PHOTOS_6_MODEL_VERSION[1]:
db_ver = 6
else:
logging.warning(f"Unknown model version: {model_ver}")
# cross our fingers and try latest version
db_ver = 6
return db_ver

View File

@@ -71,6 +71,15 @@
% endif
</%def>
<%def name="gps_info(latitude, longitude)">
% if latitude is not None and longitude is not None:
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef>
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude>
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
% endif
</%def>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
@@ -82,18 +91,22 @@
${dc_subject(subjects)}
${dc_datecreated(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
${iptc_personinimage(persons)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
${dk_tagslist(keywords)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
${adobe_createdate(photo.date)}
${adobe_modifydate(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
${gps_info(*photo.location)}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

View File

@@ -262,7 +262,10 @@ def get_preferred_uti_extension(uti):
def findfiles(pattern, path_):
"""Returns list of filenames from path_ matched by pattern
shell pattern. Matching is case-insensitive."""
shell pattern. Matching is case-insensitive.
If 'path_' is invalid/doesn't exist, returns []."""
if not os.path.isdir(path_):
return []
# See: https://gist.github.com/techtonik/5694830
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)

View File

@@ -1,19 +1,38 @@
aiohttp==4.0.0a1
altgraph==0.17
ansimarkup==1.4.0
appdirs==1.4.3
appnope==0.1.0
astroid==2.2.5
async-timeout==3.0.1
atomicwrites==1.3.0
attrs==19.1.0
backcall==0.1.0
better-exceptions-fork==0.2.1.post6
# bpylist2==2.0.3;python_version<"3.8"
https://github.com/RhetTbull/bpylist/releases/download/v2.0.3/bpylist2-2.0.3.tar.gz#egg=bpylist2;python_version<"3.8"
bpylist2==3.0.0;python_version>="3.8"
certifi==2019.3.9
black==19.10b0
bleach==3.1.4
bpylist2==3.0.2
certifi==2020.4.5.1
cffi==1.14.0
chardet==3.0.4
Click==7.0
colorama==0.4.1
coverage==4.5.4
importlib-metadata>=0.18
decorator==4.4.2
distlib==0.3.1
docutils==0.16
entrypoints==0.3
filelock==3.0.12
idna==2.9
importlib-metadata==1.6.0
ipykernel==5.1.4
ipython==7.13.0
ipython-genutils==0.2.0
isort==4.3.20
jedi==0.16.0
jupyter-client==6.1.2
jupyter-core==4.6.3
keyring==21.2.0
lazy-object-proxy==1.4.1
loguru==0.2.5
macholib==1.14
@@ -22,135 +41,166 @@ MarkupSafe==1.1.1
mccabe==0.6.1
modulegraph==0.18
more-itertools==7.2.0
multidict==4.7.6
packaging==19.0
parso==0.6.2
pathspec==0.7.0
pathvalidate==2.2.1
pexpect==4.8.0
pickleshare==0.7.5
Pillow==7.2.0
pkginfo==1.5.0.1
pluggy==0.12.0
prompt-toolkit==3.0.4
psutil==5.7.0
ptyprocess==0.6.0
py==1.8.0
py2app==0.21
Pygments==2.4.2
pycparser==2.20
pyfiglet==0.8.post1
Pygments==2.6.1
PyInstaller==3.6
pyinstaller-setuptools==2019.3
pylint==2.3.1
pyobjc==6.0.1
pyobjc-core==6.0.1
pyobjc-framework-Accounts==6.0.1
pyobjc-framework-AddressBook==6.0.1
pyobjc-framework-AdSupport==6.0.1
pyobjc-framework-AppleScriptKit==6.0.1
pyobjc-framework-AppleScriptObjC==6.0.1
pyobjc-framework-ApplicationServices==6.0.1
pyobjc-framework-AuthenticationServices==6.0.1
pyobjc-framework-Automator==6.0.1
pyobjc-framework-AVFoundation==6.0.1
pyobjc-framework-AVKit==6.0.1
pyobjc-framework-BusinessChat==6.0.1
pyobjc-framework-CalendarStore==6.0.1
pyobjc-framework-CFNetwork==6.0.1
pyobjc-framework-CloudKit==6.0.1
pyobjc-framework-Cocoa==6.0.1
pyobjc-framework-Collaboration==6.0.1
pyobjc-framework-ColorSync==6.0.1
pyobjc-framework-Contacts==6.0.1
pyobjc-framework-ContactsUI==6.0.1
pyobjc-framework-CoreAudio==6.0.1
pyobjc-framework-CoreAudioKit==6.0.1
pyobjc-framework-CoreBluetooth==6.0.1
pyobjc-framework-CoreData==6.0.1
pyobjc-framework-CoreHaptics==6.0.1
pyobjc-framework-CoreLocation==6.0.1
pyobjc-framework-CoreMedia==6.0.1
pyobjc-framework-CoreMediaIO==6.0.1
pyobjc-framework-CoreML==6.0.1
pyobjc-framework-CoreMotion==6.0.1
pyobjc-framework-CoreServices==6.0.1
pyobjc-framework-CoreSpotlight==6.0.1
pyobjc-framework-CoreText==6.0.1
pyobjc-framework-CoreWLAN==6.0.1
pyobjc-framework-CryptoTokenKit==6.0.1
pyobjc-framework-DeviceCheck==6.0.1
pyobjc-framework-DictionaryServices==6.0.1
pyobjc-framework-DiscRecording==6.0.1
pyobjc-framework-DiscRecordingUI==6.0.1
pyobjc-framework-DiskArbitration==6.0.1
pyobjc-framework-DVDPlayback==6.0.1
pyobjc-framework-EventKit==6.0.1
pyobjc-framework-ExceptionHandling==6.0.1
pyobjc-framework-ExecutionPolicy==6.0.1
pyobjc-framework-ExternalAccessory==6.0.1
pyobjc-framework-FileProvider==6.0.1
pyobjc-framework-FileProviderUI==6.0.1
pyobjc-framework-FinderSync==6.0.1
pyobjc-framework-FSEvents==6.0.1
pyobjc-framework-GameCenter==6.0.1
pyobjc-framework-GameController==6.0.1
pyobjc-framework-GameKit==6.0.1
pyobjc-framework-GameplayKit==6.0.1
pyobjc-framework-ImageCaptureCore==6.0.1
pyobjc-framework-IMServicePlugIn==6.0.1
pyobjc-framework-InputMethodKit==6.0.1
pyobjc-framework-InstallerPlugins==6.0.1
pyobjc-framework-InstantMessage==6.0.1
pyobjc-framework-Intents==6.0.1
pyobjc-framework-IOSurface==6.0.1
pyobjc-framework-iTunesLibrary==6.0.1
pyobjc-framework-LatentSemanticMapping==6.0.1
pyobjc-framework-LaunchServices==6.0.1
pyobjc-framework-libdispatch==6.0.1
pyobjc-framework-LinkPresentation==6.0.1
pyobjc-framework-LocalAuthentication==6.0.1
pyobjc-framework-MapKit==6.0.1
pyobjc-framework-MediaAccessibility==6.0.1
pyobjc-framework-MediaLibrary==6.0.1
pyobjc-framework-MediaPlayer==6.0.1
pyobjc-framework-MediaToolbox==6.0.1
pyobjc-framework-MetalKit==6.0.1
pyobjc-framework-ModelIO==6.0.1
pyobjc-framework-MultipeerConnectivity==6.0.1
pyobjc-framework-NaturalLanguage==6.0.1
pyobjc-framework-NetFS==6.0.1
pyobjc-framework-Network==6.0.1
pyobjc-framework-NetworkExtension==6.0.1
pyobjc-framework-NotificationCenter==6.0.1
pyobjc-framework-OpenDirectory==6.0.1
pyobjc-framework-OSAKit==6.0.1
pyobjc-framework-OSLog==6.0.1
pyobjc-framework-PencilKit==6.0.1
pyobjc-framework-Photos==6.0.1
pyobjc-framework-PhotosUI==6.0.1
pyobjc-framework-PreferencePanes==6.0.1
pyobjc-framework-PubSub==6.0.1
pyobjc-framework-PushKit==6.0.1
pyobjc==6.2.2
pyobjc-core==6.2.2
pyobjc-framework-Accounts==6.2.2
pyobjc-framework-AddressBook==6.2.2
pyobjc-framework-AdSupport==6.2.2
pyobjc-framework-AppleScriptKit==6.2.2
pyobjc-framework-AppleScriptObjC==6.2.2
pyobjc-framework-ApplicationServices==6.2.2
pyobjc-framework-AuthenticationServices==6.2.2
pyobjc-framework-AutomaticAssessmentConfiguration==6.2.2
pyobjc-framework-Automator==6.2.2
pyobjc-framework-AVFoundation==6.2.2
pyobjc-framework-AVKit==6.2.2
pyobjc-framework-BusinessChat==6.2.2
pyobjc-framework-CalendarStore==6.2.2
pyobjc-framework-CFNetwork==6.2.2
pyobjc-framework-CloudKit==6.2.2
pyobjc-framework-Cocoa==6.2.2
pyobjc-framework-Collaboration==6.2.2
pyobjc-framework-ColorSync==6.2.2
pyobjc-framework-Contacts==6.2.2
pyobjc-framework-ContactsUI==6.2.2
pyobjc-framework-CoreAudio==6.2.2
pyobjc-framework-CoreAudioKit==6.2.2
pyobjc-framework-CoreBluetooth==6.2.2
pyobjc-framework-CoreData==6.2.2
pyobjc-framework-CoreHaptics==6.2.2
pyobjc-framework-CoreLocation==6.2.2
pyobjc-framework-CoreMedia==6.2.2
pyobjc-framework-CoreMediaIO==6.2.2
pyobjc-framework-CoreML==6.2.2
pyobjc-framework-CoreMotion==6.2.2
pyobjc-framework-CoreServices==6.2.2
pyobjc-framework-CoreSpotlight==6.2.2
pyobjc-framework-CoreText==6.2.2
pyobjc-framework-CoreWLAN==6.2.2
pyobjc-framework-CryptoTokenKit==6.2.2
pyobjc-framework-DeviceCheck==6.2.2
pyobjc-framework-DictionaryServices==6.2.2
pyobjc-framework-DiscRecording==6.2.2
pyobjc-framework-DiscRecordingUI==6.2.2
pyobjc-framework-DiskArbitration==6.2.2
pyobjc-framework-DVDPlayback==6.2.2
pyobjc-framework-EventKit==6.2.2
pyobjc-framework-ExceptionHandling==6.2.2
pyobjc-framework-ExecutionPolicy==6.2.2
pyobjc-framework-ExternalAccessory==6.2.2
pyobjc-framework-FileProvider==6.2.2
pyobjc-framework-FileProviderUI==6.2.2
pyobjc-framework-FinderSync==6.2.2
pyobjc-framework-FSEvents==6.2.2
pyobjc-framework-GameCenter==6.2.2
pyobjc-framework-GameController==6.2.2
pyobjc-framework-GameKit==6.2.2
pyobjc-framework-GameplayKit==6.2.2
pyobjc-framework-ImageCaptureCore==6.2.2
pyobjc-framework-IMServicePlugIn==6.2.2
pyobjc-framework-InputMethodKit==6.2.2
pyobjc-framework-InstallerPlugins==6.2.2
pyobjc-framework-InstantMessage==6.2.2
pyobjc-framework-Intents==6.2.2
pyobjc-framework-IOSurface==6.2.2
pyobjc-framework-iTunesLibrary==6.2.2
pyobjc-framework-LatentSemanticMapping==6.2.2
pyobjc-framework-LaunchServices==6.2.2
pyobjc-framework-libdispatch==6.2.2
pyobjc-framework-LinkPresentation==6.2.2
pyobjc-framework-LocalAuthentication==6.2.2
pyobjc-framework-MapKit==6.2.2
pyobjc-framework-MediaAccessibility==6.2.2
pyobjc-framework-MediaLibrary==6.2.2
pyobjc-framework-MediaPlayer==6.2.2
pyobjc-framework-MediaToolbox==6.2.2
pyobjc-framework-Metal==6.2.2
pyobjc-framework-MetalKit==6.2.2
pyobjc-framework-ModelIO==6.2.2
pyobjc-framework-MultipeerConnectivity==6.2.2
pyobjc-framework-NaturalLanguage==6.2.2
pyobjc-framework-NetFS==6.2.2
pyobjc-framework-Network==6.2.2
pyobjc-framework-NetworkExtension==6.2.2
pyobjc-framework-NotificationCenter==6.2.2
pyobjc-framework-OpenDirectory==6.2.2
pyobjc-framework-OSAKit==6.2.2
pyobjc-framework-OSLog==6.2.2
pyobjc-framework-PencilKit==6.2.2
pyobjc-framework-Photos==6.2.2
pyobjc-framework-PhotosUI==6.2.2
pyobjc-framework-PreferencePanes==6.2.2
pyobjc-framework-PubSub==6.2
pyobjc-framework-PushKit==6.2.2
pyobjc-framework-QTKit==6.0.1
pyobjc-framework-Quartz==6.0.1
pyobjc-framework-QuickLookThumbnailing==6.0.1
pyobjc-framework-SafariServices==6.0.1
pyobjc-framework-SceneKit==6.0.1
pyobjc-framework-ScreenSaver==6.0.1
pyobjc-framework-ScriptingBridge==6.0.1
pyobjc-framework-SearchKit==6.0.1
pyobjc-framework-Security==6.0.1
pyobjc-framework-SecurityFoundation==6.0.1
pyobjc-framework-SecurityInterface==6.0.1
pyobjc-framework-ServiceManagement==6.0.1
pyobjc-framework-Social==6.0.1
pyobjc-framework-SoundAnalysis==6.0.1
pyobjc-framework-Speech==6.0.1
pyobjc-framework-SpriteKit==6.0.1
pyobjc-framework-StoreKit==6.0.1
pyobjc-framework-SyncServices==6.0.1
pyobjc-framework-SystemConfiguration==6.0.1
pyobjc-framework-SystemExtensions==6.0.1
pyobjc-framework-UserNotifications==6.0.1
pyobjc-framework-VideoSubscriberAccount==6.0.1
pyobjc-framework-VideoToolbox==6.0.1
pyobjc-framework-Vision==6.0.1
pyobjc-framework-WebKit==6.0.1
pyobjc-framework-Quartz==6.2.2
pyobjc-framework-QuickLookThumbnailing==6.2.2
pyobjc-framework-SafariServices==6.2.2
pyobjc-framework-SceneKit==6.2.2
pyobjc-framework-ScreenSaver==6.2.2
pyobjc-framework-ScriptingBridge==6.2.2
pyobjc-framework-SearchKit==6.2.2
pyobjc-framework-Security==6.2.2
pyobjc-framework-SecurityFoundation==6.2.2
pyobjc-framework-SecurityInterface==6.2.2
pyobjc-framework-ServiceManagement==6.2.2
pyobjc-framework-Social==6.2.2
pyobjc-framework-SoundAnalysis==6.2.2
pyobjc-framework-Speech==6.2.2
pyobjc-framework-SpriteKit==6.2.2
pyobjc-framework-StoreKit==6.2.2
pyobjc-framework-SyncServices==6.2.2
pyobjc-framework-SystemConfiguration==6.2.2
pyobjc-framework-SystemExtensions==6.2.2
pyobjc-framework-UserNotifications==6.2.2
pyobjc-framework-VideoSubscriberAccount==6.2.2
pyobjc-framework-VideoToolbox==6.2.2
pyobjc-framework-Vision==6.2.2
pyobjc-framework-WebKit==6.2.2
pyparsing==2.4.1.1
python-dateutil==2.8.1
PyYAML==5.1.2
pyzmq==18.1.1
readme-renderer==25.0
regex==2020.2.20
six==1.12.0
requests==2.23.0
requests-toolbelt==0.9.1
six==1.14.0
termcolor==1.1.0
toml==0.10.0
tornado==6.0.4
tox==3.19.0
tox-conda==0.2.1
tqdm==4.45.0
traitlets==4.3.3
twine==3.1.1
typed-ast==1.4.1
wcwidth==0.1.7
typing-extensions==3.7.4.2
urllib3==1.25.9
virtualenv==20.0.30
wcwidth==0.1.9
webencodings==0.5.1
wrapt==1.11.1
yarl==1.4.2
zipp==0.5.2

View File

@@ -48,17 +48,6 @@ with open(
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
about["long_description"] = f.read()
# ugly hack to install custom version of bpylist2 needed for Python < 3.8
# the stock version of bylist2==2.0.3 causes an error related to
# "pkg_resources.ContextualVersionConflict: (pycodestyle 2.3.1..."
# PEP 508 no help here as URL-based lookups not allowed in PyPI packages
# if you know a better way, PRs welcome!
# once I go to 3.8+ required, this won't be necessary as bpylist2 3.0+ solves this issue
if py_ver < 3.8:
os.system(
"python3 -m pip install git+git://github.com/RhetTbull/bpylist2.git#egg=bpylist2"
)
setup(
name="osxphotos",
version=about["__version__"],
@@ -78,16 +67,15 @@ setup(
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.7",
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=[
"pyobjc>=6.0.1",
"pyobjc>=6.2.2",
"Click>=7",
"PyYAML>=5.1.2",
"Mako>=1.1.1",
"bpylist2==2.0.3;python_version<'3.8'",
"bpylist2==3.0.0;python_version>='3.8'",
"bpylist2==3.0.2",
"pathvalidate==2.2.1",
"dataclasses==0.7;python_version<'3.7'",
],

View File

@@ -18,13 +18,25 @@ Some of the export tests rely on photos in my local library and will look for `O
One test for locale does not run on GitHub's automated workflow and will look for `OSXPHOTOS_TEST_LOCALE=1` to determine if it should be run. If you want to run this test, set the environment variable.
## Attribution ##
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com). All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com) and from my own photo library. All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
Images used from:
Flickr images used from:
- [Jeff Hitchcock](https://www.flickr.com/photos/arbron/48353451872/)
- [Carlos Montesdeoca](https://www.flickr.com/photos/carlosmontesdeocastudio)
- [Rydale Clothing](https://www.flickr.com/photos/rydaleclothing)
- [Marco Verch](https://www.flickr.com/photos/30478819@N08/48228222317/)
- [K M](https://www.flickr.com/photos/153387643@N08/49334338022/)
- [Shelby Mash](https://www.flickr.com/photos/shelbzyleigh/3809603052)
- [Rory MacLeod](https://www.flickr.com/photos/macrj/6969547134)
- [Md. Al Amin](https://www.flickr.com/photos/alamin_bd/45207044465)
- [Fatlum Haliti](https://www.flickr.com/photos/lumlumi/363449752)
- [Benny Mazur](https://www.flickr.com/photos/benimoto/399012465)
- [Sara Cooper PR](https://www.flickr.com/photos/saracooperpr/6422472677)
- [herval](https://www.flickr.com/photos/herval/2403994289)
- [Vox Efx](https://www.flickr.com/photos/vox_efx/141137669)
- [Bill Strain](https://www.flickr.com/photos/billstrain/5117042252)
- [Guilherme Yagui](https://www.flickr.com/photos/yagui7/15895161088/)
- [Deborah Austin](https://www.flickr.com/photos/littledebbie11/8703591799/)
- [We Are Social](https://www.flickr.com/photos/wearesocial/23309711462/)
- [cloud.shepherd](https://www.flickr.com/photos/exnucboy1/31017877125)

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-04-25T23:54:43Z</date>
<date>2020-07-27T03:16:28Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-06-27T16:03:48Z</date>
<date>2020-07-27T12:35:43Z</date>
</dict>
</plist>

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2020-04-17T17:51:16Z</date>
<date>2020-07-27T03:18:40Z</date>
</dict>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-06-27T16:03:43Z</date>
<date>2020-07-27T03:16:25Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>651</integer>
<integer>707</integer>
<key>LibraryBuildTag</key>
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
<key>LibrarySchemaVersion</key>

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>606</integer>
<integer>707</integer>
<key>LibraryBuildTag</key>
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
<key>LibrarySchemaVersion</key>
@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key>
<date>2019-07-27T13:16:43Z</date>
<key>SnapshotLastValidated</key>
<date>2020-06-27T16:03:33Z</date>
<date>2020-07-27T03:18:40Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

View File

@@ -7,7 +7,7 @@
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>763</integer>
<integer>3125</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LibrarySchemaVersion</key>
<integer>5001</integer>
<key>MetaSchemaVersion</key>
<integer>3</integer>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>hostname</key>
<string>Rhets-MacBook-Pro.local</string>
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>434</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>
<integer>501</integer>
</dict>
</plist>

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More