Compare commits

..

71 Commits

Author SHA1 Message Date
Rhet Turnbull
8682d56050 Test database updates 2019-12-24 08:58:57 -08:00
Rhet Turnbull
ce50b4f893 Fix to export logic for missing photos 2019-12-24 08:57:59 -08:00
Rhet Turnbull
ff260dc072 Added check for missing file in export_photo 2019-12-24 08:33:54 -08:00
Rhet Turnbull
a81c19a01a Updated README.md 2019-12-24 08:18:27 -08:00
Rhet Turnbull
b2a1c53d04 ran black 2019-12-22 20:45:58 -08:00
Rhet Turnbull
40167d7a67 Added --original-name to export 2019-12-22 19:50:31 -08:00
Rhet Turnbull
57485247fc Bumped version number 2019-12-22 13:02:30 -08:00
Rhet Turnbull
57f6a282d6 Added --export-edited to export 2019-12-22 13:00:03 -08:00
Rhet Turnbull
048e80599e Updated README 2019-12-22 10:47:29 -08:00
Rhet Turnbull
d73f6651f9 Test database updates 2019-12-22 10:45:23 -08:00
Rhet Turnbull
2519104928 Initial version of export added to command line 2019-12-22 10:43:45 -08:00
Rhet Turnbull
8a00318399 Updated README 2019-12-22 08:46:05 -08:00
Rhet Turnbull
9cdeeb389c Fixed bug in query related to refactoring 2019-12-22 08:35:47 -08:00
Rhet Turnbull
2a5f0a2299 Added --json to dump command 2019-12-22 08:15:21 -08:00
Rhet Turnbull
8ee8a38f0f fixed bug related to db_path properties re-factoring 2019-12-21 22:53:22 -08:00
Rhet Turnbull
2906773ba1 Updated README for sidecar usage 2019-12-21 22:18:15 -08:00
Rhet Turnbull
f643e79afd Added sidecar option to PhotoInfo.export() 2019-12-21 22:10:38 -08:00
Rhet Turnbull
e5c50fa944 Removed python3.8 -- pyobjc fails to run 2019-12-21 11:00:17 -08:00
Rhet Turnbull
9fcc7379e6 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-12-21 10:09:24 -08:00
Rhet Turnbull
d95acdf9f8 Moved PhotosDB attributes to properties instead of methods 2019-12-21 10:08:49 -08:00
Rhet Turnbull
1ddd90cbdc Refactored PhotoInfo to use properties instead of methods--major update 2019-12-21 09:38:54 -08:00
Rhet Turnbull
4f449087a6 Added python3.8 to workflow 2019-12-21 08:32:34 -08:00
Rhet Turnbull
2dc7bccfb7 Updated README 2019-12-21 08:30:39 -08:00
Rhet Turnbull
190adea3fc Renamed cmd_line so python3 -m osxphotos will work 2019-12-21 08:19:32 -08:00
Rhet Turnbull
4ac9c1a7a8 Updated doc strings 2019-12-21 08:12:34 -08:00
Rhet Turnbull
b794e226e3 Restructured entire code base to make it easier to maintain. Closes #16 2019-12-21 08:06:25 -08:00
Rhet Turnbull
cd51782ef2 test db update 2019-12-21 08:03:44 -08:00
Rhet Turnbull
18395933a5 removed old applescript code and files 2019-12-21 06:59:02 -08:00
Rhet Turnbull
591db8b5a6 Updated sidecar tests 2019-12-16 21:55:59 -08:00
Rhet Turnbull
eb7ec9b5c6 added alpha version of exiftool_json_sidecar to export() 2019-12-15 21:12:25 -08:00
Rhet Turnbull
1fe885962e changed interface for export, prepped for exiftool_json_sidecar 2019-12-15 19:21:04 -08:00
Rhet Turnbull
b35e9d73ab Updated TOC in README 2019-12-14 12:39:17 -08:00
Rhet Turnbull
c7b2b233e9 Added TOC to README; closes #24 2019-12-14 12:33:59 -08:00
Rhet Turnbull
bea1683b94 Updated exception handling in PhotosDB.__init__() 2019-12-14 10:55:11 -08:00
Rhet Turnbull
bf8aed69cf Updated export example 2019-12-14 10:35:39 -08:00
Rhet Turnbull
800daf3658 Added PhotoInfo.export(); closes #10 2019-12-14 10:29:06 -08:00
Rhet Turnbull
d5a5bd41b3 refactored private vars in PhotoInfo 2019-12-09 21:45:50 -08:00
Rhet Turnbull
911804317b Updated PhotosDB.__init__() to accept positional or named arg for dbfile and added associated tests 2019-12-08 17:20:51 -08:00
Rhet Turnbull
aaba5cabf3 Added list option to cmd_line. Closes #14 2019-12-08 09:14:48 -08:00
Rhet Turnbull
7fef67f852 list_photo_libraries now searches entire disk 2019-12-08 00:38:58 -08:00
Rhet Turnbull
62fedc7fbf added list_photo_libraries 2019-12-08 00:24:34 -08:00
Rhet Turnbull
1d006a4b50 Added get_db_path and get_library_path to PhotosDB 2019-12-07 23:54:55 -08:00
Rhet Turnbull
2cedaedebb Added get_system_library_path 2019-12-07 23:10:36 -08:00
Rhet Turnbull
22d747ebab updated test library 2019-12-07 22:38:43 -08:00
Rhet Turnbull
c1c7b0092d removed TODO 2019-12-07 21:04:43 -08:00
Rhet Turnbull
0220b0eaff refactored code for unknown persons in Photos 5 2019-12-07 21:03:48 -08:00
Rhet Turnbull
d22affebd7 Updated test for unknown persons 2019-12-07 20:58:14 -08:00
Rhet Turnbull
da320e4f56 Handle blank persons in Photos 5 2019-12-07 20:57:23 -08:00
Rhet Turnbull
591336673a Fixed cmd_line so it doesn't create PhotosDB object unless needed 2019-12-07 20:44:15 -08:00
Rhet Turnbull
906b6c0911 added edited and external_edit to cmd_line and __str__, to_json; closes #12 2019-12-07 20:32:23 -08:00
Rhet Turnbull
f21c7c8b08 Removed applescript to close Photos 2019-12-07 19:35:42 -08:00
Rhet Turnbull
1ea83e6c8e updated test library 2019-12-07 19:22:07 -08:00
Rhet Turnbull
45da323840 README update 2019-12-07 16:20:23 -08:00
Rhet Turnbull
def6f8cdfe added --version to cmd_line 2019-12-07 15:56:46 -08:00
Rhet Turnbull
6e45cf9591 added _version.py 2019-12-07 15:34:49 -08:00
Rhet Turnbull
7fb0bad6be Added test for duplicate albums on 10.14.6 2019-12-07 15:10:34 -08:00
Rhet Turnbull
811946018d Fixed warning message for duplicate edit_resource_id 2019-12-07 13:13:34 -08:00
Rhet Turnbull
2a0f27ca57 Supports duplicate album names (treated as single album) 2019-12-07 12:51:39 -08:00
Rhet Turnbull
ff4066c49c version bump 2019-12-07 10:36:23 -08:00
Rhet Turnbull
1cf3e4b954 Updated album code in process_database4 and process_database5 to use album uuid 2019-12-07 10:29:09 -08:00
Rhet Turnbull
0219a9b4da Updated doc strings 2019-12-07 09:08:28 -08:00
Rhet Turnbull
b3c798033c Cleaned up logic in cmd_line query(). Closes #17 2019-12-07 07:21:23 -08:00
Rhet Turnbull
9777e27e3a Added external_edit() to README 2019-12-01 08:14:25 -08:00
Rhet Turnbull
3a1ca343a6 Added external edit for Photos 4 2019-12-01 08:01:09 -08:00
Rhet Turnbull
42baa29c18 Added external_edit for Photos 5 2019-12-01 07:36:16 -08:00
Rhet Turnbull
6a2be3e7d9 Fixed typo in README 2019-11-30 23:14:51 -08:00
Rhet Turnbull
9c32d77b2c Added hidden --debug option to cmd_line 2019-11-30 23:08:49 -08:00
Rhet Turnbull
eb563ad297 Updated get_db_version and associated tests 2019-11-30 21:39:58 -08:00
Rhet Turnbull
d056b6f8c0 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-11-30 18:32:10 -08:00
Rhet Turnbull
5a1176ed86 README update 2019-11-30 18:31:45 -08:00
Rhet Turnbull
e77b8e8a0a README update 2019-11-30 12:22:39 -08:00
107 changed files with 4573 additions and 4331 deletions

472
README.md
View File

@@ -3,17 +3,70 @@
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
- [OSXPhotos](#osxphotos)
* [What is osxphotos?](#what-is-osxphotos)
* [Supported operating systems](#supported-operating-systems)
* [Installation instructions](#installation-instructions)
* [Command Line Usage](#command-line-usage)
* [Example uses of the module](#example-uses-of-the-module)
* [Module Interface](#module-interface)
+ [PhotosDB](#photosdb)
- [Open the default Photos library](#open-the-default-photos-library)
- [Open System Photos library](#open-system-photos-library)
- [Open a specific Photos library](#open-a-specific-photos-library)
- [```keywords```](#keywords)
- [```albums```](#albums)
- [```persons```](#persons)
- [```keywords_as_dict```](#keywords_as_dict)
- [```persons_as_dict```](#persons_as_dict)
- [```albums_as_dict```](#albums_as_dict)
- [```library_path```](#library_path)
- [```db_path```](#db_path)
- [```db_version```](#db_version)
- [`photos(keywords=[], uuid=[], persons=[], albums=[])`](#photoskeywords-uuid-persons-albums)
+ [PhotoInfo](#photoinfo)
- [`uuid`](#uuid)
- [`filename`](#filename)
- [`original_filename`](#original_filename)
- [`date`](#date)
- [`description`](#description)
- [`title`](#title)
- [`keywords`](#keywords)
- [`albums`](#albums)
- [`persons`](#persons)
- [`path`](#path)
- [`path_edited`](#path_edited)
- [`ismissing`](#ismissing)
- [`hasadjustments`](#hasadjustments)
- [`external_edit`](#external_edit)
- [`favorite`](#favorite)
- [`hidden`](#hidden)
- [`location`](#location)
- [`json()`](#json)
- [`export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue-sidecarfalse)
+ [Utility Functions](#utility-functions)
- [```get_system_library_path()```](#get_system_library_path)
- [```get_last_library_path()```](#get_last_library_path)
- [```list_photo_libraries()```](#list_photo_libraries)
- [```dd_to_dms_str(lat, lon)```](#dd_to_dms_strlat-lon)
- [```create_path_by_date(dest, dt)```](#create_path_by_datedest-dt)
+ [Examples](#examples)
* [Related Projects](#related-projects)
* [Contributing](#contributing)
* [Implementation Notes](#implementation-notes)
* [Dependencies](#dependencies)
* [Acknowledgements](#acknowledgements)
## What is osxphotos?
OSXPhotos provides the ability to interact with and query Apple's Photos app library database on MacOS. Using this module you can query the Photos database for information about the photos stored in a Photos library--for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc.
OSXPhotos provides the ability to interact with and query Apple's Photos.app library database on MacOS. Using this module you can query the Photos database for information about the photos stored in a Photos library on your Mac--for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
NOTE: OSXPhotos currently only supports image files -- e.g. it does not handle movies.
**NOTE**: OSXPhotos currently only supports image files -- e.g. it does not handle movies.
## 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 and MacOS 10.14.5, 10.14.6 / Photos 4.0. Requires python >= 3.6
NOTE: Alpha support for Mac OS 10.15.0 / Photos 5.0. Photos 5.0 uses a new database format which required rewrite of much of the code for this module. If you find bugs, please open an [issue](https://github.com/RhetTbull/osxphotos/issues/).
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 / Photos 5.0. Requires python >= 3.6
This module will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12
@@ -22,33 +75,39 @@ This module will read Photos databases for any supported version on any supporte
osxmetadata uses setuptools, thus simply run:
python setup.py install
python3 setup.py install
## Command Line Usage
This module will install a command line utility called `osxphotos` that allows you to query the Photos database.
This module will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
If you only care about the command line tool, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
After installing pipx:
`pipx install osxphotos`
Then you should be able to run `osxphotos` on the command line:
```
> osxphotos
Usage: osxphotos [OPTIONS] COMMAND [ARGS]...
Options:
--db <Photos database path> Specify database file
--json Print output in JSON format
--db <Photos database path> Specify database file.
--json Print output in JSON format.
-v, --version Show the version and exit.
-h, --help Show this message and exit.
Commands:
albums print out albums found in the Photos library
dump print list of all photos & associated info from the Photos...
help print help; for help on commands: help <command>
info print out descriptive info of the Photos library database
keywords print out keywords found in the Photos library
persons print out persons (faces) found in the Photos library
query query the Photos database using 1 or more search options
albums Print out albums found in the Photos library.
dump Print list of all photos & associated info from the Photos...
export Export photos from the Photos database.
help Print help; for help on commands: help <command>.
info Print out descriptive info of the Photos library database.
keywords Print out keywords found in the Photos library.
list Print list of Photos libraries found on the system.
persons Print out persons (faces) found in the Photos library.
query Query the Photos database using 1 or more search options; if...
```
To get help on a specific command, use `osxphotos help <command_name>`
@@ -58,28 +117,29 @@ Example: `osxphotos help query`
```
Usage: osxphotos help [OPTIONS]
Query the Photos database using 1 or more search options
If more than one option is provided, they are treated as "AND" (e.g.
search for photos matching all options)
Query the Photos database using 1 or more search options; if more than
one option is provided, they are treated as "AND" (e.g. search for photos
matching all options).
Options:
--keyword TEXT search for keyword(s)
--person TEXT search for person(s)
--album TEXT search for album(s)
--uuid TEXT search for UUID(s)
--name TEXT search for TEXT in name of photo
--no-name search for photos with no name
--description TEXT search for TEXT in description of photo
--no-description search for photos with no description
-i, --ignore-case case insensitive search for name or description. Does
not apply to keyword, person, or album
--favorite search for photos marked favorite
--not-favorite search for photos not marked favorite
--hidden search for photos marked hidden
--not-hidden search for photos not marked hidden
--missing search for photos missing from disk
--not-missing search for photos present on disk (e.g. not missing)
--keyword TEXT Search for keyword(s).
--person TEXT Search for person(s).
--album TEXT Search for album(s).
--uuid TEXT Search for UUID(s).
--title TEXT Search for TEXT in title of photo.
--no-title Search for photos with no title.
--description TEXT Search for TEXT in description of photo.
--no-description Search for photos with no description.
-i, --ignore-case Case insensitive search for title or description. Does
not apply to keyword, person, or album.
--edited Search for photos that have been edited.
--external-edit Search for photos edited in external editor.
--favorite Search for photos marked favorite.
--not-favorite Search for photos not marked favorite.
--hidden Search for photos marked hidden.
--not-hidden Search for photos not marked hidden.
--missing Search for photos missing from disk.
--not-missing Search for photos present on disk (e.g. not missing).
--json Print output in JSON format
-h, --help Show this message and exit.
```
@@ -95,13 +155,13 @@ import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
print(photosdb.keywords())
print(photosdb.persons())
print(photosdb.albums())
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.albums)
print(photosdb.keywords_as_dict())
print(photosdb.persons_as_dict())
print(photosdb.albums_as_dict())
print(photosdb.keywords_as_dict)
print(photosdb.persons_as_dict)
print(photosdb.albums_as_dict)
# find all photos with Keyword = Foo and containing John Smith
photos = photosdb.photos(keywords=["Foo"],persons=["John Smith"])
@@ -112,21 +172,52 @@ def main():
for p in photos:
print(
p.uuid,
p.filename(),
p.original_filename(),
p.date(),
p.description(),
p.name(),
p.keywords(),
p.albums(),
p.persons(),
p.path(),
p.filename,
p.original_filename,
p.date,
p.description,
p.title,
p.keywords,
p.albums,
p.persons,
p.path,
)
if __name__ == "__main__":
main()
```
```python
""" Export all photos to ~/Desktop/export
If file has been edited, export the edited version,
otherwise, export the original version """
import os.path
import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
export_path = os.path.expanduser("~/Desktop/export")
for p in photos:
if not p.ismissing:
if p.hasadjustments:
exported = p.export(export_path, edited=True)
else:
exported = p.export(export_path)
print(f"Exported {p.filename} to {exported}")
else:
print(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
main()
```
## Module Interface
### PhotosDB
@@ -134,12 +225,18 @@ if __name__ == "__main__":
#### Open the default Photos library
```python
osxphotos.PhotosDB([dbfile="path to database file"])
osxphotos.PhotosDB()
osxphotos.PhotosDB(path)
osxphotos.PhotosDB(dbfile=path)
```
Opens the Photos library database and returns a PhotosDB object. Optionally, pass the path to a specific database file. If `dbfile` is not included, will open the default (last opened) Photos database.
Opens the Photos library database and returns a PhotosDB object.
Note: this will open the last library that was opened in Photos. This is not necessarily the System Photos Library. If you have more than one Photos library, you can select which to open by holding down Option key while opening Photos.
Optionally, pass the path to a specific database file or a Photos library (e.g. "/Users/smith/Pictures/Photos Library.photoslibrary" or "/Users/smith/Pictures/Photos Library.photoslibrary/database/photos.db"). Path to photos library may be passed **either** as first argument **or** as named argument `dbfile`. If path is not passed, PhotosDB will attempt to open the default Photos library (that is, the last library that was opened in Photos.app which may or may not also be the System Photos Library). **Note**: Users may specify a different library to open by holding down the *option* key while opening Photos.app.
If an invalid path is passed, PhotosDB will raise `ValueError` exception.
Open the default (last opened) Photos library. (E.g. this is the library that would open if the user opened Photos.app)
```python
import osxphotos
@@ -147,7 +244,25 @@ import osxphotos
photosdb = osxphotos.PhotosDB()
```
Returns a PhotosDB object.
#### Open System Photos library
In Photos 5 (Catalina / MacOS 10.15), you can use `get_system_library_path()` to get the path to the System photo library if you want to ensure PhotosDB opens the system library. This does not work on older versions of MacOS. E.g.
```python
import osxphotos
path = osxphotos.get_system_library_path()
photosdb = osxphotos.PhotosDB(path)
```
also,
```python
import osxphotos
path = osxphotos.get_system_library_path()
photosdb = osxphotos.PhotosDB(dbfile=path)
```
#### Open a specific Photos library
```python
@@ -156,12 +271,22 @@ import osxphotos
photosdb = osxphotos.PhotosDB(dbfile="/Users/smith/Pictures/Test.photoslibrary/database/photos.db")
```
Pass the fully qualified path to the specific Photos database you want to open. The database is called photos.db and resides in the database folder in your Photos library
or
```python
import osxphotos
photosdb = osxphotos.PhotosDB("/Users/smith/Pictures/Test.photoslibrary")
```
Pass the fully qualified path to the Photos library or the actual database file inside the library. The database is called photos.db and resides in the database folder in your Photos library. If you pass only the path to the library, PhotosDB will add the database path automatically. The option to pass the actual database path is provided so database files can be queried even if separated from the actual .photoslibrary file.
Returns a PhotosDB object.
#### ```keywords```
```python
# assumes photosdb is a PhotosDB object (see above)
keywords = photosdb.keywords()
keywords = photosdb.keywords
```
Returns a list of the keywords found in the Photos library
@@ -169,15 +294,17 @@ Returns a list of the keywords found in the Photos library
#### ```albums```
```python
# assumes photosdb is a PhotosDB object (see above)
albums = photosdb.albums()
albums = photosdb.albums
```
Returns a list of the albums found in the Photos library
Returns a list of the albums found in the Photos library.
**Note**: In Photos 5.0 (MacOS 10.15/Catalina), It is possible to have more than one album with the same name in Photos. Albums with duplicate names are treated as a single album and the photos in each are combined. For example, if you have two albums named "Wedding" and each has 2 photos, osxphotos will treat this as a single album named "Wedding" with 4 photos in it.
#### ```persons```
```python
# assumes photosdb is a PhotosDB object (see above)
persons = photosdb.persons()
persons = photosdb.persons
```
Returns a list of the persons (faces) found in the Photos library
@@ -185,7 +312,7 @@ Returns a list of the persons (faces) found in the Photos library
#### ```keywords_as_dict```
```python
# assumes photosdb is a PhotosDB object (see above)
keyword_dict = photosdb.keywords_as_dict()
keyword_dict = photosdb.keywords_as_dict
```
Returns a dictionary of keywords found in the Photos library where key is the keyword and value is the count of how many times that keyword appears in the library (ie. how many photos are tagged with the keyword). Resulting dictionary is in reverse sorted order (e.g. keyword with the highest count is first).
@@ -193,7 +320,7 @@ Returns a dictionary of keywords found in the Photos library where key is the ke
#### ```persons_as_dict```
```python
# assumes photosdb is a PhotosDB object (see above)
persons_dict = photosdb.persons_as_dict()
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).
@@ -201,37 +328,40 @@ Returns a dictionary of persons (faces) found in the Photos library where key is
#### ```albums_as_dict```
```python
# assumes photosdb is a PhotosDB object (see above)
albums_dict = photosdb.albums_as_dict()
albums_dict = photosdb.albums_as_dict
```
Returns a dictionary of albums found in the Photos library where key is the album name and value is the count of how many photos are in the album. Resulting dictionary is in reverse sorted order (e.g. album with the most photos is listed first)
Returns a dictionary of albums found in the Photos library where key is the album name and value is the count of how many photos are in the album. Resulting dictionary is in reverse sorted order (e.g. album with the most photos is listed first).
#### ```get_photos_library_path```
**Note**: In Photos 5.0 (MacOS 10.15/Catalina), It is possible to have more than one album with the same name in Photos. Albums with duplicate names are treated as a single album and the photos in each are combined. For example, if you have two albums named "Wedding" and each has 2 photos, osxphotos will treat this as a single album named "Wedding" with 4 photos in it.
#### ```library_path```
```python
# assumes photosdb is a PhotosDB object (see above)
photosdb.get_photos_library_path()
photosdb.library_path
```
Returns the path to the Photos library as a string
#### ```get_db_path```
#### ```db_path```
```python
# assumes photosdb is a PhotosDB object (see above)
photosdb.get_db_path()
photosdb.db_path
```
Returns the path to the Photos database PhotosDB was initialized with
#### ```get_db_version```
#### ```db_version```
```python
# assumes photosdb is a PhotosDB object (see above)
photosdb.get_db_version()
photosdb.db_version
```
Returns the version number for Photos library database. You likely won't need this but it's provided in case needed for debugging. PhotosDB will print a warning to `sys.stderr` if you open a database version that has not been tested.
#### ```photos```
#### `photos(keywords=[], uuid=[], persons=[], albums=[])`
```python
# assumes photosdb is a PhotosDB object (see above)
photos = photosdb.photos([keywords=['keyword',]], [uuid=['uuid',]], [persons=['person',]], [albums=['album',]])
@@ -252,7 +382,7 @@ photos = photosdb.photos(
```
- ```keywords```: list of one or more keywords. Returns only photos containing the keyword(s). If more than one keyword is provided finds photos matching any of the keywords (e.g. treated as "or")
- ```uuid```: list of one or more uuids. Returns only photos whos UUID matches. Note: The UUID is the universally unique identifier that the Photos database uses to identify each photo. You shouldn't normally need to use this but it is a way to access a specific photo if you know the UUID. If more than more uuid is provided, returns photos that match any of the uuids (e.g. treated as "or")
- ```uuid```: list of one or more uuids. Returns only photos whos UUID matches. **Note**: The UUID is the universally unique identifier that the Photos database uses to identify each photo. You shouldn't normally need to use this but it is a way to access a specific photo if you know the UUID. If more than more uuid is provided, returns photos that match any of the uuids (e.g. treated as "or")
- ```persons```: list of one or more persons. Returns only photos containing the person(s). If more than one person provided, returns photos that match any of the persons (e.g. treated as "or")
- ```albums```: list of one or more album names. Returns only photos contained in the album(s). If more than one album name is provided, returns photos contained in any of the albums (.e.g. treated as "or")
@@ -300,91 +430,189 @@ photos3 = [p for p in photos2 if p not in photos1]
### PhotoInfo
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library.
#### `uuid()`
#### `uuid`
Returns the universally unique identifier (uuid) of the photo. This is how Photos keeps track of individual photos within the database.
#### `filename()`
Returns the current filename of the photo on disk. See also `original_filename()`
#### `filename`
Returns the current filename of the photo on disk. See also `original_filename`
#### `original_filename()`
Returns the original filename of the photo when it was imported to Photos. Note: Photos 5.0+ renames the photo when it adds the file to the library using UUID. See also `filename()`
#### `original_filename`
Returns the original filename of the photo when it was imported to Photos. **Note**: Photos 5.0+ renames the photo when it adds the file to the library using UUID. See also `filename`
#### `date()`
#### `date`
Returns the date of the photo as a datetime.datetime object
#### `description()`
#### `description`
Returns the description of the photo
#### `name()`
Returns the name (or the title as Photos calls it) of the photo
#### `title`
Returns the title of the photo
#### `keywords()`
#### `keywords`
Returns a list of keywords (e.g. tags) applied to the photo
#### `albums()`
#### `albums`
Returns a list of albums the photo is contained in
#### `persons()`
#### `persons`
Returns a list of the names of the persons in the photo
#### `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()`). If the file is missing on disk, path=`None` (see `ismissing()`)
#### `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`). If the file is missing on disk, path=`None` (see `ismissing`)
#### `path_edited()`
Returns the absolute path to the edited photo on disk as a string. If the photo has not been edited, returns `None`. See also `path()` and `hasadjustments()`.
#### `path_edited`
Returns the absolute path to the edited photo on disk as a string. If the photo has not been edited, returns `None`. See also `path` and `hasadjustments`.
#### `ismissing()`
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted. Note: this status is set by Photos and osxphotos does not verify that the file path returned by `path()` actually exists. It merely reports what Photos has stored in the library database.
#### `ismissing`
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted. **Note**: this status is set by Photos and osxphotos does not verify that the file path returned by `path` actually exists. It merely reports what Photos has stored in the library database.
#### `hasadjustments()`
Returns `True` if the file has been edited in Photos, otherwise `False`
#### `hasadjustments`
Returns `True` if the picture has been edited, otherwise `False`
#### `favorite()`
#### `external_edit`
Returns `True` if the picture was edited in an external editor (outside Photos.app), otherwise `False`
#### `favorite`
Returns `True` if the picture has been marked as a favorite, otherwise `False`
#### `hidden()`
#### `hidden`
Returns `True` if the picture has been marked as hidden, otherwise `False`
#### `location()`
#### `location`
Returns latitude and longitude as a tuple of floats (latitude, longitude). If location is not set, latitude and longitude are returned as `None`
#### `to_json()`
#### `json()`
Returns a JSON representation of all photo info
Examples:
#### `export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`
Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised).
- *filename (optional): name of picture as str; if not provided, will use current filename
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
- increment: boolean; if True (default=True), will increment file name until a non-existant name is found
- sidecar: boolean; if True (default=False) will also write a json sidecar file with EXIF data in format readable by [exiftool](https://exiftool.org/); filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg)
The json sidecar file can be used by exiftool to apply the metadata from the json file to the image. For example:
```python
# assumes photosdb is a PhotosDB object (see above)
photos=photosdb.photos()
for p in photos:
print(
p.uuid(),
p.filename(),
p.original_filename(),
p.date(),
p.description(),
p.name(),
p.keywords(),
p.albums(),
p.persons(),
p.path(),
p.ismissing(),
p.hasadjustments(),
)
import osxphotos
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
photos[0].export("/tmp","photo_name.jpg",sidecar=True)
```
## History
Then
This project started as a command line utility, `photosmeta`, available at [photosmeta](https://github.com/RhetTbull/photosmeta) This module converts the photosmeta Photos library query functionality into a module.
`exiftool -j=photo_name.jpg.json photo_name.jpg`
If overwrite=False and increment=False, export will fail if destination file already exists
Returns the full path to the exported file
**Implementation Note**: Because the usual python file copy methods don't preserve all the metadata available on MacOS, export uses /usr/bin/ditto to do the copy for export. ditto preserves most metadata such as extended attributes, permissions, ACLs, etc.
### Utility Functions
The following functions are located in osxphotos.utils
#### ```get_system_library_path()```
**MacOS 10.15 Only** Returns path to System Photo Library as string. On MacOS version < 10.15, raises Exception.
#### ```get_last_library_path()```
Returns path to last opened Photo Library as string.
#### ```list_photo_libraries()```
Returns list of Photos libraries found on the system. **Note**: On MacOS 10.15, this appears to list all libraries. On older systems, it may not find some libraries if they are not located in ~/Pictures. Provided for convenience but do not rely on this to find all libraries on the system.
#### ```dd_to_dms_str(lat, lon)```
Convert latitude, longitude in degrees to degrees, minutes, seconds as string.
lat: latitude in degrees
lon: longitude in degrees
returns: string tuple in format ("51 deg 30' 12.86\\" N", "0 deg 7' 54.50\\" W")
This is the same format used by exiftool's json format.
#### ```create_path_by_date(dest, dt)```
Creates a path in dest folder in form dest/YYYY/MM/DD/
dest: valid path as str
dt: datetime.timetuple() object
Checks to see if path exists, if it does, do nothing and return path. If path does not exist, creates it and returns path. Useful for exporting photos to a date-based folder structure.
### Examples
```python
import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
print(f"db file = {photosdb.db_path}")
print(f"db version = {photosdb.db_version}")
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.albums)
print(photosdb.keywords_as_dict)
print(photosdb.persons_as_dict)
print(photosdb.albums_as_dict)
# find all photos with Keyword = Kids and containing person Katie
photos = photosdb.photos(keywords=["Kids"], persons=["Katie"])
print(f"found {len(photos)} photos")
# find all photos that include Katie but do not contain the keyword wedding
photos = [
p
for p in photosdb.photos(persons=["Katie"])
if p not in photosdb.photos(keywords=["wedding"])
]
# get all photos in the database
photos = photosdb.photos()
for p in photos:
print(
p.uuid,
p.filename,
p.date,
p.description,
p.title,
p.keywords,
p.albums,
p.persons,
p.path,
p.ismissing,
p.hasadjustments,
)
if __name__ == "__main__":
main()
```
## Related Projects
[photosmeta](https://github.com/rhettbull/photosmeta): uses osxphotos and [exiftool](https://exiftool.org/) to apply metadata from Photos as exif data in the photo files.
## Contributing
Contributing is easy! if you find bugs or want to suggest additional features/changes, please open an [issue](https://github.com/rhettbull/osxphotos/issues/).
I'll gladly consider pull requests for bug fixes or feature implementations.
If you have an interesting example that shows usage of this module, submit an issue or pull request and i'll include it or link to it.
## Implementation Notes
This module works by creating a copy of the sqlite3 database that Photos uses to store data about the Photos library. The class PhotosDB then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc.
This module works by creating a copy of the sqlite3 database that photos uses to store data about the photos library. the class photosdb then queries this database to extract information about the photos such as persons (faces identified in the photos), albums, keywords, etc.
If Apple changes the database format this will likely break.
If apple changes the database format this will likely break.
Apple does provide an framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the funcationality in this module using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the funcationality in this module using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
## Dependencies
- [PyObjC](https://pythonhosted.org/pyobjc/)
@@ -392,9 +620,5 @@ Apple does provide an framework ([PhotoKit](https://developer.apple.com/document
- [Click](https://pypi.org/project/click/)
## Acknowledgements
This project was inspired by photo-export by Patrick Fältström see: (https://github.com/patrikhson/photo-export) Copyright (c) 2015 Patrik Fältström paf@frobbit.se
This project was originally inspired by photo-export by Patrick Fältström see: (https://github.com/patrikhson/photo-export) Copyright (c) 2015 Patrik Fältström paf@frobbit.se
To interact with the Photos app, I use [py-applescript]( https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee". Rather than import this module, I included the entire module
(which is published as public domain code) in a private module to prevent ambiguity with
other applescript modules on PyPi. py-applescript uses a native bridge via PyObjC and
is very fast compared to the other osascript based modules.

View File

@@ -3,16 +3,16 @@ import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
print(f"db file = {photosdb.get_db_path()}")
print(f"db version = {photosdb.get_db_version()}")
print(f"db file = {photosdb.db_path}")
print(f"db version = {photosdb.db_version}")
print(photosdb.keywords())
print(photosdb.persons())
print(photosdb.albums())
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.albums)
print(photosdb.keywords_as_dict())
print(photosdb.persons_as_dict())
print(photosdb.albums_as_dict())
print(photosdb.keywords_as_dict)
print(photosdb.persons_as_dict)
print(photosdb.albums_as_dict)
# find all photos with Keyword = Kids and containing person Katie
photos = photosdb.photos(keywords=["Kids"], persons=["Katie"])
@@ -29,17 +29,17 @@ def main():
photos = photosdb.photos()
for p in photos:
print(
p.uuid(),
p.filename(),
p.date(),
p.description(),
p.name(),
p.keywords(),
p.albums(),
p.persons(),
p.path(),
p.ismissing(),
p.hasadjustments(),
p.uuid,
p.filename,
p.date,
p.description,
p.title,
p.keywords,
p.albums,
p.persons,
p.path,
p.ismissing,
p.hasadjustments,
)

28
examples/export.py Normal file
View File

@@ -0,0 +1,28 @@
""" Export all photos to ~/Desktop/export
If file has been edited, export the edited version,
otherwise, export the original version """
import os.path
import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
export_path = os.path.expanduser("~/Desktop/export")
for p in photos:
if not p.ismissing:
if p.hasadjustments:
exported = p.export(export_path, edited=True)
else:
exported = p.export(export_path)
print(f"Exported {p.filename} to {exported}")
else:
print(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

714
osxphotos/__main__.py Normal file
View File

@@ -0,0 +1,714 @@
import csv
import datetime
import json
import os
import os.path
import pathlib
import sys
import click
import yaml
import osxphotos
from ._constants import _EXIF_TOOL_URL
from ._version import __version__
from .utils import create_path_by_date
# TODO: add "--any" to search any field (e.g. keyword, description, title contains "wedding") (add case insensitive option)
class CLI_Obj:
def __init__(self, db=None, json=False, debug=False):
if debug:
osxphotos._debug(True)
self.db = db
self.json = json
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CTX_SETTINGS)
@click.option(
"--db",
required=False,
metavar="<Photos database path>",
default=None,
help="Specify database file.",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format.",
)
@click.option("--debug", required=False, is_flag=True, default=False, hidden=True)
@click.version_option(__version__, "--version", "-v")
@click.pass_context
def cli(ctx, db, json, debug):
ctx.obj = CLI_Obj(db=db, json=json, debug=debug)
@cli.command()
@click.pass_obj
def keywords(cli_obj):
""" Print out keywords found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
keywords = {"keywords": photosdb.keywords_as_dict}
if cli_obj.json:
click.echo(json.dumps(keywords))
else:
click.echo(yaml.dump(keywords, sort_keys=False))
@cli.command()
@click.pass_obj
def albums(cli_obj):
""" Print out albums found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
albums = {"albums": photosdb.albums_as_dict}
if cli_obj.json:
click.echo(json.dumps(albums))
else:
click.echo(yaml.dump(albums, sort_keys=False))
@cli.command()
@click.pass_obj
def persons(cli_obj):
""" Print out persons (faces) found in the Photos library. """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
persons = {"persons": photosdb.persons_as_dict}
if cli_obj.json:
click.echo(json.dumps(persons))
else:
click.echo(yaml.dump(persons, sort_keys=False))
@cli.command()
@click.pass_obj
def info(cli_obj):
""" Print out descriptive info of the Photos library database. """
pdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
info = {}
info["database_path"] = pdb.db_path
info["database_version"] = pdb.db_version
photos = pdb.photos()
info["photo_count"] = len(photos)
keywords = pdb.keywords_as_dict
info["keywords_count"] = len(keywords)
info["keywords"] = keywords
albums = pdb.albums_as_dict
info["albums_count"] = len(albums)
info["albums"] = albums
persons = pdb.persons_as_dict
# handle empty person names (added by Photos 5.0+ when face detected but not identified)
# TODO: remove this
# noperson = "UNKNOWN"
# if "" in persons:
# if noperson in persons:
# persons[noperson].append(persons[""])
# else:
# persons[noperson] = persons[""]
# persons.pop("", None)
info["persons_count"] = len(persons)
info["persons"] = persons
if cli_obj.json:
click.echo(json.dumps(info))
else:
click.echo(yaml.dump(info, sort_keys=False))
@cli.command()
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format.",
)
@click.pass_obj
def dump(cli_obj, json):
""" Print list of all photos & associated info from the Photos library. """
pdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
photos = pdb.photos()
print_photo_info(photos, cli_obj.json or json)
@cli.command(name="list")
@click.pass_obj
def list_libraries(cli_obj):
""" Print list of Photos libraries found on the system. """
photo_libs = osxphotos.utils.list_photo_libraries()
sys_lib = None
_, major, _ = osxphotos.utils._get_os_version()
if int(major) >= 15:
sys_lib = osxphotos.utils.get_system_library_path()
last_lib = osxphotos.utils.get_last_library_path()
last_lib_flag = sys_lib_flag = False
for lib in photo_libs:
if lib == sys_lib:
click.echo(f"(*)\t{lib}")
sys_lib_flag = True
elif lib == last_lib:
click.echo(f"(#)\t{lib}")
last_lib_flag = True
else:
click.echo(f"\t{lib}")
if sys_lib_flag or last_lib_flag:
click.echo("\n")
if sys_lib_flag:
click.echo("(*)\tSystem Photos Library")
if last_lib_flag:
click.echo("(#)\tLast opened Photos Library")
@cli.command()
@click.option("--keyword", default=None, multiple=True, help="Search for keyword(s).")
@click.option("--person", default=None, multiple=True, help="Search for person(s).")
@click.option("--album", default=None, multiple=True, help="Search for album(s).")
@click.option("--uuid", default=None, multiple=True, help="Search for UUID(s).")
@click.option(
"--title", default=None, multiple=True, help="Search for TEXT in title of photo."
)
@click.option("--no-title", is_flag=True, help="Search for photos with no title.")
@click.option(
"--description",
default=None,
multiple=True,
help="Search for TEXT in description of photo.",
)
@click.option(
"--no-description", is_flag=True, help="Search for photos with no description."
)
@click.option(
"-i",
"--ignore-case",
is_flag=True,
help="Case insensitive search for title or description. Does not apply to keyword, person, or album.",
)
@click.option("--edited", is_flag=True, help="Search for photos that have been edited.")
@click.option(
"--external-edit", is_flag=True, help="Search for photos edited in external editor."
)
@click.option("--favorite", is_flag=True, help="Search for photos marked favorite.")
@click.option(
"--not-favorite", is_flag=True, help="Search for photos not marked favorite."
)
@click.option("--hidden", is_flag=True, help="Search for photos marked hidden.")
@click.option("--not-hidden", is_flag=True, help="Search for photos not marked hidden.")
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
@click.option(
"--not-missing",
is_flag=True,
help="Search for photos present on disk (e.g. not missing).",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format",
)
@click.pass_obj
@click.pass_context
def query(
ctx,
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
):
""" Query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND"
(e.g. search for photos matching all options).
"""
# if no query terms, show help and return
if not any(
[
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
]
):
click.echo(cli.commands["query"].get_help(ctx))
return
elif favorite and not_favorite:
# can't search for both favorite and notfavorite
click.echo(cli.commands["query"].get_help(ctx))
return
elif hidden and not_hidden:
# can't search for both hidden and nothidden
click.echo(cli.commands["query"].get_help(ctx))
return
elif missing and not_missing:
# can't search for both missing and notmissing
click.echo(cli.commands["query"].get_help(ctx))
return
elif title and no_title:
# can't search for both title and no_title
click.echo(cli.commands["query"].get_help(ctx))
return
elif description and no_description:
# can't search for both description and no_description
click.echo(cli.commands["query"].get_help(ctx))
return
else:
photos = _query(
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
)
print_photo_info(photos, cli_obj.json or json)
@cli.command()
@click.option("--keyword", default=None, multiple=True, help="Search for keyword(s).")
@click.option("--person", default=None, multiple=True, help="Search for person(s).")
@click.option("--album", default=None, multiple=True, help="Search for album(s).")
@click.option("--uuid", default=None, multiple=True, help="Search for UUID(s).")
@click.option(
"--title", default=None, multiple=True, help="Search for TEXT in title of photo."
)
@click.option("--no-title", is_flag=True, help="Search for photos with no title.")
@click.option(
"--description",
default=None,
multiple=True,
help="Search for TEXT in description of photo.",
)
@click.option(
"--no-description", is_flag=True, help="Search for photos with no description."
)
@click.option(
"-i",
"--ignore-case",
is_flag=True,
help="Case insensitive search for title or description. Does not apply to keyword, person, or album.",
)
@click.option("--edited", is_flag=True, help="Search for photos that have been edited.")
@click.option(
"--external-edit", is_flag=True, help="Search for photos edited in external editor."
)
@click.option("--favorite", is_flag=True, help="Search for photos marked favorite.")
@click.option(
"--not-favorite", is_flag=True, help="Search for photos not marked favorite."
)
@click.option("--hidden", is_flag=True, help="Search for photos marked hidden.")
@click.option("--not-hidden", is_flag=True, help="Search for photos not marked hidden.")
@click.option("--verbose", is_flag=True, help="Print verbose output.")
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing files. "
"Default behavior is to add (1), (2), etc to filename if file already exists. "
"Use this with caution as it may create name collisions on export. "
"(e.g. if two files happen to have the same name)",
)
@click.option(
"--export-by-date",
is_flag=True,
help="Automatically create output folders to organize photos by date created "
"(e.g. DEST/2019/12/20/photoname.jpg).",
)
@click.option(
"--export-edited",
is_flag=True,
help="Also export edited version of photo "
'if an edited version exists. Edited photo will be named in form of "photoname_edited.ext"',
)
@click.option(
"--original-name",
is_flag=True,
help="Use photo's original filename instead of current filename for export",
)
@click.option(
"--sidecar",
is_flag=True,
help="Create json sidecar for each photo exported "
f"in format useable by exiftool ({_EXIF_TOOL_URL}) "
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
'"exiftool -j=photoname.jpg.json photoname.jpg" '
"The sidecar file is named in format photoname.ext.json where ext is extension of the photo (e.g. jpg)",
)
@click.argument("dest", nargs=1)
@click.pass_obj
@click.pass_context
def export(
ctx,
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
verbose,
overwrite,
export_by_date,
export_edited,
original_name,
sidecar,
dest,
):
""" Export photos from the Photos database.
Export path DEST is required.
Optionally, query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND"
(e.g. search for photos matching all options).
If no query options are provided, all photos will be exported.
"""
# TODO: --export-edited, --export-original
if not os.path.isdir(dest):
sys.exit("DEST must be valid path")
# if no query terms, show help and return
photos = _query(
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
None, # missing -- won't export these but will warn user
None, # not-missing
)
if photos:
num_photos = len(photos)
photo_str = "photos" if num_photos > 1 else "photo"
click.echo(f"Exporting {num_photos} {photo_str} to {dest}...")
if not verbose:
# show progress bar
with click.progressbar(photos) as bar:
for p in bar:
export_photo(
p,
dest,
verbose,
export_by_date,
sidecar,
overwrite,
export_edited,
original_name,
)
else:
for p in photos:
export_path = export_photo(
p,
dest,
verbose,
export_by_date,
sidecar,
overwrite,
export_edited,
original_name,
)
if export_path:
click.echo(f"Exported {p.filename} to {export_path}")
else:
click.echo(f"Did not export missing file {p.filename}")
else:
click.echo("Did not find any photos to export")
@cli.command()
@click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
""" Print help; for help on commands: help <command>. """
if topic is None:
click.echo(ctx.parent.get_help())
else:
click.echo(cli.commands[topic].get_help(ctx))
def print_photo_info(photos, json=False):
if json:
dump = []
for p in photos:
dump.append(p.json())
click.echo(f"[{', '.join(dump)}]")
else:
# dump as CSV
csv_writer = csv.writer(
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
)
dump = []
# add headers
dump.append(
[
"uuid",
"filename",
"original_filename",
"date",
"description",
"title",
"keywords",
"albums",
"persons",
"path",
"ismissing",
"hasadjustments",
"external_edit",
"favorite",
"hidden",
"latitude",
"longitude",
"path_edited",
]
)
for p in photos:
dump.append(
[
p.uuid,
p.filename,
p.original_filename,
str(p.date),
p.description,
p.title,
", ".join(p.keywords),
", ".join(p.albums),
", ".join(p.persons),
p.path,
p.ismissing,
p.hasadjustments,
p.external_edit,
p.favorite,
p.hidden,
p._latitude,
p._longitude,
p.path_edited,
]
)
for row in dump:
csv_writer.writerow(row)
def _query(
cli_obj,
keyword,
person,
album,
uuid,
title,
no_title,
description,
no_description,
ignore_case,
json,
edited,
external_edit,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
):
""" run a query against PhotosDB to extract the photos based on user supply criteria """
""" used by query and export commands """
""" arguments must be passed in same order as query and export """
""" if either is modified, need to ensure all three functions are updated """
photosdb = osxphotos.PhotosDB(dbfile=cli_obj.db)
photos = photosdb.photos(keywords=keyword, persons=person, albums=album, uuid=uuid)
if title:
# search title field for text
# if more than one, find photos with all title values in title
if ignore_case:
# case-insensitive
for t in title:
t = t.lower()
photos = [p for p in photos if p.title and t in p.title.lower()]
else:
for t in title:
photos = [p for p in photos if p.title and t in p.title]
elif no_title:
photos = [p for p in photos if not p.title]
if description:
# search description field for text
# if more than one, find photos with all name values in in description
if ignore_case:
# case-insensitive
for d in description:
d = d.lower()
photos = [
p for p in photos if p.description and d in p.description.lower()
]
else:
for d in description:
photos = [p for p in photos if p.description and d in p.description]
elif no_description:
photos = [p for p in photos if not p.description]
if edited:
photos = [p for p in photos if p.hasadjustments]
if external_edit:
photos = [p for p in photos if p.external_edit]
if favorite:
photos = [p for p in photos if p.favorite]
elif not_favorite:
photos = [p for p in photos if not p.favorite]
if hidden:
photos = [p for p in photos if p.hidden]
elif not_hidden:
photos = [p for p in photos if not p.hidden]
if missing:
photos = [p for p in photos if p.ismissing]
elif not_missing:
photos = [p for p in photos if not p.ismissing]
return photos
def export_photo(
photo,
dest,
verbose,
export_by_date,
sidecar,
overwrite,
export_edited,
original_name,
):
""" Helper function for export that does the actual export
photo: PhotoInfo object
dest: destination path as string
verbose: boolean; print verbose output
export_by_date: boolean; create export folder in form dest/YYYY/MM/DD
sidecar: boolean; create json sidecar file with export
overwrite: boolean; overwrite dest file if it already exists
original_name: boolean; use original filename instead of current filename
returns destination path of exported photo or None if photo was missing
"""
if photo.ismissing:
space = " " if not verbose else ""
click.echo(f"{space}Skipping missing photos {photo.filename}")
return None
elif not os.path.exists(photo.path):
space = " " if not verbose else ""
click.echo(
f"{space}WARNING: file {photo.path} is missing but ismissing=False, "
f"skipping {photo.filename}"
)
return None
filename = None
if original_name:
filename = photo.original_filename
else:
filename = photo.filename
if verbose:
click.echo(f"Exporting {photo.filename} as {filename}")
if export_by_date:
date_created = photo.date.timetuple()
dest = create_path_by_date(dest, date_created)
photo_path = photo.export(dest, filename, sidecar=sidecar, overwrite=overwrite)
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments and photo.path_edited is not None:
edited_name = pathlib.Path(filename)
edited_name = f"{edited_name.stem}_edited{edited_name.suffix}"
if verbose:
click.echo(f"Exporting edited version of {filename} as {edited_name}")
photo.export(
dest, edited_name, sidecar=sidecar, overwrite=overwrite, edited=True
)
return photo_path
if __name__ == "__main__":
cli()

View File

@@ -1,207 +0,0 @@
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
"""
This code is from py-applescript, a public domain package available at:
https://github.com/rdhyee/py-applescript
I've included the whole thing here for simplicity as there is more than one
applescript packge on PyPi so there's ambiguity as to which one "import applescript"
would use if user had installed another library.
This package is used instead of the others because it uses a native PyObjC
bridge and is thus much faster than others which use osascript.
"""
import sys
from Foundation import (
NSAppleScript,
NSAppleEventDescriptor,
NSURL,
NSAppleScriptErrorMessage,
NSAppleScriptErrorBriefMessage,
NSAppleScriptErrorNumber,
NSAppleScriptErrorAppName,
NSAppleScriptErrorRange,
)
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
from . import kae
__all__ = ["AppleScript", "ScriptError", "AEType", "AEEnum", "kMissingValue", "kae"]
######################################################################
class AppleScript:
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
"""
_codecs = Codecs()
def __init__(self, source=None, path=None):
"""
source : str | None -- AppleScript source code
path : str | None -- full path to .scpt/.applescript file
Notes:
- Either the path or the source argument must be provided.
- If the script cannot be read/compiled, a ScriptError is raised.
"""
if path:
url = NSURL.fileURLWithPath_(path)
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(
url, None
)
if errorinfo:
raise ScriptError(errorinfo)
elif source:
self._script = NSAppleScript.alloc().initWithSource_(source)
else:
raise ValueError("Missing source or path argument.")
if not self._script.isCompiled():
errorinfo = self._script.compileAndReturnError_(None)[1]
if errorinfo:
raise ScriptError(errorinfo)
def __repr__(self):
s = self.source
return "AppleScript({})".format(
repr(s) if len(s) < 100 else "{}...{}".format(repr(s)[:80], repr(s)[-17:])
)
##
def _newevent(self, suite, code, args):
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
fourcharcode(suite),
fourcharcode(code),
NSAppleEventDescriptor.nullDescriptor(),
0,
0,
)
evt.setDescriptor_forKeyword_(
self._codecs.pack(args), fourcharcode(kae.keyDirectObject)
)
return evt
def _unpackresult(self, result, errorinfo):
if not result:
raise ScriptError(errorinfo)
return self._codecs.unpack(result)
##
source = property(
lambda self: str(self._script.source()), doc="str -- the script's source code"
)
def run(self, *args):
""" Run the script, optionally passing arguments to its run handler.
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
Result : anything | None -- the script's return value, if any
Notes:
- The run handler must be explicitly declared in order to pass arguments.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
if args:
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
else:
return self._unpackresult(*self._script.executeAndReturnError_(None))
def call(self, name, *args):
""" Call the specified user-defined handler.
name : str -- the handler's name (case-sensitive)
args : anything -- arguments to pass to script, if any; see documentation for supported types
Result : anything | None -- the script's return value, if any
Notes:
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
evt = self._newevent(
kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args
)
evt.setDescriptor_forKeyword_(
NSAppleEventDescriptor.descriptorWithString_(name),
fourcharcode(kae.keyASSubroutineName),
)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
##
class ScriptError(Exception):
""" Indicates an AppleScript compilation/execution error. """
def __init__(self, errorinfo):
self._errorinfo = dict(errorinfo)
def __repr__(self):
return "ScriptError({})".format(self._errorinfo)
@property
def message(self):
""" str -- the error message """
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
if not msg:
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, "Script Error")
return msg
number = property(
lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
doc="int | None -- the error number, if given",
)
appname = property(
lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
doc="str | None -- the name of the application that reported the error, where relevant",
)
@property
def range(self):
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
range = self._errorinfo.get(NSAppleScriptErrorRange)
if range:
start = range.rangeValue().location
end = start + range.rangeValue().length
return (start, end)
else:
return None
def __str__(self):
msg = self.message
for s, v in [
(" ({})", self.number),
(" app={!r}", self.appname),
(" range={0[0]}-{0[1]}", self.range),
]:
if v is not None:
msg += s.format(v)
return (
msg.encode("ascii", "replace") if sys.version_info.major < 3 else msg
) # 2.7 compatibility
##
kMissingValue = AEType(kae.cMissingValue) # convenience constant

23
osxphotos/_constants.py Normal file
View File

@@ -0,0 +1,23 @@
"""
Constants used by osxphotos
"""
# which Photos library database versions have been tested
# Photos 2.0 (10.12.6) == 2622
# Photos 3.0 (10.13.6) == 3301
# Photos 4.0 (10.14.5) == 4016
# Photos 4.0 (10.14.6) == 4025
# Photos 5.0 (10.15.0) == 6000
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
# versions later than this have a different database structure
_PHOTOS_5_VERSION = "6000"
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
# Photos 5 has persons who are empty string if unidentified face
_UNKNOWN_PERSON = "_UNKNOWN_"
_EXIF_TOOL_URL = "https://exiftool.org/"

3
osxphotos/_version.py Normal file
View File

@@ -0,0 +1,3 @@
""" version info """
__version__ = "0.17.06"

View File

@@ -1,294 +0,0 @@
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
import datetime, struct, sys
from Foundation import NSAppleEventDescriptor, NSURL
from . import kae
__all__ = ["Codecs", "AEType", "AEEnum"]
######################################################################
def fourcharcode(code):
""" Convert four-char code for use in NSAppleEventDescriptor methods.
code : bytes -- four-char code, e.g. b'utxt'
Result : int -- OSType, e.g. 1970567284
"""
return struct.unpack(">I", code)[0]
#######
class Codecs:
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
kMacEpoch = datetime.datetime(1904, 1, 1)
kUSRF = fourcharcode(kae.keyASUserRecordFields)
def __init__(self):
# Clients may add/remove/replace encoder and decoder items:
self.encoders = {
NSAppleEventDescriptor.class__(): self.packdesc,
type(None): self.packnone,
bool: self.packbool,
int: self.packint,
float: self.packfloat,
bytes: self.packbytes,
str: self.packstr,
list: self.packlist,
tuple: self.packlist,
dict: self.packdict,
datetime.datetime: self.packdatetime,
AEType: self.packtype,
AEEnum: self.packenum,
}
if sys.version_info.major < 3: # 2.7 compatibility
self.encoders[unicode] = self.packstr
self.decoders = {
fourcharcode(k): v
for k, v in {
kae.typeNull: self.unpacknull,
kae.typeBoolean: self.unpackboolean,
kae.typeFalse: self.unpackboolean,
kae.typeTrue: self.unpackboolean,
kae.typeSInt32: self.unpacksint32,
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
kae.typeUTF8Text: self.unpackunicodetext,
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
kae.typeUnicodeText: self.unpackunicodetext,
kae.typeLongDateTime: self.unpacklongdatetime,
kae.typeAEList: self.unpackaelist,
kae.typeAERecord: self.unpackaerecord,
kae.typeAlias: self.unpackfile,
kae.typeFSS: self.unpackfile,
kae.typeFSRef: self.unpackfile,
kae.typeFileURL: self.unpackfile,
kae.typeType: self.unpacktype,
kae.typeEnumeration: self.unpackenumeration,
}.items()
}
def pack(self, data):
"""Pack Python data.
data : anything -- a Python value
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
"""
try:
return self.encoders[data.__class__](data) # quick lookup by type/class
except (KeyError, AttributeError) as e:
for (
type,
encoder,
) in (
self.encoders.items()
): # slower but more thorough lookup that can handle subtypes/subclasses
if isinstance(data, type):
return encoder(data)
raise TypeError(
"Can't pack data into an AEDesc (unsupported type): {!r}".format(data)
)
def unpack(self, desc):
"""Unpack an Apple event descriptor.
desc : NSAppleEventDescriptor
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
"""
decoder = self.decoders.get(desc.descriptorType())
# unpack known type
if decoder:
return decoder(desc)
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
if rec:
rec = self.unpackaerecord(rec)
rec[AEType(kae.pClass)] = AEType(struct.pack(">I", desc.descriptorType()))
return rec
# return as-is
return desc
##
def _packbytes(self, desctype, data):
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
fourcharcode(desctype), data, len(data)
)
def packdesc(self, val):
return val
def packnone(self, val):
return NSAppleEventDescriptor.nullDescriptor()
def packbool(self, val):
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
def packint(self, val):
if (-2 ** 31) <= val < (2 ** 31):
return NSAppleEventDescriptor.descriptorWithInt32_(val)
else:
return self.pack(float(val))
def packfloat(self, val):
return self._packbytes(kae.typeFloat, struct.pack("d", val))
def packbytes(self, val):
return self._packbytes(kae.typeData, val)
def packstr(self, val):
return NSAppleEventDescriptor.descriptorWithString_(val)
def packdatetime(self, val):
delta = val - self.kMacEpoch
sec = delta.days * 3600 * 24 + delta.seconds
return self._packbytes(kae.typeLongDateTime, struct.pack("q", sec))
def packlist(self, val):
lst = NSAppleEventDescriptor.listDescriptor()
for item in val:
lst.insertDescriptor_atIndex_(self.pack(item), 0)
return lst
def packdict(self, val):
record = NSAppleEventDescriptor.recordDescriptor()
usrf = desctype = None
for key, value in val.items():
if isinstance(key, AEType):
if key.code == kae.pClass and isinstance(
value, AEType
): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
desctype = value
else:
record.setDescriptor_forKeyword_(
self.pack(value), fourcharcode(key.code)
)
else:
if not usrf:
usrf = NSAppleEventDescriptor.listDescriptor()
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
if usrf:
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
if desctype:
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
if newrecord:
record = newrecord
else: # coercion failed for some reason, so pack as normal key-value pair
record.setDescriptor_forKeyword_(
self.pack(desctype), fourcharcode(key.code)
)
return record
def packtype(self, val):
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
def packenum(self, val):
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
#######
def unpacknull(self, desc):
return None
def unpackboolean(self, desc):
return desc.booleanValue()
def unpacksint32(self, desc):
return desc.int32Value()
def unpackfloat64(self, desc):
return struct.unpack("d", bytes(desc.data()))[0]
def unpackunicodetext(self, desc):
return desc.stringValue()
def unpacklongdatetime(self, desc):
return self.kMacEpoch + datetime.timedelta(
seconds=struct.unpack("q", bytes(desc.data()))[0]
)
def unpackaelist(self, desc):
return [
self.unpack(desc.descriptorAtIndex_(i + 1))
for i in range(desc.numberOfItems())
]
def unpackaerecord(self, desc):
dct = {}
for i in range(desc.numberOfItems()):
key = desc.keywordForDescriptorAtIndex_(i + 1)
value = desc.descriptorForKeyword_(key)
if key == self.kUSRF:
lst = self.unpackaelist(value)
for i in range(0, len(lst), 2):
dct[lst[i]] = lst[i + 1]
else:
dct[AEType(struct.pack(">I", key))] = self.unpack(value)
return dct
def unpacktype(self, desc):
return AEType(struct.pack(">I", desc.typeCodeValue()))
def unpackenumeration(self, desc):
return AEEnum(struct.pack(">I", desc.enumCodeValue()))
def unpackfile(self, desc):
url = bytes(
desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()
).decode("utf8")
return NSURL.URLWithString_(url).path()
#######
class AETypeBase:
""" Base class for AEType and AEEnum.
Notes:
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
"""
def __init__(self, code):
"""
code : bytes -- four-char code, e.g. b'utxt'
"""
if not isinstance(code, bytes):
raise TypeError("invalid code (not a bytes object): {!r}".format(code))
elif len(code) != 4:
raise ValueError("invalid code (not four bytes long): {!r}".format(code))
self._code = code
code = property(
lambda self: self._code, doc="bytes -- four-char code, e.g. b'utxt'"
)
def __hash__(self):
return hash(self._code)
def __eq__(self, val):
return val.__class__ == self.__class__ and val.code == self._code
def __ne__(self, val):
return not self == val
def __repr__(self):
return "{}({!r})".format(self.__class__.__name__, self._code)
##
class AEType(AETypeBase):
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
class AEEnum(AETypeBase):
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""

View File

@@ -1,360 +0,0 @@
import csv
import json
import sys
import click
import yaml
import osxphotos
# TODO: add query for description, name (contains text)
# TODO: add "--any" to search any field (e.g. keyword, description, name contains "wedding") (add case insensitive option)
class CLI_Obj:
def __init__(self, db=None, json=False):
self.photosdb = osxphotos.PhotosDB(dbfile=db)
self.json = json
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CTX_SETTINGS)
@click.option(
"--db",
required=False,
metavar="<Photos database path>",
default=None,
help="Specify database file",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format",
)
@click.pass_context
def cli(ctx, db, json):
ctx.obj = CLI_Obj(db=db, json=json)
@cli.command()
@click.pass_obj
def keywords(cli_obj):
""" print out keywords found in the Photos library"""
keywords = {"keywords": cli_obj.photosdb.keywords_as_dict()}
if cli_obj.json:
print(json.dumps(keywords))
else:
print(yaml.dump(keywords, sort_keys=False))
@cli.command()
@click.pass_obj
def albums(cli_obj):
""" print out albums found in the Photos library """
albums = {"albums": cli_obj.photosdb.albums_as_dict()}
if cli_obj.json:
print(json.dumps(albums))
else:
print(yaml.dump(albums, sort_keys=False))
@cli.command()
@click.pass_obj
def persons(cli_obj):
""" print out persons (faces) found in the Photos library """
persons = {"persons": cli_obj.photosdb.persons_as_dict()}
if cli_obj.json:
print(json.dumps(persons))
else:
print(yaml.dump(persons, sort_keys=False))
@cli.command()
@click.pass_obj
def info(cli_obj):
""" print out descriptive info of the Photos library database """
pdb = cli_obj.photosdb
info = {}
info["database_path"] = pdb.get_db_path()
info["database_version"] = pdb.get_db_version()
photos = pdb.photos()
info["photo_count"] = len(photos)
keywords = pdb.keywords_as_dict()
info["keywords_count"] = len(keywords)
info["keywords"] = keywords
albums = pdb.albums_as_dict()
info["albums_count"] = len(albums)
info["albums"] = albums
persons = pdb.persons_as_dict()
# handle empty person names (added by Photos 5.0+ when face detected but not identified)
noperson = "UNKNOWN"
if "" in persons:
if noperson in persons:
persons[noperson].append(persons[""])
else:
persons[noperson] = persons[""]
persons.pop("", None)
info["persons_count"] = len(persons)
info["persons"] = persons
if cli_obj.json:
print(json.dumps(info))
else:
print(yaml.dump(info, sort_keys=False))
@cli.command()
@click.pass_obj
def dump(cli_obj):
""" print list of all photos & associated info from the Photos library """
pdb = cli_obj.photosdb
photos = pdb.photos()
print_photo_info(photos, cli_obj.json)
@cli.command()
@click.option("--keyword", default=None, multiple=True, help="search for keyword(s)")
@click.option("--person", default=None, multiple=True, help="search for person(s)")
@click.option("--album", default=None, multiple=True, help="search for album(s)")
@click.option("--uuid", default=None, multiple=True, help="search for UUID(s)")
@click.option(
"--name", default=None, multiple=True, help="search for TEXT in name of photo"
)
@click.option("--no-name", is_flag=True, help="search for photos with no name")
@click.option(
"--description",
default=None,
multiple=True,
help="search for TEXT in description of photo",
)
@click.option("--no-description", is_flag=True, help="search for photos with no description")
@click.option(
"-i",
"--ignore-case",
is_flag=True,
help="case insensitive search for name or description. Does not apply to keyword, person, or album",
)
@click.option("--favorite", is_flag=True, help="search for photos marked favorite")
@click.option(
"--not-favorite", is_flag=True, help="search for photos not marked favorite"
)
@click.option("--hidden", is_flag=True, help="search for photos marked hidden")
@click.option("--not-hidden", is_flag=True, help="search for photos not marked hidden")
@click.option("--missing", is_flag=True, help="search for photos missing from disk")
@click.option(
"--not-missing",
is_flag=True,
help="search for photos present on disk (e.g. not missing)",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format",
)
@click.pass_obj
@click.pass_context
def query(
ctx,
cli_obj,
keyword,
person,
album,
uuid,
name,
no_name,
description,
no_description,
ignore_case,
json,
favorite,
not_favorite,
hidden,
not_hidden,
missing,
not_missing,
):
""" Query the Photos database using 1 or more search options\n
If more than one option is provided, they are treated as "AND"
(e.g. search for photos matching all options)
"""
# if no query terms, show help and return
if (
not keyword
and not person
and not album
and not uuid
and not name
and not no_name
and not description
and not no_description
and not favorite
and not not_favorite
and not hidden
and not not_hidden
and not missing
and not not_missing
):
print(cli.commands["query"].get_help(ctx))
return
elif favorite and not_favorite:
# can't search for both favorite and notfavorite
print(cli.commands["query"].get_help(ctx))
return
elif hidden and not_hidden:
# can't search for both hidden and nothidden
print(cli.commands["query"].get_help(ctx))
return
elif missing and not_missing:
# can't search for both missing and notmissing
print(cli.commands["query"].get_help(ctx))
return
elif name and no_name:
# can't search for both name and no_name
print(cli.commands["query"].get_help(ctx))
return
elif description and no_description:
# can't search for both description and no_description
print(cli.commands["query"].get_help(ctx))
return
else:
photos = cli_obj.photosdb.photos(
keywords=keyword, persons=person, albums=album, uuid=uuid
)
if name:
# search name field for text
# if more than one, find photos with all name values in in name
if ignore_case:
# case-insensitive
for n in name:
n = n.lower()
photos = [p for p in photos if p.name() and n in p.name().lower()]
else:
for n in name:
photos = [p for p in photos if p.name() and n in p.name()]
elif no_name:
photos = [p for p in photos if not p.name()]
if description:
# search description field for text
# if more than one, find photos with all name values in in description
if ignore_case:
# case-insensitive
for d in description:
d = d.lower()
photos = [
p
for p in photos
if p.description() and d in p.description().lower()
]
else:
for d in description:
photos = [
p for p in photos if p.description() and d in p.description()
]
elif no_description:
photos = [p for p in photos if not p.description()]
if favorite:
photos = [p for p in photos if p.favorite()]
elif not_favorite:
photos = [p for p in photos if not p.favorite()]
if hidden:
photos = [p for p in photos if p.hidden()]
elif not_hidden:
photos = [p for p in photos if not p.hidden()]
if missing:
photos = [p for p in photos if p.ismissing()]
elif not_missing:
photos = [p for p in photos if not p.ismissing()]
print_photo_info(photos, cli_obj.json or json)
@cli.command()
@click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
""" print help; for help on commands: help <command> """
if topic is None:
print(ctx.parent.get_help())
else:
print(cli.commands[topic].get_help(ctx))
def print_photo_info(photos, json=False):
if json:
dump = []
for p in photos:
dump.append(p.to_json())
print(f"[{', '.join(dump)}]")
else:
# dump as CSV
csv_writer = csv.writer(
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
)
dump = []
# add headers
dump.append(
[
"uuid",
"filename",
"original_filename",
"date",
"description",
"name",
"keywords",
"albums",
"persons",
"path",
"ismissing",
"hasadjustments",
"favorite",
"hidden",
"latitude",
"longitude",
"path_edited",
]
)
for p in photos:
dump.append(
[
p.uuid(),
p.filename(),
p.original_filename(),
str(p.date()),
p.description(),
p.name(),
", ".join(p.keywords()),
", ".join(p.albums()),
", ".join(p.persons()),
p.path(),
p.ismissing(),
p.hasadjustments(),
p.favorite(),
p.hidden(),
p._latitude(),
p._longitude(),
p.path_edited(),
]
)
for row in dump:
csv_writer.writerow(row)
if __name__ == "__main__":
cli()

File diff suppressed because it is too large Load Diff

511
osxphotos/photoinfo.py Normal file
View File

@@ -0,0 +1,511 @@
"""
PhotoInfo class
Represents a single photo in the Photos library and provides access to the photo's attributes
PhotosDB.photos() returns a list of PhotoInfo objects
"""
import json
import logging
import os.path
import pathlib
import re
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
import yaml
from ._constants import _PHOTOS_5_VERSION
from .utils import _get_resource_loc, dd_to_dms_str
class PhotoInfo:
"""
Info about a specific photo, contains all the details about the photo
including keywords, persons, albums, uuid, path, etc.
"""
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
self._info = info
self._db = db
@property
def filename(self):
""" filename of the picture """
return self._info["filename"]
@property
def original_filename(self):
""" original filename of the picture """
""" Photos 5 mangles filenames upon import """
return self._info["originalFilename"]
@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)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
@property
def tzoffset(self):
""" timezone offset from UTC in seconds """
return self._info["imageTimeZoneOffsetSeconds"]
@property
def path(self):
""" absolute path on disk of the original picture """
photopath = ""
if self._db._db_version < _PHOTOS_5_VERSION:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
else:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
if self._info["isMissing"] == 1:
photopath = None # path would be meaningless until downloaded
# TODO: Is there a way to use applescript or PhotoKit to force the download in this
else:
if self._info["masterFingerprint"]:
# if masterFingerprint is not null, path appears to be valid
if self._info["directory"].startswith("/"):
photopath = os.path.join(
self._info["directory"], self._info["filename"]
)
else:
photopath = os.path.join(
self._db._masters_path,
self._info["directory"],
self._info["filename"],
)
else:
photopath = None
logging.debug(f"WARNING: masterFingerprint null {pformat(self._info)}")
# TODO: fix the logic for isMissing
if self._info["isMissing"] == 1:
photopath = None # path would be meaningless until downloaded
logging.debug(photopath)
return photopath
@property
def path_edited(self):
""" absolute path on disk of the edited picture """
""" None if photo has not been edited """
photopath = ""
if self._db._db_version < _PHOTOS_5_VERSION:
if self._info["hasAdjustments"]:
edit_id = self._info["edit_resource_id"]
if edit_id is not None:
library = self._db._library_path
folder_id, file_id = _get_resource_loc(edit_id)
# todo: is this always true or do we need to search file file_id under folder_id
photopath = os.path.join(
library,
"resources",
"media",
"version",
folder_id,
"00",
f"fullsizeoutput_{file_id}.jpeg",
)
if not os.path.isfile(photopath):
logging.warning(
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
logging.warning(
f"{self.uuid} hasAdjustments but edit_model_id is None"
)
else:
photopath = None
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
else:
# in Photos 5.0 / Catalina / MacOS 10.15:
# edited photos appear to always be converted to .jpeg and stored in
# library_name/resources/renders/X/UUID_1_201_a.jpeg
# where X = first letter of UUID
# and UUID = UUID of image
# this seems to be true even for photos not copied to Photos library and
# where original format was not jpg/jpeg
# if more than one edit, previous edit is stored as UUID_p.jpeg
if self._info["hasAdjustments"]:
library = self._db._library_path
directory = self._uuid[0] # first char of uuid
photopath = os.path.join(
library,
"resources",
"renders",
directory,
f"{self._uuid}_1_201_a.jpeg",
)
if not os.path.isfile(photopath):
logging.warning(
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
photopath = None
# TODO: might be possible for original/master to be missing but edit to still be there
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
logging.debug(photopath)
return photopath
@property
def description(self):
""" long / extended description of picture """
return self._info["extendedDescription"]
@property
def persons(self):
""" list of persons in picture """
return self._info["persons"]
@property
def albums(self):
""" list of albums picture is contained in """
albums = []
for album in self._info["albums"]:
albums.append(self._db._dbalbum_details[album]["title"])
return albums
@property
def keywords(self):
""" list of keywords for picture """
return self._info["keywords"]
@property
def title(self):
""" name / title of picture """
# TODO: Update documentation and tests to use title
return self._info["name"]
@property
def uuid(self):
""" UUID of picture """
return self._uuid
@property
def ismissing(self):
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
do not immediately get written to disk. In particular, I've noticed that downloading
an image from the cloud does not force the database to be updated until something else
e.g. an edit, keyword, etc. occurs forcing a database synch
The exact process / timing is a mystery to be but be aware that if some photos were recently
downloaded from cloud to local storate their status in the database might still show
isMissing = 1
"""
return True if self._info["isMissing"] == 1 else False
@property
def hasadjustments(self):
""" True if picture has adjustments / edits """
return True if self._info["hasAdjustments"] == 1 else False
@property
def external_edit(self):
""" Returns True if picture was edited outside of Photos using external editor """
return (
True
if self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
else False
)
@property
def favorite(self):
""" True if picture is marked as favorite """
return True if self._info["favorite"] == 1 else False
@property
def hidden(self):
""" True if picture is hidden """
return True if self._info["hidden"] == 1 else False
@property
def location(self):
""" returns (latitude, longitude) as float in degrees or None """
return (self._latitude, self._longitude)
def export(
self,
dest,
*filename,
edited=False,
overwrite=False,
increment=True,
sidecar=False,
):
""" export photo """
""" first argument must be valid destination path (or exception raised) """
""" second argument (optional): name of picture; if not provided, will use current filename """
""" if edited=True (default=False), will export the edited version of the photo (or raise exception if no edited version) """
""" if overwrite=True (default=False), will overwrite files if they alreay exist """
""" if increment=True (default=True), will increment file name until a non-existant name is found """
""" if overwrite=False and increment=False, export will fail if destination file already exists """
""" if sidecar=True, will also write a json sidecar with EXIF data in format readable by exiftool """
""" sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg) """
""" returns the full path to the exported file """
# TODO: add this docs:
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
# check arguments and get destination path and filename (if provided)
if filename and len(filename) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
else:
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
elif not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if filename and len(filename) == 1:
# second arg is filename of picture
filename = filename[0]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
edited_name = Path(self.path_edited).name
edited_suffix = Path(edited_name).suffix
filename = Path(self.filename).stem + "_edited" + edited_suffix
else:
filename = self.filename
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if not self.hasadjustments:
logging.warning(
"Attempting to export edited photo but hasadjustments=False"
)
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
)
else:
if self.ismissing:
logging.warning(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is None:
logging.warning(
f"Attempting to export photo but path is None: ismissing = {self.ismissing}"
)
raise FileNotFoundError("Cannot export photo if path is None")
else:
src = self.path
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
dest = pathlib.Path(dest)
filename = pathlib.Path(filename)
dest = dest / filename
# check to see if file exists and if so, add (1), (2), etc until we find one that works
if increment and not overwrite:
count = 1
dest_new = dest
while dest_new.exists():
dest_new = dest.parent / f"{dest.stem} ({count}){dest.suffix}"
count += 1
dest = dest_new
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, incremetn={increment}, dest exists: {dest.exists()}"
)
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
# if error on copy, subprocess will raise CalledProcessError
try:
subprocess.run(
["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE
)
except subprocess.CalledProcessError as e:
logging.critical(
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
)
raise e
if sidecar:
logging.debug("writing exiftool_json_sidecar")
sidecar_filename = f"{dest}.json"
json_sidecar_str = self._exiftool_json_sidecar()
try:
self._write_sidecar_car(sidecar_filename, json_sidecar_str)
except Exception as e:
logging.critical(f"Error writing json sidecar to {sidecar_filename}")
raise e
return str(dest)
def _exiftool_json_sidecar(self):
""" return json string of EXIF details in exiftool sidecar format """
exif = {}
exif["FileName"] = self.filename
if self.description:
exif["ImageDescription"] = self.description
exif["Description"] = self.description
if self.title:
exif["Title"] = self.title
if self.keywords:
exif["TagsList"] = exif["Keywords"] = self.keywords
if self.persons:
exif["PersonInImage"] = self.persons
# if self.favorite():
# exif["Rating"] = 5
(lat, lon) = self.location
if lat is not None and lon is not None:
lat_str, lon_str = dd_to_dms_str(lat, lon)
exif["GPSLatitude"] = lat_str
exif["GPSLongitude"] = lon_str
exif["GPSPosition"] = f"{lat_str}, {lon_str}"
lat_ref = "North" if lat >= 0 else "South"
lon_ref = "East" if lon >= 0 else "West"
exif["GPSLatitudeRef"] = lat_ref
exif["GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["DateTimeOriginal"] = datetimeoriginal
exif["OffsetTimeOriginal"] = offsettime
json_str = json.dumps([exif])
return json_str
def _write_sidecar_car(self, filename, json_str):
if not filename and not json_str:
raise (
ValueError(
f"filename {filename} and json_str {json_str} must not be None"
)
)
# TODO: catch exception?
f = open(filename, "w")
f.write(json_str)
f.close()
@property
def _longitude(self):
""" Returns longitude, in degrees """
return self._info["longitude"]
@property
def _latitude(self):
""" Returns latitude, in degrees """
return self._info["latitude"]
def __repr__(self):
# TODO: update to use __class__ and __name__
return f"osxphotos.PhotoInfo(db={self._db}, uuid='{self._uuid}', info={self._info})"
def __str__(self):
info = {
"uuid": self.uuid,
"filename": self.filename,
"original_filename": self.original_filename,
"date": str(self.date),
"description": self.description,
"name": self.name,
"keywords": self.keywords,
"albums": self.albums,
"persons": self.persons,
"path": self.path,
"ismissing": self.ismissing,
"hasadjustments": self.hasadjustments,
"external_edit": self.external_edit,
"favorite": self.favorite,
"hidden": self.hidden,
"latitude": self._latitude,
"longitude": self._longitude,
"path_edited": self.path_edited,
}
return yaml.dump(info, sort_keys=False)
def json(self):
""" return JSON representation """
# TODO: Add additional details here
pic = {
"uuid": self.uuid,
"filename": self.filename,
"original_filename": self.original_filename,
"date": str(self.date),
"description": self.description,
"title": self.title,
"keywords": self.keywords,
"albums": self.albums,
"persons": self.persons,
"path": self.path,
"ismissing": self.ismissing,
"hasadjustments": self.hasadjustments,
"external_edit": self.external_edit,
"favorite": self.favorite,
"hidden": self.hidden,
"latitude": self._latitude,
"longitude": self._longitude,
"path_edited": self.path_edited,
}
return json.dumps(pic)
# compare two PhotoInfo objects for equality
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.__dict__ == other.__dict__
else:
return False
def __ne__(self, other):
return not self.__eq__(other)

1082
osxphotos/photosdb.py Normal file

File diff suppressed because it is too large Load Diff

225
osxphotos/utils.py Normal file
View File

@@ -0,0 +1,225 @@
import glob
import logging
import os.path
import platform
import subprocess
import urllib.parse
from pathlib import Path
from plistlib import load as plistload
import CoreFoundation
import objc
from Foundation import *
def _get_os_version():
# returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6)
version = platform.mac_ver()[0].split(".")
if len(version) == 2:
(ver, major) = version
minor = "0"
elif len(version) == 3:
(ver, major, minor) = version
else:
raise (
ValueError(
f"Could not parse version string: {platform.mac_ver()} {version}"
)
)
return (ver, major, minor)
def _check_file_exists(filename):
""" returns true if file exists and is not a directory
otherwise returns false """
filename = os.path.abspath(filename)
return os.path.exists(filename) and not os.path.isdir(filename)
def _get_resource_loc(model_id):
""" returns folder_id and file_id needed to find location of edited photo """
""" and live photos for version <= Photos 4.0 """
# determine folder where Photos stores edited version
# edited images are stored in:
# Photos Library.photoslibrary/resources/media/version/XX/00/fullsizeoutput_Y.jpeg
# where XX and Y are computed based on RKModelResources.modelId
# file_id (Y in above example) is hex representation of model_id without leading 0x
file_id = hex_id = hex(model_id)[2:]
# folder_id (XX) in above example if first two chars of model_id converted to hex
# and left padded with zeros if < 4 digits
folder_id = hex_id.zfill(4)[0:2]
return folder_id, file_id
def _dd_to_dms(dd):
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
""" return tuple of int(deg), int(min), float(sec) """
dd = float(dd)
negative = dd < 0
dd = abs(dd)
min_, sec_ = divmod(dd * 3600, 60)
deg_, min_ = divmod(min_, 60)
if negative:
if deg_ > 0:
deg_ = deg_ * -1
elif min_ > 0:
min_ = min_ * -1
else:
sec_ = sec_ * -1
return int(deg_), int(min_), sec_
def dd_to_dms_str(lat, lon):
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
""" lat: latitude in degrees """
""" lon: longitude in degrees """
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
""" this is the same format used by exiftool's json format """
# TODO: add this to readme
lat_deg, lat_min, lat_sec = _dd_to_dms(lat)
lon_deg, lon_min, lon_sec = _dd_to_dms(lon)
lat_hemisphere = "N"
if any([lat_deg < 0, lat_min < 0, lat_sec < 0]):
lat_hemisphere = "S"
lon_hemisphere = "E"
if any([lon_deg < 0, lon_min < 0, lon_sec < 0]):
lon_hemisphere = "W"
lat_str = (
f"{abs(lat_deg)} deg {abs(lat_min)}' {abs(lat_sec):.2f}\" {lat_hemisphere}"
)
lon_str = (
f"{abs(lon_deg)} deg {abs(lon_min)}' {abs(lon_sec):.2f}\" {lon_hemisphere}"
)
return lat_str, lon_str
def get_system_library_path():
""" return the path to the system Photos library as string """
""" only works on MacOS 10.15+ """
""" on earlier versions, will raise exception """
_, major, _ = _get_os_version()
if int(major) < 15:
raise Exception(
"get_system_library_path not implemented for MacOS < 10.15", major
)
plist_file = Path(
str(Path.home())
+ "/Library/Containers/com.apple.photolibraryd/Data/Library/Preferences/com.apple.photolibraryd.plist"
)
if plist_file.is_file():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
logging.warning(f"could not find plist file: {str(plist_file)}")
return None
photospath = pl["SystemLibraryPath"]
if photospath is not None:
return photospath
else:
logging.warning("Could not get path to Photos database")
return None
def get_last_library_path():
""" return the path to the last opened Photos library """
# TODO: Need a module level method for this and another PhotosDB method to get current library path
plist_file = Path(
str(Path.home())
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
)
if plist_file.is_file():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
logging.warning(f"could not find plist file: {str(plist_file)}")
return None
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
# this is a serialized CFData object
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
if photosurlref is not None:
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
)
# the CFURLRef we got is a sruct that python treats as an array
# I'd like to pass this to CFURLGetFileSystemRepresentation to get the path but
# CFURLGetFileSystemRepresentation barfs when it gets an array from python instead of expected struct
# first element is the path string in form:
# file:///Users/username/Pictures/Photos%20Library.photoslibrary/
photosurlstr = photosurl[0].absoluteString() if photosurl[0] else None
# now coerce the file URI back into an OS path
# surely there must be a better way
if photosurlstr is not None:
photospath = os.path.normpath(
urllib.parse.unquote(urllib.parse.urlparse(photosurlstr).path)
)
else:
logging.warning(
"Could not extract photos URL String from IPXDefaultLibraryURLBookmark"
)
return None
return photospath
else:
logging.warning("Could not get path to Photos database")
return None
def list_photo_libraries():
""" returns list of Photos libraries found on the system """
""" on MacOS < 10.15, this may omit some libraries """
# On 10.15, mdfind appears to find all libraries
# On older MacOS versions, mdfind appears to ignore some libraries
# glob to find libraries in ~/Pictures then mdfind to find all the others
# TODO: make this more robust
lib_list = glob.glob(f"{str(Path.home())}/Pictures/*.photoslibrary")
# On older OS, may not get all libraries so make sure we get the last one
last_lib = get_last_library_path()
if last_lib:
lib_list.append(last_lib)
output = subprocess.check_output(
["/usr/bin/mdfind", "-onlyin", "/", "-name", ".photoslibrary"]
).splitlines()
for lib in output:
lib_list.append(lib.decode("utf-8"))
lib_list = list(set(lib_list))
lib_list.sort()
return lib_list
def create_path_by_date(dest, dt):
""" Creates a path in dest folder in form dest/YYYY/MM/DD/
dest: valid path as str
dt: datetime.timetuple() object
Checks to see if path exists, if it does, do nothing and return path
If path does not exist, creates it and returns path"""
if not os.path.isdir(dest):
raise FileNotFoundError(f"dest {dest} must be valid path")
yyyy, mm, dd = dt[0:3]
yyyy = str(yyyy).zfill(4)
mm = str(mm).zfill(2)
dd = str(dd).zfill(2)
new_dest = os.path.join(dest, yyyy, mm, dd)
if not os.path.isdir(new_dest):
os.makedirs(new_dest)
return new_dest

View File

@@ -26,19 +26,22 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# from distutils.core import setup
from setuptools import setup, find_packages
import os
from setuptools import find_packages, setup
# read the contents of README file
from os import path
this_directory = path.abspath(path.dirname(__file__))
with open(path.join(this_directory, "README.md"), encoding="utf-8") as f:
this_directory = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
long_description = f.read()
about = {}
with open(
os.path.join(this_directory, "osxphotos", "_version.py"), mode="r", encoding="utf-8"
) as f:
exec(f.read(), about)
setup(
name="osxphotos",
version="0.14.8",
version=about["__version__"],
description="Manipulate (read-only) Apple's Photos app library on Mac OS X",
long_description=long_description,
long_description_content_type="text/markdown",
@@ -59,5 +62,5 @@ setup(
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=["pyobjc", "Click", "pyyaml"],
entry_points={"console_scripts": ["osxphotos=osxphotos.cmd_line:cli"]},
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
)

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-08-24T02:50:48Z</date>
<date>2019-12-08T16:44:38Z</date>
</dict>
<key>PXPeopleScreenUnlocked</key>
<true/>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-08-24T02:51:33Z</date>
<date>2019-12-24T02:00:05Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-08-24T13:19:30Z</date>
<date>2019-12-24T02:00:05Z</date>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-08-24T02:51:30Z</date>
<date>2019-12-20T15:56:12Z</date>
</dict>
</plist>

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>403</integer>
<integer>414</integer>
<key>LibraryBuildTag</key>
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
<key>LibrarySchemaVersion</key>

View File

@@ -23,11 +23,11 @@
<key>key</key>
<integer>1</integer>
<key>lastKnownDisplayName</key>
<string>September 28, 2018</string>
<string>Test Album (1)</string>
<key>type</key>
<string>album</string>
<key>uuid</key>
<string>DFFKmHt3Tk+AGzZLe2Xq+g</string>
<string>Uq6qsKihRRSjMHTiD+0Azg</string>
</dict>
<key>lastKnownItemCounts</key>
<dict>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-11-18T01:38:02Z</date>
<date>2019-12-22T18:50:53Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-11-27T05:30:14Z</date>
<date>2019-12-23T14:57:50Z</date>
</dict>
</plist>

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-11-18T04:50:50Z</date>
<date>2019-12-16T06:19:55Z</date>
</dict>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-11-27T05:30:19Z</date>
<date>2019-12-22T07:53:26Z</date>
</dict>
</plist>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>522</integer>
<integer>575</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>2019-11-27T05:30:11Z</date>
<date>2019-12-22T07:56:25Z</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>1368</integer>
<integer>450</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

View File

@@ -4,6 +4,10 @@
<dict>
<key>CollapsedSidebarSectionIdentifiers</key>
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>92D68107-B6C7-453B-96D2-97B0F26D5B8B/L0/020</string>
</array>
<key>Photos</key>
<dict>
<key>CollapsedSidebarSectionIdentifiers</key>

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2019-11-13T03:46:27Z</date>
<date>2019-12-24T10:35:40Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2019-11-13T03:46:27Z</date>
<date>2019-12-24T10:35:40Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2019-11-13T03:46:27Z</date>
<date>2019-12-24T10:35:40Z</date>
<key>BackgroundJobSearch</key>
<date>2019-11-13T03:46:27Z</date>
<date>2019-12-24T10:35:40Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2019-11-13T03:46:27Z</date>
<date>2019-12-24T10:35:40Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2019-11-13T03:46:28Z</date>
<date>2019-12-24T02:51:11Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2019-11-13T03:46:28Z</date>
<date>2019-12-24T10:35:42Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-11-13T03:46:27Z</date>
<date>2019-12-24T02:51:10Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-11-13T03:46:28Z</date>
<date>2019-12-24T10:35:41Z</date>
<key>SiriPortraitDonation</key>
<date>2019-11-13T03:46:28Z</date>
<date>2019-12-24T02:51:11Z</date>
</dict>
</plist>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>FaceIDModelLastGenerationKey</key>
<date>2019-11-13T03:46:28Z</date>
<date>2019-12-24T02:51:11Z</date>
<key>LastContactClassificationKey</key>
<date>2019-11-13T03:46:29Z</date>
<date>2019-12-24T02:51:13Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>IncrementalPersonProcessingStage</key>
<integer>6</integer>
<integer>0</integer>
<key>PersonBuilderLastMinimumFaceGroupSizeForCreatingMergeCandidates</key>
<integer>15</integer>
<key>PersonBuilderMergeCandidatesEnabled</key>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>coalesceDate</key>
<date>2019-12-08T18:06:37Z</date>
<key>coalescePayloadVersion</key>
<integer>1</integer>
<key>currentPayloadVersion</key>
<integer>1</integer>
<key>snapshotDate</key>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>coalesceDate</key>
<date>2019-10-27T15:36:05Z</date>
<date>2019-12-08T18:06:37Z</date>
<key>coalescePayloadVersion</key>
<integer>1</integer>
<key>currentPayloadVersion</key>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>adjustmentBaseVersion</key>
<integer>0</integer>
<key>adjustmentData</key>
<data>
shkyAAAAAAACAAAA
</data>
<key>adjustmentEditorBundleID</key>
<string>com.apple.Photos</string>
<key>adjustmentFormatIdentifier</key>
<string>com.apple.Photos.externalEdit</string>
<key>adjustmentFormatVersion</key>
<string>1</string>
<key>adjustmentTimestamp</key>
<date>2019-12-01T15:27:48Z</date>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

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