Compare commits

..

26 Commits

Author SHA1 Message Date
Rhet Turnbull
db1947dd1e Fixed leaky memory in PhotoKit, issue #276 2021-01-09 17:24:06 -08:00
Rhet Turnbull
248fdbcf02 Updated CHANGELOG.md, [skip ci] 2021-01-09 10:32:32 -08:00
Rhet Turnbull
71cb01572d Add @Rott-Apple as a contributor 2021-01-09 10:28:33 -08:00
Rhet Turnbull
51b1058785 Added PhotoInfo.visible, PhotoInfo.date_trashed, closes #333, #334 2021-01-09 10:20:13 -08:00
Rhet Turnbull
87701822ae Merge pull request #344 from kradalby/write-jpeg-memory-leak
Force cleanup of objects in write_jpeg (fix memory leak)
2021-01-09 08:47:35 -08:00
Kristoffer Dalby
b67f11a3bb Force cleanup of objects with autorelease pool
This commit puts the content of write_jpeg into a autorelease_pool context
provided by PyObjC. This essentially means that the objects should be cleaned up
when the context is exited and prevent them from leaking (memory leak).

https://pyobjc.readthedocs.io/en/latest/api/module-objc.html#memory-management
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmAutoreleasePools.html
2021-01-09 14:44:26 +00:00
Rhet Turnbull
804e13efff Updated README [skip ci] 2021-01-08 10:24:45 -08:00
Rhet Turnbull
504b81b720 Merge pull request #328 from synox/patch-2
doc: Recorded screencast and updated of readme [skip ci]
2021-01-08 07:10:49 -08:00
Rhet Turnbull
538e8b588e Updated CHANGELOG.md, [skip ci] 2021-01-08 07:08:07 -08:00
Rhet Turnbull
c4980fc284 Added README.rst, closes #331 2021-01-08 07:04:07 -08:00
Rhet Turnbull
a7678df397 Updated tests workflow badge link 2021-01-08 06:41:05 -08:00
Rhet Turnbull
e6f45f5949 Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-01-08 06:36:07 -08:00
Rhet Turnbull
f8468c63fd Renamed workflow to tests 2021-01-08 06:35:56 -08:00
Rhet Turnbull
0545d5e321 Merge pull request #343 from RhetTbull/all-contributors/add-kradalby
All contributors/add kradalby
2021-01-08 06:26:51 -08:00
Rhet Turnbull
5de9d4f90c Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-01-08 06:23:25 -08:00
Rhet Turnbull
123ebb2cb7 Ensure merge_exif_keywords are str not int 2021-01-08 06:23:17 -08:00
Rhet Turnbull
2d09d382e9 skip action for all-contributors bot 2021-01-08 06:22:01 -08:00
allcontributors[bot]
5e676d3507 docs: update .all-contributorsrc [skip ci] 2021-01-08 14:16:30 +00:00
allcontributors[bot]
935865dc65 docs: update README.md [skip ci] 2021-01-08 14:16:25 +00:00
Rhet Turnbull
193f26bec8 Merge pull request #342 from kradalby/master
Ensure keyword list only contains strings, @all-contributors please add @kradalby for code
2021-01-08 06:06:17 -08:00
Kristoffer Dalby
7b6a0af314 Ensure keyword list only contains string
This commit ensures that every keyword in the keyword list that is to be written
to a sidecar or exif only contains strings. The current implementation fails
with an exception as the "sorted" functions will fail if the list contains a mix
of strings and integers.
2021-01-08 11:33:50 +00:00
Rhet Turnbull
549a9b3572 Updated CHANGELOG.md 2021-01-06 08:33:40 -08:00
Aravindo Wingeier
aba50c5c73 doc: fixed toc in readme 2021-01-03 22:40:49 +01:00
Aravindo Wingeier
8ca7719641 added screencast files 2021-01-03 22:38:38 +01:00
Aravindo Wingeier
5dc2eeaf9a Create terminalizer-demo.yml 2021-01-03 22:38:08 +01:00
Aravindo Wingeier
658e8ac096 doc: Recorded screencast and updated of readme
... to be more attractive. Inspired by https://github.com/faressoft/terminalizer/blob/master/README.md
2021-01-03 22:36:11 +01:00
20 changed files with 1290 additions and 457 deletions

View File

@@ -128,7 +128,26 @@
"contributions": [
"doc"
]
},
{
"login": "kradalby",
"name": "Kristoffer Dalby",
"avatar_url": "https://avatars1.githubusercontent.com/u/98431?v=4",
"profile": "https://kradalby.no",
"contributions": [
"code"
]
},
{
"login": "Rott-Apple",
"name": "Rott-Apple",
"avatar_url": "https://avatars1.githubusercontent.com/u/67875570?v=4",
"profile": "https://github.com/Rott-Apple",
"contributions": [
"research"
]
}
],
"contributorsPerLine": 7
"contributorsPerLine": 7,
"skipCi": true
}

View File

@@ -6,6 +6,7 @@ jobs:
build:
runs-on: macOS-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
strategy:
max-parallel: 4
matrix:

View File

@@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.39.12](https://github.com/RhetTbull/osxphotos/compare/v0.39.11...v0.39.12)
> 9 January 2021
- Force cleanup of objects in write_jpeg (fix memory leak) [`#344`](https://github.com/RhetTbull/osxphotos/pull/344)
- doc: Recorded screencast and updated of readme [skip ci] [`#328`](https://github.com/RhetTbull/osxphotos/pull/328)
- Added PhotoInfo.visible, PhotoInfo.date_trashed, closes #333, #334 [`#333`](https://github.com/RhetTbull/osxphotos/issues/333)
- Force cleanup of objects with autorelease pool [`b67f11a`](https://github.com/RhetTbull/osxphotos/commit/b67f11a3bb95c08a39a185b6d884092870e949f2)
- Add @Rott-Apple as a contributor [`71cb015`](https://github.com/RhetTbull/osxphotos/commit/71cb01572d2d946df18dd7b36f95b2f2e5b48f86)
- Updated README [skip ci] [`804e13e`](https://github.com/RhetTbull/osxphotos/commit/804e13efff921ab51b996493d659b32102807a8a)
#### [v0.39.11](https://github.com/RhetTbull/osxphotos/compare/v0.39.10...v0.39.11)
> 8 January 2021
- All contributors/add kradalby [`#343`](https://github.com/RhetTbull/osxphotos/pull/343)
- Ensure keyword list only contains strings, @all-contributors please add @kradalby for code [`#342`](https://github.com/RhetTbull/osxphotos/pull/342)
- Added README.rst, closes #331 [`#331`](https://github.com/RhetTbull/osxphotos/issues/331)
- Updated tests workflow badge link [`a7678df`](https://github.com/RhetTbull/osxphotos/commit/a7678df3974ff539050f5acb4c94817f525dcd56)
- Merge branch 'master' of github.com:RhetTbull/osxphotos [`e6f45f5`](https://github.com/RhetTbull/osxphotos/commit/e6f45f59491d9e805e227af8cbf8ac08ff99fdf0)
- Renamed workflow to tests [`f8468c6`](https://github.com/RhetTbull/osxphotos/commit/f8468c63fda930216f73ad5aa8c4aa92edf1adf2)
- Merge branch 'master' of github.com:RhetTbull/osxphotos [`5de9d4f`](https://github.com/RhetTbull/osxphotos/commit/5de9d4f90c1102c4fb0099befd6142180f32df3f)
- Ensure merge_exif_keywords are str not int [`123ebb2`](https://github.com/RhetTbull/osxphotos/commit/123ebb2cb752bb94291ac2b77e4a327cee996df1)
#### [v0.39.10](https://github.com/RhetTbull/osxphotos/compare/v0.39.9...v0.39.10)
> 6 January 2021
- Refactored ExportResults [`568d1b3`](https://github.com/RhetTbull/osxphotos/commit/568d1b36a631df33317dc00f27126b507c90bf51)
- Improved handling of deleted photos, #332 [`792247b`](https://github.com/RhetTbull/osxphotos/commit/792247b51cc2263221ba8c2e741d2ec454c75ca8)
- Added error_str to ExportResults [`d78097c`](https://github.com/RhetTbull/osxphotos/commit/d78097ccc0686680baf5fffa91f9e082e44b576e)
#### [v0.39.9](https://github.com/RhetTbull/osxphotos/compare/v0.39.8...v0.39.9)
> 4 January 2021
- Added test for Big Sur 16.0.1 database changes [`7deac58`](https://github.com/RhetTbull/osxphotos/commit/7deac581b1f1fb3dc59885b6e1ab9a63b382408d)
- Create terminalizer-demo.yml [`5dc2eea`](https://github.com/RhetTbull/osxphotos/commit/5dc2eeaf9a7265873c81db23bbc86d3023189a26)
- doc: Recorded screencast and updated of readme [`658e8ac`](https://github.com/RhetTbull/osxphotos/commit/658e8ac096d141fce48483dbfc1426bea317d806)
#### [v0.39.8](https://github.com/RhetTbull/osxphotos/compare/v0.39.7...v0.39.8)
> 3 January 2021
- Updated README, version [`b93d682`](https://github.com/RhetTbull/osxphotos/commit/b93d6822ac5366c57d9142cba9b809b4ab99ad98)
#### [v0.39.7](https://github.com/RhetTbull/osxphotos/compare/v0.39.6...v0.39.7)
> 3 January 2021

View File

@@ -1,47 +1,48 @@
# OSXPhotos
[![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)
[![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
[![tests](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat)](#contributors)
[![All Contributors](https://img.shields.io/badge/all_contributors-15-orange.svg?style=flat)](#contributors)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos)
* [What is osxphotos?](#what-is-osxphotos)
* [Supported operating systems](#supported-operating-systems)
* [Installation instructions](#installation-instructions)
* [Command Line Usage](#command-line-usage)
+ [Command line examples](#command-line-examples)
+ [Command line reference: export](#command-line-reference-export)
* [Package Interface](#package-interface)
+ [PhotosDB](#photosdb)
+ [PhotoInfo](#photoinfo)
+ [ExifInfo](#exifinfo)
+ [AlbumInfo](#albuminfo)
+ [ImportInfo](#importinfo)
+ [FolderInfo](#folderinfo)
+ [PlaceInfo](#placeinfo)
+ [ScoreInfo](#scoreinfo)
+ [SearchInfo](#searchinfo)
+ [PersonInfo](#personinfo)
+ [FaceInfo](#faceinfo)
+ [CommentInfo](#commentinfo)
+ [LikeInfo](#likeinfo)
+ [Raw Photos](#raw-photos)
+ [Template Substitutions](#template-substitutions)
+ [Utility Functions](#utility-functions)
* [Examples](#examples)
* [Related Projects](#related-projects)
* [Contributing](#contributing)
* [Known Bugs](#known-bugs)
* [Implementation Notes](#implementation-notes)
* [Dependencies](#dependencies)
* [Acknowledgements](#acknowledgements)
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — 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.
<p align="center"><img src="docs/screencast/demo.gif?raw=true" width="713" height="430"/></p>
# Table of Contents
* [Supported operating systems](#supported-operating-systems)
* [Installation instructions](#installation-instructions)
* [Command Line Usage](#command-line-usage)
+ [Command line examples](#command-line-examples)
+ [Command line reference: export](#command-line-reference-export)
* [Package Interface](#package-interface)
+ [PhotosDB](#photosdb)
+ [PhotoInfo](#photoinfo)
+ [ExifInfo](#exifinfo)
+ [AlbumInfo](#albuminfo)
+ [ImportInfo](#importinfo)
+ [FolderInfo](#folderinfo)
+ [PlaceInfo](#placeinfo)
+ [ScoreInfo](#scoreinfo)
+ [SearchInfo](#searchinfo)
+ [PersonInfo](#personinfo)
+ [FaceInfo](#faceinfo)
+ [CommentInfo](#commentinfo)
+ [LikeInfo](#likeinfo)
+ [Raw Photos](#raw-photos)
+ [Template Substitutions](#template-substitutions)
+ [Utility Functions](#utility-functions)
* [Examples](#examples)
* [Related Projects](#related-projects)
* [Contributing](#contributing)
* [Known Bugs](#known-bugs)
* [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 on macOS. You can query the Photos library database -- for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
## Supported operating systems
@@ -1577,9 +1578,15 @@ 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`
#### `visible`
Returns `True` if the picture is visible in library, otherwise `False`. e.g. non-selected burst photos are not hidden but also not visible
#### `intrash`
Returns `True` if the picture is in the trash ('Recently Deleted' folder), otherwise `False`
#### `date_trashed`
Returns the date the photo was placed in the trash as a datetime.datetime object or None if photo is not in the trash
#### `location`
Returns latitude and longitude as a tuple of floats (latitude, longitude). If location is not set, latitude and longitude are returned as `None`
@@ -2582,6 +2589,10 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/jstrine"><img src="https://avatars1.githubusercontent.com/u/33943447?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Jonathan Strine</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jstrine" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/finestream"><img src="https://avatars1.githubusercontent.com/u/16638513?v=4?s=75" width="75px;" alt=""/><br /><sub><b>finestream</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=finestream" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/synox"><img src="https://avatars2.githubusercontent.com/u/2250964?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Aravindo Wingeier</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=synox" title="Documentation">📖</a></td>
<td align="center"><a href="https://kradalby.no"><img src="https://avatars1.githubusercontent.com/u/98431?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Kristoffer Dalby</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=kradalby" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Rott-Apple"><img src="https://avatars1.githubusercontent.com/u/67875570?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Rott-Apple</b></sub></a><br /><a href="#research-Rott-Apple" title="Research">🔬</a></td>
</tr>
</table>

270
README.rst Normal file
View File

@@ -0,0 +1,270 @@
.. role:: raw-html-m2r(raw)
:format: html
OSXPhotos
=========
What is osxphotos?
------------------
OSXPhotos provides both the ability to interact with and query Apple's Photos.app library on macOS directly from your python code
as well as a very flexible command line interface (CLI) app for exporting photos.
You can query the Photos library database -- for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc.
You can also easily export both the original and edited photos.
Supported operating systems
---------------------------
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Catalina (10.15.7).
Beta support for macOS Big Sur (10.16.01/11.01).
This package will read Photos databases for any supported version on any supported macOS version.
E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa.
Requires python >= ``3.7``.
Installation
------------
If you are new to python and just want to use the command line application, I recommend you to install using pipx. See other advanced options below.
Installation using pipx
^^^^^^^^^^^^^^^^^^^^^^^
If you aren't familiar with installing python applications, I recommend you install ``osxphotos`` with `pipx <https://github.com/pipxproject/pipx>`_. If you use ``pipx``\ , you will not need to create a virtual environment as ``pipx`` takes care of this. The easiest way to do this on a Mac is to use `homebrew <https://brew.sh/>`_\ :
* Open ``Terminal`` (search for ``Terminal`` in Spotlight or look in ``Applications/Utilities``\ )
* Install ``homebrew`` according to instructions at `https://brew.sh/ <https://brew.sh/>`_
* Type the following into Terminal: ``brew install pipx``
* Then type this: ``pipx install osxphotos``
* Now you should be able to run ``osxphotos`` by typing: ``osxphotos``
Installation using pip
^^^^^^^^^^^^^^^^^^^^^^
You can also install directly from `pypi <https://pypi.org/project/osxphotos/>`_\ :
.. code-block::
pip install osxphotos
Installation from git repository
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
OSXPhotos uses setuptools, thus simply run:
.. code-block::
git clone https://github.com/RhetTbull/osxphotos.git
cd osxphotos
python3 setup.py install
I recommend you create a `virtual environment <https://docs.python.org/3/tutorial/venv.html>`_ before installing osxphotos.
**WARNING** The git repo for this project is very large (> 1GB) because it contains multiple Photos libraries used for testing
on different versions of macOS. If you just want to use the osxphotos package in your own code,
I recommend you install the latest version from `PyPI <https://pypi.org/project/osxphotos/>`_ which does not include all the test
libraries. If you just want to use the command line utility, you can download a pre-built executable of the latest
`release <https://github.com/RhetTbull/osxphotos/releases>`_ or you can install via ``pip`` which also installs the command line app.
If you aren't comfortable with running python on your Mac, start with the pre-built executable or ``pipx`` as described above.
Command Line Usage
------------------
This package will install a command line utility called ``osxphotos`` that allows you to query the Photos database and export photos.
Alternatively, you can also run the command line utility like this: ``python3 -m osxphotos``
.. code-block::
> osxphotos
Usage: osxphotos [OPTIONS] COMMAND [ARGS]...
Options:
--db <Photos database path> Specify Photos database path. Path to Photos
library/database can be specified using either
--db or directly as PHOTOS_LIBRARY positional
argument. If neither --db or PHOTOS_LIBRARY
provided, will attempt to find the library to
use in the following order: 1. last opened
library, 2. system library, 3.
~/Pictures/Photos Library.photoslibrary
--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...
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.
labels Print out image classification labels found in the Photos...
list Print list of Photos libraries found on the system.
persons Print out persons (faces) found in the Photos library.
places Print out places 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>``
Command line examples
^^^^^^^^^^^^^^^^^^^^^
export all photos to ~/Desktop/export group in folders by date created
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``osxphotos export --export-by-date ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export``
**Note**\ : Photos library/database path can also be specified using ``--db`` option:
``osxphotos export --export-by-date --db ~/Pictures/Photos\ Library.photoslibrary ~/Desktop/export``
find all photos with keyword "Kids" and output results to json file named results.json:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``osxphotos query --keyword Kids --json ~/Pictures/Photos\ Library.photoslibrary >results.json``
export photos to file structure based on 4-digit year and full name of month of photo's creation date:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``osxphotos export ~/Desktop/export --directory "{created.year}/{created.month}"``
(by default, it will attempt to use the system library)
export photos to file structure based on 4-digit year of photo's creation date and add keywords for media type and labels (labels are only awailable on Photos 5 and higher):
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``osxphotos export ~/Desktop/export --directory "{created.year}" --keyword-template "{label}" --keyword-template "{media_type}"``
export default library using 'country name/year' as output directory (but use "NoCountry/year" if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``osxphotos export ~/Desktop/export --directory "{place.name.country,NoCountry}/{created.year}" --person-keyword --album-keyword --keyword-template "{created.year}" --exiftool --update --verbose``
Example uses of the package
---------------------------
.. code-block:: python
""" Simple usage of the package """
import osxphotos
def main():
photosdb = osxphotos.PhotosDB()
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.album_names)
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"])
# find all photos that include Alice Smith but do not contain the keyword Bar
photos = [p for p in photosdb.photos(persons=["Alice Smith"])
if p not in photosdb.photos(keywords=["Bar"]) ]
for p in photos:
print(
p.uuid,
p.filename,
p.original_filename,
p.date,
p.description,
p.title,
p.keywords,
p.albums,
p.persons,
p.path,
)
if __name__ == "__main__":
main()
.. code-block:: python
""" Export all photos to specified directory using album names as folders
If file has been edited, also export the edited version,
otherwise, export the original version
This will result in duplicate photos if photo is in more than album """
import os.path
import pathlib
import sys
import click
from pathvalidate import is_valid_filepath, sanitize_filepath
import osxphotos
@click.command()
@click.argument("export_path", type=click.Path(exists=True))
@click.option(
"--default-album",
help="Default folder for photos with no album. Defaults to 'unfiled'",
default="unfiled",
)
@click.option(
"--library-path",
help="Path to Photos library, default to last used library",
default=None,
)
def export(export_path, default_album, library_path):
export_path = os.path.expanduser(export_path)
library_path = os.path.expanduser(library_path) if library_path else None
if library_path is not None:
photosdb = osxphotos.PhotosDB(library_path)
else:
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
for p in photos:
if not p.ismissing:
albums = p.albums
if not albums:
albums = [default_album]
for album in albums:
click.echo(f"exporting {p.filename} in album {album}")
# make sure no invalid characters in destination path (could be in album name)
album_name = sanitize_filepath(album, platform="auto")
# create destination folder, if necessary, based on album name
dest_dir = os.path.join(export_path, album_name)
# verify path is a valid path
if not is_valid_filepath(dest_dir, platform="auto"):
sys.exit(f"Invalid filepath {dest_dir}")
# create destination dir if needed
if not os.path.isdir(dest_dir):
os.makedirs(dest_dir)
# export the photo
if p.hasadjustments:
# export edited version
exported = p.export(dest_dir, edited=True)
edited_name = pathlib.Path(p.path_edited).name
click.echo(f"Exported {edited_name} to {exported}")
# export unedited version
exported = p.export(dest_dir)
click.echo(f"Exported {p.filename} to {exported}")
else:
click.echo(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
export() # pylint: disable=no-value-for-parameter
Package Interface
-----------------
Reference full documentation on `GitHub <https://github.com/RhetTbull/osxphotos/blob/master/README.md>`_

BIN
docs/screencast/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,296 @@
# how to use this file? see https://github.com/faressoft/terminalizer
# running commands:
# mkdir trip
# osxphotos export --export-by-date --from-date 2021-01-01 trip
# du -h trip
# find trip | head -20
# The configurations that used for the recording, feel free to edit them
config:
# Specify a command to be executed
# like `/bin/bash -l`, `ls`, or any other commands
# the default is bash for Linux
# or powershell.exe for Windows
command: zsh
# Specify the current working directory path
# the default is the current working directory path
cwd: /Users/aravindo/Downloads
# Export additional ENV variables
env:
recording: true
# Explicitly set the number of columns
# or use `auto` to take the current
# number of columns of your shell
cols: 91
# Explicitly set the number of rows
# or use `auto` to take the current
# number of rows of your shell
rows: 20
# Amount of times to repeat GIF
# If value is -1, play once
# If value is 0, loop indefinitely
# If value is a positive number, loop n times
repeat: 0
# Quality
# 1 - 100
quality: 100
# Delay between frames in ms
# If the value is `auto` use the actual recording delays
frameDelay: auto
# Maximum delay between frames in ms
# Ignored if the `frameDelay` isn't set to `auto`
# Set to `auto` to prevent limiting the max idle time
maxIdleTime: 2000
# The surrounding frame box
# The `type` can be null, window, floating, or solid`
# To hide the title use the value null
# Don't forget to add a backgroundColor style with a null as type
frameBox:
type: floating
title: ""
style:
border: 0px black solid
# boxShadow: none
# margin: 0px
# Add a watermark image to the rendered gif
# You need to specify an absolute path for
# the image on your machine or a URL, and you can also
# add your own CSS styles
watermark:
imagePath: null
style:
position: absolute
right: 15px
bottom: 15px
width: 100px
opacity: 0.9
# Cursor style can be one of
# `block`, `underline`, or `bar`
cursorStyle: block
# Font family
# You can use any font that is installed on your machine
# in CSS-like syntax
fontFamily: "Monaco, Lucida Console, Ubuntu Mono, Monospace"
# The size of the font
fontSize: 12
# The height of lines
lineHeight: 1
# The spacing between letters
letterSpacing: 0
# Theme
theme:
background: "transparent"
foreground: "#afafaf"
cursor: "#c7c7c7"
black: "#232628"
red: "#fc4384"
green: "#b3e33b"
yellow: "#ffa727"
blue: "#75dff2"
magenta: "#ae89fe"
cyan: "#708387"
white: "#d5d5d0"
brightBlack: "#626566"
brightRed: "#ff7fac"
brightGreen: "#c8ed71"
brightYellow: "#ebdf86"
brightBlue: "#75dff2"
brightMagenta: "#ae89fe"
brightCyan: "#b1c6ca"
brightWhite: "#f9f9f4"
# Records, feel free to edit them
records:
- delay: 100
content: "\e[1m\e[7m%\e[27m\e[1m\e[0m \r \r\e]7;file://wingeier-macOS/Users/aravindo/Downloads\a\r\e[0m\e[27m\e[24m\e[J \e[K\e[?2004h"
- delay: 100
content: m
- delay: 100
content: "\bmk"
- delay: 100
content: d
- delay: 100
content: i
- delay: 100
content: r
- delay: 100
content: ' '
- delay: 100
content: t
- delay: 100
content: r
- delay: 100
content: i
- delay: 100
content: p
- delay: 100
content: "\e[?2004l\r\r\n"
- delay: 9
content: "\e[1m\e[7m%\e[27m\e[1m\e[0m \r \r\e]7;file://wingeier-macOS/Users/aravindo/Downloads\a\r\e[0m\e[27m\e[24m\e[J \e[K\e[?2004h"
- delay: 300
content: o
- delay: 100
content: "\bos"
- delay: 100
content: x
- delay: 100
content: p
- delay: 100
content: h
- delay: 100
content: o
- delay: 100
content: t
- delay: 100
content: o
- delay: 100
content: s
- delay: 100
content: ' '
- delay: 100
content: e
- delay: 100
content: x
- delay: 100
content: p
- delay: 100
content: o
- delay: 100
content: r
- delay: 100
content: t
- delay: 100
content: ' '
- delay: 100
content: '-'
- delay: 100
content: '-'
- delay: 100
content: e
- delay: 100
content: x
- delay: 100
content: p
- delay: 100
content: o
- delay: 100
content: r
- delay: 100
content: t
- delay: 100
content: '-'
- delay: 100
content: b
- delay: 100
content: 'y'
- delay: 100
content: '-'
- delay: 100
content: d
- delay: 100
content: a
- delay: 100
content: t
- delay: 100
content: e
- delay: 100
content: ' '
- delay: 100
content: '-'
- delay: 100
content: '-'
- delay: 100
content: f
- delay: 100
content: r
- delay: 100
content: o
- delay: 100
content: m
- delay: 100
content: '-'
- delay: 100
content: d
- delay: 100
content: a
- delay: 100
content: t
- delay: 100
content: e
- delay: 100
content: ' '
- delay: 100
content: '2'
- delay: 100
content: '0'
- delay: 100
content: '2'
- delay: 100
content: '1'
- delay: 100
content: '-'
- delay: 100
content: '0'
- delay: 100
content: '1'
- delay: 100
content: '-'
- delay: 100
content: '0'
- delay: 100
content: '1'
- delay: 100
content: ' '
- delay: 100
content: t
- delay: 100
content: r
- delay: 100
content: i
- delay: 100
content: p
- delay: 300
content: "\e[?2004l\r\r\n"
- delay: 500
content: "Using last opened Photos library: /Users/user/Pictures/Photos Library.photoslibrary\r\n"
- delay: 8204
content: "Exporting 79 photos to /Users/user/trip...\r\n"
- delay: 321
content: "Processed: 79 photos, exported: 80, missing: 0, error: 0\r\nElapsed time: 0.321 seconds\r\n"
- delay: 317
content: "\e[1m\e[7m%\e[27m\e[1m\e[0m \r \r\e]7;file://wingeier-macOS/Users/aravindo/Downloads\a\r\e[0m\e[27m\e[24m\e[J \e[K\e[?2004h"
- delay: 4252
content: "\e[7mdu -h trip\e[27m"
- delay: 487
content: "\e[10D\e[27md\e[27mu\e[27m \e[27m-\e[27mh\e[27m \e[27mt\e[27mr\e[27mi\e[27mp\e[?2004l\r\r\n"
- delay: 7
content: "229M\ttrip/2021/01/03\r\n712K\ttrip/2021/01/02\r\n7.5M\ttrip/2021/01/01\r\n237M\ttrip/2021/01\r\n237M\ttrip/2021\r\n238M\ttrip\r\n\e[1m\e[7m%\e[27m\e[1m\e[0m \r \r\e]7;file://wingeier-macOS/Users/aravindo/Downloads\a\r\e[0m\e[27m\e[24m\e[J \e[K\e[?2004h"
- delay: 4280
content: "\e[7mfind trip | head -20\e[27m"
- delay: 923
content: "\e[20D\e[27mf\e[27mi\e[27mn\e[27md\e[27m \e[27mt\e[27mr\e[27mi\e[27mp\e[27m \e[27m|\e[27m \e[27mh\e[27me\e[27ma\e[27md\e[27m \e[27m-\e[27m2\e[27m0\e[?2004l\r\r\n"
- delay: 5
content: "trip\r\ntrip/2021\r\ntrip/2021/01\r\ntrip/2021/01/03\r\ntrip/2021/01/03/IMG_1234 (1).HEIC\r\ntrip/2021/01/03/IMG_1267.HEIC\r\ntrip/2021/01/03/IMG_1226.HEIC\r\ntrip/2021/01/03/IMG_1271.HEIC\r\ntrip/2021/01/03/IMG_1232 (1).JPG\r\ntrip/2021/01/03/IMG_1270.HEIC\r\ntrip/2021/01/03/IMG_1231.HEIC\r\ntrip/2021/01/03/IMG_6926.JPG\r\ntrip/2021/01/03/IMG_6932.JPG\r\ntrip/2021/01/03/IMG_1266.HEIC\r\ntrip/2021/01/03/IMG_6933.JPG\r\ntrip/2021/01/03/IMG_6927.JPG\r\ntrip/2021/01/03/IMG_1233 (1).JPG\r\ntrip/2021/01/03/IMG_1228 (1).HEIC\r\ntrip/2021/01/03/IMG_6931.JPG\r\ntrip/2021/01/03/IMG_6930.JPG\r\n\e[1m\e[7m%\e[27m\e[1m\e[0m \r \r\e]7;file://wingeier-macOS/Users/aravindo/Downloads\a\r\e[0m\e[27m\e[24m\e[J \e[K\e[?2004h"
- delay: 3615
content: "\e[?2004l\r\r\n"

View File

@@ -1,5 +1,5 @@
""" version info """
__version__ = "0.39.10"
__version__ = "0.39.13"

View File

@@ -1,11 +1,12 @@
""" ImageConverter class
Convert an image to JPEG using CoreImage --
Convert an image to JPEG using CoreImage --
for example, RAW to JPEG. Only works if Mac equipped with GPU. """
# reference: https://stackoverflow.com/questions/59330149/coreimage-ciimage-write-jpg-is-shifting-colors-macos/59334308#59334308
import pathlib
import objc
import Metal
import Quartz
from Cocoa import NSURL
@@ -20,6 +21,7 @@ class ImageConversionError(Exception):
pass
class ImageConverter:
""" Convert images to jpeg. This class is a singleton
which will re-use the Core Image CIContext to avoid
@@ -67,49 +69,58 @@ class ImageConverter:
ImageConversionError if error during conversion
"""
# accept input_path or output_path as pathlib.Path
if not isinstance(input_path, str):
input_path = str(input_path)
# Set up a dedicated objc autorelease pool for this function call.
# This is to ensure that all the NSObjects are cleaned up after each
# call to prevent memory leaks.
# https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmAutoreleasePools.html
# https://pyobjc.readthedocs.io/en/latest/api/module-objc.html#memory-management
with objc.autorelease_pool():
# accept input_path or output_path as pathlib.Path
if not isinstance(input_path, str):
input_path = str(input_path)
if not isinstance(output_path, str):
output_path = str(output_path)
if not isinstance(output_path, str):
output_path = str(output_path)
if not pathlib.Path(input_path).is_file():
raise FileNotFoundError(f"could not find {input_path}")
if not pathlib.Path(input_path).is_file():
raise FileNotFoundError(f"could not find {input_path}")
if not (0.0 <= compression_quality <= 1.0):
raise ValueError(
"illegal value for compression_quality: {compression_quality}"
if not (0.0 <= compression_quality <= 1.0):
raise ValueError(
"illegal value for compression_quality: {compression_quality}"
)
input_url = NSURL.fileURLWithPath_(input_path)
output_url = NSURL.fileURLWithPath_(output_path)
with pipes() as (out, err):
# capture stdout and stderr from system calls
# otherwise, Quartz.CIImage.imageWithContentsOfURL_
# prints to stderr something like:
# 2020-09-20 20:55:25.538 python[73042:5650492] Creating client/daemon connection: B8FE995E-3F27-47F4-9FA8-559C615FD774
# 2020-09-20 20:55:25.652 python[73042:5650492] Got the query meta data reply for: com.apple.MobileAsset.RawCamera.Camera, response: 0
input_image = Quartz.CIImage.imageWithContentsOfURL_(input_url)
if input_image is None:
raise ImageConversionError(f"Could not create CIImage for {input_path}")
output_colorspace = input_image.colorSpace() or Quartz.CGColorSpaceCreateWithName(
Quartz.CoreGraphics.kCGColorSpaceSRGB
)
input_url = NSURL.fileURLWithPath_(input_path)
output_url = NSURL.fileURLWithPath_(output_path)
with pipes() as (out, err):
# capture stdout and stderr from system calls
# otherwise, Quartz.CIImage.imageWithContentsOfURL_
# prints to stderr something like:
# 2020-09-20 20:55:25.538 python[73042:5650492] Creating client/daemon connection: B8FE995E-3F27-47F4-9FA8-559C615FD774
# 2020-09-20 20:55:25.652 python[73042:5650492] Got the query meta data reply for: com.apple.MobileAsset.RawCamera.Camera, response: 0
input_image = Quartz.CIImage.imageWithContentsOfURL_(input_url)
if input_image is None:
raise ImageConversionError(f"Could not create CIImage for {input_path}")
output_colorspace = input_image.colorSpace() or Quartz.CGColorSpaceCreateWithName(
Quartz.CoreGraphics.kCGColorSpaceSRGB
)
output_options = NSDictionary.dictionaryWithDictionary_(
{"kCGImageDestinationLossyCompressionQuality": compression_quality}
)
_, error = self.context.writeJPEGRepresentationOfImage_toURL_colorSpace_options_error_(
input_image, output_url, output_colorspace, output_options, None
)
if not error:
return True
else:
raise ImageConversionError(
"Error converting file {input_path} to jpeg at {output_path}: {error}"
output_options = NSDictionary.dictionaryWithDictionary_(
{"kCGImageDestinationLossyCompressionQuality": compression_quality}
)
(
_,
error,
) = self.context.writeJPEGRepresentationOfImage_toURL_colorSpace_options_error_(
input_image, output_url, output_colorspace, output_options, None
)
if not error:
return True
else:
raise ImageConversionError(
"Error converting file {input_path} to jpeg at {output_path}: {error}"
)

View File

@@ -776,7 +776,12 @@ def export2(
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if not photo:
all_results.error.append((str(dest), f"PhotoKitFetchFailed exception exporting photo {self.uuid}: {e}"))
all_results.error.append(
(
str(dest),
f"PhotoKitFetchFailed exception exporting photo {self.uuid}: {e}",
)
)
if photo:
try:
exported = photo.export(
@@ -821,7 +826,7 @@ def export2(
if photo:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
)
all_results.exported.extend(exported)
except Exception as e:
@@ -829,15 +834,15 @@ def export2(
else:
try:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=True,
edited=False,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
self.uuid,
dest.parent,
filestem=filestem,
original=True,
edited=False,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
all_results.exported.extend(exported)
except ExportError as e:
@@ -852,7 +857,7 @@ def export2(
all_results.new.extend(all_results.exported)
# else:
# all_results.error.append((str(dest), f"Error exporting photo {self.uuid} to {dest} with use_photos_export"))
# all_results.error.append((str(dest), f"Error exporting photo {self.uuid} to {dest} with use_photos_export"))
# export metadata
sidecars = []
@@ -1443,7 +1448,7 @@ def _exiftool_dict(
if keyword_list:
# remove duplicates
keyword_list = sorted(list(set(keyword_list)))
keyword_list = sorted(list(set([str(keyword) for keyword in keyword_list])))
exif["IPTC:Keywords"] = keyword_list.copy()
exif["XMP:Subject"] = keyword_list.copy()
exif["XMP:TagsList"] = keyword_list.copy()
@@ -1539,6 +1544,7 @@ def _get_exif_keywords(self):
kw = exifdict[field]
if kw and type(kw) != list:
kw = [kw]
kw = [str(k) for k in kw]
keywords.extend(kw)
except KeyError:
pass
@@ -1555,6 +1561,7 @@ def _get_exif_persons(self):
p = exifdict["XMP:PersonInImage"]
if p and type(p) != list:
p = [p]
p = [str(p_) for p_ in p]
persons.extend(p)
except KeyError:
pass

View File

@@ -113,15 +113,15 @@ class PhotoInfo:
# lastmodifieddate anytime photo database record is updated (e.g. adding tags)
# only report lastmodified date for Photos <=4 if photo is edited;
# even in this case, the date could be incorrect
if self.hasadjustments or self._db._db_version > _PHOTOS_4_VERSION:
imagedate = self._info["lastmodifieddate"]
if imagedate:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return imagedate.astimezone(tz=tz)
else:
return None
if not self.hasadjustments and self._db._db_version <= _PHOTOS_4_VERSION:
return None
imagedate = self._info["lastmodifieddate"]
if imagedate:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return imagedate.astimezone(tz=tz)
else:
return None
@@ -501,37 +501,52 @@ class PhotoInfo:
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
return self._info["isMissing"] == 1
@property
def hasadjustments(self):
""" True if picture has adjustments / edits """
return True if self._info["hasAdjustments"] == 1 else False
return self._info["hasAdjustments"] == 1
@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
)
return self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
@property
def favorite(self):
""" True if picture is marked as favorite """
return True if self._info["favorite"] == 1 else False
return self._info["favorite"] == 1
@property
def hidden(self):
""" True if picture is hidden """
return True if self._info["hidden"] == 1 else False
return self._info["hidden"] == 1
@property
def visible(self):
""" True if picture is visble """
return self._info["visible"]
@property
def intrash(self):
""" True if picture is in trash ('Recently Deleted' folder)"""
return self._info["intrash"]
@property
def date_trashed(self):
""" Date asset was placed in the trash or None """
# TODO: add add_timezone(dt, offset_seconds) to datetime_utils
# also update date_modified
trasheddate = self._info["trasheddate"]
if trasheddate:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return trasheddate.astimezone(tz=tz)
else:
return None
@property
def location(self):
""" returns (latitude, longitude) as float in degrees or None """
@@ -551,14 +566,15 @@ class PhotoInfo:
"""Returns Uniform Type Identifier (UTI) for the image
for example: public.jpeg or com.apple.quicktime-movie
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
if self.hasadjustments:
return self._info["UTI_edited"]
elif self.has_raw and self.raw_original:
# return UTI of the non-raw image to match Photos 5+ behavior
return self._info["raw_pair_info"]["UTI"]
else:
return self._info["UTI"]
if self._db._db_version <= _PHOTOS_4_VERSION and self.hasadjustments:
return self._info["UTI_edited"]
elif (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
and self.raw_original
):
# return UTI of the non-raw image to match Photos 5+ behavior
return self._info["raw_pair_info"]["UTI"]
else:
return self._info["UTI"]
@@ -597,12 +613,12 @@ class PhotoInfo:
@property
def ismovie(self):
"""Returns True if file is a movie, otherwise False"""
return True if self._info["type"] == _MOVIE_TYPE else False
return self._info["type"] == _MOVIE_TYPE
@property
def isphoto(self):
"""Returns True if file is an image, otherwise False"""
return True if self._info["type"] == _PHOTO_TYPE else False
return self._info["type"] == _PHOTO_TYPE
@property
def incloud(self):

View File

@@ -19,6 +19,7 @@
# add original=False to export instead of version= (and maybe others like path())
# make burst/live methods get uuid from self instead of passing as arg
import copy
import pathlib
import threading
import time
@@ -169,12 +170,14 @@ class ImageData:
requestImageDataAndOrientationForAsset_options_resultHandler_
"""
def __init__(self):
self.metadata = None
self.uti = None
self.image_data = None
self.info = None
self.orientation = None
def __init__(
self, metadata=None, uti=None, image_data=None, info=None, orientation=None
):
self.metadata = metadata
self.uti = uti
self.image_data = image_data
self.info = info
self.orientation = orientation
class AVAssetData:
@@ -475,44 +478,48 @@ class PhotoAsset:
# if self.live:
# raise NotImplementedError("Live photos not implemented yet")
filename = (
pathlib.Path(filename) if filename else pathlib.Path(self.original_filename)
)
with objc.autorelease_pool():
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
output_file = None
if self.isphoto:
imagedata = self._request_image_data(version=version)
ext = get_preferred_uti_extension(imagedata.uti)
output_file = None
if self.isphoto:
imagedata = self._request_image_data(version=version)
ext = get_preferred_uti_extension(imagedata.uti)
output_file = dest / f"{filename.stem}.{ext}"
output_file = dest / f"{filename.stem}.{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
with open(output_file, "wb") as fd:
fd.write(imagedata.image_data)
elif self.ismovie:
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
with open(output_file, "wb") as fd:
fd.write(imagedata.image_data)
del imagedata
elif self.ismovie:
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
FileUtil.copy(path, output_file)
FileUtil.copy(path, output_file)
return [str(output_file)]
return [str(output_file)]
def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL):
""" Request image data and metadata for self._phasset
@@ -529,50 +536,56 @@ class PhotoAsset:
# reference: https://developer.apple.com/documentation/photokit/phimagemanager/3237282-requestimagedataandorientationfo?language=objc
if version not in [
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
]:
raise ValueError("Invalid value for version")
with objc.autorelease_pool():
if version not in [
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
]:
raise ValueError("Invalid value for version")
# pylint: disable=no-member
options_request = Photos.PHImageRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setSynchronous_(True)
options_request.setVersion_(version)
options_request.setDeliveryMode_(
Photos.PHImageRequestOptionsDeliveryModeHighQualityFormat
)
requestdata = ImageData()
event = threading.Event()
def handler(imageData, dataUTI, orientation, info):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
nonlocal requestdata
options = {}
# pylint: disable=no-member
options[Quartz.kCGImageSourceShouldCache] = Foundation.kCFBooleanFalse
imgSrc = Quartz.CGImageSourceCreateWithData(imageData, options)
requestdata.metadata = Quartz.CGImageSourceCopyPropertiesAtIndex(
imgSrc, 0, options
options_request = Photos.PHImageRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setSynchronous_(True)
options_request.setVersion_(version)
options_request.setDeliveryMode_(
Photos.PHImageRequestOptionsDeliveryModeHighQualityFormat
)
requestdata.uti = dataUTI
requestdata.orientation = orientation
requestdata.info = info
requestdata.image_data = imageData
requestdata = ImageData()
event = threading.Event()
event.set()
def handler(imageData, dataUTI, orientation, info):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
self._manager.requestImageDataAndOrientationForAsset_options_resultHandler_(
self.phasset, options_request, handler
)
event.wait()
self._imagedata = requestdata
return requestdata
nonlocal requestdata
options = {}
# pylint: disable=no-member
options[Quartz.kCGImageSourceShouldCache] = Foundation.kCFBooleanFalse
imgSrc = Quartz.CGImageSourceCreateWithData(imageData, options)
requestdata.metadata = Quartz.CGImageSourceCopyPropertiesAtIndex(
imgSrc, 0, options
)
requestdata.uti = dataUTI
requestdata.orientation = orientation
requestdata.info = info
requestdata.image_data = imageData
event.set()
self._manager.requestImageDataAndOrientationForAsset_options_resultHandler_(
self.phasset, options_request, handler
)
event.wait()
# options_request.dealloc()
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(requestdata)
del requestdata
return data
def _make_result_handle_(self, data):
""" Make handler function and threading event to use with
@@ -634,37 +647,41 @@ class SlowMoVideoExporter(NSObject):
Returns:
path to exported file
"""
exporter = AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
)
exporter.setOutputURL_(self.url)
exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie)
exporter.setShouldOptimizeForNetworkUse_(True)
self.done = False
with objc.autorelease_pool():
exporter = AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
)
exporter.setOutputURL_(self.url)
exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie)
exporter.setShouldOptimizeForNetworkUse_(True)
def handler():
""" result handler for exportAsynchronouslyWithCompletionHandler """
self.done = True
self.done = False
exporter.exportAsynchronouslyWithCompletionHandler_(handler)
# wait for export to complete
# would be more elegant to use a dispatch queue, notification, or thread event to wait
# but I can't figure out how to make that work and this does work
while True:
status = exporter.status()
if status == AVFoundation.AVAssetExportSessionStatusCompleted:
break
elif status not in (
AVFoundation.AVAssetExportSessionStatusWaiting,
AVFoundation.AVAssetExportSessionStatusExporting,
):
raise PhotoKitExportError(
f"Error encountered during exportAsynchronouslyWithCompletionHandler: status = {status}"
)
time.sleep(MIN_SLEEP)
def handler():
""" result handler for exportAsynchronouslyWithCompletionHandler """
self.done = True
return NSURL_to_path(exporter.outputURL())
exporter.exportAsynchronouslyWithCompletionHandler_(handler)
# wait for export to complete
# would be more elegant to use a dispatch queue, notification, or thread event to wait
# but I can't figure out how to make that work and this does work
while True:
status = exporter.status()
if status == AVFoundation.AVAssetExportSessionStatusCompleted:
break
elif status not in (
AVFoundation.AVAssetExportSessionStatusWaiting,
AVFoundation.AVAssetExportSessionStatusExporting,
):
raise PhotoKitExportError(
f"Error encountered during exportAsynchronouslyWithCompletionHandler: status = {status}"
)
time.sleep(MIN_SLEEP)
exported_path = NSURL_to_path(exporter.outputURL())
# exporter.dealloc()
return exported_path
def __del__(self):
self.avasset = None
@@ -701,39 +718,43 @@ class VideoAsset(PhotoAsset):
ValueError if dest is not a valid directory
"""
if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
return [
self._export_slow_mo(
dest, filename=filename, version=version, overwrite=overwrite
)
]
with objc.autorelease_pool():
if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
return [
self._export_slow_mo(
dest, filename=filename, version=version, overwrite=overwrite
)
]
filename = (
pathlib.Path(filename) if filename else pathlib.Path(self.original_filename)
)
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
output_file = None
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
output_file = None
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
del videodata
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
FileUtil.copy(path, output_file)
FileUtil.copy(path, output_file)
return [str(output_file)]
return [str(output_file)]
def _export_slow_mo(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
@@ -752,33 +773,38 @@ class VideoAsset(PhotoAsset):
Raises:
ValueError if dest is not a valid directory
"""
if not self.slow_mo:
raise PhotoKitMediaTypeError("Not a slow-mo video")
with objc.autorelease_pool():
if not self.slow_mo:
raise PhotoKitMediaTypeError("Not a slow-mo video")
videodata = self._request_video_data(version=version)
if (
not isinstance(videodata.asset, AVFoundation.AVComposition)
or len(videodata.asset.tracks()) != 2
):
raise PhotoKitMediaTypeError("Does not appear to be slow-mo video")
videodata = self._request_video_data(version=version)
if (
not isinstance(videodata.asset, AVFoundation.AVComposition)
or len(videodata.asset.tracks()) != 2
):
raise PhotoKitMediaTypeError("Does not appear to be slow-mo video")
filename = (
pathlib.Path(filename) if filename else pathlib.Path(self.original_filename)
)
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
output_file = dest / f"{filename.stem}.mov"
output_file = dest / f"{filename.stem}.mov"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
exporter = SlowMoVideoExporter.alloc().initWithAVAsset_path_(
videodata.asset, output_file
)
return exporter.exportSlowMoVideo()
exporter = SlowMoVideoExporter.alloc().initWithAVAsset_path_(
videodata.asset, output_file
)
video = exporter.exportSlowMoVideo()
# exporter.dealloc()
return video
# todo: rewrite this with NotificationCenter and App event loop?
def _request_video_data(self, version=PHOTOS_VERSION_ORIGINAL):
@@ -793,38 +819,43 @@ class VideoAsset(PhotoAsset):
Raises:
ValueError if passed invalid value for version
"""
with objc.autorelease_pool():
if version not in [
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
]:
raise ValueError("Invalid value for version")
if version not in [
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
]:
raise ValueError("Invalid value for version")
options_request = Photos.PHVideoRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setVersion_(version)
options_request.setDeliveryMode_(
Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
)
requestdata = AVAssetData()
event = threading.Event()
options_request = Photos.PHVideoRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setVersion_(version)
options_request.setDeliveryMode_(
Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
)
requestdata = AVAssetData()
event = threading.Event()
def handler(asset, audiomix, info):
""" result handler for requestAVAssetForVideo:asset options:options resultHandler """
nonlocal requestdata
def handler(asset, audiomix, info):
""" result handler for requestAVAssetForVideo:asset options:options resultHandler """
nonlocal requestdata
requestdata.asset = asset
requestdata.audiomix = audiomix
requestdata.info = info
requestdata.asset = asset
requestdata.audiomix = audiomix
requestdata.info = info
event.set()
event.set()
self._manager.requestAVAssetForVideo_options_resultHandler_(
self.phasset, options_request, handler
)
event.wait()
self._manager.requestAVAssetForVideo_options_resultHandler_(
self.phasset, options_request, handler
)
event.wait()
return requestdata
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(requestdata)
del requestdata
return data
class LivePhotoRequest(NSObject):
@@ -843,47 +874,54 @@ class LivePhotoRequest(NSObject):
def requestLivePhotoResources(self, version=PHOTOS_VERSION_CURRENT):
""" return the photos and video components of a live video as [PHAssetResource] """
options = Photos.PHLivePhotoRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
options.setVersion_(version)
options.setDeliveryMode_(
Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
)
delegate = PhotoKitNotificationDelegate.alloc().init()
self.nc.addObserver_selector_name_object_(
delegate, "liveNotification:", None, None
)
self.live_photo = None
def handler(result, info):
""" result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler: """
if not info["PHImageResultIsDegradedKey"]:
self.live_photo = result
self.info = info
self.nc.postNotificationName_object_(
PHOTOKIT_NOTIFICATION_FINISHED_REQUEST, self
)
try:
self.manager.requestLivePhotoForAsset_targetSize_contentMode_options_resultHandler_(
self.asset,
Photos.PHImageManagerMaximumSize,
Photos.PHImageContentModeDefault,
options,
handler,
with objc.autorelease_pool():
options = Photos.PHLivePhotoRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
options.setVersion_(version)
options.setDeliveryMode_(
Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
)
AppHelper.runConsoleEventLoop(installInterrupt=True)
except KeyboardInterrupt:
AppHelper.stopEventLoop()
finally:
pass
delegate = PhotoKitNotificationDelegate.alloc().init()
asset_resources = Photos.PHAssetResource.assetResourcesForLivePhoto_(
self.live_photo
)
return asset_resources
self.nc.addObserver_selector_name_object_(
delegate, "liveNotification:", None, None
)
self.live_photo = None
def handler(result, info):
""" result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler: """
if not info["PHImageResultIsDegradedKey"]:
self.live_photo = result
self.info = info
self.nc.postNotificationName_object_(
PHOTOKIT_NOTIFICATION_FINISHED_REQUEST, self
)
try:
self.manager.requestLivePhotoForAsset_targetSize_contentMode_options_resultHandler_(
self.asset,
Photos.PHImageManagerMaximumSize,
Photos.PHImageContentModeDefault,
options,
handler,
)
AppHelper.runConsoleEventLoop(installInterrupt=True)
except KeyboardInterrupt:
AppHelper.stopEventLoop()
finally:
pass
asset_resources = Photos.PHAssetResource.assetResourcesForLivePhoto_(
self.live_photo
)
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(asset_resources)
del asset_resources
return data
def __del__(self):
self.manager = None
@@ -923,88 +961,99 @@ class LivePhotoAsset(PhotoAsset):
ValueError if dest is not a valid directory
PhotoKitExportError if error during export
"""
filename = (
pathlib.Path(filename) if filename else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
request = LivePhotoRequest.alloc().initWithManager_Asset_(
self._manager, self.phasset
)
resources = request.requestLivePhotoResources(version=version)
video_resource = None
photo_resource = None
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypePairedVideo:
video_resource = resource
elif resource.type() == Photos.PHAssetMediaTypeImage:
photo_resource = resource
if not video_resource or not photo_resource:
raise PhotoKitExportError(
"Did not find photo/video resources for live photo"
with objc.autorelease_pool():
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
photo_ext = get_preferred_uti_extension(photo_resource.uniformTypeIdentifier())
photo_output_file = dest / f"{filename.stem}.{photo_ext}"
video_ext = get_preferred_uti_extension(video_resource.uniformTypeIdentifier())
video_output_file = dest / f"{filename.stem}.{video_ext}"
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
if not overwrite:
photo_output_file = pathlib.Path(increment_filename(photo_output_file))
video_output_file = pathlib.Path(increment_filename(video_output_file))
request = LivePhotoRequest.alloc().initWithManager_Asset_(
self._manager, self.phasset
)
resources = request.requestLivePhotoResources(version=version)
# def handler(error):
# if error:
# raise PhotoKitExportError(f"writeDataForAssetResource error: {error}")
video_resource = None
photo_resource = None
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypePairedVideo:
video_resource = resource
elif resource.type() == Photos.PHAssetMediaTypeImage:
photo_resource = resource
# resource_manager = Photos.PHAssetResourceManager.defaultManager()
# options = Photos.PHAssetResourceRequestOptions.alloc().init()
# options.setNetworkAccessAllowed_(True)
# exported = []
# Note: Tried writeDataForAssetResource_toFile_options_completionHandler_ which works
# but sets quarantine flag and for reasons I can't determine (maybe quarantine flag)
# causes pathlib.Path().is_file() to fail in tests
if not video_resource or not photo_resource:
raise PhotoKitExportError(
"Did not find photo/video resources for live photo"
)
# if photo:
# photo_output_url = path_to_NSURL(photo_output_file)
# resource_manager.writeDataForAssetResource_toFile_options_completionHandler_(
# photo_resource, photo_output_url, options, handler
# )
# exported.append(str(photo_output_file))
photo_ext = get_preferred_uti_extension(
photo_resource.uniformTypeIdentifier()
)
photo_output_file = dest / f"{filename.stem}.{photo_ext}"
video_ext = get_preferred_uti_extension(
video_resource.uniformTypeIdentifier()
)
video_output_file = dest / f"{filename.stem}.{video_ext}"
# if video:
# video_output_url = path_to_NSURL(video_output_file)
# resource_manager.writeDataForAssetResource_toFile_options_completionHandler_(
# video_resource, video_output_url, options, handler
# )
# exported.append(str(video_output_file))
if not overwrite:
photo_output_file = pathlib.Path(increment_filename(photo_output_file))
video_output_file = pathlib.Path(increment_filename(video_output_file))
# def completion_handler(error):
# if error:
# raise PhotoKitExportError(f"writeDataForAssetResource error: {error}")
# def handler(error):
# if error:
# raise PhotoKitExportError(f"writeDataForAssetResource error: {error}")
# would be nice to be able to usewriteDataForAssetResource_toFile_options_completionHandler_
# but it sets quarantine flags that cause issues so instead, request the data and write the files directly
# resource_manager = Photos.PHAssetResourceManager.defaultManager()
# options = Photos.PHAssetResourceRequestOptions.alloc().init()
# options.setNetworkAccessAllowed_(True)
# exported = []
# Note: Tried writeDataForAssetResource_toFile_options_completionHandler_ which works
# but sets quarantine flag and for reasons I can't determine (maybe quarantine flag)
# causes pathlib.Path().is_file() to fail in tests
exported = []
if photo:
data = self._request_resource_data(photo_resource)
# image_data = self.request_image_data(version=version)
with open(photo_output_file, "wb") as fd:
fd.write(data)
exported.append(str(photo_output_file))
if video:
data = self._request_resource_data(video_resource)
with open(video_output_file, "wb") as fd:
fd.write(data)
exported.append(str(video_output_file))
# if photo:
# photo_output_url = path_to_NSURL(photo_output_file)
# resource_manager.writeDataForAssetResource_toFile_options_completionHandler_(
# photo_resource, photo_output_url, options, handler
# )
# exported.append(str(photo_output_file))
return exported
# if video:
# video_output_url = path_to_NSURL(video_output_file)
# resource_manager.writeDataForAssetResource_toFile_options_completionHandler_(
# video_resource, video_output_url, options, handler
# )
# exported.append(str(video_output_file))
# def completion_handler(error):
# if error:
# raise PhotoKitExportError(f"writeDataForAssetResource error: {error}")
# would be nice to be able to usewriteDataForAssetResource_toFile_options_completionHandler_
# but it sets quarantine flags that cause issues so instead, request the data and write the files directly
exported = []
if photo:
data = self._request_resource_data(photo_resource)
# image_data = self.request_image_data(version=version)
with open(photo_output_file, "wb") as fd:
fd.write(data)
exported.append(str(photo_output_file))
del data
if video:
data = self._request_resource_data(video_resource)
with open(video_output_file, "wb") as fd:
fd.write(data)
exported.append(str(video_output_file))
del data
request.dealloc()
return exported
def _request_resource_data(self, resource):
""" Request asset resource data (either photo or video component)
@@ -1015,33 +1064,40 @@ class LivePhotoAsset(PhotoAsset):
Raises:
"""
resource_manager = Photos.PHAssetResourceManager.defaultManager()
options = Photos.PHAssetResourceRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
with objc.autorelease_pool():
resource_manager = Photos.PHAssetResourceManager.defaultManager()
options = Photos.PHAssetResourceRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
requestdata = PHAssetResourceData()
event = threading.Event()
requestdata = PHAssetResourceData()
event = threading.Event()
def handler(data):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
def handler(data):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
nonlocal requestdata
nonlocal requestdata
requestdata.data += data
requestdata.data += data
def completion_handler(error):
if error:
raise PhotoKitExportError("Error requesting data for asset resource")
event.set()
def completion_handler(error):
if error:
raise PhotoKitExportError(
"Error requesting data for asset resource"
)
event.set()
resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_(
resource, options, handler, completion_handler
)
resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_(
resource, options, handler, completion_handler
)
event.wait()
options.dealloc()
return requestdata.data
event.wait()
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(requestdata.data)
del requestdata
return data
# def request_image_data(self, version=PHOTOS_VERSION_CURRENT):
# # Returns an NSImage which isn't overly useful
@@ -1127,19 +1183,20 @@ class PhotoLibrary:
"""
# pylint: disable=no-member
fetch_options = Photos.PHFetchOptions.alloc().init()
fetch_result = Photos.PHAsset.fetchAssetsWithLocalIdentifiers_options_(
uuid_list, fetch_options
)
if fetch_result and fetch_result.count() >= 1:
return [
self._asset_factory(fetch_result.objectAtIndex_(idx))
for idx in range(fetch_result.count())
]
else:
raise PhotoKitFetchFailed(
f"Fetch did not return result for uuid_list {uuid_list}"
with objc.autorelease_pool():
fetch_options = Photos.PHFetchOptions.alloc().init()
fetch_result = Photos.PHAsset.fetchAssetsWithLocalIdentifiers_options_(
uuid_list, fetch_options
)
if fetch_result and fetch_result.count() >= 1:
return [
self._asset_factory(fetch_result.objectAtIndex_(idx))
for idx in range(fetch_result.count())
]
else:
raise PhotoKitFetchFailed(
f"Fetch did not return result for uuid_list {uuid_list}"
)
def fetch_uuid(self, uuid):
""" fetch PHAsset with uuid = uuid

View File

@@ -887,7 +887,9 @@ class PhotosDB:
RKMaster.width,
RKMaster.orientation,
RKMaster.fileSize,
RKVersion.subType
RKVersion.subType,
RKVersion.inTrashDate,
RKVersion.showInLibrary
FROM RKVersion, RKMaster
WHERE RKVersion.masterUuid = RKMaster.uuid"""
)
@@ -915,7 +917,9 @@ class PhotosDB:
RKMaster.width,
RKMaster.orientation,
RKMaster.originalFileSize,
RKVersion.subType
RKVersion.subType,
RKVersion.inTrashDate,
RKVersion.showInLibrary
FROM RKVersion, RKMaster
WHERE RKVersion.masterUuid = RKMaster.uuid"""
)
@@ -962,6 +966,8 @@ class PhotosDB:
# 38 RKMaster.orientation,
# 39 RKMaster.originalFileSize
# 40 RKVersion.subType
# 41 RKVersion.inTrashDate
# 42 RKVersion.showInLibrary -- is item visible in library (e.g. non-selected burst images are not visible)
for row in c:
uuid = row[0]
@@ -1136,7 +1142,14 @@ class PhotosDB:
)
# recently deleted items
self._dbphotos[uuid]["intrash"] = True if row[32] == 1 else False
self._dbphotos[uuid]["intrash"] = row[32] == 1
self._dbphotos[uuid]["trasheddate_timestamp"] = row[41]
try:
self._dbphotos[uuid]["trasheddate"] = datetime.fromtimestamp(
row[41] + TIME_DELTA
)
except (ValueError, TypeError):
self._dbphotos[uuid]["trasheddate"] = None
# height/width/orientation
self._dbphotos[uuid]["height"] = row[33]
@@ -1147,6 +1160,10 @@ class PhotosDB:
self._dbphotos[uuid]["original_orientation"] = row[38]
self._dbphotos[uuid]["original_filesize"] = row[39]
# visibility state
self._dbphotos[uuid]["visibility_state"] = row[42]
self._dbphotos[uuid]["visible"] = row[42] == 1
# import session not yet handled for Photos 4
self._dbphotos[uuid]["import_session"] = None
self._dbphotos[uuid]["import_uuid"] = None
@@ -1840,7 +1857,9 @@ class PhotosDB:
ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE,
{depth_state},
{asset_table}.ZADJUSTMENTTIMESTAMP
{asset_table}.ZADJUSTMENTTIMESTAMP,
{asset_table}.ZVISIBILITYSTATE,
{asset_table}.ZTRASHEDDATE
FROM {asset_table}
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
ORDER BY {asset_table}.ZUUID """
@@ -1885,6 +1904,8 @@ class PhotosDB:
# 35 ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
# 36 ZGENERICASSET.ZDEPTHSTATES / ZASSET.ZDEPTHTYPE
# 37 ZGENERICASSET.ZADJUSTMENTTIMESTAMP -- when was photo edited?
# 38 ZGENERICASSET.ZVISIBILITYSTATE -- 0 if visible, 2 if not (e.g. a burst image)
# 39 ZGENERICASSET.ZTRASHEDDATE -- date item placed in the trash or null if not in trash
for row in c:
uuid = row[0]
@@ -1901,9 +1922,7 @@ class PhotosDB:
info["lastmodifieddate_timestamp"] = row[37]
try:
info["lastmodifieddate"] = datetime.fromtimestamp(row[37] + TIME_DELTA)
except ValueError:
info["lastmodifieddate"] = None
except TypeError:
except (ValueError, TypeError):
info["lastmodifieddate"] = None
info["imageTimeZoneOffsetSeconds"] = row[6]
@@ -2046,6 +2065,11 @@ class PhotosDB:
# recently deleted items
info["intrash"] = True if row[28] == 1 else False
info["trasheddate_timestamp"] = row[39]
try:
info["trasheddate"] = datetime.fromtimestamp(row[39] + TIME_DELTA)
except (ValueError, TypeError):
info["trasheddate"] = None
# height/width/orientation
info["height"] = row[29]
@@ -2056,6 +2080,11 @@ class PhotosDB:
info["original_orientation"] = row[34]
info["original_filesize"] = row[35]
# visibility state, visible (True) if 0, otherwise not visible (False)
# only values I've seen are 0 for visible, 2 for not-visible
info["visibility_state"] = row[38]
info["visible"] = row[38] == 0
# initialize import session info which will be filled in later
# not every photo has an import session so initialize all records now
info["import_session"] = None

View File

@@ -63,8 +63,8 @@ def noop(*args, **kwargs):
def _get_os_version():
# returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6)
# returns tuple of str containing OS version
# e.g. 10.13.6 = ("10", "13", "6")
version = platform.mac_ver()[0].split(".")
if len(version) == 2:
(ver, major) = version
@@ -260,10 +260,10 @@ def get_preferred_uti_extension(uti):
returns: preferred extension as str """
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
return CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
with objc.autorelease_pool():
return CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
def findfiles(pattern, path_):

View File

@@ -3,7 +3,7 @@
#
# setup.py script for osxphotos
#
# Copyright (c) 2019, 2020 Rhet Turnbull, rturnbull+git@gmail.com
# Copyright (c) 2019, 2020, 2021 Rhet Turnbull, rturnbull+git@gmail.com
# All rights reserved.
#
# Permission is hereby granted, free of charge, to any person
@@ -45,7 +45,7 @@ with open(
exec(f.read(), about)
# read README.md into long_description
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
with open(os.path.join(this_directory, "README.rst"), encoding="utf-8") as f:
about["long_description"] = f.read()
setup(
@@ -53,7 +53,7 @@ setup(
version=about["__version__"],
description="Export photos from Apple's macOS Photos app and query the Photos library database to access metadata about images.",
long_description=about["long_description"],
long_description_content_type="text/markdown",
long_description_content_type="text/x-rst",
author="Rhet Turnbull",
author_email="rturnbull+git@gmail.com",
url="https://github.com/RhetTbull/",

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,11 @@ import pytest
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.utils import _get_os_version
OS_VERSION = _get_os_version()
SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "15"
PHOTOS_DB_LOCAL = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
PHOTOS_DB = "tests/Test-10.15.7.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.15.7.photoslibrary/database/photos.db"
@@ -98,6 +103,11 @@ UUID_DICT = {
"movie": "D1359D09-1373-4F3B-B0E3-1A4DE573E4A3",
}
UUID_DICT_LOCAL = {
"not_visible": "ABF00253-78E7-4FD6-953B-709307CD489D",
"burst": "44AF1FCA-AC2D-4FA5-B288-E67DC18F9CA8",
}
UUID_PUMPKIN_FARM = [
"F12384F6-CD17-4151-ACBA-AE0E3688539E",
"D79B8D77-BFFC-460B-9312-034F2877D35B",
@@ -194,6 +204,11 @@ def photosdb():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@pytest.fixture(scope="module")
def photosdb_local():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB_LOCAL)
def test_init1():
# test named argument
@@ -413,6 +428,22 @@ def test_not_hidden(photosdb):
assert p.hidden == False
def test_visible(photosdb):
""" test visible """
photos = photosdb.photos(uuid=[UUID_DICT["not_hidden"]])
assert len(photos) == 1
p = photos[0]
assert p.visible
def test_not_burst(photosdb):
""" test not burst """
photos = photosdb.photos(uuid=[UUID_DICT["not_hidden"]])
assert len(photos) == 1
p = photos[0]
assert not p.burst
def test_location_1(photosdb):
# test photo with lat/lon info
@@ -546,6 +577,7 @@ def test_photoinfo_intrash_1(photosdb):
p = photosdb.photos(uuid=[UUID_DICT["intrash"]], intrash=True)[0]
assert p.intrash
assert p.date_trashed.isoformat() == "2120-06-10T11:24:47.685857-05:00"
def test_photoinfo_intrash_2(photosdb):
@@ -587,6 +619,7 @@ def test_photoinfo_not_intrash(photosdb):
p = photosdb.photos(uuid=[UUID_DICT["not_intrash"]])[0]
assert not p.intrash
assert p.date_trashed is None
def test_keyword_2(photosdb):
@@ -973,7 +1006,7 @@ def test_from_to_date(photosdb):
time.tzset()
photos = photosdb.photos(from_date=datetime.datetime(2018, 10, 28))
assert len(photos) == 10
assert len(photos) == 10
photos = photosdb.photos(to_date=datetime.datetime(2018, 10, 28))
assert len(photos) == 7
@@ -1133,3 +1166,22 @@ def test_original_filename(photosdb):
assert photo.original_filename == ORIGINAL_FILENAME_DICT["filename"]
photo._info["originalFilename"] = original_filename
# The following tests only run on the author's personal library
# They test things difficult to test in the test libraries
@pytest.mark.skipif(SKIP_TEST, reason="Skip if not running on author's local machine.")
def test_not_visible_burst(photosdb_local):
""" test not visible and burst (needs image from local library) """
photo = photosdb_local.get_photo(UUID_DICT_LOCAL["not_visible"])
assert not photo.visible
assert photo.burst
@pytest.mark.skipif(SKIP_TEST, reason="Skip if not running on author's local machine.")
def test_visible_burst(photosdb_local):
""" test not visible and burst (needs image from local library) """
photo = photosdb_local.get_photo(UUID_DICT_LOCAL["burst"])
assert photo.visible
assert photo.burst
assert len(photo.burst_photos) == 4

View File

@@ -2,14 +2,15 @@ import os
import pytest
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.utils import _get_os_version
skip_test = False if "OSXPHOTOS_TEST_EXPORT" in os.environ else True
OS_VERSION = _get_os_version()
SKIP_TEST = "OSXPHOTOS_TEST_EXPORT" not in os.environ or OS_VERSION[1] != "15"
PHOTOS_DB = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
pytestmark = pytest.mark.skipif(
skip_test, reason="These tests only run against system photos library"
SKIP_TEST, reason="These tests only run against system photos library"
)
PHOTOS_DB = "/Users/rhet/Pictures/Photos Library.photoslibrary"
UUID_DICT = {
"has_adjustments": "2B2D5434-6D31-49E2-BF47-B973D34A317B",
"no_adjustments": "A8D646C3-89A9-4D74-8001-4EB46BA55B94",
@@ -21,8 +22,7 @@ UUID_DICT = {
def photosdb():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
return photosdb
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
def test_export_default_name(photosdb):

View File

@@ -326,6 +326,22 @@ def test_not_hidden(photosdb):
assert p.hidden == False
def test_visible(photosdb):
""" test visible """
photos = photosdb.photos(uuid=[UUID_DICT["not_hidden"]])
assert len(photos) == 1
p = photos[0]
assert p.visible
def test_not_burst(photosdb):
""" test not burst """
photos = photosdb.photos(uuid=[UUID_DICT["not_hidden"]])
assert len(photos) == 1
p = photos[0]
assert not p.burst
def test_location_1(photosdb):
# test photo with lat/lon info
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
@@ -417,6 +433,7 @@ def test_photos_intrash_2(photosdb):
photos = photosdb.photos(intrash=True)
for p in photos:
assert p.intrash
assert p.date_trashed.isoformat() == "2305-12-17T13:19:08.978144-07:00"
def test_photos_not_intrash(photosdb):
@@ -424,6 +441,7 @@ def test_photos_not_intrash(photosdb):
photos = photosdb.photos(intrash=False)
for p in photos:
assert not p.intrash
assert p.date_trashed is None
def test_photoinfo_intrash_1(photosdb):