Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27f779b16c | ||
|
|
eec960861e | ||
|
|
4d924d0826 | ||
|
|
55c088eea2 | ||
|
|
ee2750224a | ||
|
|
db1947dd1e | ||
|
|
248fdbcf02 | ||
|
|
71cb01572d | ||
|
|
51b1058785 | ||
|
|
87701822ae | ||
|
|
b67f11a3bb | ||
|
|
804e13efff | ||
|
|
504b81b720 | ||
|
|
538e8b588e | ||
|
|
c4980fc284 | ||
|
|
a7678df397 | ||
|
|
e6f45f5949 | ||
|
|
f8468c63fd | ||
|
|
0545d5e321 | ||
|
|
5de9d4f90c | ||
|
|
123ebb2cb7 | ||
|
|
2d09d382e9 | ||
|
|
5e676d3507 | ||
|
|
935865dc65 | ||
|
|
193f26bec8 | ||
|
|
7b6a0af314 | ||
|
|
549a9b3572 | ||
|
|
792247b51c | ||
|
|
568d1b36a6 | ||
|
|
d78097ccc0 | ||
|
|
ad9dcd9ed7 | ||
|
|
aba50c5c73 | ||
|
|
8ca7719641 | ||
|
|
5dc2eeaf9a | ||
|
|
658e8ac096 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ jobs:
|
||||
build:
|
||||
|
||||
runs-on: macOS-latest
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
59
CHANGELOG.md
59
CHANGELOG.md
@@ -4,6 +4,65 @@ 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.15](https://github.com/RhetTbull/osxphotos/compare/v0.39.13...v0.39.15)
|
||||
|
||||
> 11 January 2021
|
||||
|
||||
- Completed implementation of --jpeg-ext, fixed --dry-run, closes #330, #346 [`#330`](https://github.com/RhetTbull/osxphotos/issues/330)
|
||||
- Added --jpeg-ext, implements #330 [`55c088e`](https://github.com/RhetTbull/osxphotos/commit/55c088eea2ddecb14e362221da9e2a7c0f403780)
|
||||
|
||||
#### [v0.39.13](https://github.com/RhetTbull/osxphotos/compare/v0.39.12...v0.39.13)
|
||||
|
||||
> 10 January 2021
|
||||
|
||||
- Fixed leaky memory in PhotoKit, issue #276 [`db1947d`](https://github.com/RhetTbull/osxphotos/commit/db1947dd1e3d47a487eeb68a5ceb5f7098f1df10)
|
||||
|
||||
#### [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
|
||||
|
||||
96
README.md
96
README.md
@@ -1,47 +1,48 @@
|
||||
# OSXPhotos
|
||||
[](https://github.com/python/black)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors)
|
||||
[](#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
|
||||
|
||||
@@ -294,6 +295,10 @@ Options:
|
||||
--export-as-hardlink Hardlink files instead of copying them.
|
||||
Cannot be used with --exiftool which creates
|
||||
copies of the files with embedded EXIF data.
|
||||
Note: on APFS volumes, files are cloned when
|
||||
exporting giving many of the same advantages
|
||||
as hardlinks without having to use --export-
|
||||
as-hardlink.
|
||||
--touch-file Sets the file's modification time to match
|
||||
photo date.
|
||||
--overwrite Overwrite existing files. Default behavior
|
||||
@@ -488,6 +493,15 @@ Options:
|
||||
do not include an extension in the FILENAME
|
||||
template. See below for additional details
|
||||
on templating system.
|
||||
--jpeg-ext EXTENSION Specify file extension for JPEG files.
|
||||
Photos uses .jpeg for edited images but many
|
||||
images are imported with .jpg or .JPG which
|
||||
can result in multiple different extensions
|
||||
used for JPEG files upon export. Use --jpg-
|
||||
ext to specify a single extension to use for
|
||||
all exported JPEG images. Valid values are
|
||||
jpeg, jpg, JPEG, JPG; e.g. '--jpg-ext jpg'
|
||||
to use '.jpg' for all JPEGs.
|
||||
--strip Optionally strip leading and trailing
|
||||
whitespace from any rendered templates. For
|
||||
example, if --filename template is "{title,}
|
||||
@@ -1577,9 +1591,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 +2602,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
270
README.rst
Normal 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
BIN
docs/screencast/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
BIN
docs/screencast/osx-screenshot.png
Normal file
BIN
docs/screencast/osx-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
296
docs/screencast/terminalizer-demo.yml
Normal file
296
docs/screencast/terminalizer-demo.yml
Normal 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"
|
||||
@@ -47,6 +47,7 @@ from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||
from .photoinfo import ExportResults
|
||||
from .photokit import check_photokit_authorization, request_photokit_authorization
|
||||
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
||||
from .utils import get_preferred_uti_extension
|
||||
|
||||
# global variable to control verbose output
|
||||
# set via --verbose/-V
|
||||
@@ -1345,8 +1346,10 @@ def query(
|
||||
@click.option(
|
||||
"--export-as-hardlink",
|
||||
is_flag=True,
|
||||
help="Hardlink files instead of copying them. "
|
||||
"Cannot be used with --exiftool which creates copies of the files with embedded EXIF data.",
|
||||
help="Hardlink files instead of copying them. "
|
||||
"Cannot be used with --exiftool which creates copies of the files with embedded EXIF data. "
|
||||
"Note: on APFS volumes, files are cloned when exporting giving many of the same "
|
||||
"advantages as hardlinks without having to use --export-as-hardlink.",
|
||||
)
|
||||
@click.option(
|
||||
"--touch-file",
|
||||
@@ -1586,6 +1589,16 @@ def query(
|
||||
"File extension will be added automatically--do not include an extension in the FILENAME template. "
|
||||
"See below for additional details on templating system.",
|
||||
)
|
||||
@click.option(
|
||||
"--jpeg-ext",
|
||||
multiple=False,
|
||||
metavar="EXTENSION",
|
||||
type=click.Choice(["jpeg", "jpg", "JPEG", "JPG"], case_sensitive=True),
|
||||
help="Specify file extension for JPEG files. Photos uses .jpeg for edited images but many images "
|
||||
"are imported with .jpg or .JPG which can result in multiple different extensions used for JPEG files "
|
||||
"upon export. Use --jpg-ext to specify a single extension to use for all exported JPEG images. "
|
||||
"Valid values are jpeg, jpg, JPEG, JPG; e.g. '--jpg-ext jpg' to use '.jpg' for all JPEGs.",
|
||||
)
|
||||
@click.option(
|
||||
"--strip",
|
||||
is_flag=True,
|
||||
@@ -1757,6 +1770,7 @@ def export(
|
||||
has_raw,
|
||||
directory,
|
||||
filename_template,
|
||||
jpeg_ext,
|
||||
strip,
|
||||
edited_suffix,
|
||||
original_suffix,
|
||||
@@ -1896,6 +1910,7 @@ def export(
|
||||
has_raw = cfg.has_raw
|
||||
directory = cfg.directory
|
||||
filename_template = cfg.filename_template
|
||||
jpeg_ext = cfg.jpeg_ext
|
||||
strip = cfg.strip
|
||||
edited_suffix = cfg.edited_suffix
|
||||
original_suffix = cfg.original_suffix
|
||||
@@ -2263,6 +2278,7 @@ def export(
|
||||
use_photokit=use_photokit,
|
||||
exiftool_option=exiftool_option,
|
||||
strip=strip,
|
||||
jpeg_ext=jpeg_ext,
|
||||
)
|
||||
results += export_results
|
||||
|
||||
@@ -2319,6 +2335,8 @@ def export(
|
||||
# but was missing on --update doesn't get deleted
|
||||
# (better to have old version than none)
|
||||
+ results.missing
|
||||
# include files that have error in case they exist from previous export
|
||||
+ [r[0] for r in results.error]
|
||||
+ [str(pathlib.Path(export_db_path).resolve())]
|
||||
)
|
||||
click.echo(f"Cleaning up {dest}")
|
||||
@@ -2835,6 +2853,7 @@ def export_photo(
|
||||
use_photokit=False,
|
||||
exiftool_option=None,
|
||||
strip=False,
|
||||
jpeg_ext=None,
|
||||
):
|
||||
"""Helper function for export that does the actual export
|
||||
|
||||
@@ -2872,6 +2891,7 @@ def export_photo(
|
||||
exiftool_option: optional list flags (e.g. ["-m", "-F"]) to pass to exiftool
|
||||
exiftool_merge_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
|
||||
exiftool_merge_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
|
||||
jpeg_ext: if not None, specify the extension to use for all JPEG images on export
|
||||
|
||||
Returns:
|
||||
list of path(s) of exported photo or None if photo was missing
|
||||
@@ -2929,6 +2949,7 @@ def export_photo(
|
||||
photo, filename_template, original_name, strip=strip
|
||||
)
|
||||
for filename in filenames:
|
||||
rendered_suffix = ""
|
||||
if original_suffix:
|
||||
try:
|
||||
rendered_suffix, unmatched = photo.render_template(
|
||||
@@ -2951,14 +2972,17 @@ def export_photo(
|
||||
)
|
||||
rendered_suffix = rendered_suffix[0]
|
||||
|
||||
original_filename = pathlib.Path(filename)
|
||||
original_filename = (
|
||||
original_filename.parent
|
||||
/ f"{original_filename.stem}{rendered_suffix}{original_filename.suffix}"
|
||||
)
|
||||
original_filename = str(original_filename)
|
||||
else:
|
||||
original_filename = filename
|
||||
original_filename = pathlib.Path(filename)
|
||||
file_ext = (
|
||||
"." + jpeg_ext
|
||||
if jpeg_ext and photo.uti == "public.jpeg"
|
||||
else original_filename.suffix
|
||||
)
|
||||
original_filename = (
|
||||
original_filename.parent
|
||||
/ f"{original_filename.stem}{rendered_suffix}{file_ext}"
|
||||
)
|
||||
original_filename = str(original_filename)
|
||||
|
||||
verbose_(
|
||||
f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename}"
|
||||
@@ -2995,7 +3019,19 @@ def export_photo(
|
||||
if export_original:
|
||||
if missing_original:
|
||||
space = " " if not verbose else ""
|
||||
verbose_(f"{space}Skipping missing photo {photo.original_filename}")
|
||||
verbose_(
|
||||
f"{space}Skipping missing photo {photo.original_filename} ({photo.uuid})"
|
||||
)
|
||||
results.missing.append(
|
||||
str(pathlib.Path(dest_path) / original_filename)
|
||||
)
|
||||
elif photo.intrash and (not photo.path or use_photos_export):
|
||||
# skip deleted files if they're missing or using use_photos_export
|
||||
# as AppleScript/PhotoKit cannot export deleted photos
|
||||
space = " " if not verbose else ""
|
||||
verbose_(
|
||||
f"{space}Skipping missing deleted photo {photo.original_filename} ({photo.uuid})"
|
||||
)
|
||||
results.missing.append(
|
||||
str(pathlib.Path(dest_path) / original_filename)
|
||||
)
|
||||
@@ -3030,6 +3066,7 @@ def export_photo(
|
||||
use_photokit=use_photokit,
|
||||
verbose=verbose_,
|
||||
exiftool_flags=exiftool_option,
|
||||
jpeg_ext=jpeg_ext,
|
||||
)
|
||||
results += export_results
|
||||
for warning_ in export_results.exiftool_warning:
|
||||
@@ -3044,6 +3081,14 @@ def export_photo(
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
for error_ in export_results.error:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Error exporting photo, file {error_[0]}: {error_[1]}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(
|
||||
@@ -3054,7 +3099,7 @@ def export_photo(
|
||||
err=True,
|
||||
)
|
||||
results.error.append(
|
||||
str(pathlib.Path(dest) / original_filename)
|
||||
(str(pathlib.Path(dest) / original_filename), e)
|
||||
)
|
||||
else:
|
||||
verbose_(f"Skipping original version of {photo.original_filename}")
|
||||
@@ -3062,17 +3107,16 @@ def export_photo(
|
||||
# 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:
|
||||
# if download_missing and the photo is missing or path doesn't exist,
|
||||
# try to download with Photos
|
||||
|
||||
edited_filename = pathlib.Path(filename)
|
||||
# check for correct edited suffix
|
||||
if photo.path_edited is not None:
|
||||
edited_ext = pathlib.Path(photo.path_edited).suffix
|
||||
else:
|
||||
# use filename suffix which might be wrong,
|
||||
# will be corrected by use_photos_export
|
||||
edited_ext = pathlib.Path(photo.filename).suffix
|
||||
edited_ext = (
|
||||
"." + jpeg_ext
|
||||
if jpeg_ext and photo.uti_edited == "public.jpeg"
|
||||
else "." + get_preferred_uti_extension(photo.uti_edited)
|
||||
if photo.uti_edited
|
||||
else pathlib.Path(photo.path_edited).suffix
|
||||
if photo.path_edited
|
||||
else pathlib.Path(photo.filename).suffix
|
||||
)
|
||||
|
||||
if edited_suffix:
|
||||
try:
|
||||
@@ -3107,10 +3151,23 @@ def export_photo(
|
||||
)
|
||||
if missing_edited:
|
||||
space = " " if not verbose else ""
|
||||
verbose_(f"{space}Skipping missing edited photo for {filename}")
|
||||
verbose_(
|
||||
f"{space}Skipping missing edited photo for {edited_filename}"
|
||||
)
|
||||
results.missing.append(
|
||||
str(pathlib.Path(dest_path) / edited_filename)
|
||||
)
|
||||
elif photo.intrash and (not photo.path_edited or use_photos_export):
|
||||
# skip deleted files if they're missing or using use_photos_export
|
||||
# as AppleScript/PhotoKit cannot export deleted photos
|
||||
space = " " if not verbose else ""
|
||||
verbose_(
|
||||
f"{space}Skipping missing deleted photo {photo.original_filename} ({photo.uuid})"
|
||||
)
|
||||
results.missing.append(
|
||||
str(pathlib.Path(dest_path) / edited_filename)
|
||||
)
|
||||
|
||||
else:
|
||||
try:
|
||||
export_results_edited = photo.export2(
|
||||
@@ -3141,6 +3198,7 @@ def export_photo(
|
||||
use_photokit=use_photokit,
|
||||
verbose=verbose_,
|
||||
exiftool_flags=exiftool_option,
|
||||
jpeg_ext=jpeg_ext,
|
||||
)
|
||||
results += export_results_edited
|
||||
for warning_ in export_results_edited.exiftool_warning:
|
||||
@@ -3163,7 +3221,9 @@ def export_photo(
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
results.error.append(str(pathlib.Path(dest) / edited_filename))
|
||||
results.error.append(
|
||||
(str(pathlib.Path(dest) / edited_filename), e)
|
||||
)
|
||||
|
||||
if verbose:
|
||||
if update:
|
||||
@@ -3367,7 +3427,7 @@ def write_export_report(report_file, results):
|
||||
"sidecar_json": 0,
|
||||
"sidecar_exiftool": 0,
|
||||
"missing": 0,
|
||||
"error": 0,
|
||||
"error": "",
|
||||
"exiftool_warning": "",
|
||||
"exiftool_error": "",
|
||||
"extended_attributes_written": 0,
|
||||
@@ -3425,7 +3485,7 @@ def write_export_report(report_file, results):
|
||||
all_results[result]["missing"] = 1
|
||||
|
||||
for result in results.error:
|
||||
all_results[result]["error"] = 1
|
||||
all_results[result[0]]["error"] = result[1]
|
||||
|
||||
for result in results.exiftool_warning:
|
||||
all_results[result[0]]["exiftool_warning"] = result[1]
|
||||
|
||||
@@ -58,8 +58,16 @@ _DB_TABLE_NAMES = {
|
||||
},
|
||||
}
|
||||
|
||||
# which major version operating systems have been tested
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14", "15", "16"]
|
||||
# which version operating systems have been tested
|
||||
_TESTED_OS_VERSIONS = [
|
||||
("10", "12"),
|
||||
("10", "13"),
|
||||
("10", "14"),
|
||||
("10", "15"),
|
||||
("10", "16"),
|
||||
("11", "0"),
|
||||
("11", "1"),
|
||||
]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
_UNKNOWN_PERSON = "_UNKNOWN_"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.39.9"
|
||||
|
||||
|
||||
__version__ = "0.39.16"
|
||||
|
||||
@@ -60,6 +60,11 @@ class FileUtilABC(ABC):
|
||||
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def rename(cls, src, dest):
|
||||
pass
|
||||
|
||||
|
||||
class FileUtilMacOS(FileUtilABC):
|
||||
""" Various file utilities """
|
||||
@@ -201,6 +206,21 @@ class FileUtilMacOS(FileUtilABC):
|
||||
src_file, dest_file, compression_quality=compression_quality
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def rename(cls, src, dest):
|
||||
""" Copy src to dest
|
||||
|
||||
Args:
|
||||
src: path to source file
|
||||
dest: path to destination file
|
||||
|
||||
Returns:
|
||||
Name of renamed file (dest)
|
||||
|
||||
"""
|
||||
os.rename(str(src), str(dest))
|
||||
return dest
|
||||
|
||||
@staticmethod
|
||||
def _sig(st):
|
||||
""" return tuple of (mode, size, mtime) of file based on os.stat
|
||||
@@ -266,3 +286,7 @@ class FileUtilNoOp(FileUtil):
|
||||
@classmethod
|
||||
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
||||
cls.verbose(f"convert_to_jpeg: {src_file}, {dest_file}, {compression_quality}")
|
||||
|
||||
@classmethod
|
||||
def rename(cls, src, dest):
|
||||
cls.verbose(f"rename: {src}, {dest}")
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -49,7 +49,13 @@ from ..photokit import (
|
||||
PhotoKitFetchFailed,
|
||||
PhotoLibrary,
|
||||
)
|
||||
from ..utils import dd_to_dms_str, findfiles, noop
|
||||
from ..utils import dd_to_dms_str, findfiles, noop, get_preferred_uti_extension
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
""" error during export """
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ExportResults:
|
||||
@@ -114,10 +120,10 @@ class ExportResults:
|
||||
+ self.sidecar_xmp_written
|
||||
+ self.sidecar_xmp_skipped
|
||||
+ self.missing
|
||||
+ self.error
|
||||
)
|
||||
files += [x[0] for x in self.exiftool_warning]
|
||||
files += [x[0] for x in self.exiftool_error]
|
||||
files += [x[0] for x in self.error]
|
||||
|
||||
files = list(set(files))
|
||||
return files
|
||||
@@ -188,56 +194,33 @@ def _export_photo_uuid_applescript(
|
||||
):
|
||||
"""Export photo to dest path using applescript to control Photos
|
||||
If photo is a live photo, exports both the photo and associated .mov file
|
||||
uuid: UUID of photo to export
|
||||
dest: destination path to export to
|
||||
filestem: (string) if provided, exported filename will be named stem.ext
|
||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||
If not provided, file will be named with whatever name Photos uses
|
||||
If filestem.ext exists, it wil be overwritten
|
||||
original: (boolean) if True, export original image; default = True
|
||||
edited: (boolean) if True, export edited photo; default = False
|
||||
If photo not edited and edited=True, will still export the original image
|
||||
caller must verify image has been edited
|
||||
*Note*: must be called with either edited or original but not both,
|
||||
will raise error if called with both edited and original = True
|
||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
|
||||
|
||||
Args:
|
||||
uuid: UUID of photo to export
|
||||
dest: destination path to export to
|
||||
filestem: (string) if provided, exported filename will be named stem.ext
|
||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||
If not provided, file will be named with whatever name Photos uses
|
||||
If filestem.ext exists, it wil be overwritten
|
||||
original: (boolean) if True, export original image; default = True
|
||||
edited: (boolean) if True, export edited photo; default = False
|
||||
If photo not edited and edited=True, will still export the original image
|
||||
caller must verify image has been edited
|
||||
*Note*: must be called with either edited or original but not both,
|
||||
will raise error if called with both edited and original = True
|
||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
|
||||
|
||||
Returns: list of paths to exported file(s) or None if export failed
|
||||
|
||||
Raises: ExportError if error during export
|
||||
|
||||
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
||||
has not been edited. This is due to how Photos Applescript interface works.
|
||||
"""
|
||||
|
||||
# setup the applescript to do the export
|
||||
# export_scpt = AppleScript(
|
||||
# """
|
||||
# on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
|
||||
# tell application "Photos"
|
||||
# set thePath to thePath
|
||||
# set theItem to media item id theUUID
|
||||
# set theFilename to filename of theItem
|
||||
# set itemList to {theItem}
|
||||
|
||||
# if original then
|
||||
# with timeout of theTimeOut seconds
|
||||
# export itemList to POSIX file thePath with using originals
|
||||
# end timeout
|
||||
# end if
|
||||
|
||||
# if edited then
|
||||
# with timeout of theTimeOut seconds
|
||||
# export itemList to POSIX file thePath
|
||||
# end timeout
|
||||
# end if
|
||||
|
||||
# return theFilename
|
||||
# end tell
|
||||
|
||||
# end export_by_uuid
|
||||
# """
|
||||
# )
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir():
|
||||
raise ValueError(f"dest {dest} must be a directory")
|
||||
@@ -253,12 +236,8 @@ def _export_photo_uuid_applescript(
|
||||
photo = photoscript.Photo(uuid)
|
||||
filename = photo.filename
|
||||
exported_files = photo.export(tmpdir.name, original=original, timeout=timeout)
|
||||
# filename = export_scpt.call(
|
||||
# "export_by_uuid", uuid, tmpdir.name, original, edited, timeout
|
||||
# )
|
||||
except Exception as e:
|
||||
logging.warning(f"Error exporting uuid {uuid}: {e}")
|
||||
return None
|
||||
raise ExportError(e)
|
||||
|
||||
if exported_files and filename:
|
||||
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
||||
@@ -332,6 +311,34 @@ def _check_export_suffix(src, dest, edited):
|
||||
)
|
||||
|
||||
|
||||
# not a class method, don't import into PhotoInfo
|
||||
def rename_jpeg_files(files, jpeg_ext, fileutil):
|
||||
""" rename any jpeg files in files so that extension matches jpeg_ext
|
||||
|
||||
Args:
|
||||
files: list of file paths
|
||||
jpeg_ext: extension to use for jpeg files found in files, e.g. "jpg"
|
||||
fileutil: a FileUtil object
|
||||
|
||||
Returns:
|
||||
list of files with updated names
|
||||
|
||||
Note: If non-jpeg files found, they will be ignore and returned in the return list
|
||||
"""
|
||||
jpeg_ext = "." + jpeg_ext
|
||||
jpegs = [".jpeg", ".jpg"]
|
||||
new_files = []
|
||||
for file in files:
|
||||
path = pathlib.Path(file)
|
||||
if path.suffix.lower() in jpegs and path.suffix != jpeg_ext:
|
||||
new_file = path.parent / (path.stem + jpeg_ext)
|
||||
fileutil.rename(file, new_file)
|
||||
new_files.append(new_file)
|
||||
else:
|
||||
new_files.append(file)
|
||||
return new_files
|
||||
|
||||
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
@@ -458,6 +465,7 @@ def export2(
|
||||
exiftool_flags=None,
|
||||
merge_exif_keywords=False,
|
||||
merge_exif_persons=False,
|
||||
jpeg_ext=None,
|
||||
):
|
||||
"""export photo, like export but with update and dry_run options
|
||||
dest: must be valid destination path or exception raised
|
||||
@@ -509,6 +517,7 @@ def export2(
|
||||
exiftool_flags: optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
|
||||
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
|
||||
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
|
||||
jpeg_ext: if set, will use this value for extension on jpegs converted to jpeg with convert_to_jpeg; if not set, uses jpeg; do not include the leading "."
|
||||
|
||||
Returns: ExportResults class
|
||||
ExportResults has attributes:
|
||||
@@ -527,6 +536,7 @@ def export2(
|
||||
"sidecar_xmp_skipped",
|
||||
"missing",
|
||||
"error",
|
||||
"error_str",
|
||||
"exiftool_warning",
|
||||
"exiftool_error",
|
||||
|
||||
@@ -553,24 +563,6 @@ def export2(
|
||||
# e.g. name will be filename_edited.jpg
|
||||
edited_identifier = "_edited"
|
||||
|
||||
# list of all files exported during this call to export
|
||||
exported_files = []
|
||||
|
||||
# list of new files during update
|
||||
update_new_files = []
|
||||
|
||||
# list of files that were updated
|
||||
update_updated_files = []
|
||||
|
||||
# list of all files skipped because they do not need to be updated (for use with update=True)
|
||||
update_skipped_files = []
|
||||
|
||||
# list of all files with utime touched (touch_file = True)
|
||||
touched_files = []
|
||||
|
||||
# list of all files convereted to jpeg
|
||||
converted_to_jpeg_files = []
|
||||
|
||||
# check edited and raise exception trying to export edited version of
|
||||
# photo that hasn't been edited
|
||||
if edited and not self.hasadjustments:
|
||||
@@ -614,7 +606,8 @@ def export2(
|
||||
if convert_to_jpeg and self.isphoto and uti != "public.jpeg":
|
||||
# not a jpeg but will convert to jpeg upon export so fix file extension
|
||||
fname_new = pathlib.Path(fname)
|
||||
fname = str(fname_new.parent / f"{fname_new.stem}.jpeg")
|
||||
ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
|
||||
fname = str(fname_new.parent / f"{fname_new.stem}{ext}")
|
||||
else:
|
||||
# nothing to convert
|
||||
convert_to_jpeg = False
|
||||
@@ -645,6 +638,7 @@ def export2(
|
||||
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
all_results = ExportResults()
|
||||
if not use_photos_export:
|
||||
# find the source file on disk and export
|
||||
# get path to source file and verify it's not None and is valid file
|
||||
@@ -738,12 +732,7 @@ def export2(
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
exported_files = results.exported
|
||||
update_new_files = results.new
|
||||
update_updated_files = results.updated
|
||||
update_skipped_files = results.skipped
|
||||
touched_files = results.touched
|
||||
converted_to_jpeg_files = results.converted_to_jpeg
|
||||
all_results += results
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
if live_photo and self.live_photo:
|
||||
@@ -764,12 +753,7 @@ def export2(
|
||||
fileutil=fileutil,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
exported_files.extend(results.exported)
|
||||
update_new_files.extend(results.new)
|
||||
update_updated_files.extend(results.updated)
|
||||
update_skipped_files.extend(results.skipped)
|
||||
touched_files.extend(results.touched)
|
||||
converted_to_jpeg_files.extend(results.converted_to_jpeg)
|
||||
all_results += results
|
||||
|
||||
# copy associated RAW image if requested
|
||||
if raw_photo and self.has_raw:
|
||||
@@ -791,15 +775,11 @@ def export2(
|
||||
jpeg_quality=jpeg_quality,
|
||||
ignore_signature=ignore_signature,
|
||||
)
|
||||
exported_files.extend(results.exported)
|
||||
update_new_files.extend(results.new)
|
||||
update_updated_files.extend(results.updated)
|
||||
update_skipped_files.extend(results.skipped)
|
||||
touched_files.extend(results.touched)
|
||||
converted_to_jpeg_files.extend(results.converted_to_jpeg)
|
||||
all_results += results
|
||||
else:
|
||||
# TODO: move this big if/else block to separate functions
|
||||
# e.g. _export_with_photos_export or such
|
||||
# use_photo_export
|
||||
exported = []
|
||||
# export live_photo .mov file?
|
||||
live_photo = True if live_photo and self.live_photo else False
|
||||
if edited or self.shared:
|
||||
@@ -813,7 +793,10 @@ def export2(
|
||||
else:
|
||||
# didn't get passed a filename, add _edited
|
||||
filestem = f"{dest.stem}{edited_identifier}"
|
||||
dest = dest.parent / f"{filestem}.jpeg"
|
||||
uti = self.uti_edited if edited and self.uti_edited else self.uti
|
||||
ext = get_preferred_uti_extension(uti)
|
||||
dest = dest.parent / f"{filestem}{ext}"
|
||||
|
||||
if use_photokit:
|
||||
photolib = PhotoLibrary()
|
||||
photo = None
|
||||
@@ -829,25 +812,40 @@ def export2(
|
||||
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
|
||||
photo = photo[0] if photo else None
|
||||
if not photo:
|
||||
logging.warning(f"PhotoKitFetchFailed exception exporting photo {self.uuid}: {e}")
|
||||
all_results.error.append(
|
||||
(
|
||||
str(dest),
|
||||
f"PhotoKitFetchFailed exception exporting photo {self.uuid}: {e}",
|
||||
)
|
||||
)
|
||||
if photo:
|
||||
exported = photo.export(
|
||||
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
|
||||
)
|
||||
else:
|
||||
exported = []
|
||||
if not dry_run:
|
||||
try:
|
||||
exported = photo.export(
|
||||
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except Exception as e:
|
||||
all_results.error.append((str(dest), e))
|
||||
else:
|
||||
# dry_run, don't actually export
|
||||
all_results.exported.append(str(dest))
|
||||
else:
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
original=False,
|
||||
edited=True,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
try:
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
original=False,
|
||||
edited=True,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except ExportError as e:
|
||||
all_results.error.append((str(dest), e))
|
||||
else:
|
||||
# export original version and not edited
|
||||
filestem = dest.stem
|
||||
@@ -866,37 +864,48 @@ def export2(
|
||||
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
|
||||
photo = photo[0] if photo else None
|
||||
if photo:
|
||||
exported = photo.export(
|
||||
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
|
||||
)
|
||||
else:
|
||||
exported = []
|
||||
if not dry_run:
|
||||
try:
|
||||
exported = photo.export(
|
||||
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except Exception as e:
|
||||
all_results.error.append((str(dest), e))
|
||||
else:
|
||||
# dry_run, don't actually export
|
||||
all_results.exported.append(str(dest))
|
||||
else:
|
||||
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,
|
||||
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,
|
||||
)
|
||||
all_results.exported.extend(exported)
|
||||
except ExportError as e:
|
||||
all_results.error.append((str(dest), e))
|
||||
if all_results.exported:
|
||||
if jpeg_ext:
|
||||
# use_photos_export (both PhotoKit and AppleScript) don't use the
|
||||
# file extension provided (instead they use extension for UTI)
|
||||
# so if jpeg_ext is set, rename any non-conforming jpegs
|
||||
all_results.exported = rename_jpeg_files(
|
||||
all_results.exported, jpeg_ext, fileutil
|
||||
)
|
||||
if exported:
|
||||
if touch_file:
|
||||
for exported_file in exported:
|
||||
touched_files.append(exported_file)
|
||||
for exported_file in all_results.exported:
|
||||
all_results.touched.append(exported_file)
|
||||
ts = int(self.date.timestamp())
|
||||
fileutil.utime(exported_file, (ts, ts))
|
||||
exported_files.extend(exported)
|
||||
if update:
|
||||
update_new_files.extend(exported)
|
||||
|
||||
else:
|
||||
logging.warning(
|
||||
f"Error exporting photo {self.uuid} to {dest} with use_photos_export"
|
||||
)
|
||||
all_results.new.extend(all_results.exported)
|
||||
|
||||
# export metadata
|
||||
sidecars = []
|
||||
@@ -1008,14 +1017,10 @@ def export2(
|
||||
|
||||
# if exiftool, write the metadata
|
||||
if update:
|
||||
exif_files = update_new_files + update_updated_files + update_skipped_files
|
||||
exif_files = all_results.new + all_results.updated + all_results.skipped
|
||||
else:
|
||||
exif_files = exported_files
|
||||
exif_files = all_results.exported
|
||||
|
||||
exif_files_updated = []
|
||||
exiftool_warning = []
|
||||
exiftool_error = []
|
||||
errors = []
|
||||
# TODO: remove duplicative code from below
|
||||
if exiftool and update and exif_files:
|
||||
for exported_file in exif_files:
|
||||
@@ -1054,10 +1059,10 @@ def export2(
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
)
|
||||
if warning_:
|
||||
exiftool_warning.append((exported_file, warning_))
|
||||
all_results.exiftool_warning.append((exported_file, warning_))
|
||||
if error_:
|
||||
exiftool_error.append((exported_file, error_))
|
||||
errors.append(exported_file)
|
||||
all_results.exiftool_error.append((exported_file, error_))
|
||||
all_results.error.append((exported_file, error_))
|
||||
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
@@ -1074,7 +1079,7 @@ def export2(
|
||||
export_db.set_stat_exif_for_file(
|
||||
exported_file, fileutil.file_sig(exported_file)
|
||||
)
|
||||
exif_files_updated.append(exported_file)
|
||||
all_results.exif_updated.append(exported_file)
|
||||
else:
|
||||
verbose(f"Skipped up to date exiftool metadata for {exported_file}")
|
||||
elif exiftool and exif_files:
|
||||
@@ -1093,10 +1098,10 @@ def export2(
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
)
|
||||
if warning_:
|
||||
exiftool_warning.append((exported_file, warning_))
|
||||
all_results.exiftool_warning.append((exported_file, warning_))
|
||||
if error_:
|
||||
exiftool_error.append((exported_file, error_))
|
||||
errors.append(exported_file)
|
||||
all_results.exiftool_error.append((exported_file, error_))
|
||||
all_results.error.append((exported_file, error_))
|
||||
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
@@ -1113,36 +1118,25 @@ def export2(
|
||||
export_db.set_stat_exif_for_file(
|
||||
exported_file, fileutil.file_sig(exported_file)
|
||||
)
|
||||
exif_files_updated.append(exported_file)
|
||||
all_results.exif_updated.append(exported_file)
|
||||
|
||||
if touch_file:
|
||||
for exif_file in exif_files_updated:
|
||||
for exif_file in all_results.exif_updated:
|
||||
verbose(f"Updating file modification time for {exif_file}")
|
||||
touched_files.append(exif_file)
|
||||
all_results.touched.append(exif_file)
|
||||
ts = int(self.date.timestamp())
|
||||
fileutil.utime(exif_file, (ts, ts))
|
||||
|
||||
touched_files = list(set(touched_files))
|
||||
all_results.touched = list(set(all_results.touched))
|
||||
|
||||
results = ExportResults(
|
||||
exported=exported_files,
|
||||
new=update_new_files,
|
||||
updated=update_updated_files,
|
||||
skipped=update_skipped_files,
|
||||
exif_updated=exif_files_updated,
|
||||
touched=touched_files,
|
||||
converted_to_jpeg=converted_to_jpeg_files,
|
||||
sidecar_json_written=sidecar_json_files_written,
|
||||
sidecar_json_skipped=sidecar_json_files_skipped,
|
||||
sidecar_exiftool_written=sidecar_exiftool_files_written,
|
||||
sidecar_exiftool_skipped=sidecar_exiftool_files_skipped,
|
||||
sidecar_xmp_written=sidecar_xmp_files_written,
|
||||
sidecar_xmp_skipped=sidecar_xmp_files_skipped,
|
||||
error=errors,
|
||||
exiftool_error=exiftool_error,
|
||||
exiftool_warning=exiftool_warning,
|
||||
)
|
||||
return results
|
||||
all_results.sidecar_json_written = sidecar_json_files_written
|
||||
all_results.sidecar_json_skipped = sidecar_json_files_skipped
|
||||
all_results.sidecar_exiftool_written = sidecar_exiftool_files_written
|
||||
all_results.sidecar_exiftool_skipped = sidecar_exiftool_files_skipped
|
||||
all_results.sidecar_xmp_written = sidecar_xmp_files_written
|
||||
all_results.sidecar_xmp_skipped = sidecar_xmp_files_skipped
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
def _export_photo(
|
||||
@@ -1502,7 +1496,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()
|
||||
@@ -1598,6 +1592,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
|
||||
@@ -1614,6 +1609,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
|
||||
@@ -1818,3 +1814,4 @@ def _write_sidecar(self, filename, sidecar_str):
|
||||
f = open(filename, "w")
|
||||
f.write(sidecar_str)
|
||||
f.close()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,12 +85,12 @@ class PhotosDB:
|
||||
|
||||
# Check OS version
|
||||
system = platform.system()
|
||||
(_, major, _) = _get_os_version()
|
||||
if system != "Darwin" or (major not in _TESTED_OS_VERSIONS):
|
||||
(ver, major, _) = _get_os_version()
|
||||
if system != "Darwin" or ((ver, major) not in _TESTED_OS_VERSIONS):
|
||||
logging.warning(
|
||||
f"WARNING: This module has only been tested with MacOS 10."
|
||||
f"[{', '.join(_TESTED_OS_VERSIONS)}]: "
|
||||
f"you have {system}, OS version: {major}"
|
||||
f"WARNING: This module has only been tested with macOS versions "
|
||||
f"[{', '.join(f'{v}.{m}' for (v, m) in _TESTED_OS_VERSIONS)}]: "
|
||||
f"you have {system}, OS version: {ver}.{major}"
|
||||
)
|
||||
|
||||
if verbose is None:
|
||||
@@ -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
|
||||
|
||||
@@ -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_):
|
||||
|
||||
6
setup.py
6
setup.py
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -565,6 +565,14 @@ UUID_NO_LIKES = [
|
||||
"1C1C8F1F-826B-4A24-B1CB-56628946A834",
|
||||
]
|
||||
|
||||
UUID_JPEGS_DICT = {
|
||||
"4D521201-92AC-43E5-8F7C-59BC41C37A96": ["IMG_1997", "JPG"],
|
||||
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": ["wedding", "jpg"],
|
||||
"E2078879-A29C-4D6F-BACB-E3BBE6C3EB91": ["screenshot-really-a-png", "jpeg"],
|
||||
}
|
||||
|
||||
UUID_HEIC = {"7783E8E6-9CAC-40F3-BE22-81FB7051C266": "IMG_3092"}
|
||||
|
||||
|
||||
def modify_file(filename):
|
||||
""" appends data to a file to modify it """
|
||||
@@ -5238,3 +5246,77 @@ def test_export_xattr_template():
|
||||
assert sorted(md.keywords) == sorted(expected)
|
||||
assert md.comment == CLI_FINDER_TAGS[uuid]["XMP:Title"]
|
||||
|
||||
|
||||
def test_export_jpeg_ext():
|
||||
""" test --jpeg-ext """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
for uuid, fileinfo in UUID_JPEGS_DICT.items():
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", uuid]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
filename, ext = fileinfo
|
||||
assert f"{filename}.{ext}" in files
|
||||
|
||||
for jpeg_ext in ["jpg", "JPG", "jpeg", "JPEG"]:
|
||||
with runner.isolated_filesystem():
|
||||
for uuid, fileinfo in UUID_JPEGS_DICT.items():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
uuid,
|
||||
"--jpeg-ext",
|
||||
jpeg_ext,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
filename, ext = fileinfo
|
||||
assert f"{filename}.{jpeg_ext}" in files
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
|
||||
reason="Skip if running in Github actions, no GPU.",
|
||||
)
|
||||
def test_export_jpeg_ext_convert_to_jpeg():
|
||||
""" test --jpeg-ext with --convert-to-jpeg """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
for uuid, filename in UUID_HEIC.items():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--uuid",
|
||||
uuid,
|
||||
"--convert-to-jpeg",
|
||||
"--jpeg-ext",
|
||||
"jpg",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert f"{filename}.jpg" in files
|
||||
|
||||
@@ -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):
|
||||
@@ -83,6 +83,7 @@ def test_all_files():
|
||||
setattr(results, x, [f"{x}1"])
|
||||
results.exiftool_warning = [("exiftool_warning1", "foo")]
|
||||
results.exiftool_error = [("exiftool_error1", "foo")]
|
||||
results.error = [("error1", "foo")]
|
||||
|
||||
assert sorted(results.all_files()) == sorted(
|
||||
[f"{x}1" for x in EXPORT_RESULT_ATTRIBUTES]
|
||||
|
||||
@@ -107,3 +107,21 @@ def test_convert_to_jpeg_quality():
|
||||
assert FileUtil.convert_to_jpeg(imgfile, outfile, compression_quality=0.1)
|
||||
assert outfile.is_file()
|
||||
assert outfile.stat().st_size < 1000000
|
||||
|
||||
|
||||
def test_rename_file():
|
||||
# rename file with valid src, dest
|
||||
import pathlib
|
||||
import tempfile
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
src = "tests/test-images/wedding.jpg"
|
||||
dest = f"{temp_dir.name}/foo.jpg"
|
||||
dest2 = f"{temp_dir.name}/bar.jpg"
|
||||
FileUtil.copy(src, dest)
|
||||
result = FileUtil.rename(dest, dest2)
|
||||
assert result
|
||||
assert pathlib.Path(dest2).exists()
|
||||
assert not pathlib.Path(dest).exists()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
18
utils/README.md
Normal file
18
utils/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Utils
|
||||
|
||||
These are various utilities used in my development workflow. They may or may not be useful to you if you're working on osxphotos. If using the AppleScripts to get data from Photos, I highly recommend the excellent [FastScripts](https://redsweater.com/fastscripts/) from Red Sweater Software.
|
||||
|
||||
## Files
|
||||
|
||||
|File | Description |
|
||||
|-----|-------------|
|
||||
|build_help_table.py| Builds the template substitutions table used in main README.md |
|
||||
|check_uuid.py| Use with output file created by dump_photo_info.scpt to check ouput of osxphotos vs what Photos reports|
|
||||
|copy_uuid_to_clipboard.applescript| Copy UUID of selected photo in Photos to the Clipboard|
|
||||
|dump_photo_info.applescript| Dumps UUID and other info about every photo in Photos.app to a test file; see check_uuid.py|
|
||||
|dump_photo_info.scpt| Compiled version of dump_photo_info.applescript|
|
||||
|gen_face_test_data.py| Generate test data for test_faceinfo.py|
|
||||
|generate_search_info_test_data.py | Create the test data needed for test_search_info_10_15_7.py|
|
||||
|get_photo_info.applescript| Displays UUID and other info about selected photos, useful for debugging|
|
||||
|get_photo_info.scpt| Compiled version of above|
|
||||
|write_uuid_to_file.applescript| Writes the UUIDs of selected images in Photos to a text file; can generate input for --uuid-from-file|
|
||||
20
utils/copy_uuid_to_clipboard.applescript
Normal file
20
utils/copy_uuid_to_clipboard.applescript
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Copies UUID of selected photo to the clipboard, if more than one selection, copies uuid from the last item
|
||||
-- Useful for debugging with osxphotos
|
||||
|
||||
|
||||
tell application "Photos"
|
||||
set uuid to ""
|
||||
set theSelection to selection
|
||||
repeat with theItem in theSelection
|
||||
set uuid to ((id of theItem) as text)
|
||||
set oldDelimiter to AppleScript's text item delimiters
|
||||
set AppleScript's text item delimiters to "/"
|
||||
set theTextItems to every text item of uuid
|
||||
set uuid to first item of theTextItems
|
||||
set AppleScript's text item delimiters to oldDelimiter
|
||||
end repeat
|
||||
set the clipboard to uuid
|
||||
|
||||
end tell
|
||||
|
||||
|
||||
Reference in New Issue
Block a user