Compare commits

...

32 Commits

Author SHA1 Message Date
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
459 changed files with 5266 additions and 389 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,46 @@ 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

165
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,9 @@ 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.
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 +50,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 +138,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.
@@ -790,7 +791,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
@@ -806,7 +815,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
@@ -892,8 +902,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)
@@ -929,6 +938,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")
@@ -1003,6 +1014,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.
@@ -1040,6 +1054,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)).
@@ -1353,8 +1373,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"]
@@ -1528,6 +1548,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()`
@@ -1692,10 +1824,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

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.30.7"
__version__ = "0.31.2"

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}"

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

View File

@@ -34,7 +34,7 @@ def _process_exifinfo_5(photosdb):
photosdb: PhotosDB instance """
db = photosdb._tmp_db
(conn, cursor) = _open_sql_file(db)
result = conn.execute(
@@ -54,3 +54,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,328 @@
""" Methods for PhotosDB to add Photos face info
"""
import logging
from .._constants import _PHOTOS_4_VERSION
from ..utils import _open_sql_file
"""
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
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
"""
SELECT
ZDETECTEDFACE.Z_PK,
ZGENERICASSET.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 ZGENERICASSET ON ZGENERICASSET.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

@@ -143,3 +143,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):

View File

@@ -11,7 +11,7 @@ import platform
import sqlite3
import sys
import tempfile
from datetime import datetime
from datetime import datetime, timedelta, timezone
from pprint import pformat
from shutil import copyfile
@@ -34,6 +34,8 @@ from .._constants import (
)
from .._version import __version__
from ..albuminfo import AlbumInfo, FolderInfo
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo
from ..utils import (
_check_file_exists,
@@ -44,7 +46,6 @@ from ..utils import (
get_last_library_path,
)
# TODO: Add test for imageTimeZoneOffsetSeconds = None
# TODO: Add test for __str__
# TODO: Add special albums and magic albums
@@ -55,6 +56,7 @@ class PhotosDB:
# import additional methods
from ._photosdb_process_exif import _process_exifinfo
from ._photosdb_process_faceinfo import _process_faceinfo
from ._photosdb_process_searchinfo import (
_process_searchinfo,
labels,
@@ -87,6 +89,7 @@ class PhotosDB:
# set up the data structures used to store all the Photo database info
# TODO: I don't think these keywords flags are actually used
# if True, will treat persons as keywords when exporting metadata
self.use_persons_as_keywords = False
@@ -122,17 +125,28 @@ class PhotosDB:
# currently used to get information on RAW images
self._dbphotos_master = {}
# Dict with information about all persons by person PK
# key is person PK, value is dict with info about each person
# e.g. {3: {"pk": 3, "fullname": "Maria Smith"...}}
self._dbpersons_pk = {}
# Dict with information about all persons by person fullname
# key is person PK, value is list of person PKs with fullname
# there may be more than one person PK with the same fullname
# e.g. {"Maria Smith": [1, 2]}
self._dbpersons_fullname = {}
# Dict with information about all persons/photos by uuid
# key is photo UUID, value is list of face names in that photo
# key is photo UUID, value is list of person primary keys of persons in the photo
# Note: Photos 5 identifies faces even if not given a name
# and those are labeled by process_database as _UNKNOWN_
# e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': ['Katie', '_UNKNOWN_', 'Suzy']}
# e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': [1, 3, 5]}
self._dbfaces_uuid = {}
# Dict with information about all persons/photos by person
# key is person name, value is list of photo UUIDs
# e.g. {'Maria': ['E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51']}
self._dbfaces_person = {}
# Dict with information about detected faces by person primary key
# key is person pk, value is list of photo UUIDs
# e.g. {3: ['E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51']}
self._dbfaces_pk = {}
# Dict with information about all keywords/photos by uuid
# key is photo uuid and value is list of keywords
@@ -156,8 +170,8 @@ class PhotosDB:
self._dbalbums_pk = {}
# Dict with information about all albums/photos by album
# key is album UUID, value is list of photo UUIDs contained in that album
# e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': ['1EB2B765-0765-43BA-A90C-0D0580E6172C']}
# key is album UUID, value is list of tuples of (photo UUID, sort order) contained in that album
# e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': [('1EB2B765-0765-43BA-A90C-0D0580E6172C', 1024)]}
self._dbalbums_album = {}
# Dict with information about album details
@@ -303,7 +317,13 @@ class PhotosDB:
@property
def persons_as_dict(self):
""" return persons as dict of person, count in reverse sorted order (descending) """
persons = {k: len(self._dbfaces_person[k]) for k in self._dbfaces_person.keys()}
persons = {}
for pk in self._dbfaces_pk:
fullname = self._dbpersons_pk[pk]["fullname"]
try:
persons[fullname] += len(self._dbfaces_pk[pk])
except KeyError:
persons[fullname] = len(self._dbfaces_pk[pk])
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
return persons
@@ -352,9 +372,20 @@ class PhotosDB:
@property
def persons(self):
""" return list of persons found in photos database """
persons = self._dbfaces_person.keys()
persons = {self._dbpersons_pk[k]["fullname"] for k in self._dbfaces_pk}
return list(persons)
@property
def person_info(self):
""" return list of PersonInfo objects for each person in the photos database """
try:
return self._person_info
except AttributeError:
self._person_info = [
PersonInfo(db=self, pk=pk) for pk in self._dbpersons_pk
]
return self._person_info
@property
def folder_info(self):
""" return list FolderInfo objects representing top-level folders in the photos database """
@@ -536,41 +567,139 @@ class PhotosDB:
(conn, c) = _open_sql_file(self._tmp_db)
# Look for all combinations of persons and pictures
# get info to associate persons with photos
# then get detected faces in each photo and link to persons
c.execute(
""" select RKPerson.name, RKVersion.uuid from RKFace, RKPerson, RKVersion, RKMaster
where RKFace.personID = RKperson.modelID and RKVersion.modelId = RKFace.ImageModelId
and RKVersion.masterUuid = RKMaster.uuid
""" SELECT
RKPerson.modelID,
RKPerson.uuid,
RKPerson.name,
RKPerson.faceCount,
RKPerson.displayName,
RKPerson.representativeFaceId
FROM RKPerson
"""
)
# 0 RKPerson.modelID,
# 1 RKPerson.uuid,
# 2 RKPerson.name,
# 3 RKPerson.faceCount,
# 4 RKPerson.displayName
# 5 RKPerson.representativeFaceId
for person in c:
if person[0] is None:
continue
if not person[1] in self._dbfaces_uuid:
self._dbfaces_uuid[person[1]] = []
if not person[0] in self._dbfaces_person:
self._dbfaces_person[person[0]] = []
self._dbfaces_uuid[person[1]].append(person[0])
self._dbfaces_person[person[0]].append(person[1])
pk = person[0]
fullname = person[2] if person[2] is not None else _UNKNOWN_PERSON
self._dbpersons_pk[pk] = {
"pk": pk,
"uuid": person[1],
"fullname": fullname,
"facecount": person[3],
"keyface": person[5],
"displayname": person[4],
"photo_uuid": None,
"keyface_uuid": None,
}
try:
self._dbpersons_fullname[fullname].append(pk)
except KeyError:
self._dbpersons_fullname[fullname] = [pk]
# get info on key face
c.execute(
""" SELECT
RKPerson.modelID,
RKPerson.representativeFaceId,
RKVersion.uuid,
RKFace.uuid
FROM RKPerson, RKFace, RKVersion
WHERE
RKFace.modelId = RKPerson.representativeFaceId AND
RKVersion.modelId = RKFace.ImageModelId
"""
)
# 0 RKPerson.modelID,
# 1 RKPerson.representativeFaceId
# 2 RKVersion.uuid,
# 3 RKFace.uuid
for person in c:
pk = person[0]
try:
self._dbpersons_pk[pk]["photo_uuid"] = person[2]
self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
except KeyError:
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
# get information on detected faces
c.execute(
""" SELECT
RKPerson.modelID,
RKVersion.uuid
FROM
RKFace, RKPerson, RKVersion, RKMaster
WHERE
RKFace.personID = RKperson.modelID AND
RKVersion.modelId = RKFace.ImageModelId AND
RKVersion.masterUuid = RKMaster.uuid
"""
)
# 0 RKPerson.modelID
# 1 RKVersion.uuid
for face in c:
pk = face[0]
uuid = face[1]
try:
self._dbfaces_uuid[uuid].append(pk)
except KeyError:
self._dbfaces_uuid[uuid] = [pk]
try:
self._dbfaces_pk[pk].append(uuid)
except KeyError:
self._dbfaces_pk[pk] = [uuid]
if _debug():
logging.debug(f"Finished walking through persons")
logging.debug(pformat(self._dbpersons_pk))
logging.debug(pformat(self._dbpersons_fullname))
logging.debug(pformat(self._dbfaces_pk))
logging.debug(pformat(self._dbfaces_uuid))
# Get info on albums
c.execute(
""" select
""" SELECT
RKAlbum.uuid,
RKVersion.uuid
from RKAlbum, RKVersion, RKAlbumVersion
where RKAlbum.modelID = RKAlbumVersion.albumId and
RKAlbumVersion.versionID = RKVersion.modelId
RKVersion.uuid,
RKCustomSortOrder.orderNumber
FROM RKVersion
JOIN RKCustomSortOrder on RKCustomSortOrder.objectUuid = RKVersion.uuid
JOIN RKAlbum on RKAlbum.uuid = RKCustomSortOrder.containerUuid
"""
)
# 0 RKAlbum.uuid,
# 1 RKVersion.uuid,
# 2 RKCustomSortOrder.orderNumber
for album in c:
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
if not album[1] in self._dbalbums_uuid:
self._dbalbums_uuid[album[1]] = []
if not album[0] in self._dbalbums_album:
self._dbalbums_album[album[0]] = []
self._dbalbums_uuid[album[1]].append(album[0])
self._dbalbums_album[album[0]].append(album[1])
album_uuid = album[0]
photo_uuid = album[1]
sort_order = album[2]
try:
self._dbalbums_uuid[photo_uuid].append(album_uuid)
except KeyError:
self._dbalbums_uuid[photo_uuid] = [album_uuid]
try:
self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
except KeyError:
self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]
# now get additional details about albums
c.execute(
@@ -824,15 +953,23 @@ class PhotosDB:
except TypeError:
self._dbphotos[uuid]["lastmodifieddate"] = None
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
try:
self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp(row[5] + td)
imagedate = datetime.fromtimestamp(row[5] + td)
seconds = self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
self._dbphotos[uuid]["imageDate"] = imagedate.astimezone(tz=tz)
except ValueError:
self._dbphotos[uuid]["imageDate"] = datetime(1970, 1, 1)
# sometimes imageDate is invalid so use 1 Jan 1970 in UTC as image date
imagedate = datetime(1970, 1, 1)
tz = timezone(timedelta(0))
self._dbphotos[uuid]["imageDate"] = imagedate.astimezone(tz=tz)
self._dbphotos[uuid]["mainRating"] = row[6]
self._dbphotos[uuid]["hasAdjustments"] = row[7]
self._dbphotos[uuid]["hasKeywords"] = row[8]
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
self._dbphotos[uuid]["volumeId"] = row[10]
self._dbphotos[uuid]["imagePath"] = row[11]
self._dbphotos[uuid]["extendedDescription"] = row[12]
@@ -1199,6 +1336,9 @@ class PhotosDB:
# done with the database connection
conn.close()
# process faces
self._process_faceinfo()
# add faces and keywords to photo data
for uuid in self._dbphotos:
# keywords
@@ -1233,8 +1373,8 @@ class PhotosDB:
logging.debug("Faces (_dbfaces_uuid):")
logging.debug(pformat(self._dbfaces_uuid))
logging.debug("Faces by person (_dbfaces_person):")
logging.debug(pformat(self._dbfaces_person))
logging.debug("Persons (_dbpersons_pk):")
logging.debug(pformat(self._dbpersons_pk))
logging.debug("Keywords by uuid (_dbkeywords_uuid):")
logging.debug(pformat(self._dbkeywords_uuid))
@@ -1295,8 +1435,12 @@ class PhotosDB:
return folders
def _process_database5(self):
""" process the Photos database to extract info """
""" works on Photos version >= 5.0 """
""" process the Photos database to extract info
works on Photos version >= 5.0
This is a big hairy 700 line function that should probably be refactored
but it works so don't touch it.
"""
if _debug():
logging.debug(f"_process_database5")
@@ -1310,45 +1454,136 @@ class PhotosDB:
if _debug():
logging.debug(f"Getting information about persons")
# get info to associate persons with photos
# then get detected faces in each photo and link to persons
c.execute(
"SELECT ZPERSON.ZFULLNAME, ZGENERICASSET.ZUUID "
"FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET "
"WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK "
""" SELECT
ZPERSON.Z_PK,
ZPERSON.ZPERSONUUID,
ZPERSON.ZFULLNAME,
ZPERSON.ZFACECOUNT,
ZPERSON.ZKEYFACE,
ZPERSON.ZDISPLAYNAME
FROM ZPERSON
"""
)
# 0 ZPERSON.Z_PK,
# 1 ZPERSON.ZPERSONUUID,
# 2 ZPERSON.ZFULLNAME,
# 3 ZPERSON.ZFACECOUNT,
# 4 ZPERSON.ZKEYFACE,
# 5 ZPERSON.ZDISPLAYNAME
for person in c:
if person[0] is None:
continue
person_name = person[0] if person[0] != "" else _UNKNOWN_PERSON
if not person[1] in self._dbfaces_uuid:
self._dbfaces_uuid[person[1]] = []
if not person_name in self._dbfaces_person:
self._dbfaces_person[person_name] = []
self._dbfaces_uuid[person[1]].append(person_name)
self._dbfaces_person[person_name].append(person[1])
pk = person[0]
fullname = person[2] if person[2] != "" else _UNKNOWN_PERSON
self._dbpersons_pk[pk] = {
"pk": pk,
"uuid": person[1],
"fullname": fullname,
"facecount": person[3],
"keyface": person[4],
"displayname": person[5],
"photo_uuid": None,
"keyface_uuid": None,
}
try:
self._dbpersons_fullname[fullname].append(pk)
except KeyError:
self._dbpersons_fullname[fullname] = [pk]
# get info on keyface -- some photos have null keyface so can't do a single query
# (at least not with my SQL skills)
c.execute(
""" SELECT
ZPERSON.Z_PK,
ZPERSON.ZKEYFACE,
ZGENERICASSET.ZUUID,
ZDETECTEDFACE.ZUUID
FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET
WHERE ZDETECTEDFACE.Z_PK = ZPERSON.ZKEYFACE AND
ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK
"""
)
# 0 ZPERSON.Z_PK,
# 1 ZPERSON.ZKEYFACE,
# 2 ZGENERICASSET.ZUUID,
# 3 ZDETECTEDFACE.ZUUID
for person in c:
pk = person[0]
try:
self._dbpersons_pk[pk]["photo_uuid"] = person[2]
self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
except KeyError:
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
# get information on detected faces
c.execute(
""" SELECT
ZPERSON.Z_PK,
ZGENERICASSET.ZUUID
FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET
WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND
ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK
"""
)
# 0 ZPERSON.Z_PK,
# 1 ZGENERICASSET.ZUUID,
for face in c:
pk = face[0]
uuid = face[1]
try:
self._dbfaces_uuid[uuid].append(pk)
except KeyError:
self._dbfaces_uuid[uuid] = [pk]
try:
self._dbfaces_pk[pk].append(uuid)
except KeyError:
self._dbfaces_pk[pk] = [uuid]
if _debug():
logging.debug(f"Finished walking through persons")
logging.debug(pformat(self._dbfaces_person))
logging.debug(self._dbfaces_uuid)
logging.debug(pformat(self._dbpersons_pk))
logging.debug(pformat(self._dbpersons_fullname))
logging.debug(pformat(self._dbfaces_pk))
logging.debug(pformat(self._dbfaces_uuid))
# get details about albums
c.execute(
"SELECT ZGENERICALBUM.ZUUID, ZGENERICASSET.ZUUID "
"FROM ZGENERICASSET "
"JOIN Z_26ASSETS ON Z_26ASSETS.Z_34ASSETS = ZGENERICASSET.Z_PK "
"JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS "
""" SELECT
ZGENERICALBUM.ZUUID,
ZGENERICASSET.ZUUID,
Z_26ASSETS.Z_FOK_34ASSETS
FROM ZGENERICASSET
JOIN Z_26ASSETS ON Z_26ASSETS.Z_34ASSETS = ZGENERICASSET.Z_PK
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS
"""
)
# 0 ZGENERICALBUM.ZUUID,
# 1 ZGENERICASSET.ZUUID,
# 2 Z_26ASSETS.Z_FOK_34ASSETS
for album in c:
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
album_uuid = album[0]
photo_uuid = album[1]
sort_order = album[2]
try:
self._dbalbums_uuid[album[1]].append(album[0])
self._dbalbums_uuid[photo_uuid].append(album_uuid)
except KeyError:
self._dbalbums_uuid[album[1]] = [album[0]]
self._dbalbums_uuid[photo_uuid] = [album_uuid]
try:
self._dbalbums_album[album[0]].append(album[1])
self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
except KeyError:
self._dbalbums_album[album[0]] = [album[1]]
self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]
# now get additional details about albums
c.execute(
@@ -1483,15 +1718,15 @@ class PhotosDB:
ZGENERICASSET.ZCLOUDBATCHPUBLISHDATE,
ZGENERICASSET.ZKIND,
ZGENERICASSET.ZUNIFORMTYPEIDENTIFIER,
ZGENERICASSET.ZAVALANCHEUUID,
ZGENERICASSET.ZAVALANCHEPICKTYPE,
ZGENERICASSET.ZAVALANCHEUUID,
ZGENERICASSET.ZAVALANCHEPICKTYPE,
ZGENERICASSET.ZKINDSUBTYPE,
ZGENERICASSET.ZCUSTOMRENDEREDVALUE,
ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE,
ZGENERICASSET.ZCLOUDASSETGUID,
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
ZGENERICASSET.ZMOMENT,
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
ZGENERICASSET.ZTRASHEDSTATE,
ZGENERICASSET.ZHEIGHT,
ZGENERICASSET.ZWIDTH,
@@ -1562,12 +1797,20 @@ class PhotosDB:
except TypeError:
info["lastmodifieddate"] = None
try:
info["imageDate"] = datetime.fromtimestamp(row[5] + td)
except ValueError:
info["imageDate"] = datetime(1970, 1, 1)
info["imageTimeZoneOffsetSeconds"] = row[6]
try:
imagedate = datetime.fromtimestamp(row[5] + td)
seconds = info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
info["imageDate"] = imagedate.astimezone(tz=tz)
except ValueError:
# sometimes imageDate is invalid so use 1 Jan 1970 in UTC as image date
imagedate = datetime(1970, 1, 1)
tz = timezone(timedelta(0))
info["imageDate"] = imagedate.astimezone(tz=tz)
info["hidden"] = row[9]
info["favorite"] = row[10]
info["originalFilename"] = row[3]
@@ -1907,6 +2150,9 @@ class PhotosDB:
# close connection and remove temporary files
conn.close()
# process face info
self._process_faceinfo()
# process search info
self._process_searchinfo()
@@ -1921,8 +2167,8 @@ class PhotosDB:
logging.debug("Faces (_dbfaces_uuid):")
logging.debug(pformat(self._dbfaces_uuid))
logging.debug("Faces by person (_dbfaces_person):")
logging.debug(pformat(self._dbfaces_person))
logging.debug("Persons (_dbpersons_pk):")
logging.debug(pformat(self._dbpersons_pk))
logging.debug("Keywords by uuid (_dbkeywords_uuid):")
logging.debug(pformat(self._dbkeywords_uuid))
@@ -2207,23 +2453,29 @@ class PhotosDB:
to_date=None,
intrash=False,
):
"""
Return a list of PhotoInfo objects
""" Return a list of PhotoInfo objects
If called with no args, returns the entire database of photos
If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
If more than one keyword, uuid, persons, albums is passed, they are treated as "OR" criteria
e.g. keywords=["wedding","vacation"] returns photos matching either keyword
keywords: list of keywords to search for
uuid: list of UUIDs to search for
persons: list of persons to search for
albums: list of album names to search for
images: if True, returns image files, if False, does not return images; default is True
movies: if True, returns movie files, if False, does not return movies; default is True
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
intrash: if True, returns only images in "Recently deleted items" folder,
if False returns only photos that aren't deleted; default is False
from_date and to_date may be either naive or timezone-aware datetime.datetime objects.
If naive, timezone will be assumed to be local timezone.
Args:
keywords: list of keywords to search for
uuid: list of UUIDs to search for
persons: list of persons to search for
albums: list of album names to search for
images: if True, returns image files, if False, does not return images; default is True
movies: if True, returns movie files, if False, does not return movies; default is True
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
intrash: if True, returns only images in "Recently deleted items" folder,
if False returns only photos that aren't deleted; default is False
Returns:
list of PhotoInfo objects
"""
# implementation is a bit kludgy but it works
@@ -2253,7 +2505,9 @@ class PhotosDB:
title_set = set()
for album_id in self._dbalbum_titles[album]:
try:
title_set.update(self._dbalbums_album[album_id])
# _dbalbums_album value is list of tuples: [(uuid, sort order)]
uuid_in_album, _ = zip(*self._dbalbums_album[album_id])
title_set.update(uuid_in_album)
except KeyError:
# an empty album will be in _dbalbum_titles but not _dbalbums_album
pass
@@ -2283,8 +2537,13 @@ class PhotosDB:
if persons:
person_set = set()
for person in persons:
if person in self._dbfaces_person:
person_set.update(self._dbfaces_person[person])
if person in self._dbpersons_fullname:
for pk in self._dbpersons_fullname[person]:
try:
person_set.update(self._dbfaces_pk[pk])
except KeyError:
# some persons have zero photos so they won't be in _dbfaces_pk
pass
else:
logging.debug(f"Could not find person '{person}' in database")
photos_sets.append(person_set)
@@ -2292,6 +2551,8 @@ class PhotosDB:
if from_date or to_date: # sourcery off
dsel = self._dbphotos
if from_date:
if not datetime_has_tz(from_date):
from_date = datetime_naive_to_local(from_date)
dsel = {
k: v for k, v in dsel.items() if v["imageDate"] >= from_date
}
@@ -2299,6 +2560,8 @@ class PhotosDB:
f"Found %i items with from_date {from_date}" % len(dsel)
)
if to_date:
if not datetime_has_tz(to_date):
to_date = datetime_naive_to_local(to_date)
dsel = {k: v for k, v in dsel.items() if v["imageDate"] <= to_date}
logging.debug(f"Found %i items with to_date {to_date}" % len(dsel))
photos_sets.append(set(dsel.keys()))
@@ -2323,6 +2586,42 @@ class PhotosDB:
return photoinfo
def get_photo(self, uuid):
""" Returns a single photo matching uuid
Arguments:
uuid: the UUID of photo to get
Returns:
PhotoInfo instance for photo with UUID matching uuid or None if no match
"""
try:
return PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid])
except KeyError:
return None
# TODO: add to docs and test
def photos_by_uuid(self, uuids):
""" Returns a list of photos with UUID in uuids.
Does not generate error if invalid or missing UUID passed.
This is faster than using PhotosDB.photos if you have list of UUIDs.
Returns photos regardless of intrash state.
Arguments:
uuid: list of UUIDs of photos to get
Returns:
list of PhotoInfo instance for photo with UUID matching uuid or [] if no match
"""
photos = []
for uuid in uuids:
try:
photos.append(PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid]))
except KeyError:
# ignore missing/invlaid UUID
pass
return photos
def __repr__(self):
return f"osxphotos.{self.__class__.__name__}(dbfile='{self.db_path}')"

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>1613</integer>
<integer>3125</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

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