Compare commits

...

81 Commits

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

https://pyobjc.readthedocs.io/en/latest/api/module-objc.html#memory-management
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmAutoreleasePools.html
2021-01-09 14:44:26 +00:00
Rhet Turnbull
804e13efff Updated README [skip ci] 2021-01-08 10:24:45 -08:00
Rhet Turnbull
504b81b720 Merge pull request #328 from synox/patch-2
doc: Recorded screencast and updated of readme [skip ci]
2021-01-08 07:10:49 -08:00
Rhet Turnbull
538e8b588e Updated CHANGELOG.md, [skip ci] 2021-01-08 07:08:07 -08:00
Rhet Turnbull
c4980fc284 Added README.rst, closes #331 2021-01-08 07:04:07 -08:00
Rhet Turnbull
a7678df397 Updated tests workflow badge link 2021-01-08 06:41:05 -08:00
Rhet Turnbull
e6f45f5949 Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-01-08 06:36:07 -08:00
Rhet Turnbull
f8468c63fd Renamed workflow to tests 2021-01-08 06:35:56 -08:00
Rhet Turnbull
0545d5e321 Merge pull request #343 from RhetTbull/all-contributors/add-kradalby
All contributors/add kradalby
2021-01-08 06:26:51 -08:00
Rhet Turnbull
5de9d4f90c Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-01-08 06:23:25 -08:00
Rhet Turnbull
123ebb2cb7 Ensure merge_exif_keywords are str not int 2021-01-08 06:23:17 -08:00
Rhet Turnbull
2d09d382e9 skip action for all-contributors bot 2021-01-08 06:22:01 -08:00
allcontributors[bot]
5e676d3507 docs: update .all-contributorsrc [skip ci] 2021-01-08 14:16:30 +00:00
allcontributors[bot]
935865dc65 docs: update README.md [skip ci] 2021-01-08 14:16:25 +00:00
Rhet Turnbull
193f26bec8 Merge pull request #342 from kradalby/master
Ensure keyword list only contains strings, @all-contributors please add @kradalby for code
2021-01-08 06:06:17 -08:00
Kristoffer Dalby
7b6a0af314 Ensure keyword list only contains string
This commit ensures that every keyword in the keyword list that is to be written
to a sidecar or exif only contains strings. The current implementation fails
with an exception as the "sorted" functions will fail if the list contains a mix
of strings and integers.
2021-01-08 11:33:50 +00:00
Rhet Turnbull
549a9b3572 Updated CHANGELOG.md 2021-01-06 08:33:40 -08:00
Rhet Turnbull
792247b51c Improved handling of deleted photos, #332 2021-01-06 08:24:58 -08:00
Rhet Turnbull
568d1b36a6 Refactored ExportResults 2021-01-06 06:56:26 -08:00
Rhet Turnbull
d78097ccc0 Added error_str to ExportResults 2021-01-05 21:34:46 -08:00
Rhet Turnbull
ad9dcd9ed7 Updated help text for --export-as-hardlink, #287 2021-01-05 21:10:37 -08:00
Rhet Turnbull
fb5fb8ebc7 Added additional warning to _photoinfo_export 2021-01-04 12:37:12 -08:00
Rhet Turnbull
7deac581b1 Added test for Big Sur 16.0.1 database changes 2021-01-03 19:40:34 -08:00
Rhet Turnbull
1173c00ce7 Updated all-contributors 2021-01-03 15:24:40 -08:00
Rhet Turnbull
2bf83e4b1f Updated all-contributors 2021-01-03 15:23:21 -08:00
Aravindo Wingeier
aba50c5c73 doc: fixed toc in readme 2021-01-03 22:40:49 +01:00
Aravindo Wingeier
8ca7719641 added screencast files 2021-01-03 22:38:38 +01:00
Aravindo Wingeier
5dc2eeaf9a Create terminalizer-demo.yml 2021-01-03 22:38:08 +01:00
Aravindo Wingeier
658e8ac096 doc: Recorded screencast and updated of readme
... to be more attractive. Inspired by https://github.com/faressoft/terminalizer/blob/master/README.md
2021-01-03 22:36:11 +01:00
Rhet Turnbull
b93d6822ac Updated README, version 2021-01-03 10:41:47 -08:00
Rhet Turnbull
90b493b7a4 Merge pull request #327 from synox/patch-1
doc: start with examples before the export reference, thanks to @synox
2021-01-03 10:30:28 -08:00
Rhet Turnbull
2480f2a325 Added tag_groups arg to ExifTool.asdict(), issue #324 2021-01-03 10:28:44 -08:00
Aravindo Wingeier
a59bb5b02f remove extra spaces 2021-01-03 19:09:29 +01:00
Aravindo Wingeier
7c8bfc811a Adding back dependency https://github.com/RhetTbull/PhotoScript) 2021-01-03 19:08:32 +01:00
Aravindo Wingeier
7c7bf1be6b doc: start with examples before the export reference
Because the very long export reference might makes it hard to spot the examples.
2021-01-03 19:04:49 +01:00
Rhet Turnbull
b1cab32ff4 Updated dependencies in README.md 2021-01-03 09:48:01 -08:00
Rhet Turnbull
05f111a287 Added exception handling/capture for convert-to-jpeg, issue #322 2021-01-03 09:36:26 -08:00
Rhet Turnbull
83915c65ab Add @synox as a contributor 2021-01-03 09:24:45 -08:00
Rhet Turnbull
22f44f7f40 Merge pull request #326 from synox/master
Make readme easier for beginners, thanks to @synox
2021-01-03 09:21:20 -08:00
Aravindo Wingeier
02ef0f9a25 doc simplify readme 2021-01-03 18:04:51 +01:00
Rhet Turnbull
6347d94dfb Updated CHANGELOG.md 2021-01-03 08:47:16 -08:00
Rhet Turnbull
a32c102d62 Updated CHANGELOG.md 2021-01-03 08:46:36 -08:00
Aravindo Wingeier
38842ff924 Cleanup up the readme
simplify as much as possible
2021-01-03 17:31:42 +01:00
Rhet Turnbull
478715a363 Implemented text replacement for templates, issue #316 2021-01-03 07:38:07 -08:00
Rhet Turnbull
74f1002b9a Updated CHANGELOG.md 2020-12-31 13:11:21 -08:00
Rhet Turnbull
2f57abd23c Fixed modified template to use creation time if no modificationd date, issue #312 2020-12-31 13:07:00 -08:00
Rhet Turnbull
f9a43b92c1 Updated CHANGELOG.md 2020-12-31 12:36:16 -08:00
Rhet Turnbull
bf2a55d7f6 Added --xattr-template, closes #242 2020-12-31 12:33:15 -08:00
Rhet Turnbull
34bb7f2cdc Updated CHANGELOG.md 2020-12-30 20:48:38 -08:00
Rhet Turnbull
3394c52768 Fixed --exiftool-path bug, issue #311, #313 2020-12-30 20:21:05 -08:00
Rhet Turnbull
27282af3b9 Updated CHANGELOG.md 2020-12-30 14:00:31 -08:00
Rhet Turnbull
b7b06b9fdb Merge pull request #310 from RhetTbull/finder_tags
Added Finder tags, partial implementation for issue #242
2020-12-30 13:52:37 -08:00
Rhet Turnbull
29e424575a Added tests for Finder tags 2020-12-30 13:37:15 -08:00
Rhet Turnbull
ea373c4197 Updated requirements.txt 2020-12-30 08:52:58 -08:00
Rhet Turnbull
f25a299309 Updated README for finder tags 2020-12-30 08:51:01 -08:00
Rhet Turnbull
5885b23d32 Initial implementation for Finder tags 2020-12-30 08:32:42 -08:00
Rhet Turnbull
5dccdf7750 Fixed --exiftool-path bug, issue #308 2020-12-30 07:31:07 -08:00
Rhet Turnbull
e9134f84df Updated CHANGELOG.md 2020-12-29 09:51:18 -08:00
Rhet Turnbull
3872e7ae64 Fixed --exiftool-path to work with --exiftool-merge-keywords/persons 2020-12-29 09:47:03 -08:00
Rhet Turnbull
b3e86dffc8 Updated CHANGELOG.md 2020-12-29 09:46:25 -08:00
Rhet Turnbull
4897fc4b05 Added --exiftool-path to CLI 2020-12-29 09:23:51 -08:00
Rhet Turnbull
1dbf22fdc9 Updated CHANGELOG.md 2020-12-29 08:03:21 -08:00
Rhet Turnbull
fa58af8b88 Added exiftool signature to JSON output, issue #303 2020-12-29 07:51:34 -08:00
Rhet Turnbull
9c9bcb08b3 Updated CHANGELOG.md 2020-12-28 15:42:09 -08:00
Rhet Turnbull
b1cb99f83f Added --exiftool-merge-keywords/persons, issue #299, #292 2020-12-28 15:31:31 -08:00
Rhet Turnbull
d3605f6303 Updated CHANGELOG.md 2020-12-28 11:58:00 -08:00
Rhet Turnbull
dce002cdfe Added --sidecar-drop-ext, issue #291 2020-12-28 11:54:02 -08:00
Rhet Turnbull
7bd189e9b2 Updated Template Substitution table 2020-12-28 09:26:38 -08:00
Rhet Turnbull
baa86c77f6 Updated CHANGELOG.md 2020-12-28 09:17:06 -08:00
Rhet Turnbull
0d086bf851 Added searchinfo templates, issue #302 2020-12-28 09:14:08 -08:00
Rhet Turnbull
ade98fc150 Refactored sidecar code 2020-12-28 08:23:23 -08:00
Rhet Turnbull
0d66759b1c Refactored export2 to use sidecar bit field 2020-12-27 22:45:47 -08:00
Rhet Turnbull
d833c14ef4 Added --sidecar exiftool, issue #303 2020-12-27 22:17:56 -08:00
Rhet Turnbull
34841f86c0 Updated CHANGELOG.md 2020-12-27 09:29:32 -08:00
Rhet Turnbull
4cc40d24cf Bug fix for --description-template, issue #304 2020-12-27 09:26:54 -08:00
Rhet Turnbull
1ccf03e158 Updated CHANGELOG.md 2020-12-27 08:45:49 -08:00
227 changed files with 5960 additions and 1216 deletions

View File

@@ -6,7 +6,8 @@
"files": [
"README.md"
],
"imageSize": 100,
"imageSize": 75,
"badgeTemplate": "[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat)](#contributors)",
"commit": true,
"commitConvention": "none",
"contributors": [
@@ -118,7 +119,35 @@
"contributions": [
"doc"
]
},
{
"login": "synox",
"name": "Aravindo Wingeier",
"avatar_url": "https://avatars2.githubusercontent.com/u/2250964?v=4",
"profile": "https://github.com/synox",
"contributions": [
"doc"
]
},
{
"login": "kradalby",
"name": "Kristoffer Dalby",
"avatar_url": "https://avatars1.githubusercontent.com/u/98431?v=4",
"profile": "https://kradalby.no",
"contributions": [
"code"
]
},
{
"login": "Rott-Apple",
"name": "Rott-Apple",
"avatar_url": "https://avatars1.githubusercontent.com/u/67875570?v=4",
"profile": "https://github.com/Rott-Apple",
"contributions": [
"research"
]
}
],
"contributorsPerLine": 7
"contributorsPerLine": 7,
"skipCi": true
}

View File

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

View File

@@ -4,6 +4,175 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.39.12](https://github.com/RhetTbull/osxphotos/compare/v0.39.11...v0.39.12)
> 9 January 2021
- Force cleanup of objects in write_jpeg (fix memory leak) [`#344`](https://github.com/RhetTbull/osxphotos/pull/344)
- doc: Recorded screencast and updated of readme [skip ci] [`#328`](https://github.com/RhetTbull/osxphotos/pull/328)
- Added PhotoInfo.visible, PhotoInfo.date_trashed, closes #333, #334 [`#333`](https://github.com/RhetTbull/osxphotos/issues/333)
- Force cleanup of objects with autorelease pool [`b67f11a`](https://github.com/RhetTbull/osxphotos/commit/b67f11a3bb95c08a39a185b6d884092870e949f2)
- Add @Rott-Apple as a contributor [`71cb015`](https://github.com/RhetTbull/osxphotos/commit/71cb01572d2d946df18dd7b36f95b2f2e5b48f86)
- Updated README [skip ci] [`804e13e`](https://github.com/RhetTbull/osxphotos/commit/804e13efff921ab51b996493d659b32102807a8a)
#### [v0.39.11](https://github.com/RhetTbull/osxphotos/compare/v0.39.10...v0.39.11)
> 8 January 2021
- All contributors/add kradalby [`#343`](https://github.com/RhetTbull/osxphotos/pull/343)
- Ensure keyword list only contains strings, @all-contributors please add @kradalby for code [`#342`](https://github.com/RhetTbull/osxphotos/pull/342)
- Added README.rst, closes #331 [`#331`](https://github.com/RhetTbull/osxphotos/issues/331)
- Updated tests workflow badge link [`a7678df`](https://github.com/RhetTbull/osxphotos/commit/a7678df3974ff539050f5acb4c94817f525dcd56)
- Merge branch 'master' of github.com:RhetTbull/osxphotos [`e6f45f5`](https://github.com/RhetTbull/osxphotos/commit/e6f45f59491d9e805e227af8cbf8ac08ff99fdf0)
- Renamed workflow to tests [`f8468c6`](https://github.com/RhetTbull/osxphotos/commit/f8468c63fda930216f73ad5aa8c4aa92edf1adf2)
- Merge branch 'master' of github.com:RhetTbull/osxphotos [`5de9d4f`](https://github.com/RhetTbull/osxphotos/commit/5de9d4f90c1102c4fb0099befd6142180f32df3f)
- Ensure merge_exif_keywords are str not int [`123ebb2`](https://github.com/RhetTbull/osxphotos/commit/123ebb2cb752bb94291ac2b77e4a327cee996df1)
#### [v0.39.10](https://github.com/RhetTbull/osxphotos/compare/v0.39.9...v0.39.10)
> 6 January 2021
- Refactored ExportResults [`568d1b3`](https://github.com/RhetTbull/osxphotos/commit/568d1b36a631df33317dc00f27126b507c90bf51)
- Improved handling of deleted photos, #332 [`792247b`](https://github.com/RhetTbull/osxphotos/commit/792247b51cc2263221ba8c2e741d2ec454c75ca8)
- Added error_str to ExportResults [`d78097c`](https://github.com/RhetTbull/osxphotos/commit/d78097ccc0686680baf5fffa91f9e082e44b576e)
#### [v0.39.9](https://github.com/RhetTbull/osxphotos/compare/v0.39.8...v0.39.9)
> 4 January 2021
- Added test for Big Sur 16.0.1 database changes [`7deac58`](https://github.com/RhetTbull/osxphotos/commit/7deac581b1f1fb3dc59885b6e1ab9a63b382408d)
- Create terminalizer-demo.yml [`5dc2eea`](https://github.com/RhetTbull/osxphotos/commit/5dc2eeaf9a7265873c81db23bbc86d3023189a26)
- doc: Recorded screencast and updated of readme [`658e8ac`](https://github.com/RhetTbull/osxphotos/commit/658e8ac096d141fce48483dbfc1426bea317d806)
#### [v0.39.8](https://github.com/RhetTbull/osxphotos/compare/v0.39.7...v0.39.8)
> 3 January 2021
- Updated README, version [`b93d682`](https://github.com/RhetTbull/osxphotos/commit/b93d6822ac5366c57d9142cba9b809b4ab99ad98)
#### [v0.39.7](https://github.com/RhetTbull/osxphotos/compare/v0.39.6...v0.39.7)
> 3 January 2021
- doc: start with examples before the export reference, thanks to @synox [`#327`](https://github.com/RhetTbull/osxphotos/pull/327)
- Added tag_groups arg to ExifTool.asdict(), issue #324 [`2480f2a`](https://github.com/RhetTbull/osxphotos/commit/2480f2a325dbb09689f8c417618b7b9e976bfcb9)
- doc: start with examples before the export reference [`7c7bf1b`](https://github.com/RhetTbull/osxphotos/commit/7c7bf1be6b6382a995a4e17906adfd8720d0a1c3)
- Updated dependencies in README.md [`b1cab32`](https://github.com/RhetTbull/osxphotos/commit/b1cab32ff4c7b65ae4c9a5a9a11c175dbd487c0a)
- remove extra spaces [`a59bb5b`](https://github.com/RhetTbull/osxphotos/commit/a59bb5b02f10fa554dae346a7271be37f50d8bcc)
- Adding back dependency https://github.com/RhetTbull/PhotoScript) [`7c8bfc8`](https://github.com/RhetTbull/osxphotos/commit/7c8bfc811ab3a93dabadf1655f7d0e217d6c7b01)
#### [v0.39.6](https://github.com/RhetTbull/osxphotos/compare/v0.39.5...v0.39.6)
> 3 January 2021
- Make readme easier for beginners, thanks to @synox [`#326`](https://github.com/RhetTbull/osxphotos/pull/326)
- doc simplify readme [`02ef0f9`](https://github.com/RhetTbull/osxphotos/commit/02ef0f9a254e83a3729a09cea1ae523407074896)
- Added exception handling/capture for convert-to-jpeg, issue #322 [`05f111a`](https://github.com/RhetTbull/osxphotos/commit/05f111a287e882ed6b451a550a87753501316aba)
- Add @synox as a contributor [`83915c6`](https://github.com/RhetTbull/osxphotos/commit/83915c65abb880036f80ebd830eb1e34292f9599)
#### [v0.39.5](https://github.com/RhetTbull/osxphotos/compare/v0.39.4...v0.39.5)
> 3 January 2021
- Cleanup up the readme [`38842ff`](https://github.com/RhetTbull/osxphotos/commit/38842ff9249e6f5b3069a88a759c8df97ddce51c)
#### [v0.39.4](https://github.com/RhetTbull/osxphotos/compare/v0.39.3...v0.39.4)
> 3 January 2021
- Implemented text replacement for templates, issue #316 [`478715a`](https://github.com/RhetTbull/osxphotos/commit/478715a363f5009e4a38148e832bf0ad3c4cc4f8)
#### [v0.39.3](https://github.com/RhetTbull/osxphotos/compare/v0.39.2...v0.39.3)
> 31 December 2020
- Fixed modified template to use creation time if no modificationd date, issue #312 [`2f57abd`](https://github.com/RhetTbull/osxphotos/commit/2f57abd23cabe57bcf667a1713c37689b330a702)
#### [v0.39.2](https://github.com/RhetTbull/osxphotos/compare/v0.39.1...v0.39.2)
> 31 December 2020
- Added --xattr-template, closes #242 [`#242`](https://github.com/RhetTbull/osxphotos/issues/242)
#### [v0.39.1](https://github.com/RhetTbull/osxphotos/compare/v0.39.0...v0.39.1)
> 31 December 2020
- Fixed --exiftool-path bug, issue #311, #313 [`3394c52`](https://github.com/RhetTbull/osxphotos/commit/3394c527682d8fdd2f20f4f778d802dab86b6372)
#### [v0.39.0](https://github.com/RhetTbull/osxphotos/compare/v0.38.22...v0.39.0)
> 30 December 2020
- Added Finder tags, partial implementation for issue #242 [`#310`](https://github.com/RhetTbull/osxphotos/pull/310)
- Added tests for Finder tags [`29e4245`](https://github.com/RhetTbull/osxphotos/commit/29e424575a522ae03efe5a140be46bfd0a1346c5)
- Initial implementation for Finder tags [`5885b23`](https://github.com/RhetTbull/osxphotos/commit/5885b23d3249cf91953092a6b1ce967da2667e29)
- Updated README for finder tags [`f25a299`](https://github.com/RhetTbull/osxphotos/commit/f25a2993097ad7b2b8ab2d1c787db58c0d799a41)
- Updated requirements.txt [`ea373c4`](https://github.com/RhetTbull/osxphotos/commit/ea373c4197ce1cce00e89157fe560d1366f7e764)
#### [v0.38.22](https://github.com/RhetTbull/osxphotos/compare/v0.38.21...v0.38.22)
> 30 December 2020
- Fixed --exiftool-path bug, issue #308 [`5dccdf7`](https://github.com/RhetTbull/osxphotos/commit/5dccdf7750611c78de5356bb02f6023d4fc382c5)
#### [v0.38.21](https://github.com/RhetTbull/osxphotos/compare/v0.38.20...v0.38.21)
> 29 December 2020
- Fixed --exiftool-path to work with --exiftool-merge-keywords/persons [`3872e7a`](https://github.com/RhetTbull/osxphotos/commit/3872e7ae649f42d849de472a7dbf78a241d54407)
#### [v0.38.20](https://github.com/RhetTbull/osxphotos/compare/v0.38.19...v0.38.20)
> 29 December 2020
- Added --exiftool-path to CLI [`4897fc4`](https://github.com/RhetTbull/osxphotos/commit/4897fc4b05cc7a3bea314f9cce8a2163bf3922b2)
#### [v0.38.19](https://github.com/RhetTbull/osxphotos/compare/v0.38.18...v0.38.19)
> 29 December 2020
- Added exiftool signature to JSON output, issue #303 [`fa58af8`](https://github.com/RhetTbull/osxphotos/commit/fa58af8b883da11fdfa723d2da75a600d927d46e)
#### [v0.38.18](https://github.com/RhetTbull/osxphotos/compare/v0.38.17...v0.38.18)
> 28 December 2020
- Added --exiftool-merge-keywords/persons, issue #299, #292 [`b1cb99f`](https://github.com/RhetTbull/osxphotos/commit/b1cb99f83f55128a314d265d4588134cb79026c6)
#### [v0.38.17](https://github.com/RhetTbull/osxphotos/compare/v0.38.16...v0.38.17)
> 28 December 2020
- Added --sidecar-drop-ext, issue #291 [`dce002c`](https://github.com/RhetTbull/osxphotos/commit/dce002cdfe12fa5fa4ada4d5097828a5375c2ecd)
- Updated Template Substitution table [`7bd189e`](https://github.com/RhetTbull/osxphotos/commit/7bd189e9b22a2ad5a8a80deb7cb93c61be37c771)
#### [v0.38.16](https://github.com/RhetTbull/osxphotos/compare/v0.38.15...v0.38.16)
> 28 December 2020
- Added searchinfo templates, issue #302 [`0d086bf`](https://github.com/RhetTbull/osxphotos/commit/0d086bf85102ce78b3111c64bfa88673fbc19559)
#### [v0.38.15](https://github.com/RhetTbull/osxphotos/compare/v0.38.14...v0.38.15)
> 28 December 2020
- Added --sidecar exiftool, issue #303 [`d833c14`](https://github.com/RhetTbull/osxphotos/commit/d833c14ef4b3f9375a85034cf0fb0f85a68cabb4)
- Refactored sidecar code [`ade98fc`](https://github.com/RhetTbull/osxphotos/commit/ade98fc15051684bfb54d0199d9c370481b70dcc)
- Refactored export2 to use sidecar bit field [`0d66759`](https://github.com/RhetTbull/osxphotos/commit/0d66759b1c200f1ecda202e28c259f88fd3db599)
#### [v0.38.14](https://github.com/RhetTbull/osxphotos/compare/v0.38.13...v0.38.14)
> 27 December 2020
- Bug fix for --description-template, issue #304 [`4cc40d2`](https://github.com/RhetTbull/osxphotos/commit/4cc40d24cfb11ef8668c5d3c3bab40371fdd0436)
#### [v0.38.13](https://github.com/RhetTbull/osxphotos/compare/v0.38.12...v0.38.13)
> 27 December 2020
- Set XMP:Subject to match Keywords, issue #302 [`75888cd`](https://github.com/RhetTbull/osxphotos/commit/75888cd6633d3f0180d24fef4f6776986a136f0f)
#### [v0.38.12](https://github.com/RhetTbull/osxphotos/compare/v0.38.11...v0.38.12)
> 26 December 2020

611
README.md
View File

@@ -1,70 +1,70 @@
# OSXPhotos
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
[![tests](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-12-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-15-orange.svg?style=flat)](#contributors)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos)
* [What is osxphotos?](#what-is-osxphotos)
* [Supported operating systems](#supported-operating-systems)
* [Installation instructions](#installation-instructions)
* [Command Line Usage](#command-line-usage)
* [Example uses of the package](#example-uses-of-the-package)
* [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 database on MacOS. Using this package you can query the Photos database for information about the photos stored in a Photos library on your Mac--for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
## Supported operating systems
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.7 / Photos 5.0.
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 10.16/MacOS 11 Big Sur Beta / Photos 6.0. Not tested on M1 / Apple silicon Macs.
| macOS Version | macOS name | Photos.app version |
| ----------------- |------------|:-------------------|
| 10.16 | Big Sur | 6.0 ⚠️ Beta support, not tested on M1 / Apple silicon Macs. |
| 10.15.1 - 10.15.7 | Catalina | 5.0 ✅ |
| 10.14.5, 10.14.6 | Mojave | 4.0 ✅ |
| 10.13.6 | High Sierra| 3.0 ✅ |
| 10.12.6 | Sierra | 2.0 ✅ |
Requires python >= 3.7.
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.
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running MacOS 10.12 and vice versa.
Requires python >= `3.7`.
## Installation instructions
OSXPhotos uses setuptools, thus simply run:
python3 setup.py install
You can also install directly from [pypi](https://pypi.org/project/osxphotos/):
pip install osxphotos
I recommend you create a [virtual environment](https://docs.python.org/3/tutorial/venv.html) before installing osxphotos.
## Installation
If you are new to python, 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`)
@@ -73,13 +73,26 @@ If you aren't familiar with installing python applications, I recommend you inst
- Then type this: `pipx install osxphotos`
- Now you should be able to run `osxphotos` by typing: `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.
### Installation using pip
You can also install directly from [pypi](https://pypi.org/project/osxphotos/):
pip install osxphotos
### Installation from git repository
OSXPhotos uses setuptools, thus simply run:
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. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
After installing per instructions above, you should be able to run `osxphotos` on the command line:
```
> osxphotos
@@ -114,7 +127,37 @@ Commands:
To get help on a specific command, use `osxphotos help <command_name>`
Example: `osxphotos help export`
### 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`
### Command line reference: export
`osxphotos help export`
```
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
@@ -301,23 +344,45 @@ Options:
the primary photo will be exported--
associated burst images will be skipped.
--sidecar FORMAT Create sidecar for each photo exported;
valid FORMAT values: xmp, json; --sidecar
json: create JSON sidecar useable by
exiftool (https://exiftool.org/) The sidecar
file can be used to apply metadata to the
file with exiftool, for example: "exiftool
valid FORMAT values: xmp, json, exiftool;
--sidecar xmp: create XMP sidecar used by
Adobe Lightroom, etc. The sidecar file is
named in format photoname.ext.xmp The XMP
sidecar exports the following tags:
Description, Title, Keywords/Tags, Subject
(set to Keywords + PersonInImage),
PersonInImage, CreateDate, ModifyDate,
GPSLongitude.
--sidecar json: create JSON
sidecar useable by exiftool
(https://exiftool.org/) The sidecar file can
be used to apply metadata to the file with
exiftool, for example: "exiftool
-j=photoname.jpg.json photoname.jpg" The
sidecar file is named in format
photoname.ext.json --sidecar xmp: create
XMP sidecar used by Adobe Lightroom, etc.The
sidecar file is named in format
photoname.ext.xmpThe XMP sidecar exports the
following tags: Description, Title,
Keywords/Tags, Subject (set to Keywords +
PersonInImage), PersonInImage, CreateDate,
ModifyDate, GPSLongitude. For a list of tags
exported in the JSON sidecar, see
--exiftool.
photoname.ext.json; format includes tag
groups (equivalent to running 'exiftool -G
-j').
--sidecar exiftool: create JSON
sidecar compatible with output of 'exiftool
-j'. Unlike '--sidecar json', '--sidecar
exiftool' does not export tag groups.
Sidecar filename is in format
photoname.ext.json; For a list of tags
exported in the JSON and exiftool sidecar,
see '--exiftool'.
--sidecar-drop-ext Drop the photo's extension when naming
sidecar files. By default, sidecar files are
named in format
'photo_filename.photo_ext.sidecar_ext', e.g.
'IMG_1234.JPG.json'. Use '--sidecar-drop-
ext' to ignore the photo extension.
Resulting sidecar files will have name in
format 'IMG_1234.json'. Warning: this may
result in sidecar filename collisions if
there are files of different types but the
same name in the output directory, e.g.
'IMG_1234.JPG' and 'IMG_1234.MOV'.
--exiftool Use exiftool to write metadata directly to
exported photos. To use this option,
exiftool must be installed and in the path.
@@ -327,14 +392,12 @@ Options:
metadata: EXIF:ImageDescription,
XMP:Description (see also --description-
template); XMP:Title; XMP:TagsList,
IPTC:Keywords (see also --keyword-template,
--person-keyword, --album-keyword);
XMP:Subject (set to keywords + person in
image to mirror Photos' behavior);
XMP:PersonInImage; EXIF:GPSLatitudeRef;
EXIF:GPSLongitudeRef; EXIF:GPSLatitude;
EXIF:GPSLongitude; EXIF:GPSPosition;
EXIF:DateTimeOriginal;
IPTC:Keywords, XMP:Subject (see also
--keyword-template, --person-keyword,
--album-keyword); XMP:PersonInImage;
EXIF:GPSLatitudeRef; EXIF:GPSLongitudeRef;
EXIF:GPSLatitude; EXIF:GPSLongitude;
EXIF:GPSPosition; EXIF:DateTimeOriginal;
EXIF:OffsetTimeOriginal; EXIF:ModifyDate
(see --ignore-date-modified);
IPTC:DateCreated; IPTC:TimeCreated; (video
@@ -343,6 +406,8 @@ Options:
(see also --ignore-date-modified);
QuickTime:GPSCoordinates;
UserData:GPSCoordinates.
--exiftool-path EXIFTOOL_PATH Optionally specify path to exiftool; if not
provided, will look for exiftool in $PATH.
--exiftool-option OPTION Optional flag/option to pass to exiftool
when using --exiftool. For example,
--exiftool-option '-m' to ignore minor
@@ -353,6 +418,12 @@ Options:
may be specified by repeating the option,
e.g. --exiftool-option '-m' --exiftool-
option '-F'.
--exiftool-merge-keywords Merge any keywords found in the original
file with keywords used for '--exiftool' and
'--sidecar'.
--exiftool-merge-persons Merge any persons found in the original file
with persons used for '--exiftool' and '--
sidecar'.
--ignore-date-modified If used with --exiftool or --sidecar, will
ignore the photo modification date and set
EXIF:ModifyDate to EXIF:DateTimeOriginal;
@@ -384,6 +455,30 @@ Options:
could specify --description-template
"{descr} exported with osxphotos on
{today.date}" See Templating System below.
--finder-tag-template TEMPLATE Set MacOS Finder tags to TEMPLATE. These
tags can be searched in the Finder or
Spotlight with 'tag:tagname' format. For
example, '--finder-tag-template "{label}"'
to set Finder tags to photo labels. You may
specify multiple TEMPLATE values by using '
--finder-tag-template' multiple times. See
also '--finder-tag-keywords and Extended
Attributes below.'.
--finder-tag-keywords Set MacOS Finder tags to keywords; any
keywords specified via '--keyword-template',
'--person-keyword', etc. will also be used
as Finder tags. See also '--finder-tag-
template and Extended Attributes below.'.
--xattr-template ATTRIBUTE TEMPLATE
Set extended attribute ATTRIBUTE to TEMPLATE
value. Valid attributes are: 'authors',
'comment', 'copyright', 'description',
'findercomment', 'headline', 'keywords'. For
example, to set Finder comment to the
photo's title and description: '--xattr-
template findercomment "{title}; {descr}"
See Extended Attributes below for additional
details on this option.
--directory DIRECTORY Optional template for specifying name of
output directory in the form
'{name,DEFAULT}'. See below for additional
@@ -394,6 +489,13 @@ Options:
do not include an extension in the FILENAME
template. See below for additional details
on templating system.
--strip Optionally strip leading and trailing
whitespace from any rendered templates. For
example, if --filename template is "{title,}
{original_name}" and image has no title,
resulting file would have a leading space
but if used with --strip, this will be
removed.
--edited-suffix SUFFIX Optional suffix template for naming edited
photos. Default name for edited photos is
in form 'photoname_edited.ext'. For example,
@@ -486,53 +588,74 @@ option to re-export the entire library thus rebuilding the
'.osxphotos_export.db' database.
** Extended Attributes **
Some options (currently '--finder-tag-template', '--finder-tag-keywords',
'-xattr-template') write additional metadata to extended attributes in the
file. These options will only work if the destination filesystem supports
extended attributes (most do). For example, --finder-tag-keyword writes all
keywords (including any specified by '--keyword-template' or other options) to
Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'.
For example, if you have images with keyword "Travel" then using '--finder-
tag-keywords' you could quickly find those images in the Finder by typing
'tag:Travel' in the Spotlight search bar. Finder tags are written to the
'com.apple.metadata:_kMDItemUserTags' extended attribute. Unlike EXIF
metadata, extended attributes do not modify the actual file. Most cloud
storage services do not synch extended attributes. Dropbox does sync them and
any changes to a file's extended attributes will cause Dropbox to re-sync the
files.
The following attributes may be used with '--xattr-template':
authors The author, or authors, of the contents of the file. A list
of strings. (com.apple.metadata:kMDItemAuthors)
comment A comment related to the file. This differs from the Finder
comment, kMDItemFinderComment. A string.
(com.apple.metadata:kMDItemComment)
copyright The copyright owner of the file contents. A string.
(com.apple.metadata:kMDItemCopyright)
description A description of the content of the resource. The
description may include an abstract, table of contents,
reference to a graphical representation of content or a free-
text account of the content. A string.
(com.apple.metadata:kMDItemDescription)
findercomment Finder comments for this file. A string.
(com.apple.metadata:kMDItemFinderComment)
headline A publishable entry providing a synopsis of the contents of
the file. A string. (com.apple.metadata:kMDItemHeadline)
keywords Keywords associated with this file. For example, “Birthday”,
“Important”, etc. This differs from Finder tags
(_kMDItemUserTags) which are keywords/tags shown in the
Finder and searchable in Spotlight using "tag:tag_name". A
list of strings. (com.apple.metadata:kMDItemKeywords)
For additional information on extended attributes see: https://developer.apple
.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute
_keys
** Templating System **
Several options, such as --directory, allow you to specify a template which
will be rendered to substitute template fields with values from the photo.
For example, '{created.month}' would be replaced with the month name of the
photo creation date. e.g. 'November'.
will be rendered to substitute template fields with values from the photo. For
example, '{created.month}' would be replaced with the month name of the photo
creation date. e.g. 'November'.
Some options supporting templates may be repeated e.g., --keyword-template
'{label}' --keyword-template '{media_type}' to add both labels and media
types to the keywords.
The general format for a template is '{TEMPLATE_FIELD[,[DEFAULT]]}'. Some
templates have optional modifiers in form
'{[[DELIM]+]TEMPLATE_FIELD[(PATH_SEP)][?VALUE_IF_TRUE][,[DEFAULT]]}'
The general format for a template is '{TEMPLATE_FIELD,DEFAULT}'. The full
template format is:
'{DELIM+TEMPLATE_FIELD(PATH_SEP)[OLD,NEW]?VALUE_IF_TRUE,DEFAULT}'
The ',' and DEFAULT value are optional. If TEMPLATE_FIELD results in a null
(empty) value, the default is '_'. You may specify an alternate default
value by appending ',DEFAULT' after template_field. e.g. '{title,no_title}'
would result in 'no_title' if the photo had no title. You may include other
text in the template string outside the {} and use more than one template
field, e.g. '{created.year} - {created.month}' (e.g. '2020 - November').
With a few exceptions (like '{created.strftime}') everything but the
TEMPLATE_FIELD is optional.
Some template fields such as 'hdr' are boolean and resolve to True or False.
These take the form: '{TEMPLATE_FIELD?VALUE_IF_TRUE,VALUE_IF_FALSE}', e.g.
{hdr?is_hdr,not_hdr} which would result in 'is_hdr' if photo is an HDR image
and 'not_hdr' otherwise.
Some template fields such as 'folder_template' are "path-like" in that they
join multiple elements into a single path-like string. For example, if photo
is in album Album1 in folder Folder1, '{folder_album}` results in
'Folder1/Album1'. This is so these template fields may be used as paths in
--directory. If you intend to use such a field as a string, e.g. in the
filename, you may specify a different path separator using the form:
'{TEMPLATE_FIELD(PATH_SEP)}'. For example, using the example above,
'{folder_album(-)}' would result in 'Folder1-Album1' and '{folder_album()}'
would result in 'Folder1Album1'.
Some templates may resolve to more than one value. For example, a photo can
have multiple keywords so '{keyword}' can result in multiple values. If used
in a filename or directory, these templates may result in more than one copy
of the photo being exported. For example, if photo has keywords "foo" and
"bar", --directory '{keyword}' will result in copies of the photo being
exported to 'foo/image_name.jpeg' and 'bar/image_name.jpeg'.
Multi-value template fields such as '{keyword}' may be expanded 'in place'
with an optional delimiter using the template form '{DELIM+TEMPLATE_FIELD}'.
For example, a photo with keywords 'foo' and 'bar':
- 'DELIM+' Multi-value template fields such as '{keyword}' may be expanded 'in
place' with an optional delimiter using the template form
'{DELIM+TEMPLATE_FIELD}'. For example, a photo with keywords 'foo' and 'bar':
'{keyword}' renders to 'foo' and 'bar'
@@ -542,6 +665,62 @@ For example, a photo with keywords 'foo' and 'bar':
'{+keyword}' renders to 'foobar'
- 'TEMPLATE_FIELD' The name of the template field, for example 'keyword'
- '(PATH_SEP)' Some template fields such as '{folder_album}' are "path-like"
in that they join multiple elements into a single path-like string. For
example, if photo is in album Album1 in folder Folder1, '{folder_album}'
results in 'Folder1/Album1'. This is so these template fields may be used as
paths in --directory. If you intend to use such a field as a string, e.g. in
the filename, you may specify a different path separator using the form:
'{TEMPLATE_FIELD(PATH_SEP)}'. For example, using the example above,
'{folder_album(-)}' would result in 'Folder1-Album1' and '{folder_album()}'
would result in 'Folder1Album1'.
- '[OLD,NEW]' Use the [OLD,NEW] option to replace text "OLD" in the template
value with text "NEW". For example, if you have album names with '/' in the
album name you could replace '/' with "-" using the template '{album[/,-]}'.
This would replace any occurence of "/" in the album name with "-"; album
"Vacation/2019" would thus become "Vacation-2019". You may specify more than
one pair of OLD,NEW values by listing them delimited by '|'. For example:
'{album[/,-|:,-]}' to replace both '/' and ':' by '-'. You can also use the
[OLD,NEW] syntax to delete a character by omitting the NEW value as in
'{album[/,]}'.
- '?' Some template fields such as 'hdr' are boolean and resolve to True or
False. These take the form: '{TEMPLATE_FIELD?VALUE_IF_TRUE,VALUE_IF_FALSE}',
e.g. {hdr?is_hdr,not_hdr} which would result in 'is_hdr' if photo is an HDR
image and 'not_hdr' otherwise.
- ',DEFAULT' The ',' and DEFAULT value are optional. If TEMPLATE_FIELD
results in a null (empty) value, the template will result in default value of
'_'. You may specify an alternate default value by appending ',DEFAULT' after
template_field. Example: '{title,no_title}' would result in 'no_title' if the
photo had no title. Example: '{created.year}/{place.address,NO_ADDRESS}' but
there was no address associated with the photo, the resulting output would
be: '2020/NO_ADDRESS/photoname.jpg'. If specified, the default value may not
contain a brace symbol ('{' or '}').
Again, if you do not specify a default value and the template substitution has
no value, '_' (underscore) will be used as the default value. For example, in
the above example, this would result in '2020/_/photoname.jpg' if address was
null.
You may specify a null default (e.g. "" or empty string) by omitting the value
after the comma, e.g. {title,} which would render to "" if title had no value
thus effectively deleting the template from the resulting string.
You may include other text in the template string outside the {} and use more
than one template field in a single string, e.g. '{created.year} -
{created.month}' (e.g. '2020 - November').
Some templates may resolve to more than one value. For example, a photo can
have multiple keywords so '{keyword}' can result in multiple values. If used
in a filename or directory, these templates may result in more than one copy
of the photo being exported. For example, if photo has keywords "foo" and
"bar", --directory '{keyword}' will result in copies of the photo being
exported to 'foo/image_name.jpeg' and 'bar/image_name.jpeg'.
Some template fields such as '{media_type}' use the 'DEFAULT' value to allow
customization of the output. For example, '{media_type}' resolves to the
special media type of the photo such as 'panorama' or 'selfie'. You may use
@@ -551,6 +730,28 @@ photo is a time_lapse photo, 'media_type' would resolve to 'vidéo_accélérée
instead of 'time_lapse' and video would resolve to 'vidéo' if photo is an
ordinary video.
With the --directory and --filename options you may specify a template for the
export directory or filename, respectively. The directory will be appended to
the export path specified in the export DEST argument to export. For example,
if template is '{created.year}/{created.month}', and export destination DEST
is '/Users/maria/Pictures/export', the actual export directory for a photo
would be '/Users/maria/Pictures/export/2020/March' if the photo was created in
March 2020.
The templating system may also be used with the --keyword-template option to
set keywords on export (with --exiftool or --sidecar), for example, to set a
new keyword in format 'folder/subfolder/album' to preserve the folder/album
structure, you can use --keyword-template "{folder_album}"
In the template, valid template substitutions will be replaced by the
corresponding value from the table below. Invalid substitutions will result
in an error.
If you want the actual text of the template substition to appear in the
rendered name, use double braces, e.g. '{{' or '}}', thus using
'{created.year}/{{name}}' for --directory would result in output of
2020/{name}/photoname.jpg
With the --directory and --filename options you may specify a template for the
export directory or filename, respectively. The directory will be appended to
the export path specified in the export DEST argument to export. For example,
@@ -641,27 +842,39 @@ Substitution Description
https://strftime.org/ for help on strftime
templates.
{modified.date} Photo's modification date in ISO format,
e.g. '2020-03-22'
{modified.year} 4-digit year of photo modification time
{modified.yy} 2-digit year of photo modification time
e.g. '2020-03-22'; uses creation date if
photo is not modified
{modified.year} 4-digit year of photo modification time;
uses creation date if photo is not modified
{modified.yy} 2-digit year of photo modification time;
uses creation date if photo is not modified
{modified.mm} 2-digit month of the photo modification time
(zero padded)
(zero padded); uses creation date if photo
is not modified
{modified.month} Month name in user's locale of the photo
modification time
modification time; uses creation date if
photo is not modified
{modified.mon} Month abbreviation in the user's locale of
the photo modification time
the photo modification time; uses creation
date if photo is not modified
{modified.dd} 2-digit day of the month (zero padded) of
the photo modification time
the photo modification time; uses creation
date if photo is not modified
{modified.dow} Day of week in user's locale of the photo
modification time
modification time; uses creation date if
photo is not modified
{modified.doy} 3-digit day of year (e.g Julian day) of
photo modification time, starting from 1
(zero padded)
{modified.hour} 2-digit hour of the photo modification time
(zero padded); uses creation date if photo
is not modified
{modified.hour} 2-digit hour of the photo modification time;
uses creation date if photo is not modified
{modified.min} 2-digit minute of the photo modification
time
time; uses creation date if photo is not
modified
{modified.sec} 2-digit second of the photo modification
time
time; uses creation date if photo is not
modified
{today.date} Current date in iso format, e.g.
'2020-03-22'
{today.year} 4-digit year of current date
@@ -718,6 +931,23 @@ Substitution Description
'United States'
{place.address.country_code} ISO country code of the postal address, e.g.
'US'
{searchinfo.season} Season of the year associated with a photo,
e.g. 'Summer'; (Photos 5+ only, applied
automatically by Photos' image
categorization algorithms).
{exif.camera_make} Camera make from original photo's EXIF
inormation as imported by Photos, e.g.
'Apple'
{exif.camera_model} Camera model from original photo's EXIF
inormation as imported by Photos, e.g.
'iPhone 6s'
{exif.lens_model} Lens model from original photo's EXIF
inormation as imported by Photos, e.g.
'iPhone 6s back camera 4.15mm f/2.2'
{uuid} Photo's internal universally unique
identifier (UUID) for the photo, a
36-character string unique to the photo,
e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'
The following substitutions may result in multiple values. Thus if specified
for --directory these could result in multiple copies of a photo being being
@@ -734,10 +964,10 @@ Substitution Description
{keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo
{label} Image categorization label associated with a photo
(Photos 5 only)
{label_normalized} All lower case version of 'label' (Photos 5 only)
(Photos 5+ only)
{label_normalized} All lower case version of 'label' (Photos 5+ only)
{comment} Comment(s) on shared Photos; format is 'Person
name: comment text' (Photos 5 only)
name: comment text' (Photos 5+ only)
{exiftool:GROUP:TAGNAME} Use exiftool (https://exiftool.org) to extract
metadata, in form GROUP:TAGNAME, from image. E.g.
'{exiftool:EXIF:Make}' to get camera make, or
@@ -746,31 +976,23 @@ Substitution Description
tag names. You must specify group (e.g. EXIF,
IPTC, etc) as used in `exiftool -G`. exiftool must
be installed in the path to use this template.
{searchinfo.holiday} Holiday names associated with a photo, e.g.
'Christmas Day'; (Photos 5+ only, applied
automatically by Photos' image categorization
algorithms).
{searchinfo.activity} Activities associated with a photo, e.g. 'Sporting
Event'; (Photos 5+ only, applied automatically by
Photos' image categorization algorithms).
{searchinfo.venue} Venues associated with a photo, e.g. name of
restaurant; (Photos 5+ only, applied automatically
by Photos' image categorization algorithms).
{searchinfo.venue_type} Venue types associated with a photo, e.g.
'Restaurant'; (Photos 5+ only, applied
automatically by Photos' image categorization
algorithms).
```
Example: 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`
Example: 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`
Example: 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}"`
Example: 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}"`
Example: 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
@@ -1356,9 +1578,15 @@ Returns `True` if the picture has been marked as a favorite, otherwise `False`
#### `hidden`
Returns `True` if the picture has been marked as hidden, otherwise `False`
#### `visible`
Returns `True` if the picture is visible in library, otherwise `False`. e.g. non-selected burst photos are not hidden but also not visible
#### `intrash`
Returns `True` if the picture is in the trash ('Recently Deleted' folder), otherwise `False`
#### `date_trashed`
Returns the date the photo was placed in the trash as a datetime.datetime object or None if photo is not in the trash
#### `location`
Returns latitude and longitude as a tuple of floats (latitude, longitude). If location is not set, latitude and longitude are returned as `None`
@@ -1517,7 +1745,7 @@ exiftool must be installed in the path for this to work. If exiftool cannot be
`ExifTool` provides the following methods:
- `asdict()`: returns all EXIF metadata found in the file as a dictionary in following form (Note: this shows just a subset of available metadata). See [exiftool](https://exiftool.org/) documentation to understand which metadata keys are available.
- `asdict(tag_groups=True)`: returns all EXIF metadata found in the file as a dictionary in following form (Note: this shows just a subset of available metadata). See [exiftool](https://exiftool.org/) documentation to understand which metadata keys are available. If `tag_groups` is True (default) dict keys are in form "GROUP:TAG", e.g. "IPTC:Keywords". If `tag_groups` is False, dict keys do not have group names, e.g. "Keywords".
```python
{'Composite:Aperture': 2.2,
@@ -1556,7 +1784,7 @@ Returns a JSON representation of all photo info.
Returns a dictionary representation of all photo info.
#### `export()`
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised).
@@ -1567,6 +1795,8 @@ Export photo from the Photos library to another destination on disk.
- live_photo: boolean; if True (default=False), will also export the associted .mov for live photos; exported live photo will be named filename.mov
- increment: boolean; if True (default=True), will increment file name until a non-existent name is found
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will include tag group names (e.g. `exiftool -G -j`)
- sidecar_exiftool: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will not include tag group names (e.g. `exiftool -j`)
- sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with metadata; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name
- use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using.
- timeout: (int, default=120) timeout in seconds used with use_photos_export
@@ -1595,7 +1825,7 @@ If overwrite=False and increment=False, export will fail if destination file alr
#### <a name="rendertemplate">`render_template()`</a>
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None, filename=False, dirname=False, replacement=":",)`
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None, filename=False, dirname=False, strip=False)`
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
@@ -1606,7 +1836,7 @@ Render template string for photo. none_str is used if template substitution res
- `inplace_sep`: optional string to use as separator between multi-valued keywords with expand_inplace; default is ','
- `filename`: if True, template output will be sanitized to produce valid file name
- `dirname`: if True, template output will be sanitized to produce valid directory name
- `replacement`: str, value to replace any illegal file path characters with; default = ":"
- `strip`: if True, leading/trailign whitespace will be stripped from rendered template strings
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
@@ -1620,7 +1850,7 @@ Some substitutions, notably `album`, `keyword`, and `person` could return multip
The template field format contains optional modifiers:
`"{[[DELIM]+]name[(PATH_SEP)][?TRUE_VALUE][,[DEFAULT]]}"`
`"{DELIM+name(PATH_SEP)[OLD,NEW]?TRUE_VALUE,DEFAULT}"`
`DELIM`: optional delimiter string to use when expanding multi-valued template values in-place
@@ -1641,6 +1871,8 @@ e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album(:)}"` renders to `["Folder1:Album1"]`
- `"{folder_album()}"` renders to `["Folder1Album1"]`
`[OLD,NEW]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`.
`?TRUE_VALUE`: optional value to use if name is boolean-type field which evaluates to true. For example `"{hdr}"` evaluates to True if photo is an high dynamic range (HDR) image and False otherwise. In these types of fields, use `?TRUE_VALUE` to provide the value if True and `,DEFAULT` to provide the value of False.
e.g. if photo is an HDR image,
@@ -2178,18 +2410,18 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{created.min}|2-digit minute of the photo creation time|
|{created.sec}|2-digit second of the photo creation time|
|{created.strftime}|Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'|
|{modified.year}|4-digit year of photo modification time|
|{modified.yy}|2-digit year of photo modification time|
|{modified.mm}|2-digit month of the photo modification time (zero padded)|
|{modified.month}|Month name in user's locale of the photo modification time|
|{modified.mon}|Month abbreviation in the user's locale of the photo modification time|
|{modified.dd}|2-digit day of the month (zero padded) of the photo modification time|
|{modified.dow}|Day of week in user's locale of the photo modification time|
|{modified.doy}|3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)|
|{modified.hour}|2-digit hour of the photo modification time|
|{modified.min}|2-digit minute of the photo modification time|
|{modified.sec}|2-digit second of the photo modification time|
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified|
|{modified.year}|4-digit year of photo modification time; uses creation date if photo is not modified|
|{modified.yy}|2-digit year of photo modification time; uses creation date if photo is not modified|
|{modified.mm}|2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified|
|{modified.month}|Month name in user's locale of the photo modification time; uses creation date if photo is not modified|
|{modified.mon}|Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified|
|{modified.dd}|2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified|
|{modified.dow}|Day of week in user's locale of the photo modification time; uses creation date if photo is not modified|
|{modified.doy}|3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified|
|{modified.hour}|2-digit hour of the photo modification time; uses creation date if photo is not modified|
|{modified.min}|2-digit minute of the photo modification time; uses creation date if photo is not modified|
|{modified.sec}|2-digit second of the photo modification time; uses creation date if photo is not modified|
|{today.date}|Current date in iso format, e.g. '2020-03-22'|
|{today.year}|4-digit year of current date|
|{today.yy}|2-digit year of current date|
@@ -2216,14 +2448,23 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{place.address.postal_code}|Postal code part of the postal address, e.g. '20009'|
|{place.address.country}|Country name of the postal address, e.g. 'United States'|
|{place.address.country_code}|ISO country code of the postal address, e.g. 'US'|
|{searchinfo.season}|Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{exif.camera_make}|Camera make from original photo's EXIF inormation as imported by Photos, e.g. 'Apple'|
|{exif.camera_model}|Camera model from original photo's EXIF inormation as imported by Photos, e.g. 'iPhone 6s'|
|{exif.lens_model}|Lens model from original photo's EXIF inormation as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'|
|{uuid}|Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'|
|{album}|Album(s) photo is contained in|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|{keyword}|Keyword(s) assigned to photo|
|{person}|Person(s) / face(s) in a photo|
|{label}|Image categorization label associated with a photo (Photos 5 only)|
|{label_normalized}|All lower case version of 'label' (Photos 5 only)|
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)|
|{label}|Image categorization label associated with a photo (Photos 5+ only)|
|{label_normalized}|All lower case version of 'label' (Photos 5+ only)|
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)|
|{exiftool:GROUP:TAGNAME}|Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. exiftool must be installed in the path to use this template.|
|{searchinfo.holiday}|Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.activity}|Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|{searchinfo.venue_type}|Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
### Utility Functions
@@ -2333,20 +2574,25 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/britiscurious"><img src="https://avatars1.githubusercontent.com/u/25646439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>britiscurious</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Documentation">📖</a> <a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/mwort"><img src="https://avatars3.githubusercontent.com/u/8170417?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michel Wortmann</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=mwort" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/PabloKohan"><img src="https://avatars3.githubusercontent.com/u/8790976?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Pablo 'merKur' Kohan</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=PabloKohan" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hshore29"><img src="https://avatars2.githubusercontent.com/u/7023497?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hshore29</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hshore29" title="Code">💻</a></td>
<td align="center"><a href="http://3e.org/"><img src="https://avatars0.githubusercontent.com/u/41439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel M. Drucker</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dmd" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jystervinou"><img src="https://avatars3.githubusercontent.com/u/132356?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jean-Yves Stervinou</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jystervinou" title="Code">💻</a></td>
<td align="center"><a href="https://dethi.me/"><img src="https://avatars2.githubusercontent.com/u/1011520?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thibault Deutsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dethi" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/britiscurious"><img src="https://avatars1.githubusercontent.com/u/25646439?v=4?s=75" width="75px;" alt=""/><br /><sub><b>britiscurious</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Documentation">📖</a> <a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/mwort"><img src="https://avatars3.githubusercontent.com/u/8170417?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Michel Wortmann</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=mwort" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/PabloKohan"><img src="https://avatars3.githubusercontent.com/u/8790976?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Pablo 'merKur' Kohan</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=PabloKohan" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hshore29"><img src="https://avatars2.githubusercontent.com/u/7023497?v=4?s=75" width="75px;" alt=""/><br /><sub><b>hshore29</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hshore29" title="Code">💻</a></td>
<td align="center"><a href="http://3e.org/"><img src="https://avatars0.githubusercontent.com/u/41439?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Daniel M. Drucker</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dmd" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jystervinou"><img src="https://avatars3.githubusercontent.com/u/132356?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Jean-Yves Stervinou</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jystervinou" title="Code">💻</a></td>
<td align="center"><a href="https://dethi.me/"><img src="https://avatars2.githubusercontent.com/u/1011520?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Thibault Deutsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dethi" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/grundsch"><img src="https://avatars0.githubusercontent.com/u/3874928?v=4?s=100" width="100px;" alt=""/><br /><sub><b>grundsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=grundsch" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jstrine"><img src="https://avatars1.githubusercontent.com/u/33943447?v=4?s=100" width="100px;" 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=100" width="100px;" 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/grundsch"><img src="https://avatars0.githubusercontent.com/u/3874928?v=4?s=75" width="75px;" alt=""/><br /><sub><b>grundsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=grundsch" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
<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>
@@ -2382,6 +2628,7 @@ For additional details about how osxphotos is implemented or if you would like t
- [pathvalidate](https://pypi.org/project/pathvalidate/)
- [wurlitzer](https://pypi.org/project/wurlitzer/)
- [toml](https://github.com/uiri/toml)
- [PhotoScript](https://github.com/RhetTbull/PhotoScript)
## Acknowledgements

270
README.rst Normal file
View File

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

BIN
docs/screencast/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -108,7 +108,7 @@ SEARCH_CATEGORY_NEIGHBORHOOD = 3
SEARCH_CATEGORY_LOCALITY_4 = 4
SEARCH_CATEGORY_SUB_LOCALITY_5 = 5
SEARCH_CATEGORY_SUB_LOCALITY_6 = 6
SEARCH_CATEGORY_CITY = 7
SEARCH_CATEGORY_CITY = 7
SEARCH_CATEGORY_LOCALITY_8 = 8
SEARCH_CATEGORY_NAMED_AREA = 9
SEARCH_CATEGORY_ALL_LOCALITY = [
@@ -178,3 +178,20 @@ DEFAULT_ORIGINAL_SUFFIX = ""
# Colors for print CLI messages
CLI_COLOR_ERROR = "red"
CLI_COLOR_WARNING = "yellow"
# Bit masks for --sidecar
SIDECAR_JSON = 0x1
SIDECAR_EXIFTOOL = 0x2
SIDECAR_XMP = 0x4
# supported attributes for --xattr-template
EXTENDED_ATTRIBUTE_NAMES = [
"authors",
"comment",
"copyright",
"description",
"findercomment",
"headline",
"keywords",
]
EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]

View File

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

View File

@@ -9,11 +9,11 @@
import json
import logging
import os
import re
import shutil
import subprocess
from functools import lru_cache # pylint: disable=syntax-error
# exiftool -stay_open commands outputs this EOF marker after command is run
EXIFTOOL_STAYOPEN_EOF = "{ready}"
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
@@ -33,8 +33,8 @@ def get_exiftool_path():
class _ExifToolProc:
""" Runs exiftool in a subprocess via Popen
Creates a singleton object """
"""Runs exiftool in a subprocess via Popen
Creates a singleton object"""
def __new__(cls, *args, **kwargs):
""" create new object or return instance of already created singleton """
@@ -44,20 +44,20 @@ class _ExifToolProc:
return cls.instance
def __init__(self, exiftool=None):
""" construct _ExifToolProc singleton object or return instance of already created object
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
"""construct _ExifToolProc singleton object or return instance of already created object
exiftool: optional path to exiftool binary (if not provided, will search path to find it)"""
if hasattr(self, "_process_running") and self._process_running:
# already running
if exiftool is not None:
if exiftool is not None and exiftool != self._exiftool:
logging.warning(
f"exiftool subprocess already running, "
f"ignoring exiftool={exiftool}"
)
return
self._exiftool = exiftool or get_exiftool_path()
self._process_running = False
self._exiftool = exiftool or get_exiftool_path()
self._start_proc()
@property
@@ -106,8 +106,8 @@ class _ExifToolProc:
def _stop_proc(self):
""" stop the exiftool process if it's running, otherwise, do nothing """
if not self._process_running:
logging.warning("exiftool process is not running")
return
self._process.stdin.write(b"-stay_open\n")
@@ -133,7 +133,7 @@ class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
""" Create ExifTool object
"""Create ExifTool object
Args:
file: path to image file
@@ -157,15 +157,15 @@ class ExifTool:
self._read_exif()
def setvalue(self, tag, value):
""" Set tag to value(s); if value is None, will delete tag
"""Set tag to value(s); if value is None, will delete tag
Args:
tag: str; name of tag to set
value: str; value to set tag to
Returns:
True if success otherwise False
If error generated by exiftool, returns False and sets self.error to error string
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
If called in context manager, returns True (execution is delayed until exiting context manager)
@@ -184,26 +184,26 @@ class ExifTool:
return error is None
def addvalues(self, tag, *values):
""" Add one or more value(s) to tag
"""Add one or more value(s) to tag
If more than one value is passed, each value will be added to the tag
Args:
tag: str; tag to set
*values: str; one or more values to set
Returns:
True if success otherwise False
If error generated by exiftool, returns False and sets self.error to error string
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
If called in context manager, returns True (execution is delayed until exiting context manager)
Notes: exiftool may add duplicate values for some tags so the caller must ensure
the values being added are not already in the EXIF data
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
It's up to the caller to know what exiftool will do for each tag
If setvalue called before addvalues, exiftool does not appear to add duplicates,
If setvalue called before addvalues, exiftool does not appear to add duplicates,
but if addvalues called without first calling setvalue, exiftool will add duplicate values
"""
if not values:
@@ -226,7 +226,7 @@ class ExifTool:
return error is None
def run_commands(self, *commands, no_file=False):
""" Run commands in the exiftool process and return result.
"""Run commands in the exiftool process and return result.
Args:
*commands: exiftool commands to run
@@ -266,7 +266,7 @@ class ExifTool:
+ b"\n"
+ b"-execute\n"
)
# send the command
self._process.stdin.write(command_str)
self._process.stdin.flush()
@@ -300,17 +300,28 @@ class ExifTool:
ver, _, _ = self.run_commands("-ver", no_file=True)
return ver.decode("utf-8")
def asdict(self):
""" return dictionary of all EXIF tags and values from exiftool
returns empty dict if no tags
def asdict(self, tag_groups=True):
"""return dictionary of all EXIF tags and values from exiftool
returns empty dict if no tags
Args:
tag_groups: if True (default), dict keys have tag groups, e.g. "IPTC:Keywords"; if False, drops groups from keys, e.g. "Keywords"
"""
json_str, _, _ = self.run_commands("-json")
if json_str:
exifdict = json.loads(json_str)
return exifdict[0]
else:
if not json_str:
return dict()
exifdict = json.loads(json_str)
exifdict = exifdict[0]
if not tag_groups:
# strip tag groups
exif_new = {}
for k, v in exifdict.items():
k = re.sub(r".*:", "", k)
exif_new[k] = v
exifdict = exif_new
return exifdict
def json(self):
""" returns JSON string containing all EXIF tags and values from exiftool """
json, _, _ = self.run_commands("-json")

View File

@@ -1,12 +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 logging
import pathlib
import objc
import Metal
import Quartz
from Cocoa import NSURL
@@ -16,6 +16,12 @@ from Foundation import NSDictionary
from wurlitzer import pipes
class ImageConversionError(Exception):
"""Base class for exceptions in this module. """
pass
class ImageConverter:
""" Convert images to jpeg. This class is a singleton
which will re-use the Core Image CIContext to avoid
@@ -60,53 +66,61 @@ class ImageConverter:
Raises:
ValueError if compression quality not in range 0.0 to 1.0
FileNotFoundError if input_path doesn't exist
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:
logging.debug(f"Could not create CIImage for {input_path}")
return False
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:
logging.debug(
"Error converting file {input_path} to jpeg at {output_path}: {error}"
output_options = NSDictionary.dictionaryWithDictionary_(
{"kCGImageDestinationLossyCompressionQuality": compression_quality}
)
return False
(
_,
error,
) = self.context.writeJPEGRepresentationOfImage_toURL_colorSpace_options_error_(
input_image, output_url, output_colorspace, output_options, None
)
if not error:
return True
else:
raise ImageConversionError(
"Error converting file {input_path} to jpeg at {output_path}: {error}"
)

View File

@@ -18,12 +18,11 @@ def exiftool(self):
return self._exiftool
except AttributeError:
try:
exiftool_path = get_exiftool_path()
exiftool_path = self._db._exiftool_path or get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifTool(self.path)
exiftool = ExifTool(self.path, exiftool=exiftool_path)
else:
exiftool = None
logging.debug(f"exiftool: missing path {self.uuid}")
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None

View File

@@ -5,12 +5,15 @@
_export_photo
_write_exif_data
_exiftool_json_sidecar
_get_exif_keywords
_get_exif_persons
_exiftool_dict
_xmp_sidecar
_write_sidecar
"""
# TODO: should this be its own PhotoExporter class?
# TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask
import glob
import hashlib
@@ -32,6 +35,9 @@ from .._constants import (
_TEMPLATE_DIR,
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
)
from ..datetime_utils import datetime_tz_to_utc
from ..exiftool import ExifTool
@@ -40,12 +46,18 @@ from ..fileutil import FileUtil
from ..photokit import (
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PhotoLibrary,
PhotoKitFetchFailed,
PhotoLibrary,
)
from ..utils import dd_to_dms_str, findfiles, noop
class ExportError(Exception):
""" error during export """
pass
class ExportResults:
""" holds export results for export2 """
@@ -60,12 +72,16 @@ class ExportResults:
converted_to_jpeg=None,
sidecar_json_written=None,
sidecar_json_skipped=None,
sidecar_exiftool_written=None,
sidecar_exiftool_skipped=None,
sidecar_xmp_written=None,
sidecar_xmp_skipped=None,
missing=None,
error=None,
exiftool_warning=None,
exiftool_error=None,
xattr_written=None,
xattr_skipped=None,
):
self.exported = exported or []
self.new = new or []
@@ -76,12 +92,16 @@ class ExportResults:
self.converted_to_jpeg = converted_to_jpeg or []
self.sidecar_json_written = sidecar_json_written or []
self.sidecar_json_skipped = sidecar_json_skipped or []
self.sidecar_exiftool_written = sidecar_exiftool_written or []
self.sidecar_exiftool_skipped = sidecar_exiftool_skipped or []
self.sidecar_xmp_written = sidecar_xmp_written or []
self.sidecar_xmp_skipped = sidecar_xmp_skipped or []
self.missing = missing or []
self.error = error or []
self.exiftool_warning = exiftool_warning or []
self.exiftool_error = exiftool_error or []
self.xattr_written = xattr_written or []
self.xattr_skipped = xattr_skipped or []
def all_files(self):
""" return all filenames contained in results """
@@ -95,13 +115,15 @@ class ExportResults:
+ self.converted_to_jpeg
+ self.sidecar_json_written
+ self.sidecar_json_skipped
+ self.sidecar_exiftool_written
+ self.sidecar_exiftool_skipped
+ 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
@@ -116,6 +138,8 @@ class ExportResults:
self.converted_to_jpeg += other.converted_to_jpeg
self.sidecar_json_written += other.sidecar_json_written
self.sidecar_json_skipped += other.sidecar_json_skipped
self.sidecar_exiftool_written += other.sidecar_exiftool_written
self.sidecar_exiftool_skipped += other.sidecar_exiftool_skipped
self.sidecar_xmp_written += other.sidecar_xmp_written
self.sidecar_xmp_skipped += other.sidecar_xmp_skipped
self.missing += other.missing
@@ -136,6 +160,8 @@ class ExportResults:
+ f",converted_to_jpeg={self.converted_to_jpeg}"
+ f",sidecar_json_written={self.sidecar_json_written}"
+ f",sidecar_json_skipped={self.sidecar_json_skipped}"
+ f",sidecar_exiftool_written={self.sidecar_exiftool_written}"
+ f",sidecar_exiftool_skipped={self.sidecar_exiftool_skipped}"
+ f",sidecar_xmp_written={self.sidecar_xmp_written}"
+ f",sidecar_xmp_skipped={self.sidecar_xmp_skipped}"
+ f",missing={self.missing}"
@@ -168,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")
@@ -233,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
@@ -323,6 +322,7 @@ def export(
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_exiftool=False,
sidecar_xmp=False,
use_photos_export=False,
timeout=120,
@@ -352,10 +352,12 @@ def export(
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.json
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
sidecar_xmp: if set will write an XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
@@ -372,6 +374,14 @@ def export(
# Implementation note: calls export2 to actually do the work
sidecar = 0
if sidecar_json:
sidecar |= SIDECAR_JSON
if sidecar_exiftool:
sidecar |= SIDECAR_EXIFTOOL
if sidecar_xmp:
sidecar |= SIDECAR_XMP
results = self.export2(
dest,
*filename,
@@ -381,8 +391,7 @@ def export(
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
increment=increment,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
sidecar=sidecar,
use_photos_export=use_photos_export,
timeout=timeout,
exiftool=exiftool,
@@ -405,8 +414,8 @@ def export2(
export_as_hardlink=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_xmp=False,
sidecar=0,
sidecar_drop_ext=False,
use_photos_export=False,
timeout=120,
exiftool=False,
@@ -426,6 +435,8 @@ def export2(
use_photokit=False,
verbose=None,
exiftool_flags=None,
merge_exif_keywords=False,
merge_exif_persons=False,
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -445,10 +456,14 @@ def export2(
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.json
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar: bit field: set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
SIDECAR_EXIFTOOL: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
SIDECAR_XMP: if set will write an XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
sidecar_drop_ext: (boolean, default=False); if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
@@ -471,6 +486,8 @@ def export2(
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
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)
Returns: ExportResults class
ExportResults has attributes:
@@ -483,10 +500,13 @@ def export2(
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_exiftool_written",
"sidecar_exiftool_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
"missing",
"error",
"error_str",
"exiftool_warning",
"exiftool_error",
@@ -513,24 +533,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:
@@ -605,6 +607,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
@@ -698,12 +701,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:
@@ -724,12 +722,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:
@@ -751,15 +744,9 @@ 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:
# 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:
@@ -779,7 +766,7 @@ def export2(
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed:
except PhotoKitFetchFailed as e:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
@@ -788,24 +775,37 @@ def export2(
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if not photo:
all_results.error.append(
(
str(dest),
f"PhotoKitFetchFailed exception exporting photo {self.uuid}: {e}",
)
)
if photo:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
)
else:
exported = []
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:
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
@@ -824,81 +824,98 @@ 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 = []
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:
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,
)
if exported:
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 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)
all_results.new.extend(all_results.exported)
else:
logging.warning(
f"Error exporting photo {self.uuid} to {dest} with use_photos_export"
)
# else:
# all_results.error.append((str(dest), f"Error exporting photo {self.uuid} to {dest} with use_photos_export"))
# export metadata
sidecars = []
sidecar_json_files_skipped = []
sidecar_json_files_written = []
if sidecar_json:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json")
sidecar_exiftool_files_skipped = []
sidecar_exiftool_files_written = []
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
dest_suffix = "" if sidecar_drop_ext else dest.suffix
if sidecar & SIDECAR_JSON:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=dest.name,
)
sidecar_digest = hexdigest(sidecar_str)
old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
sidecar_filename
)
write_sidecar = (
not update
or (update and not sidecar_filename.exists())
or (
update
and (sidecar_digest != old_sidecar_digest)
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
sidecars.append(
(
sidecar_filename,
sidecar_str,
sidecar_json_files_written,
sidecar_json_files_skipped,
"JSON",
)
)
if write_sidecar:
verbose(f"Writing exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str)
export_db.set_sidecar_for_file(
sidecar_filename,
sidecar_digest,
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_skipped.append(str(sidecar_filename))
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
if sidecar_xmp:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
if sidecar & SIDECAR_EXIFTOOL:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
tag_groups=False,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=dest.name,
)
sidecars.append(
(
sidecar_filename,
sidecar_str,
sidecar_exiftool_files_written,
sidecar_exiftool_files_skipped,
"exiftool",
)
)
if sidecar & SIDECAR_XMP:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.xmp")
sidecar_str = self._xmp_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
@@ -906,6 +923,23 @@ def export2(
description_template=description_template,
extension=dest.suffix[1:] if dest.suffix else None,
)
sidecars.append(
(
sidecar_filename,
sidecar_str,
sidecar_xmp_files_written,
sidecar_xmp_files_skipped,
"XMP",
)
)
for data in sidecars:
sidecar_filename = data[0]
sidecar_str = data[1]
files_written = data[2]
files_skipped = data[3]
sidecar_type = data[4]
sidecar_digest = hexdigest(sidecar_str)
old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
sidecar_filename
@@ -920,8 +954,8 @@ def export2(
)
)
if write_sidecar:
verbose(f"Writing XMP sidecar {sidecar_filename}")
sidecar_xmp_files_written.append(str(sidecar_filename))
verbose(f"Writing {sidecar_type} sidecar {sidecar_filename}")
files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str)
export_db.set_sidecar_for_file(
@@ -930,19 +964,15 @@ def export2(
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
sidecar_xmp_files_skipped.append(str(sidecar_filename))
verbose(f"Skipped up to date {sidecar_type} sidecar {sidecar_filename}")
files_skipped.append(str(sidecar_filename))
# 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:
@@ -957,6 +987,8 @@ def export2(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
)
)[0]
if old_data != current_data:
@@ -975,12 +1007,14 @@ def export2(
description_template=description_template,
ignore_date_modified=ignore_date_modified,
flags=exiftool_flags,
merge_exif_keywords=merge_exif_keywords,
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,
@@ -990,12 +1024,14 @@ def export2(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
),
)
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:
@@ -1010,12 +1046,14 @@ def export2(
description_template=description_template,
ignore_date_modified=ignore_date_modified,
flags=exiftool_flags,
merge_exif_keywords=merge_exif_keywords,
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,
@@ -1025,39 +1063,32 @@ def export2(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
),
)
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_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(
@@ -1233,6 +1264,8 @@ def _export_photo(
converted_to_jpeg=converted_to_jpeg_files,
sidecar_json_written=[],
sidecar_json_skipped=[],
sidecar_exiftool_written=[],
sidecar_exiftool_skipped=[],
sidecar_xmp_written=[],
sidecar_xmp_skipped=[],
missing=[],
@@ -1249,6 +1282,8 @@ def _write_exif_data(
description_template=None,
ignore_date_modified=False,
flags=None,
merge_exif_keywords=False,
merge_exif_persons=False,
):
"""write exif data to image file at filepath
@@ -1271,9 +1306,11 @@ def _write_exif_data(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
)
with ExifTool(filepath, flags=flags) as exiftool:
with ExifTool(filepath, flags=flags, exiftool=self._db._exiftool_path) as exiftool:
for exiftag, val in exif_info.items():
if type(val) == list:
for v in val:
@@ -1290,16 +1327,22 @@ def _exiftool_dict(
keyword_template=None,
description_template=None,
ignore_date_modified=False,
merge_exif_keywords=False,
merge_exif_persons=False,
filename=None,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
Args:
filename: name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
description_template: (list of strings); list of template strings to render for the description
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
merge_exif_keywords: merge keywords in the file's exif metadata (requires exiftool)
merge_exif_persons: merge persons in the file's exif metadata (requires exiftool)
Returns: dict with exiftool tags / values
@@ -1326,11 +1369,21 @@ def _exiftool_dict(
UserData:GPSCoordinates
"""
exif = {}
exif = (
{
"SourceFile": filename,
"ExifTool:ExifToolVersion": "12.00",
"File:FileName": filename,
}
if filename is not None
else {}
)
if description_template is not None:
description = self.render_template(
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
description = " ".join(rendered) if rendered else ""
exif["EXIF:ImageDescription"] = description
exif["XMP:Description"] = description
elif self.description:
@@ -1341,13 +1394,19 @@ def _exiftool_dict(
exif["XMP:Title"] = self.title
keyword_list = []
if merge_exif_keywords:
keyword_list.extend(self._get_exif_keywords())
if self.keywords:
keyword_list.extend(self.keywords)
person_list = []
if merge_exif_persons:
person_list.extend(self._get_exif_persons())
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
person_list.extend([p for p in self.persons if p != _UNKNOWN_PERSON])
if use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
@@ -1389,7 +1448,7 @@ def _exiftool_dict(
if keyword_list:
# remove duplicates
keyword_list = sorted(list(set(keyword_list)))
keyword_list = sorted(list(set([str(keyword) for keyword in keyword_list])))
exif["IPTC:Keywords"] = keyword_list.copy()
exif["XMP:Subject"] = keyword_list.copy()
exif["XMP:TagsList"] = keyword_list.copy()
@@ -1474,6 +1533,41 @@ def _exiftool_dict(
return exif
def _get_exif_keywords(self):
""" returns list of keywords found in the file's exif metadata """
keywords = []
exif = self.exiftool
if exif:
exifdict = exif.asdict()
for field in ["IPTC:Keywords", "XMP:TagsList", "XMP:Subject"]:
try:
kw = exifdict[field]
if kw and type(kw) != list:
kw = [kw]
kw = [str(k) for k in kw]
keywords.extend(kw)
except KeyError:
pass
return keywords
def _get_exif_persons(self):
""" returns list of persons found in the file's exif metadata """
persons = []
exif = self.exiftool
if exif:
exifdict = exif.asdict()
try:
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
return persons
def _exiftool_json_sidecar(
self,
use_albums_as_keywords=False,
@@ -1481,6 +1575,10 @@ def _exiftool_json_sidecar(
keyword_template=None,
description_template=None,
ignore_date_modified=False,
tag_groups=True,
merge_exif_keywords=False,
merge_exif_persons=False,
filename=None,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
@@ -1491,6 +1589,10 @@ def _exiftool_json_sidecar(
keyword_template: (list of strings); list of template strings to render as keywords
description_template: (list of strings); list of template strings to render for the description
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
tag_groups: if True, tags are in form Group:TagName, e.g. IPTC:Keywords, otherwise group name is omitted, e.g. Keywords
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)
filename: filename of the destination image file for including in exiftool signature in JSON sidecar
Returns: dict with exiftool tags / values
@@ -1522,7 +1624,19 @@ def _exiftool_json_sidecar(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=filename,
)
if not tag_groups:
# strip tag groups
exif_new = {}
for k, v in exif.items():
k = re.sub(r".*:", "", k)
exif_new[k] = v
exif = exif_new
return json.dumps([exif])
@@ -1533,12 +1647,17 @@ def _xmp_sidecar(
keyword_template=None,
description_template=None,
extension=None,
merge_exif_keywords=False,
merge_exif_persons=False,
):
"""returns string for XMP sidecar
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
description_template: string; optional template string that will be rendered for use as photo description"""
description_template: string; optional template string that will be rendered for use as photo description
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)
"""
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
@@ -1547,13 +1666,17 @@ def _xmp_sidecar(
extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None:
description = self.render_template(
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
description = " ".join(rendered) if rendered else ""
else:
description = self.description if self.description is not None else ""
keyword_list = []
if merge_exif_keywords:
keyword_list.extend(self._get_exif_keywords())
if self.keywords:
keyword_list.extend(self.keywords)
@@ -1561,9 +1684,12 @@ def _xmp_sidecar(
# good candidate for pulling out in a function
person_list = []
if merge_exif_persons:
person_list.extend(self._get_exif_persons())
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
person_list.extend([p for p in self.persons if p != _UNKNOWN_PERSON])
if use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
@@ -1622,7 +1748,7 @@ def _xmp_sidecar(
)
# remove extra lines that mako inserts from template
xmp_str = "\n".join([line for line in xmp_str.split("\n") if line.strip() != ""])
xmp_str = "\n".join(line for line in xmp_str.split("\n") if line.strip() != "")
return xmp_str

View File

@@ -21,11 +21,11 @@ from .._constants import (
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_VERSION,
_PHOTOS_5_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
_PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION,
)
from ..albuminfo import AlbumInfo, ImportInfo
from ..personinfo import FaceInfo, PersonInfo
@@ -56,6 +56,8 @@ class PhotoInfo:
_export_photo,
_exiftool_dict,
_exiftool_json_sidecar,
_get_exif_keywords,
_get_exif_persons,
_write_exif_data,
_write_sidecar,
_xmp_sidecar,
@@ -84,8 +86,8 @@ class PhotoInfo:
@property
def original_filename(self):
""" original filename of the picture
Photos 5 mangles filenames upon import """
"""original filename of the picture
Photos 5 mangles filenames upon import"""
if (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
@@ -104,22 +106,22 @@ class PhotoInfo:
@property
def date_modified(self):
""" image modification date as timezone aware datetime object
or None if no modification date set """
"""image modification date as timezone aware datetime object
or None if no modification date set"""
# Photos <= 4 provides no way to get date of adjustment and will update
# 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
@@ -490,46 +492,61 @@ class PhotoInfo:
@property
def ismissing(self):
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
"""returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
do not immediately get written to disk. In particular, I've noticed that downloading
do not immediately get written to disk. In particular, I've noticed that downloading
an image from the cloud does not force the database to be updated until something else
e.g. an edit, keyword, etc. occurs forcing a database synch
The exact process / timing is a mystery to be but be aware that if some photos were recently
downloaded from cloud to local storate their status in the database might still show
isMissing = 1
"""
return True if self._info["isMissing"] == 1 else False
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 """
@@ -537,8 +554,8 @@ class PhotoInfo:
@property
def shared(self):
""" returns True if photos is in a shared iCloud album otherwise false
Only valid on Photos 5; returns None on older versions """
"""returns True if photos is in a shared iCloud album otherwise false
Only valid on Photos 5; returns None on older versions"""
if self._db._db_version > _PHOTOS_4_VERSION:
return self._info["shared"]
else:
@@ -546,24 +563,25 @@ class PhotoInfo:
@property
def uti(self):
""" Returns Uniform Type Identifier (UTI) for the image
for example: public.jpeg or com.apple.quicktime-movie
"""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"]
@property
def uti_original(self):
""" Returns Uniform Type Identifier (UTI) for the original image
for example: public.jpeg or com.apple.quicktime-movie
"""Returns Uniform Type Identifier (UTI) for the original image
for example: public.jpeg or com.apple.quicktime-movie
"""
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
return self._info["raw_pair_info"]["UTI"]
@@ -575,9 +593,9 @@ class PhotoInfo:
@property
def uti_edited(self):
""" Returns Uniform Type Identifier (UTI) for the edited image
if the photo has been edited, otherwise None;
for example: public.jpeg
"""Returns Uniform Type Identifier (UTI) for the edited image
if the photo has been edited, otherwise None;
for example: public.jpeg
"""
if self._db._db_version >= _PHOTOS_5_VERSION:
return self.uti if self.hasadjustments else None
@@ -586,36 +604,34 @@ class PhotoInfo:
@property
def uti_raw(self):
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
for example: com.canon.cr2-raw-image
Returns None if no associated RAW image
"""Returns Uniform Type Identifier (UTI) for the RAW image if there is one
for example: com.canon.cr2-raw-image
Returns None if no associated RAW image
"""
return self._info["UTI_raw"]
@property
def ismovie(self):
""" Returns True if file is a movie, otherwise False
"""
return True if self._info["type"] == _MOVIE_TYPE else False
"""Returns True if file is a movie, otherwise 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
"""Returns True if file is an image, otherwise False"""
return self._info["type"] == _PHOTO_TYPE
@property
def incloud(self):
""" Returns True if photo is cloud asset and is synched to cloud
False if photo is cloud asset and not yet synched to cloud
None if photo is not cloud asset
"""Returns True if photo is cloud asset and is synched to cloud
False if photo is cloud asset and not yet synched to cloud
None if photo is not cloud asset
"""
return self._info["incloud"]
@property
def iscloudasset(self):
""" Returns True if photo is a cloud asset (in an iCloud library),
otherwise False
"""Returns True if photo is a cloud asset (in an iCloud library),
otherwise False
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return (
@@ -634,9 +650,9 @@ class PhotoInfo:
@property
def burst_photos(self):
""" If photo is a burst photo, returns list of PhotoInfo objects
that are part of the same burst photo set; otherwise returns empty list.
self is not included in the returned list """
"""If photo is a burst photo, returns list of PhotoInfo objects
that are part of the same burst photo set; otherwise returns empty list.
self is not included in the returned list"""
if self._info["burst"]:
burst_uuid = self._info["burstUUID"]
return [
@@ -654,9 +670,9 @@ class PhotoInfo:
@property
def path_live_photo(self):
""" Returns path to the associated video file for a live photo
If photo is not a live photo, returns None
If photo is missing, returns None """
"""Returns path to the associated video file for a live photo
If photo is not a live photo, returns None
If photo is missing, returns None"""
photopath = None
if self._db._db_version <= _PHOTOS_4_VERSION:
@@ -783,9 +799,9 @@ class PhotoInfo:
@property
def raw_original(self):
""" returns True if associated raw image and the raw image is selected in Photos
via "Use RAW as Original "
otherwise returns False """
"""returns True if associated raw image and the raw image is selected in Photos
via "Use RAW as Original "
otherwise returns False"""
return self._info["raw_is_original"]
@property
@@ -832,27 +848,27 @@ class PhotoInfo:
inplace_sep=None,
filename=False,
dirname=False,
replacement=":",
strip=False,
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
Args:
template_str: a template string with fields to render
none_str: a str to use if template field renders to None, default is "_".
path_sep: a single character str to use as path separator when joining
path_sep: a single character str to use as path separator when joining
fields like folder_album; if not provided, defaults to os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
replacement: str, value to replace any illegal file path characters with; default = ":"
dirname: if True, template output will be sanitized to produce valid directory name
strip: if True, strips leading/trailing white space from resulting template
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
template = PhotoTemplate(self)
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
return template.render(
template_str,
none_str=none_str,
@@ -861,7 +877,7 @@ class PhotoInfo:
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
strip=strip,
)
@property
@@ -875,11 +891,11 @@ class PhotoInfo:
return self._info["latitude"]
def _get_album_uuids(self):
""" Return list of album UUIDs this photo is found in
"""Return list of album UUIDs this photo is found in
Filters out albums in the trash and any special album types
Returns: list of album UUIDs
Returns: list of album UUIDs
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
version4 = True

View File

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

View File

@@ -70,12 +70,13 @@ class PhotosDB:
from ._photosdb_process_scoreinfo import _process_scoreinfo
from ._photosdb_process_comments import _process_comments
def __init__(self, dbfile=None, verbose=None):
def __init__(self, dbfile=None, verbose=None, exiftool=None):
""" Create a new PhotosDB object.
Args:
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
Raises:
FileNotFoundError if dbfile is not a valid Photos library.
@@ -98,6 +99,8 @@ class PhotosDB:
raise TypeError("verbose must be callable")
self._verbose = verbose
self._exiftool_path = exiftool
# create a temporary directory
# tempfile.TemporaryDirectory gets cleaned up when the object does
self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@@ -884,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"""
)
@@ -912,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"""
)
@@ -959,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]
@@ -1133,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]
@@ -1144,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
@@ -1837,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 """
@@ -1882,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]
@@ -1898,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]
@@ -2043,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]
@@ -2053,6 +2080,11 @@ class PhotosDB:
info["original_orientation"] = row[34]
info["original_filesize"] = row[35]
# visibility state, visible (True) if 0, otherwise not visible (False)
# only values I've seen are 0 for visible, 2 for not-visible
info["visibility_state"] = row[38]
info["visible"] = row[38] == 0
# initialize import session info which will be filled in later
# not every photo has an import session so initialize all records now
info["import_session"] = None

View File

@@ -6,9 +6,9 @@
# 2. Needed to handle default values if template not found
# 3. Didn't want user to need to know python (e.g. by using Mako which is
# already used elsewhere in this project)
# 4. Couldn't figure out how to do #1 and #2 with str.format()
#
# This code isn't elegant but it seems to work well. PRs gladly accepted.
# This code isn't elegant and is prime for refactoring but it seems to work well. PRs gladly accepted.
import datetime
import locale
import os
@@ -70,18 +70,18 @@ TEMPLATE_SUBSTITUTIONS = {
+ "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+ "If used with no template will return null value. "
+ "See https://strftime.org/ for help on strftime templates.",
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
"{modified.year}": "4-digit year of photo modification time",
"{modified.yy}": "2-digit year of photo modification time",
"{modified.mm}": "2-digit month of the photo modification time (zero padded)",
"{modified.month}": "Month name in user's locale of the photo modification time",
"{modified.mon}": "Month abbreviation in the user's locale of the photo modification time",
"{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time",
"{modified.dow}": "Day of week in user's locale of the photo modification time",
"{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)",
"{modified.hour}": "2-digit hour of the photo modification time",
"{modified.min}": "2-digit minute of the photo modification time",
"{modified.sec}": "2-digit second of the photo modification time",
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified",
"{modified.year}": "4-digit year of photo modification time; uses creation date if photo is not modified",
"{modified.yy}": "2-digit year of photo modification time; uses creation date if photo is not modified",
"{modified.mm}": "2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified",
"{modified.month}": "Month name in user's locale of the photo modification time; uses creation date if photo is not modified",
"{modified.mon}": "Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified",
"{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified",
"{modified.dow}": "Day of week in user's locale of the photo modification time; uses creation date if photo is not modified",
"{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified",
"{modified.hour}": "2-digit hour of the photo modification time; uses creation date if photo is not modified",
"{modified.min}": "2-digit minute of the photo modification time; uses creation date if photo is not modified",
"{modified.sec}": "2-digit second of the photo modification time; uses creation date if photo is not modified",
# "{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
# + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
# + "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
@@ -117,6 +117,11 @@ TEMPLATE_SUBSTITUTIONS = {
"{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
"{place.address.country}": "Country name of the postal address, e.g. 'United States'",
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
"{searchinfo.season}": "Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{exif.camera_make}": "Camera make from original photo's EXIF inormation as imported by Photos, e.g. 'Apple'",
"{exif.camera_model}": "Camera model from original photo's EXIF inormation as imported by Photos, e.g. 'iPhone 6s'",
"{exif.lens_model}": "Lens model from original photo's EXIF inormation as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'",
"{uuid}": "Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
@@ -125,13 +130,17 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
"{keyword}": "Keyword(s) assigned to photo",
"{person}": "Person(s) / face(s) in a photo",
"{label}": "Image categorization label associated with a photo (Photos 5 only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)",
"{label}": "Image categorization label associated with a photo (Photos 5+ only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5+ only)",
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)",
"{exiftool:GROUP:TAGNAME}": "Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. "
"E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. "
"See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) "
"as used in `exiftool -G`. exiftool must be installed in the path to use this template.",
"{searchinfo.holiday}": "Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.activity}": "Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.venue}": "Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.venue_type}": "Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
}
# Just the multi-valued substitution names without the braces
@@ -140,25 +149,48 @@ MULTI_VALUE_SUBSTITUTIONS = [
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
]
# regular expressions for matching template syntax
RE_OPENING_BRACE = r"(?<!\{)\{" # match { but not {{
RE_DELIM = r"([^}]*\+)?" # group 1: optional DELIM+
RE_FIELD_NAME = r"([^\\,}+\?]+)" # group 2: field name
RE_PATH_SEP = r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
# + r"(\[[^{}\)]*\])?" # group 4: optional [REPLACE]
RE_REPLACE = r"(\[[^{}]*\])?" # group 4: optional [REPLACE]
RE_BOOL_VAL = r"(\?[^\\,}]*)?" # group 5: optional ?TRUE_VALUE for boolean fields
RE_DEFAULT_VAL = r"(,[\w\=\;\-\%. ]*)?" # group 6: optional ,DEFAULT
RE_CLOSING_BRACE = r"(?=\}(?!\}))\}" # match } but not }}
MATCH_GROUPS_TOTAL = 6
MATCH_GROUPS_DELIM = 1
MATCH_GROUPS_FIELD = 2
MATCH_GROUPS_PATH_SEP = 3
MATCH_GROUPS_REPLACE = 4
MATCH_GROUPS_BOOL_VAL = 5
MATCH_GROUPS_DEFAULT = 6
# default values for string manipulation template options
INPLACE_DEFAULT = ","
PATH_SEP_DEFAULT = os.path.sep
class PhotoTemplate:
""" PhotoTemplate class to render a template string from a PhotoInfo object """
def __init__(self, photo):
""" Inits PhotoTemplate class with photo, non_str, and path_sep
def __init__(self, photo, exiftool_path=None):
""" Inits PhotoTemplate class with photo
Args:
photo: a PhotoInfo instance.
exiftool_path: optional path to exiftool for use with {exiftool:} template; if not provided, will look for exiftool in $PATH
"""
self.photo = photo
self.exiftool_path = exiftool_path
# holds value of current date/time for {today.x} fields
# gets initialized in get_template_value
self.today = None
def make_subst_function(
self, none_str, filename, dirname, replacement, get_func=None
):
def make_subst_function(self, none_str, filename, dirname, get_func=None):
""" returns: substitution function for use in re.sub
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
@@ -167,37 +199,39 @@ class PhotoTemplate:
if get_func is None:
# used by make_subst_function to get the value for a template substitution
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
self.get_template_value, filename=filename, dirname=dirname
)
# closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups != 5:
if groups != MATCH_GROUPS_TOTAL:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
f"Unexpected number of groups: expected {MATCH_GROUPS_TOTAL}, got {groups}"
)
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
delim = matchobj.group(MATCH_GROUPS_DELIM)
field = matchobj.group(MATCH_GROUPS_FIELD)
path_sep = matchobj.group(MATCH_GROUPS_PATH_SEP)
replace = matchobj.group(MATCH_GROUPS_REPLACE)
bool_val = matchobj.group(MATCH_GROUPS_BOOL_VAL)
default = matchobj.group(MATCH_GROUPS_DEFAULT)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop [] from replace
replace = replace[1:-1] if replace is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
val = get_func(
field, default_val, bool_val, delim, path_sep, replacement=replace
)
except ValueError:
return matchobj.group(0)
@@ -221,7 +255,7 @@ class PhotoTemplate:
inplace_sep=None,
filename=False,
dirname=False,
replacement=":",
strip=False,
):
""" Render a filename or directory template
@@ -235,17 +269,17 @@ class PhotoTemplate:
with expand_inplace; default is ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
replacement: str, value to replace any illegal file path characters with; default = ":"
strip: if True, strips leading/trailing whitespace from rendered templates
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
if path_sep is None:
path_sep = os.path.sep
path_sep = PATH_SEP_DEFAULT
if inplace_sep is None:
inplace_sep = ","
inplace_sep = INPLACE_DEFAULT
# the rendering happens in two phases:
# phase 1: handle all the single-value template substitutions
@@ -258,19 +292,20 @@ class PhotoTemplate:
# regex to find {template_field,optional_default} in strings
# pylint: disable=anomalous-backslash-in-string
regex = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"([^\\,}+\?]+)" # group 2: field name
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
RE_OPENING_BRACE
+ RE_DELIM
+ RE_FIELD_NAME
+ RE_PATH_SEP
+ RE_REPLACE
+ RE_BOOL_VAL
+ RE_DEFAULT_VAL
+ RE_CLOSING_BRACE
)
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
subst_func = self.make_subst_function(none_str, filename, dirname, replacement)
subst_func = self.make_subst_function(none_str, filename, dirname)
# do the replacements
rendered = re.sub(regex, subst_func, template)
@@ -299,14 +334,7 @@ class PhotoTemplate:
# '2011/Album2/keyword2/person1',]
rendered_strings = self._render_multi_valued_templates(
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
rendered, none_str, path_sep, expand_inplace, inplace_sep, filename, dirname
)
# process exiftool: templates
@@ -318,7 +346,6 @@ class PhotoTemplate:
inplace_sep,
filename,
dirname,
replacement,
)
# find any {fields} that weren't replaced
@@ -343,6 +370,11 @@ class PhotoTemplate:
sanitize_filename(rendered_str) for rendered_str in rendered_strings
]
if strip:
rendered_strings = [
rendered_str.strip() for rendered_str in rendered_strings
]
return rendered_strings, unmatched
def _render_multi_valued_templates(
@@ -354,7 +386,6 @@ class PhotoTemplate:
inplace_sep,
filename,
dirname,
replacement,
):
rendered_strings = [rendered]
new_rendered_strings = []
@@ -363,15 +394,16 @@ class PhotoTemplate:
for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
RE_OPENING_BRACE
+ RE_DELIM
+ r"("
+ field # group 2: field name
+ r")"
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
+ RE_PATH_SEP
+ RE_REPLACE
+ RE_BOOL_VAL
+ RE_DEFAULT_VAL
+ RE_CLOSING_BRACE
)
regex_multi = re.compile(re_str)
@@ -382,21 +414,29 @@ class PhotoTemplate:
matches = regex_multi.search(str_template)
if matches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
matches.group(MATCH_GROUPS_PATH_SEP).strip("()")
if matches.group(MATCH_GROUPS_PATH_SEP) is not None
else path_sep
)
replace = (
matches.group(MATCH_GROUPS_REPLACE)[1:-1]
if matches.group(MATCH_GROUPS_REPLACE) is not None
else None
)
values = self.get_template_value_multi(
field,
path_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
replacement=replace,
)
if expand_inplace or matches.group(1) is not None:
if (
expand_inplace
or matches.group(MATCH_GROUPS_DELIM) is not None
):
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
matches.group(MATCH_GROUPS_DELIM)[:-1]
if matches.group(MATCH_GROUPS_DELIM) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
@@ -406,7 +446,9 @@ class PhotoTemplate:
else None
)
def lookup_template_value_multi(lookup_value, *_):
def lookup_template_value_multi(
lookup_value, *args, **kwargs
):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
@@ -422,7 +464,6 @@ class PhotoTemplate:
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
@@ -433,7 +474,9 @@ class PhotoTemplate:
# create a new template string for each value
for val in values:
def lookup_template_value_multi(lookup_value, *_):
def lookup_template_value_multi(
lookup_value, *args, **kwargs
):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
@@ -449,7 +492,6 @@ class PhotoTemplate:
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
@@ -468,7 +510,6 @@ class PhotoTemplate:
inplace_sep,
filename,
dirname,
replacement,
):
# TODO: lots of code commonality with render_multi_valued_templates -- combine or pull out
# TODO: put these in globals
@@ -479,15 +520,15 @@ class PhotoTemplate:
inplace_sep = ","
# Build a regex that matches only the field being processed
# todo: pull out regexes into globals?
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"(exiftool:[^\\,}+\?]+)" # group 3 field name
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
RE_OPENING_BRACE
+ RE_DELIM
+ r"(exiftool:[^\\,}+\?\[\]]+)" # group 3 field name
+ RE_PATH_SEP
+ RE_REPLACE
+ RE_BOOL_VAL
+ RE_DEFAULT_VAL
+ RE_CLOSING_BRACE
)
regex_multi = re.compile(re_str)
@@ -502,17 +543,21 @@ class PhotoTemplate:
# allmatches = regex_multi.finditer(str_template)
# for matches in allmatches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
matches.group(MATCH_GROUPS_PATH_SEP).strip("()")
if matches.group(MATCH_GROUPS_PATH_SEP) is not None
else path_sep
)
field = matches.group(2)
replace = (
matches.group(MATCH_GROUPS_REPLACE)[1:-1]
if matches.group(MATCH_GROUPS_REPLACE) is not None
else None
)
field = matches.group(MATCH_GROUPS_FIELD)
subfield = field[9:]
if not self.photo.path:
values = [None]
else:
exif = ExifTool(self.photo.path)
exif = ExifTool(self.photo.path, exiftool=self.exiftool_path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
subfield = subfield.lower()
@@ -521,12 +566,24 @@ class PhotoTemplate:
values = (
[values] if not isinstance(values, list) else values
)
if replace and values:
new_values = []
for value in values:
new_values.append(self.replace(value, replace))
values = new_values
# sanitize directory names if needed
if filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname:
values = [sanitize_dirname(value) for value in values]
else:
values = [None]
if expand_inplace or matches.group(1) is not None:
if expand_inplace or matches.group(MATCH_GROUPS_DELIM) is not None:
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
matches.group(MATCH_GROUPS_DELIM)[:-1]
if matches.group(MATCH_GROUPS_DELIM) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
@@ -534,7 +591,7 @@ class PhotoTemplate:
delim.join(sorted(values)) if values and values[0] else None
)
def lookup_template_value_exif(lookup_value, *_):
def lookup_template_value_exif(lookup_value, *args, **kwargs):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
@@ -548,7 +605,6 @@ class PhotoTemplate:
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
@@ -558,7 +614,9 @@ class PhotoTemplate:
# create a new template string for each value
for val in values:
def lookup_template_value_exif(lookup_value, *_):
def lookup_template_value_exif(
lookup_value, *args, **kwargs
):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
@@ -574,7 +632,6 @@ class PhotoTemplate:
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
@@ -592,7 +649,7 @@ class PhotoTemplate:
path_sep=None,
filename=False,
dirname=False,
replacement=":",
replacement=None,
):
"""lookup value for template field (single-value template substitutions)
@@ -672,73 +729,73 @@ class PhotoTemplate:
value = (
DateTimeFormatter(self.photo.date_modified).date
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).date
)
elif field == "modified.year":
value = (
DateTimeFormatter(self.photo.date_modified).year
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).year
)
elif field == "modified.yy":
value = (
DateTimeFormatter(self.photo.date_modified).yy
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).yy
)
elif field == "modified.mm":
value = (
DateTimeFormatter(self.photo.date_modified).mm
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).mm
)
elif field == "modified.month":
value = (
DateTimeFormatter(self.photo.date_modified).month
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).month
)
elif field == "modified.mon":
value = (
DateTimeFormatter(self.photo.date_modified).mon
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).mon
)
elif field == "modified.dd":
value = (
DateTimeFormatter(self.photo.date_modified).dd
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).dd
)
elif field == "modified.dow":
value = (
DateTimeFormatter(self.photo.date_modified).dow
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).dow
)
elif field == "modified.doy":
value = (
DateTimeFormatter(self.photo.date_modified).doy
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).doy
)
elif field == "modified.hour":
value = (
DateTimeFormatter(self.photo.date_modified).hour
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).hour
)
elif field == "modified.min":
value = (
DateTimeFormatter(self.photo.date_modified).min
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).min
)
elif field == "modified.sec":
value = (
DateTimeFormatter(self.photo.date_modified).sec
if self.photo.date_modified
else None
else DateTimeFormatter(self.photo.date).sec
)
elif field == "today.date":
value = DateTimeFormatter(self.today).date
@@ -842,18 +899,58 @@ class PhotoTemplate:
if self.photo.place and self.photo.place.address.iso_country_code
else None
)
elif field == "searchinfo.season":
value = self.photo.search_info.season if self.photo.search_info else None
elif field == "exif.camera_make":
value = self.photo.exif_info.camera_make if self.photo.exif_info else None
elif field == "exif.camera_model":
value = self.photo.exif_info.camera_model if self.photo.exif_info else None
elif field == "exif.lens_model":
value = self.photo.exif_info.lens_model if self.photo.exif_info else None
elif field == "uuid":
value = self.photo.uuid
else:
# if here, didn't get a match
raise ValueError(f"Unhandled template value: {field}")
if value and replacement:
value = self.replace(value, replacement)
# process character replacements
if filename:
value = sanitize_pathpart(value, replacement=replacement)
value = sanitize_pathpart(value)
elif dirname:
value = sanitize_dirname(value, replacement=replacement)
value = sanitize_dirname(value)
return value
def replace(self, value, replacement):
""" process REPLACE template option
Args:
value: str value to process
replacement: str in form OLD,NEW|OLD,NEW... with old and new values for replacement
Returns:
value with all replacements done
Raises:
ValueError if replacement string is in wrong format
"""
if not value:
return value
replacements = replacement.split("|")
for r in replacements:
try:
old, new = r.split(",")
except ValueError:
raise ValueError(f"Invalid template REPLACE value: {replacement}")
value = value.replace(old, new)
return value
def get_template_value_multi(
self, field, path_sep, filename=False, dirname=False, replacement=":"
self, field, path_sep, filename=False, dirname=False, replacement=None
):
"""lookup value for template field (multi-value template substitutions)
@@ -892,12 +989,9 @@ class PhotoTemplate:
if dirname:
# being used as a filepath so sanitize each part
folder = path_sep.join(
sanitize_dirname(f, replacement=replacement)
for f in album.folder_names
)
folder += path_sep + sanitize_dirname(
album.title, replacement=replacement
sanitize_dirname(f) for f in album.folder_names
)
folder += path_sep + sanitize_dirname(album.title)
else:
folder = path_sep.join(album.folder_names)
folder += path_sep + album.title
@@ -905,28 +999,41 @@ class PhotoTemplate:
else:
# album not in folder
if dirname:
values.append(
sanitize_dirname(album.title, replacement=replacement)
)
values.append(sanitize_dirname(album.title))
else:
values.append(album.title)
elif field == "comment":
values = [
f"{comment.user}: {comment.text}" for comment in self.photo.comments
]
elif field == "searchinfo.holiday":
values = self.photo.search_info.holidays if self.photo.search_info else []
elif field == "searchinfo.activity":
values = self.photo.search_info.activities if self.photo.search_info else []
elif field == "searchinfo.venue":
values = self.photo.search_info.venues if self.photo.search_info else []
elif field == "searchinfo.venue_type":
values = (
self.photo.search_info.venue_types if self.photo.search_info else []
)
elif not field.startswith("exiftool:"):
# exiftool: templates handled by _render_exiftool_template
raise ValueError(f"Unhandled template value: {field}")
# do any replacements needs
if replacement:
new_values = []
for value in values:
# process replacements
new_values.append(self.replace(value, replacement))
values = new_values
# sanitize directory names if needed, folder_album handled differently above
if filename:
values = [
sanitize_pathpart(value, replacement=replacement) for value in values
]
values = [sanitize_pathpart(value) for value in values]
elif dirname and field != "folder_album":
# skip folder_album because it would have been handled above
values = [
sanitize_dirname(value, replacement=replacement) for value in values
]
values = [sanitize_dirname(value) for value in values]
# If no values, insert None so code below will substite none_str for None
values = values or [None]

View File

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

View File

@@ -42,6 +42,7 @@ mccabe==0.6.1
modulegraph==0.18
more-itertools==7.2.0
multidict==4.7.6
osxmetadata>=0.99.11
packaging==19.0
parso==0.6.2
pathspec==0.7.0

View File

@@ -3,7 +3,7 @@
#
# setup.py script for osxphotos
#
# Copyright (c) 2019, 2020 Rhet Turnbull, rturnbull+git@gmail.com
# Copyright (c) 2019, 2020, 2021 Rhet Turnbull, rturnbull+git@gmail.com
# All rights reserved.
#
# Permission is hereby granted, free of charge, to any person
@@ -45,15 +45,15 @@ 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(
name="osxphotos",
version=about["__version__"],
description="Manipulate (read-only) Apple's Photos app library on Mac OS X",
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/",
@@ -81,6 +81,7 @@ setup(
"wurlitzer>=2.0.1",
"photoscript>=0.1.0",
"toml>=0.10.0",
"osxmetadata>=0.99.13",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 532 KiB

View File

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

View File

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

Binary file not shown.

View File

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

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>insertAlbum</key>
<array/>
<key>insertAsset</key>
<array/>
<key>insertHighlight</key>
<array/>
<key>insertMemory</key>
<array/>
<key>insertMoment</key>
<array/>
<key>removeAlbum</key>
<array/>
<key>removeAsset</key>
<array/>
<key>removeHighlight</key>
<array/>
<key>removeMemory</key>
<array/>
<key>removeMoment</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>embeddingVersion</key>
<string>1</string>
<key>localeIdentifier</key>
<string>en_US</string>
<key>sceneTaxonomySHA</key>
<string>87914a047c69fbe8013fad2c70fa70c6c03b08b56190fe4054c880e6b9f57cc3</string>
<key>searchIndexVersion</key>
<string>10</string>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>MigrationService</key>
<dict>
<key>State</key>
<integer>4</integer>
</dict>
<key>MigrationService.LastCompletedTask</key>
<integer>12</integer>
<key>MigrationService.ValidationCounts</key>
<dict>
<key>MigrationDetectedFaceprint</key>
<integer>6</integer>
<key>MigrationManagedAsset</key>
<integer>0</integer>
<key>MigrationSceneClassification</key>
<integer>44</integer>
<key>MigrationUnmanagedAdjustment</key>
<integer>0</integer>
<key>RDVersion.cloudLocalState.CPLIsNotPushed</key>
<integer>7</integer>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CollapsedSidebarSectionIdentifiers</key>
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>92D68107-B6C7-453B-96D2-97B0F26D5B8B/L0/020</string>
<string>88A5F8B8-5B9A-43C7-BB85-3952B81580EB/L0/020</string>
<string>29EF7A97-7E76-4D5F-A5E0-CC0A93E8524C/L0/020</string>
<string>2C2AF115-BD1D-4434-A747-D1C8BD8E2045/L0/020</string>
<string>CB051A4C-2CB7-4B90-B59B-08CC4D0C2823/L0/020</string>
</array>
<key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict>
<key>kZoomLevelIdentifierPhotosGrid</key>
<integer>2</integer>
</dict>
<key>Photos</key>
<dict>
<key>CollapsedSidebarSectionIdentifiers</key>
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>TopLevelAlbums</string>
<string>TopLevelSlideshows</string>
</array>
<key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict>
<key>kZoomLevelIdentifierAlbums</key>
<integer>7</integer>
<key>kZoomLevelIdentifierVersions</key>
<integer>7</integer>
</dict>
<key>lastAddToDestination</key>
<dict>
<key>key</key>
<integer>1</integer>
<key>lastKnownDisplayName</key>
<string>September 28, 2018</string>
<key>type</key>
<string>album</string>
<key>uuid</key>
<string>DFFKmHt3Tk+AGzZLe2Xq+g</string>
</dict>
<key>lastKnownItemCounts</key>
<dict>
<key>other</key>
<integer>0</integer>
<key>photos</key>
<integer>7</integer>
<key>videos</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2020-06-24T04:02:13Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2020-06-24T04:02:12Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2020-06-24T04:02:13Z</date>
<key>BackgroundJobSearch</key>
<date>2020-06-24T04:02:13Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2020-06-24T04:02:12Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2020-06-24T04:02:13Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-05-30T02:16:06Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-05-29T04:31:37Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-06-24T04:02:13Z</date>
<key>SiriPortraitDonation</key>
<date>2020-06-24T04:02:13Z</date>
</dict>
</plist>

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