Compare commits

...

124 Commits

Author SHA1 Message Date
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
Rhet Turnbull
55a6b5bf1c version bump 2019-11-30 11:03:07 -08:00
Rhet Turnbull
37dfc1e151 Fixed path_edited() for Photos 4.0 2019-11-30 10:54:43 -08:00
Rhet Turnbull
01b2f4420f fixed typo 2019-11-29 08:48:35 -08:00
Rhet Turnbull
68eef42599 Added path_edited() for Photos 5, still needs to be added for Photos <= 4.0 2019-11-29 08:47:11 -08:00
Rhet Turnbull
3dc0943453 cleaned up commented out code 2019-11-29 06:56:33 -08:00
Rhet Turnbull
7818fe0fcf Now copy write-ahead log and shared memory files which fixes problem with access to recently changed data 2019-11-28 09:25:22 -08:00
Rhet Turnbull
792fff13b8 added hasadjustments and tests for Photo5 2019-11-27 22:37:42 -08:00
Rhet Turnbull
b2242da9b7 cleaned up test code 2019-11-27 21:37:49 -08:00
Rhet Turnbull
1e5633efc8 TODO update 2019-11-27 07:42:44 -08:00
Rhet Turnbull
27b3469513 Added latitude, longitude to cmd_line 2019-11-27 07:39:48 -08:00
Rhet Turnbull
aa25c9eab7 Updated TODO 2019-11-27 07:32:11 -08:00
Rhet Turnbull
44321da243 Added location (latitude/longitude), closes issue #2 2019-11-27 07:27:49 -08:00
Rhet Turnbull
67127ef370 removed .jpg_originals that were added by mistake 2019-11-26 21:48:37 -08:00
Rhet Turnbull
51e720dce9 Added tests for hidden and favorite to both 14.6 and 15.1 2019-11-26 21:47:05 -08:00
Rhet Turnbull
45b8150d5f Added TODO 2019-11-25 21:25:21 -08:00
Rhet Turnbull
6ebacb7f38 README update 2019-11-25 20:57:48 -08:00
Rhet Turnbull
1b17608a02 Added TODOs 2019-11-24 21:24:02 -08:00
Rhet Turnbull
d4353d48bd Fixed bug in date() if imageTimeZoneOffsetSeconds was None 2019-11-24 21:12:26 -08:00
Rhet Turnbull
5af2b3e039 Added name and description to cmd_line 2019-11-24 21:08:01 -08:00
Rhet Turnbull
b36b7e7eb2 Added hidden/favorite/missing to cmd_line 2019-11-24 20:14:59 -08:00
Rhet Turnbull
99be93d88f version bump 2019-11-24 09:46:51 -08:00
Rhet Turnbull
d840c11ddb Added favorite and hidden to cmd_line 2019-11-24 09:45:36 -08:00
Rhet Turnbull
b1fd019120 Added hidden and favorite and test for 10.15 2019-11-24 09:39:36 -08:00
Rhet Turnbull
8b4a386c13 Fixed original_filename for older database versions 2019-11-24 08:46:02 -08:00
Rhet Turnbull
6abd10eddf removed old progress bar code 2019-11-24 08:35:30 -08:00
Rhet Turnbull
aa73c2f055 removed loguru code 2019-11-24 08:33:00 -08:00
Rhet Turnbull
966954340b version bump for release to pypi 2019-11-23 19:40:29 -08:00
Rhet Turnbull
7732708cb2 fixed command line to better handle detected but unidentified faces in Photos 5 2019-11-23 19:24:20 -08:00
Rhet Turnbull
a7d9f72372 Updated doc string 2019-11-23 19:06:52 -08:00
Rhet Turnbull
eabda3ea93 Turned off debug 2019-11-23 19:05:25 -08:00
Rhet Turnbull
a995a8d610 README update 2019-11-23 14:53:17 -08:00
Rhet Turnbull
ecd75c775d Added link to issues 2019-11-23 14:51:32 -08:00
Rhet Turnbull
ae7fc69b33 version bump 2019-11-23 14:49:13 -08:00
Rhet Turnbull
83186a655d Changed path() to return absolute path and fixed tests 2019-11-23 14:48:38 -08:00
Rhet Turnbull
243492df88 added test for 10.15/Catalina 2019-11-23 13:56:26 -08:00
Rhet Turnbull
986a092130 fixed == / != None 2019-11-23 13:27:54 -08:00
Rhet Turnbull
1c323338c6 version bump 2019-11-18 21:04:30 -08:00
Rhet Turnbull
b005f70133 fixed bug on 3.6 due to logging 2019-11-18 21:03:55 -08:00
Rhet Turnbull
9023a69073 Initial release for MacOS 10.15 / Photos 5 2019-11-17 18:01:03 -08:00
Rhet Turnbull
e7958c94e8 alpha version of filenames/paths 2019-11-17 17:19:33 -08:00
Rhet Turnbull
17bc04a24a replaced debug print statements with logging.debug 2019-11-17 16:46:34 -08:00
Rhet Turnbull
e0b1113870 replaced debug print statements with logging.debug 2019-11-17 16:45:46 -08:00
Rhet Turnbull
10f0cf1092 query will now run on Photos 5 2019-11-17 08:53:24 -08:00
Rhet Turnbull
a4b5f2a501 basic Photos 5 info now being read 2019-11-17 08:43:34 -08:00
Rhet Turnbull
02fcfbca2c More updates to _process_photos5 2019-11-16 11:01:04 -08:00
Rhet Turnbull
7eff015439 moved process_photos to process_photos4 and process_photos5 2019-11-16 08:18:22 -08:00
Rhet Turnbull
1c7e81b578 Fixed cleanup code 2019-11-16 07:59:59 -08:00
Rhet Turnbull
8649cda11f Fixed cleanup code 2019-11-16 07:59:04 -08:00
Rhet Turnbull
0c152c8c91 Updated some doc strings 2019-11-16 07:28:45 -08:00
Rhet Turnbull
b0a9e87d00 added _cleanup_tmp_files 2019-11-13 21:48:50 -08:00
Rhet Turnbull
fc6d7b1cf5 added comments/TODOs 2019-11-11 21:39:51 -08:00
Rhet Turnbull
5234567974 Moved db version check to function in prep for move to Photos 5 code 2019-11-11 21:30:51 -08:00
Rhet Turnbull
f62a9d3d4e fixed version check for Catalina 2019-10-14 09:44:11 -07:00
Rhet Turnbull
d311431c91 README update, added todo 2019-09-30 09:57:55 -07:00
Rhet Turnbull
572e32b71b version bump 2019-08-31 13:01:25 -07:00
Rhet Turnbull
3744a596b5 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-08-31 13:00:29 -07:00
Rhet Turnbull
221df0029e Added pythonpackage.yml for CI workflow 2019-08-31 12:52:21 -07:00
Rhet Turnbull
a1038314e2 added todo 2019-08-24 08:36:57 -07:00
Rhet Turnbull
39ef8ddf3f fixed typo in README 2019-08-24 08:31:34 -07:00
217 changed files with 5574 additions and 3509 deletions

34
.github/workflows/pythonpackage.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Python package
on: [push]
jobs:
build:
runs-on: macOS-10.14
strategy:
max-parallel: 4
matrix:
python-version: [3.6, 3.7]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint with flake8
run: |
# pip install flake8
# stop the build if there are Python syntax errors or undefined names
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pip install pytest
python -m pytest tests/

456
README.md
View File

@@ -3,15 +3,71 @@
[![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)
+ [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 Mac OS X. 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.
## Supported operating systems
Only works on Mac OS X. Tested on Mac OS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0 and Mac OS 10.14.5, 10.14.6 / Photos 4.0. Requires python >= 3.6
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 / Photos 5.0. Requires python >= 3.6
This module will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12
This 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 Mac OS 10.4 on a machine running Mac OS 10.12
## Installation instructions
@@ -21,29 +77,35 @@ osxmetadata uses setuptools, thus simply run:
## Command Line Usage
This module will install a command line utility called `osxphotos` that allows you to query the Photos database.
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 install 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>`
@@ -53,35 +115,51 @@ Example: `osxphotos help query`
```
Usage: osxphotos help [OPTIONS]
query the Photos database using 1 or more search 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)
--json Print output in JSON format
-h, --help Show this message and exit.
--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.
```
Example: find all photos with keyword "Kids" and output results to json file named results.json:
`osxphotos query --keyword Kids --json >results.json`
## Example uses of the module
## Example uses of the module
```python
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"])
@@ -92,20 +170,52 @@ def main():
for p in photos:
print(
p.uuid,
p.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
@@ -113,12 +223,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
@@ -126,7 +242,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
@@ -135,12 +269,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
@@ -148,15 +292,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
@@ -164,7 +310,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).
@@ -172,7 +318,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).
@@ -180,37 +326,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',]])
@@ -231,7 +380,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")
@@ -279,75 +428,184 @@ 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 filename of the photo
#### `filename`
Returns the current filename of the photo on disk. See also `original_filename`
#### `date()`
#### `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`
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 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`)
#### `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.
#### `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`.
#### `hasadjustments()`
Returns `True` if the file has been edited in Photos, otherwise `False`
#### `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.
#### `to_json()`
#### `hasadjustments`
Returns `True` if the picture has been edited, otherwise `False`
#### `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`
Returns `True` if the picture has been marked as hidden, otherwise `False`
#### `location`
Returns latitude and longitude as a tuple of floats (latitude, longitude). If location is not set, latitude and longitude are returned as `None`
#### `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.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.
### 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 is very kludgy. It 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.
The sqlite3 database used by Photos uses write ahead logging that is updated asynchronously in the background by a Photos helper service. Sometimes the update takes a long time meaning the latest changes made in Photos (e.g. add a keyword) will not show up in the database for sometime. I know of no way around this.
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/)
@@ -355,9 +613,5 @@ The sqlite3 database used by Photos uses write ahead logging that is updated asy
- [Click](https://pypi.org/project/click/)
## Acknowledgements
This code 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()

View File

@@ -1,727 +1,34 @@
import json
import os.path
import platform
import pprint
import sqlite3
import sys
import tempfile
import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path
from plistlib import load as plistload
from shutil import copyfile
import logging
import CoreFoundation
import objc
import yaml
from Foundation import *
from ._version import __version__
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from . import _applescript
# from loguru import logger
# TODO: standardize _ and __ as leading char for private variables
# TODO: fix use of ''' and """
# TODO: find edited photos: see https://github.com/orangeturtle739/photos-export/blob/master/extract_photos.py
# TODO: Add test for imageTimeZoneOffsetSeconds = None
# TODO: Fix command line so multiple --keyword, etc. are AND (instead of OR as they are in .photos())
# Or fix the help text to match behavior
# TODO: Add test for __str__ and to_json
# TODO: fix docstrings
# TODO: fix versions tested to include 10.14.6
# TODO: Add special albums and magic albums
# TODO: cleanup os.path and pathlib code (import pathlib and also from pathlib import Path)
# 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.4.6) == 4025
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
_TESTED_DB_VERSIONS = ["4025", "4016", "3301", "2622"]
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14"]
# set _DEBUG = True to enable debug output
_DEBUG = False
_debug = False
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s",
)
if not _DEBUG:
logging.disable(logging.DEBUG)
def _get_os_version():
# returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6)
(ver, major, minor) = platform.mac_ver()[0].split(".")
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)
class PhotosDB:
def __init__(self, dbfile=None):
# Check OS version
system = platform.system()
(_, major, _) = _get_os_version()
# logger.debug(system, major)
if system != "Darwin" or (major not in _TESTED_OS_VERSIONS):
print(
"WARNING: This module has only been tested with MacOS 10."
+ f"[{', '.join(_TESTED_OS_VERSIONS)}]: "
+ f"you have {system}, OS version: {major}",
file=sys.stderr,
)
# Dict with information about all photos by uuid
self._dbphotos = {}
# Dict with information about all persons/photos by uuid
self._dbfaces_uuid = {}
# Dict with information about all persons/photos by person
self._dbfaces_person = {}
# Dict with information about all keywords/photos by uuid
self._dbkeywords_uuid = {}
# Dict with information about all keywords/photos by keyword
self._dbkeywords_keyword = {}
# Dict with information about all albums/photos by uuid
self._dbalbums_uuid = {}
# Dict with information about all albums/photos by album
self._dbalbums_album = {}
# Dict with information about all the volumes/photos by uuid
self._dbvolumes = {}
# logger.debug(dbfile)
if dbfile is None:
library_path = self.get_photos_library_path()
# logger.debug("library_path: " + library_path)
# TODO: verify library path not None
dbfile = os.path.join(library_path, "database/photos.db")
# logger.debug(dbfile)
# logger.debug(f"filename = {dbfile}")
# TODO: replace os.path with pathlib
# TODO: clean this up -- we'll already know library_path
library_path = os.path.dirname(dbfile)
(library_path, _) = os.path.split(library_path)
masters_path = os.path.join(library_path, "Masters")
self._masters_path = masters_path
# logger.debug(f"library = {library_path}, masters = {masters_path}")
if not _check_file_exists(dbfile):
sys.exit(f"_dbfile {dbfile} does not exist")
# logger.info(f"database filename = {dbfile}")
self._dbfile = dbfile
self._setup_applescript()
self._process_database()
def keywords_as_dict(self):
# return keywords as dict of keyword, count in reverse sorted order (descending)
keywords = {}
for k in self._dbkeywords_keyword.keys():
keywords[k] = len(self._dbkeywords_keyword[k])
keywords = dict(sorted(keywords.items(), key=lambda kv: kv[1], reverse=True))
return keywords
def persons_as_dict(self):
# return persons as dict of person, count in reverse sorted order (descending)
persons = {}
for k in self._dbfaces_person.keys():
persons[k] = len(self._dbfaces_person[k])
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
return persons
def albums_as_dict(self):
# return albums as dict of albums, count in reverse sorted order (descending)
albums = {}
for k in self._dbalbums_album.keys():
albums[k] = len(self._dbalbums_album[k])
albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
return albums
def keywords(self):
# return list of keywords found in photos database
keywords = self._dbkeywords_keyword.keys()
return list(keywords)
def persons(self):
# return persons as dict of person, count in reverse sorted order (descending)
persons = self._dbfaces_person.keys()
return list(persons)
def albums(self):
# return albums as dict of albums, count in reverse sorted order (descending)
albums = self._dbalbums_album.keys()
return list(albums)
# Various AppleScripts we need
def _setup_applescript(self):
self._scpt_export = ""
self._scpt_launch = ""
self._scpt_quit = ""
# Compile apple script that exports one image
# self._scpt_export = _applescript.AppleScript('''
# on run {arg}
# set thepath to "%s"
# tell application "Photos"
# set theitem to media item id arg
# set thelist to {theitem}
# export thelist to POSIX file thepath
# end tell
# end run
# ''' % (tmppath))
#
# Compile apple script that launches Photos.App
self._scpt_launch = _applescript.AppleScript(
"""
on run
tell application "Photos"
activate
end tell
end run
"""
)
# Compile apple script that quits Photos.App
self._scpt_quit = _applescript.AppleScript(
"""
on run
tell application "Photos"
quit
end tell
end run
"""
)
def get_db_version(self):
# return the database version as stored in LiGlobals table
return self.__db_version
def get_db_path(self):
""" return path to the Photos library database PhotosDB was initialized with """
return os.path.abspath(self._dbfile)
def get_photos_library_path(self):
# return the path to the Photos library
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:
print("could not find plist file: " + str(plist_file), file=sys.stderr)
return None
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
# this is a serialized CFData object
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
if photosurlref != 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:
print(
"Could not extract photos URL String from IPXDefaultLibraryURLBookmark",
file=sys.stderr,
)
return None
return photospath
else:
print("Could not get path to Photos database", file=sys.stderr)
return None
# TODO: do we need to copy the db-wal write-ahead log file?
def _copy_db_file(self, fname):
# copies the sqlite database file to a temp file
# returns the name of the temp file
# required because python's sqlite3 implementation can't read a locked file
_, tmp = tempfile.mkstemp(suffix=".db", prefix="photos")
# logger.debug("copying " + fname + " to " + tmp)
try:
copyfile(fname, tmp)
except:
print("Error copying " + fname + " to " + tmp, file=sys.stderr)
raise Exception
return tmp
def _open_sql_file(self, file):
fname = file
# logger.debug(f"Trying to open database {fname}")
try:
conn = sqlite3.connect(f"{fname}")
c = conn.cursor()
except sqlite3.Error as e:
print(f"An error occurred: {e.args[0]} {fname}")
sys.exit(3)
# logger.debug("SQLite database is open")
return (conn, c)
def _process_database(self):
global _debug
fname = self._dbfile
# Epoch is Jan 1, 2001
td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
# Ensure Photos.App is not running
self._scpt_quit.run()
tmp_db = self._copy_db_file(fname)
(conn, c) = self._open_sql_file(tmp_db)
# logger.debug("Have connection with database")
# get database version
c.execute(
"SELECT value from LiGlobals where LiGlobals.keyPath is 'libraryVersion'"
)
for ver in c:
self.__db_version = ver[0]
break # TODO: is there a more pythonic way to do get the first element from cursor?
if self.__db_version not in _TESTED_DB_VERSIONS:
print(
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
+ f" You have database version={self.__db_version} which has not been tested"
)
# Look for all combinations of persons and pictures
# logger.debug("Getting information about persons")
i = 0
c.execute(
"select count(*) from RKFace, RKPerson, RKVersion where RKFace.personID = RKperson.modelID "
+ "and RKFace.imageModelId = RKVersion.modelId and RKVersion.isInTrash = 0"
)
# init_pbar_status("Faces", c.fetchone()[0])
# c.execute("select RKPerson.name, RKFace.imageID from RKFace, RKPerson where RKFace.personID = RKperson.modelID")
c.execute(
"select RKPerson.name, RKVersion.uuid from RKFace, RKPerson, RKVersion, RKMaster "
+ "where RKFace.personID = RKperson.modelID and RKVersion.modelId = RKFace.ImageModelId "
+ "and RKVersion.type = 2 and RKVersion.masterUuid = RKMaster.uuid and "
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
for person in c:
if person[0] == None:
# logger.debug(f"skipping person = None {person[1]}")
continue
if not person[1] in self._dbfaces_uuid:
self._dbfaces_uuid[person[1]] = []
if not person[0] in self._dbfaces_person:
self._dbfaces_person[person[0]] = []
self._dbfaces_uuid[person[1]].append(person[0])
self._dbfaces_person[person[0]].append(person[1])
# set_pbar_status(i)
i = i + 1
# logger.debug("Finished walking through persons")
# close_pbar_status()
# logger.debug("Getting information about albums")
i = 0
c.execute(
"select count(*) from RKAlbum, RKVersion, RKAlbumVersion where "
+ "RKAlbum.modelID = RKAlbumVersion.albumId and "
+ "RKAlbumVersion.versionID = RKVersion.modelId and "
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
# init_pbar_status("Albums", c.fetchone()[0])
# c.execute("select RKPerson.name, RKFace.imageID from RKFace, RKPerson where RKFace.personID = RKperson.modelID")
c.execute(
"select RKAlbum.name, RKVersion.uuid from RKAlbum, RKVersion, RKAlbumVersion "
+ "where RKAlbum.modelID = RKAlbumVersion.albumId and "
+ "RKAlbumVersion.versionID = RKVersion.modelId and RKVersion.type = 2 and "
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
for album in c:
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
if not album[1] in self._dbalbums_uuid:
self._dbalbums_uuid[album[1]] = []
if not album[0] in self._dbalbums_album:
self._dbalbums_album[album[0]] = []
self._dbalbums_uuid[album[1]].append(album[0])
self._dbalbums_album[album[0]].append(album[1])
# logger.debug(f"{album[1]} {album[0]}")
# set_pbar_status(i)
i = i + 1
# logger.debug("Finished walking through albums")
# close_pbar_status()
# logger.debug("Getting information about keywords")
c.execute(
"select count(*) from RKKeyword, RKKeywordForVersion,RKVersion, RKMaster "
+ "where RKKeyword.modelId = RKKeyWordForVersion.keywordID and "
+ "RKVersion.modelID = RKKeywordForVersion.versionID and RKMaster.uuid = "
+ "RKVersion.masterUuid and RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
# init_pbar_status("Keywords", c.fetchone()[0])
c.execute(
"select RKKeyword.name, RKVersion.uuid, RKMaster.uuid from "
+ "RKKeyword, RKKeywordForVersion, RKVersion, RKMaster "
+ "where RKKeyword.modelId = RKKeyWordForVersion.keywordID and "
+ "RKVersion.modelID = RKKeywordForVersion.versionID "
+ "and RKMaster.uuid = RKVersion.masterUuid and RKVersion.type = 2 "
+ "and RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
i = 0
for keyword in c:
if not keyword[1] in self._dbkeywords_uuid:
self._dbkeywords_uuid[keyword[1]] = []
if not keyword[0] in self._dbkeywords_keyword:
self._dbkeywords_keyword[keyword[0]] = []
self._dbkeywords_uuid[keyword[1]].append(keyword[0])
self._dbkeywords_keyword[keyword[0]].append(keyword[1])
# logger.debug(f"{keyword[1]} {keyword[0]}")
# set_pbar_status(i)
i = i + 1
# logger.debug("Finished walking through keywords")
# close_pbar_status()
# logger.debug("Getting information about volumes")
c.execute("select count(*) from RKVolume")
# init_pbar_status("Volumes", c.fetchone()[0])
c.execute("select RKVolume.modelId, RKVolume.name from RKVolume")
i = 0
for vol in c:
self._dbvolumes[vol[0]] = vol[1]
# logger.debug(f"{vol[0]} {vol[1]}")
# set_pbar_status(i)
i = i + 1
# logger.debug("Finished walking through volumes")
# close_pbar_status()
# logger.debug("Getting information about photos")
c.execute(
"select count(*) from RKVersion, RKMaster where RKVersion.isInTrash = 0 and "
+ "RKVersion.type = 2 and RKVersion.masterUuid = RKMaster.uuid and "
+ "RKVersion.filename not like '%.pdf'"
)
# init_pbar_status("Photos", c.fetchone()[0])
c.execute(
"select RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename, "
+ "RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating, "
+ "RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds, "
+ "RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name, "
+ "RKMaster.isMissing "
+ "from RKVersion, RKMaster where RKVersion.isInTrash = 0 and RKVersion.type = 2 and "
+ "RKVersion.masterUuid = RKMaster.uuid and RKVersion.filename not like '%.pdf'"
)
i = 0
for row in c:
# set_pbar_status(i)
i = i + 1
uuid = row[0]
if _debug:
print(f"i = {i:d}, uuid = '{uuid}, master = '{row[2]}")
self._dbphotos[uuid] = {}
self._dbphotos[uuid]["modelID"] = row[1]
self._dbphotos[uuid]["masterUuid"] = row[2]
self._dbphotos[uuid]["filename"] = row[3]
try:
self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp(
row[4] + td
)
except:
self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp(
row[5] + td
)
self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp(
row[5] + td
) # - row[9], timezone.utc)
self._dbphotos[uuid]["mainRating"] = row[6]
self._dbphotos[uuid]["hasAdjustments"] = row[7]
self._dbphotos[uuid]["hasKeywords"] = row[8]
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
self._dbphotos[uuid]["volumeId"] = row[10]
self._dbphotos[uuid]["imagePath"] = row[11]
self._dbphotos[uuid]["extendedDescription"] = row[12]
self._dbphotos[uuid]["name"] = row[13]
self._dbphotos[uuid]["isMissing"] = row[14]
# logger.debug(
# "Fetching data for photo %d %s %s %s %s %s: %s"
# % (
# i,
# uuid,
# self._dbphotos[uuid]["masterUuid"],
# self._dbphotos[uuid]["volumeId"],
# self._dbphotos[uuid]["filename"],
# self._dbphotos[uuid]["extendedDescription"],
# self._dbphotos[uuid]["imageDate"],
# )
# )
# close_pbar_status()
conn.close()
# add faces and keywords to photo data
for uuid in self._dbphotos:
# keywords
if self._dbphotos[uuid]["hasKeywords"] == 1:
self._dbphotos[uuid]["keywords"] = self._dbkeywords_uuid[uuid]
else:
self._dbphotos[uuid]["keywords"] = []
if uuid in self._dbfaces_uuid:
self._dbphotos[uuid]["hasPersons"] = 1
self._dbphotos[uuid]["persons"] = self._dbfaces_uuid[uuid]
else:
self._dbphotos[uuid]["hasPersons"] = 0
self._dbphotos[uuid]["persons"] = []
if uuid in self._dbalbums_uuid:
self._dbphotos[uuid]["albums"] = self._dbalbums_uuid[uuid]
self._dbphotos[uuid]["hasAlbums"] = 1
else:
self._dbphotos[uuid]["albums"] = []
self._dbphotos[uuid]["hasAlbums"] = 0
if self._dbphotos[uuid]["volumeId"] is not None:
self._dbphotos[uuid]["volume"] = self._dbvolumes[
self._dbphotos[uuid]["volumeId"]
]
else:
self._dbphotos[uuid]["volume"] = None
# remove temporary copy of the database
try:
# logger.info("Removing temporary database file: " + tmp_db)
os.remove(tmp_db)
except:
print("Could not remove temporary database: " + tmp_db, file=sys.stderr)
if _debug:
pp = pprint.PrettyPrinter(indent=4)
print("Faces:")
pp.pprint(self._dbfaces_uuid)
print("Keywords by uuid:")
pp.pprint(self._dbkeywords_uuid)
print("Keywords by keyword:")
pp.pprint(self._dbkeywords_keyword)
print("Albums by uuid:")
pp.pprint(self._dbalbums_uuid)
print("Albums by album:")
pp.pprint(self._dbalbums_album)
print("Volumes:")
pp.pprint(self._dbvolumes)
print("Photos:")
pp.pprint(self._dbphotos)
# logger.debug(f"processed {len(self._dbphotos)} photos")
"""
Return a list of PhotoInfo objects
If called with no args, returns the entire database of photos
If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
"""
def photos(self, keywords=[], uuid=[], persons=[], albums=[]):
# TODO: remove the logger code then dangling else: pass statements
photos_sets = [] # list of photo sets to perform intersection of
if not keywords and not uuid and not persons and not albums:
# return all the photos
# append keys of all photos as a single set to photos_sets
# logger.debug("return all photos")
photos_sets.append(set(self._dbphotos.keys()))
else:
if albums:
for album in albums:
# logger.info(f"album={album}")
if album in self._dbalbums_album:
# logger.info(f"processing album {album}:")
photos_sets.append(set(self._dbalbums_album[album]))
else:
# logger.debug(f"Could not find album '{album}' in database")
pass
if uuid:
for u in uuid:
# logger.info(f"uuid={u}")
if u in self._dbphotos:
# logger.info(f"processing uuid {u}:")
photos_sets.append(set([u]))
else:
# logger.debug(f"Could not find uuid '{u}' in database")
pass
if keywords:
for keyword in keywords:
# logger.info(f"keyword={keyword}")
if keyword in self._dbkeywords_keyword:
# logger.info(f"processing keyword {keyword}:")
photos_sets.append(set(self._dbkeywords_keyword[keyword]))
# logger.debug(f"photos_sets {photos_sets}")
else:
# logger.debug(f"Could not find keyword '{keyword}' in database")
pass
if persons:
for person in persons:
# logger.info(f"person={person}")
if person in self._dbfaces_person:
# logger.info(f"processing person {person}:")
photos_sets.append(set(self._dbfaces_person[person]))
else:
# logger.debug(f"Could not find person '{person}' in database")
pass
photoinfo = []
if photos_sets: # found some photos
# get the intersection of each argument/search criteria
for p in set.intersection(*photos_sets):
# logger.debug(f"p={p}")
info = PhotoInfo(db=self, uuid=p, info=self._dbphotos[p])
# logger.debug(f"info={info}")
photoinfo.append(info)
return photoinfo
def __repr__(self):
return f"osxphotos.PhotosDB(dbfile='{self.get_db_path()}')"
"""
Info about a specific photo, contains all the details we know about the photo
including keywords, persons, albums, uuid, path, etc.
"""
class PhotoInfo:
def __init__(self, db=None, uuid=None, info=None):
self.__uuid = uuid
self.__info = info
self.__db = db
def filename(self):
return self.__info["filename"]
def date(self):
""" image creation date as timezone aware datetime object """
imagedate = self.__info["imageDate"]
delta = timedelta(seconds=self.__info["imageTimeZoneOffsetSeconds"])
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
def tzoffset(self):
""" timezone offset from UTC in seconds """
return self.__info["imageTimeZoneOffsetSeconds"]
def path(self):
photopath = ""
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:
# logger.warning(
# f"Skipping photo, not yet downloaded from iCloud: {photopath}"
# )
# logger.debug(self.__info)
photopath = None # path would be meaningless until downloaded
# TODO: Is there a way to use applescript to force the download in this
return photopath
def description(self):
return self.__info["extendedDescription"]
def persons(self):
return self.__info["persons"]
def albums(self):
return self.__info["albums"]
def keywords(self):
return self.__info["keywords"]
def name(self):
return self.__info["name"]
def uuid(self):
return self.__uuid
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
def hasadjustments(self):
return True if self.__info["hasAdjustments"] == 1 else False
def __repr__(self):
return f"osxphotos.PhotoInfo(db={self.__db}, uuid='{self.__uuid}', info={self.__info})"
def __str__(self):
info = {
"uuid": self.uuid(),
"filename": self.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(),
}
return yaml.dump(info, sort_keys=False)
def to_json(self):
""" return JSON representation """
pic = {
"uuid": self.uuid(),
"filename": self.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(),
}
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)
def _debug(debug):
""" Enable or disable debug logging """
if debug:
logging.disable(logging.NOTSET)
else:
logging.disable(logging.DEBUG)

690
osxphotos/__main__.py Normal file
View File

@@ -0,0 +1,690 @@
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__
# 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(
"--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,
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,
)
else:
for p in photos:
export_path = export_photo(
p, dest, verbose, export_by_date, sidecar, overwrite, export_edited
)
click.echo(f"Exported {p.filename} to {export_path}")
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
):
""" 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
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
if verbose:
click.echo(f"Exporting {photo.filename}")
if export_by_date:
date_created = photo.date.timetuple()
dest = create_path_by_date(dest, date_created)
photo_path = photo.export(dest, 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(photo.filename)
edited_name = f"{edited_name.stem}_edited{edited_name.suffix}"
if verbose:
click.echo(f"Exporting edited version of {photo.filename} as {edited_name}")
photo.export(
dest, edited_name, sidecar=sidecar, overwrite=overwrite, edited=True
)
return photo_path
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
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.02"

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,200 +0,0 @@
import csv
import json
import sys
import click
import yaml
import osxphotos
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()
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(
"--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, json):
""" query the Photos database using 1 or more search options """
# if no query terms, show help and return
if not keyword and not person and not album and not uuid:
print(cli.commands["query"].get_help(ctx))
return
else:
photos = cli_obj.photosdb.photos(
keywords=keyword, persons=person, albums=album, uuid=uuid
)
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",
"date",
"description",
"name",
"keywords",
"albums",
"persons",
"path",
"ismissing",
"hasadjustments",
]
)
for p in photos:
dump.append(
[
p.uuid(),
p.filename(),
str(p.date()),
p.description(),
p.name(),
", ".join(p.keywords()),
", ".join(p.albums()),
", ".join(p.persons()),
p.path(),
p.ismissing(),
p.hasadjustments(),
]
)
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

207
osxphotos/utils.py Normal file
View File

@@ -0,0 +1,207 @@
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

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# setup.py script for osxphotos
# setup.py script for osxphotos
#
# Copyright (c) 2019 Rhet Turnbull, rturnbull+git@gmail.com
# All rights reserved.
@@ -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.12.2",
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",
@@ -47,7 +50,7 @@ setup(
url="https://github.com/RhetTbull/",
project_urls={"GitHub": "https://github.com/RhetTbull/osxphotos"},
download_url="https://github.com/RhetTbull/osxphotos",
packages=find_packages(exclude=["tests","examples"]),
packages=find_packages(exclude=["tests", "examples"]),
license="License :: OSI Approved :: MIT License",
classifiers=[
"Development Status :: 4 - Beta",
@@ -58,8 +61,6 @@ setup(
"Programming Language :: Python :: 3.6",
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=["pyobjc","Click","pyyaml",],
entry_points = {
'console_scripts' : ['osxphotos=osxphotos.cmd_line:cli'],
}
install_requires=["pyobjc", "Click", "pyyaml"],
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-22T15:58:39Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-08-24T13:19:30Z</date>
<date>2019-12-22T15:58:39Z</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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 453 KiB

View File

@@ -14,7 +14,7 @@
<key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict>
<key>kZoomLevelIdentifierAlbums</key>
<integer>7</integer>
<integer>10</integer>
<key>kZoomLevelIdentifierVersions</key>
<integer>7</integer>
</dict>
@@ -23,18 +23,18 @@
<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>
<key>other</key>
<integer>0</integer>
<key>photos</key>
<integer>7</integer>
<integer>6</integer>
<key>videos</key>
<integer>0</integer>
</dict>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-08-07T02:26:15Z</date>
<date>2019-12-21T18:09:07Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-08-17T14:26:34Z</date>
<date>2019-12-22T07:53:27Z</date>
</dict>
</plist>

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-08-04T13:32:55Z</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-08-16T02:08:50Z</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>508</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>508</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-08-16T02:08:49Z</date>
<date>2019-12-22T07:56:25Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

View File

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

View File

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

Binary file not shown.

View File

@@ -0,0 +1,186 @@
<?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>BlacklistedMeaningsByMeaning</key>
<dict/>
<key>SceneWhitelist</key>
<array>
<string>Graduation</string>
<string>Aquarium</string>
<string>Food</string>
<string>Ice Skating</string>
<string>Mountain</string>
<string>Cliff</string>
<string>Basketball</string>
<string>Tennis</string>
<string>Jewelry</string>
<string>Cheese</string>
<string>Softball</string>
<string>Football</string>
<string>Circus</string>
<string>Jet Ski</string>
<string>Playground</string>
<string>Carousel</string>
<string>Paint Ball</string>
<string>Windsurfing</string>
<string>Sailboat</string>
<string>Sunbathing</string>
<string>Dam</string>
<string>Fireplace</string>
<string>Flower</string>
<string>Scuba</string>
<string>Hiking</string>
<string>Cetacean</string>
<string>Pier</string>
<string>Bowling</string>
<string>Snowboarding</string>
<string>Zoo</string>
<string>Snowmobile</string>
<string>Theater</string>
<string>Boat</string>
<string>Casino</string>
<string>Car</string>
<string>Diving</string>
<string>Cycling</string>
<string>Musical Instrument</string>
<string>Board Game</string>
<string>Castle</string>
<string>Sunset Sunrise</string>
<string>Martial Arts</string>
<string>Motocross</string>
<string>Submarine</string>
<string>Cat</string>
<string>Snow</string>
<string>Kiteboarding</string>
<string>Squash</string>
<string>Geyser</string>
<string>Music</string>
<string>Archery</string>
<string>Desert</string>
<string>Blackjack</string>
<string>Fireworks</string>
<string>Sportscar</string>
<string>Feline</string>
<string>Soccer</string>
<string>Museum</string>
<string>Baby</string>
<string>Fencing</string>
<string>Railroad</string>
<string>Nascar</string>
<string>Sky Surfing</string>
<string>Bird</string>
<string>Games</string>
<string>Baseball</string>
<string>Dressage</string>
<string>Snorkeling</string>
<string>Pyramid</string>
<string>Kite</string>
<string>Rowboat</string>
<string>Golf</string>
<string>Watersports</string>
<string>Lightning</string>
<string>Canyon</string>
<string>Auditorium</string>
<string>Night Sky</string>
<string>Karaoke</string>
<string>Skiing</string>
<string>Parade</string>
<string>Forest</string>
<string>Hot Air Balloon</string>
<string>Dragon Parade</string>
<string>Easter Egg</string>
<string>Monument</string>
<string>Jungle</string>
<string>Thanksgiving</string>
<string>Jockey Horse</string>
<string>Stadium</string>
<string>Airplane</string>
<string>Ballet</string>
<string>Yoga</string>
<string>Coral Reef</string>
<string>Skating</string>
<string>Wrestling</string>
<string>Bicycle</string>
<string>Tattoo</string>
<string>Amusement Park</string>
<string>Canoe</string>
<string>Cheerleading</string>
<string>Ping Pong</string>
<string>Fishing</string>
<string>Magic</string>
<string>Reptile</string>
<string>Winter Sport</string>
<string>Waterfall</string>
<string>Train</string>
<string>Bonsai</string>
<string>Surfing</string>
<string>Dog</string>
<string>Cake</string>
<string>Sledding</string>
<string>Sandcastle</string>
<string>Glacier</string>
<string>Lighthouse</string>
<string>Equestrian</string>
<string>Rafting</string>
<string>Shore</string>
<string>Hockey</string>
<string>Santa Claus</string>
<string>Formula One Car</string>
<string>Sport</string>
<string>Vehicle</string>
<string>Boxing</string>
<string>Rollerskating</string>
<string>Underwater</string>
<string>Orchestra</string>
<string>Carnival</string>
<string>Rocket</string>
<string>Skateboarding</string>
<string>Helicopter</string>
<string>Performance</string>
<string>Oktoberfest</string>
<string>Water Polo</string>
<string>Skate Park</string>
<string>Animal</string>
<string>Nightclub</string>
<string>String Instrument</string>
<string>Dinosaur</string>
<string>Gymnastics</string>
<string>Cricket</string>
<string>Volcano</string>
<string>Lake</string>
<string>Aurora</string>
<string>Dancing</string>
<string>Concert</string>
<string>Rock Climbing</string>
<string>Hang Glider</string>
<string>Rodeo</string>
<string>Fish</string>
<string>Art</string>
<string>Motorcycle</string>
<string>Volleyball</string>
<string>Wake Boarding</string>
<string>Badminton</string>
<string>Motor Sport</string>
<string>Sumo</string>
<string>Parasailing</string>
<string>Skydiving</string>
<string>Kickboxing</string>
<string>Pinata</string>
<string>Foosball</string>
<string>Go Kart</string>
<string>Poker</string>
<string>Kayak</string>
<string>Swimming</string>
<string>Atv</string>
<string>Beach</string>
<string>Dartboard</string>
<string>Athletics</string>
<string>Camping</string>
<string>Tornado</string>
<string>Billiards</string>
<string>Rugby</string>
<string>Airshow</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,26 @@
<?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>insertAlbum</key>
<array/>
<key>insertAsset</key>
<array/>
<key>insertHighlight</key>
<array/>
<key>insertMemory</key>
<array/>
<key>insertMoment</key>
<array/>
<key>removeAlbum</key>
<array/>
<key>removeAsset</key>
<array/>
<key>removeHighlight</key>
<array/>
<key>removeMemory</key>
<array/>
<key>removeMoment</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?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>embeddingVersion</key>
<string>1</string>
<key>localeIdentifier</key>
<string>en_US</string>
<key>sceneTaxonomySHA</key>
<string>87914a047c69fbe8013fad2c70fa70c6c03b08b56190fe4054c880e6b9f57cc3</string>
<key>searchIndexVersion</key>
<string>10</string>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

View File

@@ -0,0 +1,26 @@
<?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>MigrationService</key>
<dict>
<key>State</key>
<integer>4</integer>
</dict>
<key>MigrationService.LastCompletedTask</key>
<integer>12</integer>
<key>MigrationService.ValidationCounts</key>
<dict>
<key>MigrationDetectedFaceprint</key>
<integer>6</integer>
<key>MigrationManagedAsset</key>
<integer>0</integer>
<key>MigrationSceneClassification</key>
<integer>44</integer>
<key>MigrationUnmanagedAdjustment</key>
<integer>0</integer>
<key>RDVersion.cloudLocalState.CPLIsNotPushed</key>
<integer>7</integer>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,49 @@
<?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>CollapsedSidebarSectionIdentifiers</key>
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>92D68107-B6C7-453B-96D2-97B0F26D5B8B/L0/020</string>
</array>
<key>Photos</key>
<dict>
<key>CollapsedSidebarSectionIdentifiers</key>
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>TopLevelAlbums</string>
<string>TopLevelSlideshows</string>
</array>
<key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict>
<key>kZoomLevelIdentifierAlbums</key>
<integer>7</integer>
<key>kZoomLevelIdentifierVersions</key>
<integer>7</integer>
</dict>
<key>lastAddToDestination</key>
<dict>
<key>key</key>
<integer>1</integer>
<key>lastKnownDisplayName</key>
<string>September 28, 2018</string>
<key>type</key>
<string>album</string>
<key>uuid</key>
<string>DFFKmHt3Tk+AGzZLe2Xq+g</string>
</dict>
<key>lastKnownItemCounts</key>
<dict>
<key>other</key>
<integer>0</integer>
<key>photos</key>
<integer>7</integer>
<key>videos</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

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