Compare commits

...

107 Commits

Author SHA1 Message Date
Rhet Turnbull
ed3a9711dc Removed --use-photokit authorization check, issue 278 2020-11-29 18:26:55 -08:00
Rhet Turnbull
1bc0926948 Updated CHANGELOG.md 2020-11-29 18:26:00 -08:00
Rhet Turnbull
25eacc7cad Added --missing to export, see issue #277 2020-11-29 15:30:45 -08:00
Rhet Turnbull
d9dcf0917a Catch errors in export_photo 2020-11-28 20:00:10 -08:00
Rhet Turnbull
4f36c7c948 Updated CHANGELOG.md 2020-11-28 09:27:12 -08:00
Rhet Turnbull
d22eaf39ed Added --report option to CLI, implements #253 2020-11-28 09:24:16 -08:00
Rhet Turnbull
adf2ba7678 Updated CHANGELOG.md 2020-11-27 17:00:53 -08:00
Rhet Turnbull
af827d7a57 Updated template values 2020-11-27 16:58:11 -08:00
Rhet Turnbull
48acb42631 Added {exiftool} template, implements issue #259 2020-11-27 16:43:48 -08:00
Rhet Turnbull
eba661acf7 Updated CHANGELOG.md 2020-11-26 19:53:35 -08:00
Rhet Turnbull
399d432a66 Added --original-suffix for issue #263 2020-11-26 18:36:17 -08:00
Rhet Turnbull
4cebc57d60 Updated CHANGELOG.md 2020-11-26 15:26:54 -08:00
Rhet Turnbull
489fea56e9 Added tests for issue #265 2020-11-26 13:21:40 -08:00
Rhet Turnbull
0632a97f55 Simplified sidecar table in export_db 2020-11-26 10:42:10 -08:00
Rhet Turnbull
d5a9f76719 More work on issue #265 2020-11-26 10:15:09 -08:00
Rhet Turnbull
382fca3f92 Initial implementation for issue #265 2020-11-26 09:08:26 -08:00
Rhet Turnbull
a807894095 Removed debug code from _photoinfo_export.py 2020-11-25 21:42:27 -08:00
Rhet Turnbull
559350f71d Updated CHANGELOG.md 2020-11-25 20:55:15 -08:00
Rhet Turnbull
b5195f9d2b version bump 2020-11-25 20:32:36 -08:00
Rhet Turnbull
fa332186ab Fix for missing original_filename, issue #267 2020-11-25 20:31:19 -08:00
Rhet Turnbull
aa2ebf55bb Updated test 2020-11-25 19:04:36 -08:00
Rhet Turnbull
d1fbb9fe86 Updated CHANGELOG.md 2020-11-25 18:58:48 -08:00
Rhet Turnbull
116cb662fb Added test for missing original_filename 2020-11-25 18:32:12 -08:00
Rhet Turnbull
db68defc44 version bump 2020-11-25 17:55:07 -08:00
Rhet Turnbull
7460bc88fc Add @jstrine as a contributor 2020-11-25 17:54:53 -08:00
Rhet Turnbull
dbbbbf10a8 Merge pull request #272 from jstrine/fix_xml_escaping
Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix!
2020-11-25 17:52:22 -08:00
Rhet Turnbull
0633814ab2 Merge pull request #270 from jstrine/fix_gps_xmp
Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix!
2020-11-25 17:51:57 -08:00
Rhet Turnbull
df7d45659a Merge pull request #268 from jstrine/fix_path_none
Continue even if the original filename is None, thanks to @jstrine for the fix!
2020-11-25 17:51:38 -08:00
Jonathan Strine
cec266bba4 Fix tests again
Third times the charm to fix a find-replace error this time.
2020-11-25 19:51:09 -05:00
Jonathan Strine
d0d2e80800 Fix tests for apostrophe
Previous commit didn't reflect all locations and had a copy/paste error.
2020-11-25 19:45:21 -05:00
Jonathan Strine
aafdbea564 Fix test to accomodate for escaped apostrophe 2020-11-25 19:36:09 -05:00
Jonathan Strine
c42050a10c Escape characters which cause XML parsing issues 2020-11-25 19:31:51 -05:00
Jonathan Strine
c27cfb1223 Fix test for XMP sidecar with GPS info 2020-11-25 19:24:56 -05:00
Jonathan Strine
ad144da8a0 Use GPSCoordinate format for geolocation 2020-11-25 18:09:38 -05:00
Jonathan Strine
5352aec3b9 Continue even if the original filename is None
Some photos seemed to be missing the original_filename (returning None).
This fix prevents the traceback.
2020-11-25 17:00:22 -05:00
Rhet Turnbull
e951e5361e Exposed --use-photos-export and --use-photokit 2020-11-25 09:15:16 -08:00
Rhet Turnbull
f7bd1376e1 Updated CHANGELOG.md 2020-11-24 06:50:52 -08:00
Rhet Turnbull
26f96d582c Added photokit export as hidden --use-photokit option 2020-11-23 06:23:19 -08:00
Rhet Turnbull
8cb15d1555 Removed debug statement in _photoinfo_export 2020-11-18 22:03:23 -08:00
Rhet Turnbull
2d9429c8ee Fixed missing data file for photoscript 2020-11-14 14:18:47 -08:00
Rhet Turnbull
3b6dd08d2b Version bump, updated requirements 2020-11-14 13:37:46 -08:00
Rhet Turnbull
3c85f26f90 Moved AppleScript to photoscript 2020-11-14 13:34:50 -08:00
Rhet Turnbull
52c054f81f Updated CHANGELOG.md 2020-11-14 09:32:08 -08:00
Rhet Turnbull
8dc59cbc35 Fixed erroneous attempt to export edited with --download-missing 2020-11-12 06:51:36 -08:00
Rhet Turnbull
802e2f069a version bump 2020-11-12 06:18:56 -08:00
Rhet Turnbull
5d4d7d7db7 Fixed path for photos actually missing off disk 2020-11-12 06:18:28 -08:00
Rhet Turnbull
ea9b41bae4 Avoid copying db files if not necessary 2020-11-11 07:03:57 -08:00
Rhet Turnbull
38397b507b Fix for issue #247 2020-11-08 21:17:19 -08:00
Rhet Turnbull
3636fcbc76 Refactored phototemplate.py to add PATH_SEP option 2020-11-08 16:09:51 -08:00
Rhet Turnbull
a6231e29ff More work on phototemplate.py to add inline expansion 2020-11-08 09:10:09 -08:00
Rhet Turnbull
8c36c6712a Updated CHANGELOG.md 2020-11-07 23:14:34 -08:00
Rhet Turnbull
7fa3704840 Implemented boolean type template fields 2020-11-07 23:06:36 -08:00
Rhet Turnbull
e829212987 Bug fix in handling missing edited photos 2020-11-07 22:47:44 -08:00
Rhet Turnbull
df37a017a8 Fixed message in CLI 2020-11-07 21:33:03 -08:00
Rhet Turnbull
101525c95f Updated CHANGELOG.md 2020-11-07 20:40:27 -08:00
Rhet Turnbull
ae2fd2e3db Implemented issue #255 2020-11-07 18:22:17 -08:00
Rhet Turnbull
9588853ea2 Updated CHANGELOG.md 2020-11-07 09:29:31 -08:00
Rhet Turnbull
9d38885416 Fix for exporting slow mo videos, issue #252 2020-11-07 07:58:37 -08:00
Rhet Turnbull
653b7e6600 Refactored regex in phototemplate 2020-11-06 19:55:03 -08:00
Rhet Turnbull
9429ea8ace Updated CHANGELOG.md 2020-11-04 22:02:32 -08:00
Rhet Turnbull
2202f1b1e9 Refactored exiftool.py 2020-11-04 21:37:20 -08:00
Rhet Turnbull
a509ef18d3 README.md update 2020-11-03 21:32:39 -08:00
Rhet Turnbull
0492f94060 Updated CHANGELOG.md 2020-11-03 19:10:34 -08:00
Rhet Turnbull
cf7fab4c7a Implemented context manager for ExifTool, closes #250 2020-11-03 18:51:09 -08:00
Rhet Turnbull
c7c5320587 Fix for issue #39 2020-11-02 05:53:11 -08:00
Rhet Turnbull
cd710771cd Updated CHANGELOG.md 2020-11-01 09:20:21 -08:00
Rhet Turnbull
663e33bc17 Added --ignore-date-modified flag, issue #247 2020-11-01 09:13:45 -08:00
Rhet Turnbull
3660b6360a Updated CHANGELOG.md 2020-10-31 22:19:34 -07:00
Rhet Turnbull
11459d1da4 Updated --exiftool to set dates/times as Photos does, issue #247 2020-10-31 22:11:00 -07:00
Rhet Turnbull
fd14242022 Version bump 2020-10-31 21:05:42 -07:00
Rhet Turnbull
6ac311199e Partial fix for issue #247 on Mojave 2020-10-31 21:04:44 -07:00
Rhet Turnbull
13df6a2395 Add @hhoeck as a contributor 2020-10-31 10:10:21 -07:00
Rhet Turnbull
28dce72a67 Add @agprimatic as a contributor 2020-10-31 10:10:07 -07:00
Rhet Turnbull
e5548ed160 Add @grundsch as a contributor 2020-10-31 10:09:55 -07:00
Rhet Turnbull
5714509765 Add @dethi as a contributor 2020-10-31 10:09:24 -07:00
Rhet Turnbull
46b62af4e2 Add @jystervinou as a contributor 2020-10-31 10:09:06 -07:00
Rhet Turnbull
01ea88fe57 Add @dmd as a contributor 2020-10-31 10:08:43 -07:00
Rhet Turnbull
e6d043ab65 Add @hshore29 as a contributor 2020-10-31 10:08:18 -07:00
Rhet Turnbull
5b1174db5d Add @PabloKohan as a contributor 2020-10-31 10:07:12 -07:00
Rhet Turnbull
9cff8e89c6 Add @mwort as a contributor 2020-10-31 10:03:13 -07:00
Rhet Turnbull
1553563629 Add @britiscurious as a contributor 2020-10-31 10:00:42 -07:00
Rhet Turnbull
db262f58b0 Updated CHANGELOG.md 2020-10-31 09:01:53 -07:00
Rhet Turnbull
0cce234a8c Fixed handling of date_modified for Catalina, issue #247 2020-10-31 08:46:35 -07:00
Rhet Turnbull
c5dba8c89b Added --has-comment/--has-likes to CLI, issue #240 2020-10-29 21:34:15 -07:00
Rhet Turnbull
603dabb8f4 Cleaned up as_dict/asdict, issue #144, #188 2020-10-27 06:54:42 -07:00
Rhet Turnbull
091f1d9bb4 Updated CHANGELOG.md 2020-10-25 22:25:18 -07:00
Rhet Turnbull
d16932d0fd Updated README.md 2020-10-25 22:24:47 -07:00
Rhet Turnbull
23de6b5890 Added comments/likes, implements #214 2020-10-25 22:12:02 -07:00
Rhet Turnbull
4fe58bf2af fixed test 2020-10-25 09:18:20 -07:00
Rhet Turnbull
d87b8f30a4 Added verbose to PhotosDB(), partial fix for #110 2020-10-25 08:16:54 -07:00
Rhet Turnbull
667c89e32c Cleaned up constructor for PhotosDB 2020-10-24 17:20:46 -07:00
Rhet Turnbull
f9cac05f0d Updated CHANGELOG.md 2020-10-24 13:52:27 -07:00
Rhet Turnbull
48f29e138e Fix for issue #238 2020-10-24 13:45:10 -07:00
Rhet Turnbull
7f2701f6ee Updated CHANGELOG.md 2020-10-24 09:17:16 -07:00
Rhet Turnbull
8551981f68 Fixed shared, not_shared in cli 2020-10-24 09:03:34 -07:00
Rhet Turnbull
a416de29e4 Fix for issue #237 2020-10-21 22:29:16 -07:00
Rhet Turnbull
a960468887 Updated related projects 2020-10-20 22:13:10 -07:00
Rhet Turnbull
ea68229dda Added test for issue #235 2020-10-18 21:34:50 -07:00
Rhet Turnbull
a95193aaa4 Updated README.md with better install instructions 2020-10-18 20:35:40 -07:00
Rhet Turnbull
71ef5e5195 Updated get_shared_photo_comments.py 2020-10-18 16:13:43 -07:00
Rhet Turnbull
53b2498e59 Updated get_shared_photo_comments.py 2020-10-18 16:11:45 -07:00
Rhet Turnbull
15e0914af6 Added get_shared_photo_comments.py to examples 2020-10-18 15:52:18 -07:00
Rhet Turnbull
3b3eb1625e Updated README.md 2020-10-18 14:09:40 -07:00
Rhet Turnbull
338b1501d0 Updated CHANGELOG.md 2020-10-17 23:31:47 -07:00
Rhet Turnbull
bda3a029de Updated README.md 2020-10-17 23:31:09 -07:00
Rhet Turnbull
ff0fdffa9b refactored template code to fix #213 2020-10-17 23:21:08 -07:00
Rhet Turnbull
1332e7b45a Updated CHANGELOG.md 2020-10-15 06:44:03 -07:00
399 changed files with 7949 additions and 1573 deletions

115
.all-contributorsrc Normal file
View File

@@ -0,0 +1,115 @@
{
"projectName": "osxphotos",
"projectOwner": "RhetTbull",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": true,
"commitConvention": "none",
"contributors": [
{
"login": "britiscurious",
"name": "britiscurious",
"avatar_url": "https://avatars1.githubusercontent.com/u/25646439?v=4",
"profile": "https://github.com/britiscurious",
"contributions": [
"doc",
"code"
]
},
{
"login": "mwort",
"name": "Michel Wortmann",
"avatar_url": "https://avatars3.githubusercontent.com/u/8170417?v=4",
"profile": "https://github.com/mwort",
"contributions": [
"code"
]
},
{
"login": "PabloKohan",
"name": "Pablo 'merKur' Kohan",
"avatar_url": "https://avatars3.githubusercontent.com/u/8790976?v=4",
"profile": "https://github.com/PabloKohan",
"contributions": [
"code"
]
},
{
"login": "hshore29",
"name": "hshore29",
"avatar_url": "https://avatars2.githubusercontent.com/u/7023497?v=4",
"profile": "https://github.com/hshore29",
"contributions": [
"code"
]
},
{
"login": "dmd",
"name": "Daniel M. Drucker",
"avatar_url": "https://avatars0.githubusercontent.com/u/41439?v=4",
"profile": "http://3e.org/",
"contributions": [
"code"
]
},
{
"login": "jystervinou",
"name": "Jean-Yves Stervinou",
"avatar_url": "https://avatars3.githubusercontent.com/u/132356?v=4",
"profile": "https://github.com/jystervinou",
"contributions": [
"code"
]
},
{
"login": "dethi",
"name": "Thibault Deutsch",
"avatar_url": "https://avatars2.githubusercontent.com/u/1011520?v=4",
"profile": "https://dethi.me/",
"contributions": [
"code"
]
},
{
"login": "grundsch",
"name": "grundsch",
"avatar_url": "https://avatars0.githubusercontent.com/u/3874928?v=4",
"profile": "https://github.com/grundsch",
"contributions": [
"code"
]
},
{
"login": "agprimatic",
"name": "Ag Primatic",
"avatar_url": "https://avatars1.githubusercontent.com/u/4685054?v=4",
"profile": "https://github.com/agprimatic",
"contributions": [
"code"
]
},
{
"login": "hhoeck",
"name": "Horst Höck",
"avatar_url": "https://avatars1.githubusercontent.com/u/6313998?v=4",
"profile": "https://github.com/hhoeck",
"contributions": [
"code"
]
},
{
"login": "jstrine",
"name": "Jonathan Strine",
"avatar_url": "https://avatars1.githubusercontent.com/u/33943447?v=4",
"profile": "https://github.com/jstrine",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7
}

View File

@@ -4,6 +4,228 @@ 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.37.2](https://github.com/RhetTbull/osxphotos/compare/v0.37.1...v0.37.2)
> 29 November 2020
- Catch errors in export_photo [`d9dcf09`](https://github.com/RhetTbull/osxphotos/commit/d9dcf0917a541725d1e472e7f918733e4e2613d0)
- Added --missing to export, see issue #277 [`25eacc7`](https://github.com/RhetTbull/osxphotos/commit/25eacc7caddd6721232b3f77a02532fcd35f7836)
#### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1)
> 28 November 2020
- Added --report option to CLI, implements #253 [`d22eaf3`](https://github.com/RhetTbull/osxphotos/commit/d22eaf39edc8b0b489b011d6d21345dcedcc8dff)
- Updated template values [`af827d7`](https://github.com/RhetTbull/osxphotos/commit/af827d7a5769f41579d300a7cc511251d86b7eed)
#### [v0.37.0](https://github.com/RhetTbull/osxphotos/compare/v0.36.25...v0.37.0)
> 28 November 2020
- Added {exiftool} template, implements issue #259 [`48acb42`](https://github.com/RhetTbull/osxphotos/commit/48acb42631226a71bfc636eea2d3151f1b7165f4)
#### [v0.36.25](https://github.com/RhetTbull/osxphotos/compare/v0.36.24...v0.36.25)
> 27 November 2020
- Added --original-suffix for issue #263 [`399d432`](https://github.com/RhetTbull/osxphotos/commit/399d432a66354b9c235f30d10c6985fbde1b7e4f)
#### [v0.36.24](https://github.com/RhetTbull/osxphotos/compare/v0.36.23...v0.36.24)
> 26 November 2020
- Initial implementation for issue #265 [`382fca3`](https://github.com/RhetTbull/osxphotos/commit/382fca3f92a3c251c12426dd0dc6d7dc21b691cf)
- More work on issue #265 [`d5a9f76`](https://github.com/RhetTbull/osxphotos/commit/d5a9f767199d25ebd9d5925d05ee39ea7e51ac26)
- Simplified sidecar table in export_db [`0632a97`](https://github.com/RhetTbull/osxphotos/commit/0632a97f55af67c7e5265b0d3283155c7c087e89)
#### [v0.36.23](https://github.com/RhetTbull/osxphotos/compare/v0.36.22...v0.36.23)
> 26 November 2020
- Fix for missing original_filename, issue #267 [`fa33218`](https://github.com/RhetTbull/osxphotos/commit/fa332186ab3cdbe1bfd6496ff29b652ef984a5f8)
- version bump [`b5195f9`](https://github.com/RhetTbull/osxphotos/commit/b5195f9d2b81cf6737b65e3cd3793ea9b0da13eb)
- Updated test [`aa2ebf5`](https://github.com/RhetTbull/osxphotos/commit/aa2ebf55bb50eec14f86a532334b376e407f4bbc)
#### [v0.36.22](https://github.com/RhetTbull/osxphotos/compare/v0.36.21...v0.36.22)
> 26 November 2020
- Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix! [`#272`](https://github.com/RhetTbull/osxphotos/pull/272)
- Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix! [`#270`](https://github.com/RhetTbull/osxphotos/pull/270)
- Continue even if the original filename is None, thanks to @jstrine for the fix! [`#268`](https://github.com/RhetTbull/osxphotos/pull/268)
- Added test for missing original_filename [`116cb66`](https://github.com/RhetTbull/osxphotos/commit/116cb662fbddf9153f6858c6ea97dc7f65c77705)
- Add @jstrine as a contributor [`7460bc8`](https://github.com/RhetTbull/osxphotos/commit/7460bc88fcc5e1e7435c9b9bcdf7ec9c7c5e39ea)
- Escape characters which cause XML parsing issues [`c42050a`](https://github.com/RhetTbull/osxphotos/commit/c42050a10cac40b0b5ac70c587e07f257a9b50dd)
- Fix tests for apostrophe [`d0d2e80`](https://github.com/RhetTbull/osxphotos/commit/d0d2e8080096bf66f93a830386800ce713680c51)
- Fix test for XMP sidecar with GPS info [`c27cfb1`](https://github.com/RhetTbull/osxphotos/commit/c27cfb1223fa82b9e5549b93c283e9444693270a)
#### [v0.36.21](https://github.com/RhetTbull/osxphotos/compare/v0.36.20...v0.36.21)
> 25 November 2020
- Exposed --use-photos-export and --use-photokit [`e951e53`](https://github.com/RhetTbull/osxphotos/commit/e951e5361e59060229787bb1ea3fc4e088ffff99)
#### [v0.36.20](https://github.com/RhetTbull/osxphotos/compare/v0.36.19...v0.36.20)
> 23 November 2020
- Added photokit export as hidden --use-photokit option [`26f96d5`](https://github.com/RhetTbull/osxphotos/commit/26f96d582c01ce9816b1f54f0e74c8570f133f7c)
#### [v0.36.19](https://github.com/RhetTbull/osxphotos/compare/v0.36.18...v0.36.19)
> 19 November 2020
- Removed debug statement in _photoinfo_export [`8cb15d1`](https://github.com/RhetTbull/osxphotos/commit/8cb15d15551094dcaf1b0ef32d6ac0273be7fd37)
#### [v0.36.18](https://github.com/RhetTbull/osxphotos/compare/v0.36.17...v0.36.18)
> 14 November 2020
- Moved AppleScript to photoscript [`3c85f26`](https://github.com/RhetTbull/osxphotos/commit/3c85f26f901645ce297685ccd639792757fbc995)
- Fixed missing data file for photoscript [`2d9429c`](https://github.com/RhetTbull/osxphotos/commit/2d9429c8eefabe6233fc580f65511c48ee6c01e5)
- Version bump, updated requirements [`3b6dd08`](https://github.com/RhetTbull/osxphotos/commit/3b6dd08d2bb2b20a55064bf24fe7ce788e7268ef)
#### [v0.36.17](https://github.com/RhetTbull/osxphotos/compare/v0.36.15...v0.36.17)
> 12 November 2020
- Fixed path for photos actually missing off disk [`5d4d7d7`](https://github.com/RhetTbull/osxphotos/commit/5d4d7d7db7ca1109b6230803fe777d7a30882efe)
- Fixed erroneous attempt to export edited with --download-missing [`8dc59cb`](https://github.com/RhetTbull/osxphotos/commit/8dc59cbc35c33e71d0d912f4139e855180ac4fbd)
- version bump [`802e2f0`](https://github.com/RhetTbull/osxphotos/commit/802e2f069a5f8b37ddc6b3b8ba07519ce10f88a7)
#### [v0.36.15](https://github.com/RhetTbull/osxphotos/compare/v0.36.14...v0.36.15)
> 11 November 2020
- Avoid copying db files if not necessary [`ea9b41b`](https://github.com/RhetTbull/osxphotos/commit/ea9b41bae41a05aad53454f67871c5e6c9a49f79)
#### [v0.36.14](https://github.com/RhetTbull/osxphotos/compare/v0.36.13...v0.36.14)
> 9 November 2020
- Fix for issue #247 [`38397b5`](https://github.com/RhetTbull/osxphotos/commit/38397b507b456169cf3be2d2dc6743ec8653feb3)
#### [v0.36.13](https://github.com/RhetTbull/osxphotos/compare/v0.36.11...v0.36.13)
> 9 November 2020
- Refactored phototemplate.py to add PATH_SEP option [`3636fcb`](https://github.com/RhetTbull/osxphotos/commit/3636fcbc76100d9898a59f24ed6e9b1965cc6022)
- More work on phototemplate.py to add inline expansion [`a6231e2`](https://github.com/RhetTbull/osxphotos/commit/a6231e29ff28b2c7dc3239445f41afcb35926a7a)
#### [v0.36.11](https://github.com/RhetTbull/osxphotos/compare/v0.36.10...v0.36.11)
> 8 November 2020
- Implemented boolean type template fields [`7fa3704`](https://github.com/RhetTbull/osxphotos/commit/7fa3704840f7800689b4ac5f8edee8210eb3e8db)
- Bug fix in handling missing edited photos [`e829212`](https://github.com/RhetTbull/osxphotos/commit/e829212987bbc1a88f845922abcffef70c159883)
- Fixed message in CLI [`df37a01`](https://github.com/RhetTbull/osxphotos/commit/df37a017a8efdc8d0b9bc8d00a4452dc4cb892b3)
#### [v0.36.10](https://github.com/RhetTbull/osxphotos/compare/v0.36.9...v0.36.10)
> 8 November 2020
- Implemented issue #255 [`ae2fd2e`](https://github.com/RhetTbull/osxphotos/commit/ae2fd2e3db984756e6cc3f7b3338b8ba819ce28c)
#### [v0.36.9](https://github.com/RhetTbull/osxphotos/compare/v0.36.8...v0.36.9)
> 7 November 2020
- Refactored regex in phototemplate [`653b7e6`](https://github.com/RhetTbull/osxphotos/commit/653b7e6600e0738ecd00f74d510a893e0d447ca4)
- Fix for exporting slow mo videos, issue #252 [`9d38885`](https://github.com/RhetTbull/osxphotos/commit/9d38885416b528bd8c91bb09120be85a8b109f29)
#### [v0.36.8](https://github.com/RhetTbull/osxphotos/compare/v0.36.7...v0.36.8)
> 5 November 2020
- Refactored exiftool.py [`2202f1b`](https://github.com/RhetTbull/osxphotos/commit/2202f1b1e9c4f83558ef48e58cb94af6b3a38cdd)
- README.md update [`a509ef1`](https://github.com/RhetTbull/osxphotos/commit/a509ef18d3db2ac15a661e763a7254974cf8d84a)
#### [v0.36.7](https://github.com/RhetTbull/osxphotos/compare/v0.36.6...v0.36.7)
> 4 November 2020
- Implemented context manager for ExifTool, closes #250 [`#250`](https://github.com/RhetTbull/osxphotos/issues/250)
#### [v0.36.6](https://github.com/RhetTbull/osxphotos/compare/v0.36.5...v0.36.6)
> 2 November 2020
- Fix for issue #39 [`c7c5320`](https://github.com/RhetTbull/osxphotos/commit/c7c5320587e31070b55cc8c7e74f30b0f9e61379)
#### [v0.36.5](https://github.com/RhetTbull/osxphotos/compare/v0.36.4...v0.36.5)
> 1 November 2020
- Added --ignore-date-modified flag, issue #247 [`663e33b`](https://github.com/RhetTbull/osxphotos/commit/663e33bc1709f767e1a08242f6bfe86a3fc78552)
#### [v0.36.4](https://github.com/RhetTbull/osxphotos/compare/v0.36.2...v0.36.4)
> 1 November 2020
- Updated --exiftool to set dates/times as Photos does, issue #247 [`11459d1`](https://github.com/RhetTbull/osxphotos/commit/11459d1da4d7d13e36e9db4bdc940b74baad9d11)
- Partial fix for issue #247 on Mojave [`6ac3111`](https://github.com/RhetTbull/osxphotos/commit/6ac311199e9f7afe6170cbbd68ceaa1bb9f0682b)
- Add @mwort as a contributor [`9cff8e8`](https://github.com/RhetTbull/osxphotos/commit/9cff8e89c6e939d3d371a4f60649f6e5595a55b9)
#### [v0.36.2](https://github.com/RhetTbull/osxphotos/compare/v0.36.1...v0.36.2)
> 31 October 2020
- Fixed handling of date_modified for Catalina, issue #247 [`0cce234`](https://github.com/RhetTbull/osxphotos/commit/0cce234a8cbba63dc1cba439c06fe9de078ff480)
#### [v0.36.1](https://github.com/RhetTbull/osxphotos/compare/v0.36.0...v0.36.1)
> 30 October 2020
- Added --has-comment/--has-likes to CLI, issue #240 [`c5dba8c`](https://github.com/RhetTbull/osxphotos/commit/c5dba8c89bba35d7a77e087b180b2a3d7b94280a)
- Cleaned up as_dict/asdict, issue #144, #188 [`603dabb`](https://github.com/RhetTbull/osxphotos/commit/603dabb8f420a89e993d5aadcd3a5614bbb262dd)
- Updated README.md [`d16932d`](https://github.com/RhetTbull/osxphotos/commit/d16932d0fd8d160ccf44e9842329d5933dc25b36)
#### [v0.36.0](https://github.com/RhetTbull/osxphotos/compare/v0.35.7...v0.36.0)
> 26 October 2020
- Added verbose to PhotosDB(), partial fix for #110 [`d87b8f3`](https://github.com/RhetTbull/osxphotos/commit/d87b8f30a45cbb6fdb315a12f8585e2bdc21be6b)
- Added comments/likes, implements #214 [`23de6b5`](https://github.com/RhetTbull/osxphotos/commit/23de6b58908371d9ca55d1d1999c6d56de454180)
- Cleaned up constructor for PhotosDB [`667c89e`](https://github.com/RhetTbull/osxphotos/commit/667c89e32c3f96baeafebc03e83517ea05693b00)
#### [v0.35.7](https://github.com/RhetTbull/osxphotos/compare/v0.35.6...v0.35.7)
> 24 October 2020
- Fix for issue #238 [`48f29e1`](https://github.com/RhetTbull/osxphotos/commit/48f29e138e4e9da3eba78f3681ee9b8cb28910df)
#### [v0.35.6](https://github.com/RhetTbull/osxphotos/compare/v0.35.5...v0.35.6)
> 24 October 2020
- Fixed shared, not_shared in cli [`8551981`](https://github.com/RhetTbull/osxphotos/commit/8551981f68f0cd2a3a081cc21ae287ff981b9b4b)
#### [v0.35.5](https://github.com/RhetTbull/osxphotos/compare/v0.35.4...v0.35.5)
> 22 October 2020
- Added get_shared_photo_comments.py to examples [`15e0914`](https://github.com/RhetTbull/osxphotos/commit/15e0914af6301a945bc751173aef6718487d9637)
- Fix for issue #237 [`a416de2`](https://github.com/RhetTbull/osxphotos/commit/a416de29e4ac39a5c323f7913b05a8c38ad205be)
- Added test for issue #235 [`ea68229`](https://github.com/RhetTbull/osxphotos/commit/ea68229ddac2e2301ac2d5607451cf7d00207d5d)
#### [v0.35.4](https://github.com/RhetTbull/osxphotos/compare/v0.35.3...v0.35.4)
> 18 October 2020
- refactored template code to fix #213 [`#213`](https://github.com/RhetTbull/osxphotos/issues/213)
#### [v0.35.3](https://github.com/RhetTbull/osxphotos/compare/v0.35.2...v0.35.3)
> 15 October 2020
- Fix for issue #235, #236 [`41b2399`](https://github.com/RhetTbull/osxphotos/commit/41b23991df3d1d553b70889ede237f83b6874519)
#### [v0.35.2](https://github.com/RhetTbull/osxphotos/compare/v0.35.1...v0.35.2)
> 12 October 2020
- Fix for issue #234 [`da100f9`](https://github.com/RhetTbull/osxphotos/commit/da100f93a9b849ca4750336d7f90e9023e39dd07)
#### [v0.35.1](https://github.com/RhetTbull/osxphotos/compare/v0.35.0...v0.35.1)
> 12 October 2020

443
README.md
View File

@@ -1,8 +1,10 @@
# 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)
[![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos)
* [What is osxphotos?](#what-is-osxphotos)
@@ -21,6 +23,8 @@
+ [ScoreInfo](#scoreinfo)
+ [PersonInfo](#personinfo)
+ [FaceInfo](#faceinfo)
+ [CommentInfo](#commentinfo)
+ [LikeInfo](#likeinfo)
+ [Raw Photos](#raw-photos)
+ [Template Substitutions](#template-substitutions)
+ [Utility Functions](#utility-functions)
@@ -57,19 +61,24 @@ OSXPhotos uses setuptools, thus simply run:
You can also install directly from [pypi](https://pypi.org/project/osxphotos/):
pip install 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/). 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.
I recommend you create a [virtual environment](https://docs.python.org/3/tutorial/venv.html) before installing osxphotos.
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`
**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`
If you only care about the command line tool, you can download an executable of the latest [release](https://github.com/RhetTbull/osxphotos/releases). Alternatively, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
After installing pipx:
`pipx install osxphotos`
Then you should be able to run `osxphotos` on the command line:
After installing per instructions above, you should be able to run `osxphotos` on the command line:
```
> osxphotos
@@ -214,6 +223,13 @@ Options:
2000-01-12T12:00:00,
2001-01-12T12:00:00-07:00, or 2000-12-31
(ISO 8601).
--has-comment Search for photos that have comments.
--no-comment Search for photos with no comments.
--has-likes Search for photos that have likes.
--no-likes Search for photos with no likes.
--missing Export only photos missing from the Photos
library; must be used with --download-
missing.
--deleted Include photos from the 'Recently Deleted'
folder.
--deleted-only Include only photos from the 'Recently
@@ -221,7 +237,8 @@ Options:
--update Only export new or updated files. See notes
below on export and --update.
--dry-run Dry run (test) the export but don't actually
export any files; most useful with --verbose
export any files; most useful with
--verbose.
--export-as-hardlink Hardlink files instead of copying them.
Cannot be used with --exiftool which creates
copies of the files with embedded EXIF data.
@@ -316,6 +333,11 @@ Options:
exiftool may be installed from
https://exiftool.org/. Cannot be used with
--export-as-hardlink.
--ignore-date-modified If used with --exiftool or --sidecar, will
ignore the photo modification date and set
EXIF:ModifyDate to EXIF:DateTimeOriginal;
this is consistent with how Photos handles
the EXIF:ModifyDate tag.
--directory DIRECTORY Optional template for specifying name of
output directory in the form
'{name,DEFAULT}'. See below for additional
@@ -333,11 +355,30 @@ Options:
photo would be named
'photoname_bearbeiten.ext'. The default
suffix is '_edited'.
--original-suffix SUFFIX Optional suffix for naming original photos.
Default name for original photos is in form
'filename.ext'. For example, with '--
original-suffix _original', the original
photo would be named
'filename_original.ext'. The default suffix
is '' (no suffix).
--no-extended-attributes Don't copy extended attributes when
exporting. You only need this if exporting
to a filesystem that doesn't support Mac OS
extended attributes. Only use this if you
get an error while exporting.
--use-photos-export Force the use of AppleScript or PhotoKit to
export even if not missing (see also '--
download-missing' and '--use-photokit').
--use-photokit Use with '--download-missing' or '--use-
photos-export' to use direct Photos
interface instead of AppleScript to export.
Highly experimental alpha feature; does not
work with iTerm2 (use with Terminal.app).
This is faster and more reliable than the
default AppleScript interface.
--report REPORTNAME.CSV Write a CSV formatted report of all files
that were exported.
-h, --help Show this message and exit.
** Export **
@@ -370,16 +411,72 @@ option to re-export the entire library thus rebuilding the
** 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'.
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 ',' 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').
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':
'{keyword}' renders to 'foo' and 'bar'
'{,+keyword}' renders to: 'foo,bar'
'{; +keyword}' renders to: 'foo; bar'
'{+keyword}' renders to 'foobar'
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
the 'DEFAULT' value to override these in form:
'{media_type,video=vidéo;time_lapse=vidéo_accélérée}'. In this example, if
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 desitnation DEST
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. Some template substitutions may result in more than one value, for
example '{album}' if photo is in more than one album or '{keyword}' if photo
has more than one keyword. In this case, more than one copy of the photo will
be exported, each in a separate directory or with a different filename.
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
@@ -408,31 +505,48 @@ 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.
Substitution Description
{name} Current filename of the photo
{original_name} Photo's original filename when imported to
Photos
{title} Title of the photo
{descr} Description of the photo
{media_type} Special media type resolved in this
precedence: selfie, time_lapse, panorama,
slow_mo, screenshot, portrait, live_photo,
burst, photo, video. Defaults to 'photo' or
'video' if no special type. Customize one or
more media types using format: '{media_type,
video=vidéo;time_lapse=vidéo_accélérée}'
{photo_or_video} 'photo' or 'video' depending on what type
the image is. To customize, use default
value as in
'{photo_or_video,photo=fotos;video=videos}'
{hdr} Photo is HDR?; True/False value, use in
format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'
{created.date} Photo's creation date in ISO format, e.g.
'2020-03-22'
{created.year} 4-digit year of file creation time
{created.yy} 2-digit year of file creation time
{created.mm} 2-digit month of the file creation time
{created.year} 4-digit year of photo creation time
{created.yy} 2-digit year of photo creation time
{created.mm} 2-digit month of the photo creation time
(zero padded)
{created.month} Month name in user's locale of the file
{created.month} Month name in user's locale of the photo
creation time
{created.mon} Month abbreviation in the user's locale of
the file creation time
the photo creation time
{created.dd} 2-digit day of the month (zero padded) of
file creation time
{created.dow} Day of week in user's locale of the file
photo creation time
{created.dow} Day of week in user's locale of the photo
creation time
{created.doy} 3-digit day of year (e.g Julian day) of file
creation time, starting from 1 (zero padded)
{created.hour} 2-digit hour of the file creation time
{created.min} 2-digit minute of the file creation time
{created.sec} 2-digit second of the file creation time
{created.doy} 3-digit day of year (e.g Julian day) of
photo creation time, starting from 1 (zero
padded)
{created.hour} 2-digit hour of the photo creation time
{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
@@ -444,22 +558,26 @@ Substitution Description
templates.
{modified.date} Photo's modification date in ISO format,
e.g. '2020-03-22'
{modified.year} 4-digit year of file modification time
{modified.yy} 2-digit year of file modification time
{modified.mm} 2-digit month of the file modification time
{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 file
{modified.month} Month name in user's locale of the photo
modification time
{modified.mon} Month abbreviation in the user's locale of
the file modification time
the photo modification time
{modified.dd} 2-digit day of the month (zero padded) of
the file modification time
{modified.doy} 3-digit day of year (e.g Julian day) of file
modification time, starting from 1 (zero
padded)
{modified.hour} 2-digit hour of the file modification time
{modified.min} 2-digit minute of the file modification time
{modified.sec} 2-digit second of the file modification time
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
{today.date} Current date in iso format, e.g.
'2020-03-22'
{today.year} 4-digit year of current date
@@ -524,16 +642,26 @@ exported, one to each directory. For example: --directory
of the following directories if the photos were created in 2019 and were in
albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family
Substitution Description
{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)
Substitution Description
{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)
{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.
```
Example: export all photos to ~/Desktop/export group in folders by date created
@@ -692,7 +820,7 @@ osxphotos.PhotosDB(dbfile=path)
Reads the Photos library database and returns a PhotosDB object.
Pass the path to a Photos library or to a specific database file (e.g. "/Users/smith/Pictures/Photos Library.photoslibrary" or "/Users/smith/Pictures/Photos Library.photoslibrary/database/photos.db"). Normally, it's recommended you pass the path the .photoslibrary folder, not the actual database path. The latter option is provided for debugging -- e.g. for reading a database file if you don't have the entire library. Path to photos library may be passed **either** as first argument **or** as named argument `dbfile`. **Note**: In Photos, users may specify a different library to open by holding down the *option* key while opening Photos.app. See also [get_last_library_path](#get_last_library_path) and [get_system_library_path](#get_system_library_path)
Pass the path to a Photos library or to a specific database file (e.g. "/Users/smith/Pictures/Photos Library.photoslibrary" or "/Users/smith/Pictures/Photos Library.photoslibrary/database/photos.db"). Normally, it's recommended you pass the path the .photoslibrary folder, not the actual database path. **Note**: In Photos, users may specify a different library to open by holding down the *option* key while opening Photos.app. See also [get_last_library_path](#get_last_library_path) and [get_system_library_path](#get_system_library_path)
If an invalid path is passed, PhotosDB will raise `FileNotFoundError` exception.
@@ -1152,7 +1280,17 @@ Returns a [PlaceInfo](#PlaceInfo) object with reverse geolocation data or None i
#### `shared`
Returns True if photo is in a shared album, otherwise False.
**Note**: *Only valid on Photos 5 / MacOS 10.15*; on Photos <= 4, returns None instead of True/False.
**Note**: *Only valid on Photos 5 / MacOS 10.15+; on Photos <= 4, returns None instead of True/False.
#### `comments`
Returns list of [CommentInfo](#commentinfo) objects for comments on shared photos or empty list if no comments.
**Note**: *Only valid on Photos 5 / MacOS 10.15+; on Photos <= 4, returns empty list.
#### `likes`
Returns list of [LikeInfo](#likeinfo) objects for likes on shared photos or empty list if no likes.
**Note**: *Only valid on Photos 5 / MacOS 10.15+; on Photos <= 4, returns empty list.
#### `isphoto`
Returns True if type is photo/still image, otherwise False
@@ -1234,6 +1372,9 @@ Returns True if photo is a panorama, otherwise False.
**Note**: The result of `PhotoInfo.panorama` will differ from the "Panoramas" Media Types smart album in that it will also identify panorama photos from older phones that Photos does not recognize as panoramas.
#### `slow_mo`
Returns True if photo is a slow motion video, otherwise False
#### `labels`
Returns image categorization labels associated with the photo as list of str.
@@ -1276,7 +1417,8 @@ exiftool must be installed in the path for this to work. If exiftool cannot be
`ExifTool` provides the following methods:
- `as_dict()`: 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()`: 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.
```python
{'Composite:Aperture': 2.2,
'Composite:GPSPosition': '-34.9188916666667 138.596861111111',
@@ -1289,7 +1431,7 @@ exiftool must be installed in the path for this to work. If exiftool cannot be
}
```
- `json()`: returns same information as `as_dict()` but as a serialized JSON string.
- `json()`: returns same information as `asdict()` but as a serialized JSON string.
- `setvalue(tag, value)`: write to the EXIF data in the photo file. To delete a tag, use setvalue with value = `None`. For example:
```python
@@ -1300,7 +1442,7 @@ photo.exiftool.setvalue("XMP:Title", "Title of photo")
photo.exiftool.addvalues("IPTC:Keywords", "vacation", "beach")
```
**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.as_dict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`.
**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.asdict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`.
#### `score`
Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the computed aesthetic scores for each photo.
@@ -1308,7 +1450,10 @@ Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the
**Note**: Valid only for Photos 5; returns None for earlier Photos versions.
#### `json()`
Returns a JSON representation of all photo info
Returns a JSON representation of all photo info.
#### `asdict()`
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, no_xattr=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
@@ -1352,24 +1497,79 @@ 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)`
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template_str`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None, filename=False, dirname=False, replacement=":",)`
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template_str`: str in format "{[[DELIM]+]name[(PATH_SEP)][?TRUE_VALUE][,[DEFAULT]]}" where name is one of the values in the [Template Substitutions](#template-substitutions) table. See notes below regarding specific details of the syntax.
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
- `path_sep`: optional character to use as path separator, default is os.path.sep
- `path_sep`: optional character to use as path separator, default is `os.path.sep`
- `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 = ":"
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"].
e.g. `render_filepath_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
e.g. `render_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
If you want to include "{" or "}" in the output, use "{{" or "}}"
e.g. `render_filepath_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
e.g. `render_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
The template field format contains optional modifiers:
`"{[[DELIM]+]name[(PATH_SEP)][?TRUE_VALUE][,[DEFAULT]]}"`
`DELIM`: optional delimiter string to use when expanding multi-valued template values in-place
`+`: If present before template `name`, expands the template in place. If `DELIM` not provided, values are joined with no delimiter.
e.g. if Photo keywords are `["foo","bar"]`:
- `"{keyword}"` renders to `["foo", "bar"]`
- `"{,+keyword}"` renders to: `["foo,bar"]`
- `"{; +keyword}"` renders to: `["foo; bar"]`
- `"{+keyword}"` renders to `["foobar"]`
`PATH_SEP`: optional path separator to use when joining path like fields, for example `{folder_album}`. May also be provided as `path_sep` argument in `render_template()`. If provided both in the call to `render_template()` and in the template itself, the value in the template string takes precedence. If not provided in either the template string or in `path_sep` argument, defaults to `os.path.sep`.
e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album}"` renders to `["Folder1/Album1"]`
- `"{folder_album(:)}"` renders to `["Folder1:Album1"]`
- `"{folder_album()}"` renders to `["Folder1Album1"]`
`?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,
- `"{hdr?ISHDR,NOTHDR}"` renders to `["ISHDR"]`
and if it is not an HDR image,
- `"{hdr?ISHDR,NOTHDR}"` renders to `["NOTHDR"]`
Either or both `TRUE_VALUE` or `DEFAULT` (False value) may be empty which would result in empty string `[""]` when rendered.
`,DEFAULT`: optional default value to use if the template name has no value. This modifier is also used for the value if False for boolean-type fields (see above) as well as to hold a sub-template for values like `{created.strftime}`. If no default value provided, "_" is used. May also be provided in the `none_str` argument to `render_template()`. If provided both in the template string and in `none_str`, the value in the template string takes precedence.
e.g., if photo has no title set,
- `"{title}"` renders to ["_"]
- `"{title,I have no title}"` renders to `["I have no title"]`
Template fields such as `created.strftime` use the DEFAULT value to pass the template to use for `strftime`.
e.g., if photo date is 4 February 2020, 19:07:38,
- `"{created.strftime,%Y-%m-%d-%H%M%S}"` renders to `["2020-02-04-190738"]`
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 the `DEFAULT` value to override these in form: `"{media_type,video=vidéo;time_lapse=vidéo_accélérée}"`. In this example, if photo was a time_lapse photo, `media_type` would resolve to `vidéo_accélérée` instead of `time_lapse`.
See [Template Substitutions](#template-substitutions) for additional details.
### ExifInfo
@@ -1487,6 +1687,11 @@ Returns the title or name of the folder.
#### `album_info`
Returns a list of [AlbumInfo](#AlbumInfo) objects representing each album contained in the folder.
#### `album_info_shared`
Returns a list of [AlbumInfo](#AlbumInfo) objects for each shared album in the photos database.
**Note**: Only valid for Photos 5+; on Photos <= 4, prints warning and returns empty list.
#### `subfolders`
Returns a list of [FolderInfo](#FolderInfo) objects representing the sub-folders of the folder.
@@ -1648,6 +1853,9 @@ Returns a list of [FaceInfo](#faceinfo) objects associated with this person sort
#### `json()`
Returns a json string representation of the PersonInfo instance.
#### `asdict()`
Returns a dictionary representation of the PersonInfo instance.
### FaceInfo
[PhotoInfo.face_info](#photofaceinfo) return a list of FaceInfo objects representing detected faces in a photo. The FaceInfo class has the following properties and methods.
@@ -1733,6 +1941,21 @@ Returns a dictionary representation of the FaceInfo instance.
#### `json()`
Returns a JSON representation of the FaceInfo instance.
### CommentInfo
[PhotoInfo.comments](#comments) returns a list of CommentInfo objects for comments on shared photos. (Photos 5/MacOS 10.15+ only). The list of CommentInfo objects will be sorted in ascending order by date comment was made. CommentInfo contains the following fields:
- `datetime`: `datetime.datetime`, date/time comment was made
- `user`: `str`, name of user who made the comment
- `ismine`: `bool`, True if comment was made by person who owns the Photos library being operated on
- `text`: `str`, text of the actual comment
### LikeInfo
[PhotoInfo.likes](#likes) returns a list of LikeInfo objects for "likes" on shared photos. (Photos 5/MacOS 10.15+ only). The list of LikeInfo objects will be sorted in ascending order by date like was made. LikeInfo contains the following fields:
- `datetime`: `datetime.datetime`, date/time like was made
- `user`: `str`, name of user who made the like
- `ismine`: `bool`, True if like was made by person who owns the Photos library being operated on
### Raw Photos
Handling raw photos in `osxphotos` requires a bit of extra work. Raw photos in Photos can be imported in two different ways: 1) a single raw photo with no associated JPEG image is imported 2) a raw+JPEG pair is imported -- two separate images with same file stem (e.g. `IMG_0001.CR2` and `IMG_001.JPG`) are imported.
@@ -1767,37 +1990,42 @@ To get the path of every raw photo, whether it's a single raw photo or a raw+JPE
### Template Substitutions
The following substitutions are availabe for use with `PhotoInfo.render_template()`
The following template field substitutions are availabe for use with `PhotoInfo.render_template()`
| Substitution | Description |
|--------------|-------------|
|{name}|Current filename of the photo|
|{original_name}|Photo's original filename when imported to Photos|
|{title}|Title of the photo|
|{descr}|Description of the photo|
|{media_type}|Special media type resolved in this precedence: selfie, time_lapse, panorama, slow_mo, screenshot, portrait, live_photo, burst, photo, video. Defaults to 'photo' or 'video' if no special type. Customize one or more media types using format: '{media_type,video=vidéo;time_lapse=vidéo_accélérée}'|
|{photo_or_video}|'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'|
|{hdr}|Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'|
|{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'|
|{created.year}|4-digit year of file creation time|
|{created.yy}|2-digit year of file creation time|
|{created.mm}|2-digit month of the file creation time (zero padded)|
|{created.month}|Month name in user's locale of the file creation time|
|{created.mon}|Month abbreviation in the user's locale of the file creation time|
|{created.dd}|2-digit day of the month (zero padded) of file creation time|
|{created.dow}|Day of week in user's locale of the file creation time|
|{created.doy}|3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)|
|{created.hour}|2-digit hour of the file creation time|
|{created.min}|2-digit minute of the file creation time|
|{created.sec}|2-digit second of the file creation time|
|{created.year}|4-digit year of photo creation time|
|{created.yy}|2-digit year of photo creation time|
|{created.mm}|2-digit month of the photo creation time (zero padded)|
|{created.month}|Month name in user's locale of the photo creation time|
|{created.mon}|Month abbreviation in the user's locale of the photo creation time|
|{created.dd}|2-digit day of the month (zero padded) of photo creation time|
|{created.dow}|Day of week in user's locale of the photo creation time|
|{created.doy}|3-digit day of year (e.g Julian day) of photo creation time, starting from 1 (zero padded)|
|{created.hour}|2-digit hour of the photo creation time|
|{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 file modification time|
|{modified.yy}|2-digit year of file modification time|
|{modified.mm}|2-digit month of the file modification time (zero padded)|
|{modified.month}|Month name in user's locale of the file modification time|
|{modified.mon}|Month abbreviation in the user's locale of the file modification time|
|{modified.dd}|2-digit day of the month (zero padded) of the file modification time|
|{modified.doy}|3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)|
|{modified.hour}|2-digit hour of the file modification time|
|{modified.min}|2-digit minute of the file modification time|
|{modified.sec}|2-digit second of the file modification time|
|{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|
|{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|
@@ -1830,6 +2058,8 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|{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)|
|{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.|
### Utility Functions
@@ -1912,6 +2142,7 @@ if __name__ == "__main__":
- [rhettbull/photosmeta](https://github.com/rhettbull/photosmeta): uses osxphotos and [exiftool](https://exiftool.org/) to apply metadata from Photos as exif data in the photo files. Can also export photos while preserving metadata and also apply Photos keywords as spotlight tags to make it easier to search for photos using spotlight. This is mostly made obsolete by osxphotos. The one feature that photosmeta has that osxphotos does not is ability to update the metadata of the actual photo files in the Photos library without exporting them. (Use with caution!)
- [rhettbull/PhotoScript](https://github.com/RhetTbull/PhotoScript): python wrapper around Photos' applescript API allowing automation of Photos (including creation/deletion of items) from python.
- [patrikhson/photo-export](https://github.com/patrikhson/photo-export): Exports older versions of Photos databases. Provided the inspiration for osxphotos.
- [doersino/apple-photos-export](https://github.com/doersino/apple-photos-export): Photos export script for Mojave.
- [orangeturtle739/photos-export](https://github.com/orangeturtle739/photos-export): Set of scripts to export Photos libraries.
- [ndbroadbent/icloud_photos_downloader](https://github.com/ndbroadbent/icloud_photos_downloader): Download photos from iCloud. Currently unmaintained.
- [AaronVanGeffen/ExportPhotosLibrary](https://github.com/AaronVanGeffen/ExportPhotosLibrary): Another python script for exporting older versions of Photos libraries.
@@ -1928,21 +2159,38 @@ If you have an interesting example that shows usage of this package, submit an i
Testing against "real world" Photos libraries would be especially helpful. If you discover issues in testing against your Photos libraries, please open an issue. I've done extensive testing against my own Photos library but that's a since data point and I'm certain there are issues lurking in various edge cases I haven't discovered yet.
### Contributors
Thank-you to the following people who have contributed to improving osxphotos! If I've inadvertently left you off, please open an issue or send me a note.
### Contributors ✨
- [britiscurious](https://github.com/britiscurious)
- [Michel Wortmann](https://github.com/mwort)
- [hshore29](https://github.com/hshore29)
- [Pablo 'merKur' Kohan](https://github.com/PabloKohan)
- [Jean-Yves Stervinou](https://github.com/jystervinou)
- [Thibault Deutsch](https://github.com/dethi)
- [grundsch](https://github.com/grundsch)
- [Ag Primatic](https://github.com/agprimatic)
- [Daniel M. Drucker](https://github.com/dmd)
- [Horst Höck](https://github.com/hhoeck)
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- 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>
</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>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
## Known Bugs
@@ -1975,5 +2223,4 @@ For additional details about how osxphotos is implemented or if you would like t
## Acknowledgements
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.

View File

@@ -0,0 +1,156 @@
""" get shared comments associated with a photo """
import datetime
import sys
from dataclasses import dataclass
import osxphotos
from osxphotos._constants import TIME_DELTA
@dataclass
class Comment:
""" Class for shared photo comments """
uuid: str
sort_fok: int
datetime: datetime.datetime
user: str
ismine: bool
text: str
@dataclass
class Like:
""" Class for shared photo likes """
uuid: str
sort_fok: int
datetime: datetime.datetime
user: str
ismine: bool
def get_shared_person_info(photosdb, hashed_person_id):
""" returns tuple of (first name, last name, full name)
for person invited to shared album with
ZINVITEEHASHEDPERSONID = hashed_person_id
Args:
photosdb: a osxphotos.PhotosDB object
hashed_person_id: str, value of ZINVITEEHASHEDPERSONID to lookup
"""
conn, _ = photosdb.get_db_connection()
results = conn.execute(
"""
SELECT
ZINVITEEHASHEDPERSONID,
ZINVITEEFIRSTNAME,
ZINVITEELASTNAME,
ZINVITEEFULLNAME
FROM
ZCLOUDSHAREDALBUMINVITATIONRECORD
WHERE
ZINVITEEHASHEDPERSONID = ?
LIMIT 1
""",
([hashed_person_id]),
).fetchall()
if results:
row = results[0]
return (row[1], row[2], row[3])
else:
return (None, None, None)
def get_comments(photosdb, uuid):
""" return comments and likes, if any, for photo with uuid
Args:
photosdb: a osxphotos.PhotosDB object
uuid: uuid of the photo
Returns:
tuple of (list of comments as Comment objects or [] if no comments, list of likes as Like objects or [] if no likes)
"""
conn, _ = photosdb.get_db_connection()
results = conn.execute(
"""
SELECT
ZGENERICASSET.ZUUID, --0: UUID of the photo
ZCLOUDSHAREDCOMMENT.ZISLIKE, --1: comment is actually a "like"
ZCLOUDSHAREDCOMMENT.Z_FOK_COMMENTEDASSET, --2: sort order for comments on a photo
ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, --3: date of comment
ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, --4: text of comment
ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, --5: hashed ID of person who made comment/like
ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT --6: is my (this user's) comment
FROM ZCLOUDSHAREDCOMMENT
JOIN ZGENERICASSET ON
ZGENERICASSET.Z_PK = ZCLOUDSHAREDCOMMENT.ZCOMMENTEDASSET
OR
ZGENERICASSET.Z_PK = ZCLOUDSHAREDCOMMENT.ZLIKEDASSET
WHERE ZGENERICASSET.ZUUID = ?
""",
([uuid]),
).fetchall()
comments = []
likes = []
for row in results:
photo_uuid = row[0]
sort_fok = row[2] or 0 # sort_fok is Null/None for likes
is_like = bool(row[1])
text = row[4]
user_info = get_shared_person_info(photosdb, row[5])
try:
dt = datetime.datetime.fromtimestamp(row[3] + TIME_DELTA)
except:
dt = datetime.datetime(1970, 1, 1)
ismine = bool(row[6])
if is_like:
# it's a like
likes.append(Like(photo_uuid, sort_fok, dt, user_info[2], ismine))
elif text:
# comment
comments.append(
Comment(photo_uuid, sort_fok, dt, user_info[2], ismine, text)
)
if likes:
likes.sort(key=lambda x: x.datetime)
if comments:
comments.sort(key=lambda x: x.sort_fok)
return (comments, likes)
def main():
if len(sys.argv) > 1:
# library as first argument
photosdb = osxphotos.PhotosDB(dbfile=sys.argv[1])
else:
# open default library
photosdb = osxphotos.PhotosDB()
# shared albums
shared_albums = photosdb.album_info_shared
for album in shared_albums:
print(f"Processing album {album.title}")
# only shared albums can have comments
for photo in album.photos:
comments, likes = get_comments(photosdb, photo.uuid)
if comments or likes:
print(f"{photo.uuid}, {photo.original_filename}: ")
if likes:
print("Likes:")
for like in likes:
print(like)
if comments:
print("Comments:")
for comment in comments:
print(comment)
if __name__ == "__main__":
main()

View File

@@ -42,7 +42,7 @@ def main():
if db:
print("loading database")
tic = time.perf_counter()
photosdb = osxphotos.PhotosDB(dbfile=db)
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=print)
toc = time.perf_counter()
print(f"done: took {toc-tic} seconds")
return photosdb

View File

@@ -5,4 +5,4 @@
# If you need to install pyinstaller:
# python3 -m pip install --upgrade pyinstaller
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py
pyinstaller osxphotos.spec

48
osxphotos.spec Normal file
View File

@@ -0,0 +1,48 @@
# -*- mode: python ; coding: utf-8 -*-
# spec file for pyinstaller
# run `pyinstaller osxphotos.spec`
import os
import importlib
pathex = os.getcwd()
# include necessary data files
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates')]
package_imports = [['photoscript', ['photoscript.applescript']]]
for package, files in package_imports:
proot = os.path.dirname(importlib.import_module(package).__file__)
datas.extend((os.path.join(proot, f), package) for f in files)
block_cipher = None
a = Analysis(['cli.py'],
pathex=[pathex],
binaries=[],
datas=datas,
hiddenimports=['pkg_resources.py2_warn'],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='osxphotos',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True )

View File

@@ -1,8 +1,7 @@
import logging
from ._version import __version__
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
from .phototemplate import PhotoTemplate
from .utils import _debug, _get_logger, _set_debug

File diff suppressed because it is too large Load Diff

View File

@@ -102,3 +102,10 @@ _OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
SEARCH_CATEGORY_LABEL = 2024
# Max filename length on MacOS
MAX_FILENAME_LEN = 255
# Max directory name length on MacOS
MAX_DIRNAME_LEN = 255

View File

@@ -1,4 +1,4 @@
""" version info """
__version__ = "0.35.3"
__version__ = "0.37.3"

View File

@@ -2,6 +2,7 @@
I rolled my own for following reasons:
1. I wanted something under MIT license (best alternative was licensed under GPL/BSD)
2. I wanted singleton behavior so only a single exiftool process was ever running
3. When used as a context manager, I wanted the operations to batch until exiting the context (improved performance)
If these aren't important to you, I highly recommend you use Sven Marnach's excellent
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
@@ -10,10 +11,8 @@ import logging
import os
import shutil
import subprocess
import sys
from functools import lru_cache # pylint: disable=syntax-error
from .utils import _debug
# exiftool -stay_open commands outputs this EOF marker after command is run
EXIFTOOL_STAYOPEN_EOF = "{ready}"
@@ -23,9 +22,7 @@ EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
@lru_cache(maxsize=1)
def get_exiftool_path():
""" return path of exiftool, cache result """
exiftool_path = shutil.which('exiftool')
if _debug():
logging.debug("exiftool path = %s" % (exiftool_path))
exiftool_path = shutil.which("exiftool")
if exiftool_path:
return exiftool_path.rstrip()
else:
@@ -59,7 +56,7 @@ class _ExifToolProc:
)
return
self._exiftool = exiftool if exiftool else get_exiftool_path()
self._exiftool = exiftool or get_exiftool_path()
self._process_running = False
self._start_proc()
@@ -98,12 +95,12 @@ class _ExifToolProc:
"-", # read from stdin
"-common_args", # specifies args common to all commands subsequently run
"-n", # no print conversion (e.g. print tag values in machine readable format)
"-P", # Preserve file modification date/time (possible interfere w/ --touch-file)
"-P", # Preserve file modification date/time
"-G", # print group name for each tag
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
)
self._process_running = True
@@ -136,38 +133,72 @@ class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """
def __init__(self, filepath, exiftool=None, overwrite=True):
""" Return ExifTool object
file: path to image file
exiftool: path to exiftool, if not specified will look in path
overwrite: if True, will overwrite image file without creating backup, default=False """
""" Create ExifTool object
Args:
file: path to image file
exiftool: path to exiftool, if not specified will look in path
overwrite: if True, will overwrite image file without creating backup, default=False
Returns:
ExifTool instance
"""
self.file = filepath
self.overwrite = overwrite
self.data = {}
self.error = None
# if running as a context manager, self._context_mgr will be True
self._context_mgr = False
self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
self._process = self._exiftoolproc.process
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 called in context manager, returns True (execution is delayed until exiting context manager)
"""
if value is None:
value = ""
command = [f"-{tag}={value}"]
if self.overwrite:
if self.overwrite and not self._context_mgr:
command.append("-overwrite_original")
self.run_commands(*command)
if self._context_mgr:
self._commands.extend(command)
return True
else:
_, self.error = self.run_commands(*command)
return self.error is None
def addvalues(self, tag, *values):
""" Add one or more value(s) to tag
If more than one value is passed, each value will be added to the tag
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,
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,
but if addvalues called without first calling setvalue, exiftool will add duplicate values
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 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,
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,
but if addvalues called without first calling setvalue, exiftool will add duplicate values
"""
if not values:
raise ValueError("Must pass at least one value")
@@ -178,23 +209,41 @@ class ExifTool:
raise ValueError("Can't add None value to tag")
command.append(f"-{tag}+={value}")
if self.overwrite:
if self.overwrite and not self._context_mgr:
command.append("-overwrite_original")
if command:
self.run_commands(*command)
if self._context_mgr:
self._commands.extend(command)
return True
else:
_, self.error = self.run_commands(*command)
return self.error is None
def run_commands(self, *commands, no_file=False):
""" run commands in the exiftool process and return result
no_file: (bool) do not pass the filename to exiftool (default=False)
by default, all commands will be run against self.file
use no_file=True to run a command without passing the filename """
""" Run commands in the exiftool process and return result.
Args:
*commands: exiftool commands to run
no_file: (bool) do not pass the filename to exiftool (default=False)
by default, all commands will be run against self.file
use no_file=True to run a command without passing the filename
Returns:
(output, errror)
output: bytes is containing output of exiftool commands
error: if exiftool generated an error, bytes containing error string otherwise None
Note: Also sets self.error if error generated.
"""
if not (hasattr(self, "_process") and self._process):
raise ValueError("exiftool process is not running")
if not commands:
raise TypeError("must provide one or more command to run")
if self._context_mgr and self.overwrite:
commands = list(commands)
commands.append("-overwrite_original")
filename = os.fsencode(self.file) if not no_file else b""
command_str = (
b"\n".join([c.encode("utf-8") for c in commands])
@@ -204,18 +253,22 @@ class ExifTool:
+ b"-execute\n"
)
if _debug():
logging.debug(command_str)
# send the command
self._process.stdin.write(command_str)
self._process.stdin.flush()
# read the output
output = b""
error = b""
while EXIFTOOL_STAYOPEN_EOF not in str(output):
output += self._process.stdout.readline().strip()
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
line = self._process.stdout.readline()
if line.startswith(b"Warning"):
error += line
else:
output += line.strip()
error = None if error == b"" else error
self.error = error
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], error
@property
def pid(self):
@@ -225,14 +278,14 @@ class ExifTool:
@property
def version(self):
""" returns exiftool version """
ver = self.run_commands("-ver", no_file=True)
ver, _ = self.run_commands("-ver", no_file=True)
return ver.decode("utf-8")
def as_dict(self):
def asdict(self):
""" return dictionary of all EXIF tags and values from exiftool
returns empty dict if no tags
"""
json_str = self.run_commands("-json")
json_str, _ = self.run_commands("-json")
if json_str:
exifdict = json.loads(json_str)
return exifdict[0]
@@ -241,12 +294,24 @@ class ExifTool:
def json(self):
""" returns JSON string containing all EXIF tags and values from exiftool """
return self.run_commands("-json")
json, _ = self.run_commands("-json")
return json
def _read_exif(self):
""" read exif data from file """
data = self.as_dict()
data = self.asdict()
self.data = {k: v for k, v in data.items()}
def __str__(self):
return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
def __enter__(self):
self._context_mgr = True
self._commands = []
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type:
return False
elif self._commands:
_, self.error = self.run_commands(*self._commands)

View File

@@ -14,7 +14,7 @@ from sqlite3 import Error
from ._version import __version__
OSXPHOTOS_EXPORTDB_VERSION = "2.0"
OSXPHOTOS_EXPORTDB_VERSION = "3.2"
class ExportDB_ABC(ABC):
@@ -76,6 +76,14 @@ class ExportDB_ABC(ABC):
def set_exifdata_for_file(self, uuid, exifdata):
pass
@abstractmethod
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
pass
@abstractmethod
def get_sidecar_for_file(self, filename):
pass
@abstractmethod
def set_data(
self,
@@ -141,6 +149,12 @@ class ExportDBNoOp(ExportDB_ABC):
def set_exifdata_for_file(self, uuid, exifdata):
pass
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
pass
def get_sidecar_for_file(self, filename):
return None, (None, None, None)
def set_data(
self,
filename,
@@ -379,6 +393,48 @@ class ExportDB(ExportDB_ABC):
except Error as e:
logging.warning(e)
def get_sidecar_for_file(self, filename):
""" returns the sidecar data and signature for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"SELECT sidecar_data, mode, size, mtime FROM sidecar WHERE filepath_normalized = ?",
(filename,),
)
results = c.fetchone()
if results:
sidecar_data = results[0]
sidecar_sig = (
results[1],
results[2],
int(results[3]) if results[3] is not None else None,
)
else:
sidecar_data = None
sidecar_sig = (None, None, None)
except Error as e:
logging.warning(e)
sidecar_data = None
sidecar_sig = (None, None, None)
return sidecar_data, sidecar_sig
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
""" sets the sidecar data and signature for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO sidecar(filepath_normalized, sidecar_data, mode, size, mtime) VALUES (?, ?, ?, ?, ?);",
(filename, sidecar_data, *sidecar_sig),
)
conn.commit()
except Error as e:
logging.warning(e)
def set_data(
self,
filename,
@@ -479,13 +535,11 @@ class ExportDB(ExportDB_ABC):
if not os.path.isfile(dbfile):
conn = self._get_db_connection(dbfile)
if conn:
self._create_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION
else:
if not conn:
raise Exception("Error getting connection to database {dbfile}")
self._create_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
else:
conn = self._get_db_connection(dbfile)
self.was_created = False
@@ -495,8 +549,7 @@ class ExportDB(ExportDB_ABC):
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
else:
self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION
self.version = OSXPHOTOS_EXPORTDB_VERSION
return conn
def _get_db_connection(self, dbfile):
@@ -570,11 +623,20 @@ class ExportDB(ExportDB_ABC):
size INTEGER,
mtime REAL
); """,
"sql_sidecar_table": """ CREATE TABLE IF NOT EXISTS sidecar (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
sidecar_data TEXT,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
"sql_files_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
"sql_info_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
"sql_exifdata_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
"sql_edited_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""",
"sql_converted_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""",
"sql_sidecar_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
}
try:
c = conn.cursor()

View File

@@ -1,6 +1,5 @@
""" FileUtil class with methods for copy, hardlink, unlink, etc. """
import logging
import os
import pathlib
import stat
@@ -74,7 +73,6 @@ class FileUtilMacOS(FileUtilABC):
try:
os.link(src, dest)
except Exception as e:
logging.critical(f"os.link returned error: {e}")
raise e
@classmethod
@@ -92,7 +90,7 @@ class FileUtilMacOS(FileUtilABC):
if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest)
if not os.path.isfile(src):
if not os.path.exists(src):
raise FileNotFoundError("src file does not appear to exist", src)
if norsrc:
@@ -104,9 +102,6 @@ class FileUtilMacOS(FileUtilABC):
try:
result = subprocess.run(command, check=True, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
logging.critical(
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
)
raise e
return result.returncode

79
osxphotos/path_utils.py Normal file
View File

@@ -0,0 +1,79 @@
""" utility functions for validating/sanitizing path components """
import pathvalidate
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
def sanitize_filepath(filepath):
""" sanitize a filepath """
return pathvalidate.sanitize_filepath(filepath, platform="macos")
def is_valid_filepath(filepath):
""" returns True if a filepath is valid otherwise False """
return pathvalidate.is_valid_filepath(filepath, platform="macos")
def sanitize_filename(filename, replacement=":"):
""" replace any illegal characters in a filename and truncate filename if needed
Args:
filename: str, filename to sanitze
replacement: str, value to replace any illegal characters with; default = ":"
Returns:
filename with any illegal characters replaced by replacement and truncated if necessary
"""
if filename:
filename = filename.replace("/", replacement)
if len(filename) > MAX_FILENAME_LEN:
parts = filename.split(".")
drop = len(filename) - MAX_FILENAME_LEN
if len(parts) > 1:
# has an extension
ext = parts.pop(-1)
stem = ".".join(parts)
if drop > len(stem):
ext = ext[:-drop]
else:
stem = stem[:-drop]
filename = f"{stem}.{ext}"
else:
filename = filename[:-drop]
return filename
def sanitize_dirname(dirname, replacement=":"):
""" replace any illegal characters in a directory name and truncate directory name if needed
Args:
dirname: str, directory name to sanitze
replacement: str, value to replace any illegal characters with; default = ":"
Returns:
dirname with any illegal characters replaced by replacement and truncated if necessary
"""
if dirname:
dirname = sanitize_pathpart(dirname, replacement=replacement)
return dirname
def sanitize_pathpart(pathpart, replacement=":"):
""" replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
Args:
pathpart: str, path part to sanitze
replacement: str, value to replace any illegal characters with; default = ":"
Returns:
pathpart with any illegal characters replaced by replacement and truncated if necessary
"""
if pathpart:
pathpart = pathpart.replace("/", replacement)
if len(pathpart) > MAX_DIRNAME_LEN:
drop = len(pathpart) - MAX_DIRNAME_LEN
pathpart = pathpart[:-drop]
return pathpart

View File

@@ -66,10 +66,10 @@ class PersonInfo:
# no faces
return []
def json(self):
""" Returns JSON representation of class instance """
def asdict(self):
""" Returns dictionary representation of class instance """
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
person = {
return {
"uuid": self.uuid,
"name": self.name,
"displayname": self.display_name,
@@ -77,7 +77,10 @@ class PersonInfo:
"facecount": self.facecount,
"keyphoto": keyphoto,
}
return json.dumps(person)
def json(self):
""" Returns JSON representation of class instance """
return json.dumps(self.asdict())
def __str__(self):
return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})"

View File

@@ -0,0 +1,17 @@
""" PhotoInfo methods to expose comments and likes for shared photos """
@property
def comments(self):
""" Returns list of Comment objects for any comments on the photo (sorted by date) """
try:
return self._db._db_comments_uuid[self.uuid]["comments"]
except:
return []
@property
def likes(self):
""" Returns list of Like objects for any likes on the photo (sorted by date) """
try:
return self._db._db_comments_uuid[self.uuid]["likes"]
except:
return []

View File

@@ -5,6 +5,7 @@
_export_photo
_write_exif_data
_exiftool_json_sidecar
_exiftool_dict
_xmp_sidecar
_write_sidecar
"""
@@ -12,6 +13,7 @@
# TODO: should this be its own PhotoExporter class?
import glob
import hashlib
import json
import logging
import os
@@ -20,9 +22,10 @@ import re
import tempfile
from collections import namedtuple # pylint: disable=syntax-error
import photoscript
from mako.template import Template
from .._applescript import AppleScript
# from .._applescript import AppleScript
from .._constants import (
_MAX_IPTC_KEYWORD_LEN,
_OSXPHOTOS_NONE_SENTINEL,
@@ -30,17 +33,43 @@ from .._constants import (
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
)
from ..export_db import ExportDBNoOp
from ..exiftool import ExifTool
from ..export_db import ExportDBNoOp
from ..fileutil import FileUtil
from ..utils import dd_to_dms_str, findfiles
from ..photokit import (
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PhotoLibrary,
PhotoKitFetchFailed,
)
from ..utils import dd_to_dms_str, findfiles, noop
ExportResults = namedtuple(
"ExportResults",
["exported", "new", "updated", "skipped", "exif_updated", "touched"],
[
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
],
)
# hexdigest is not a class method, don't import this into PhotoInfo
def hexdigest(strval):
""" hexdigest of a string, using blake2b """
h = hashlib.blake2b(digest_size=20)
h.update(bytes(strval, "utf-8"))
return h.hexdigest()
# _export_photo_uuid_applescript is not a class method, don't import this into PhotoInfo
def _export_photo_uuid_applescript(
uuid,
@@ -77,33 +106,33 @@ def _export_photo_uuid_applescript(
"""
# 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
# 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}
end export_by_uuid
"""
)
# 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():
@@ -114,32 +143,36 @@ def _export_photo_uuid_applescript(
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
# export original
exported_files = []
filename = None
try:
filename = export_scpt.call(
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout
)
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
if filename is not None:
if exported_files and filename:
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
# TemporaryDirectory will cleanup on return
filename_stem = pathlib.Path(filename).stem
files = glob.glob(os.path.join(tmpdir.name, "*"))
exported_paths = []
for fname in files:
path = pathlib.Path(fname)
if len(files) > 1 and not live_photo and path.suffix.lower() == ".mov":
for fname in exported_files:
path = pathlib.Path(tmpdir.name) / fname
if (
len(exported_files) > 1
and not live_photo
and path.suffix.lower() == ".mov"
):
# it's the .mov part of live photo but not requested, so don't export
logging.debug(f"Skipping live photo file {path}")
continue
if len(files) > 1 and burst and path.stem != filename_stem:
if len(exported_files) > 1 and burst and path.stem != filename_stem:
# skip any burst photo that's not the one we asked for
logging.debug(f"Skipping burst photo file {path}")
continue
if filestem:
# rename the file based on filestem, keeping original extension
@@ -147,7 +180,6 @@ def _export_photo_uuid_applescript(
else:
# use the name Photos provided
dest_new = dest / path.name
logging.debug(f"exporting {path} to dest_new: {dest_new}")
if not dry_run:
FileUtil.copy(str(path), str(dest_new))
exported_paths.append(str(dest_new))
@@ -308,6 +340,9 @@ def export2(
touch_file=False,
convert_to_jpeg=False,
jpeg_quality=1.0,
ignore_date_modified=False,
use_photokit=False,
verbose=None,
):
""" export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -350,6 +385,8 @@ def export2(
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
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.
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths
@@ -366,6 +403,12 @@ def export2(
if export_db is None:
export_db = ExportDBNoOp()
if verbose is None:
verbose = noop
elif not callable(verbose):
raise TypeError("verbose must be callable")
self._verbose = verbose
# suffix to add to edited files
# e.g. name will be filename_edited.jpg
edited_identifier = "_edited"
@@ -385,6 +428,9 @@ def export2(
# 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:
@@ -471,11 +517,6 @@ def export2(
f"Cannot export edited photo if path_edited is None"
)
else:
if self.ismissing:
logging.debug(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is not None:
src = self.path
else:
@@ -484,32 +525,22 @@ def export2(
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
if not _check_export_suffix(src, dest, edited):
logging.debug(
f"Invalid destination suffix: {dest.suffix} for {self.path}, "
+ f"edited={edited}, path_edited={self.path_edited}, "
+ f"original_filename={self.original_filename}, filename={self.filename}"
)
# found source now try to find right destination
if update and dest.exists():
# destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted
logging.debug(
f"Found matching file with blank uuid: {self.uuid}, {dest}"
)
dest_uuid = self.uuid
export_db.set_data(
dest,
self.uuid,
fileutil.file_sig(dest),
(None, None, None),
(None, None, None),
(None, None, None),
self.json(),
None,
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
if dest_uuid != self.uuid:
# not the right file, find the right one
@@ -528,14 +559,14 @@ def export2(
dest = pathlib.Path(file_)
found_match = True
export_db.set_data(
dest,
self.uuid,
fileutil.file_sig(dest),
(None, None, None),
(None, None, None),
(None, None, None),
self.json(),
None,
filename=dest,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest),
exif_stat=(None, None, None),
converted_stat=(None, None, None),
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
break
@@ -572,6 +603,7 @@ def export2(
update_updated_files = results.updated
update_skipped_files = results.skipped
touched_files = results.touched
converted_to_jpeg_files = results.converted_to_jpeg
# copy live photo associated .mov if requested
if live_photo and self.live_photo:
@@ -579,9 +611,6 @@ def export2(
src_live = self.path_live_photo
if src_live is not None:
logging.debug(
f"Exporting live photo video of {filename} as {live_name.name}"
)
results = self._export_photo(
src_live,
live_name,
@@ -600,8 +629,7 @@ def export2(
update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched)
else:
logging.debug(f"Skipping missing live movie for {filename}")
converted_to_jpeg_files.extend(results.converted_to_jpeg)
# copy associated RAW image if requested
if raw_photo and self.has_raw:
@@ -609,7 +637,6 @@ def export2(
raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}"
if raw_path is not None:
logging.debug(f"Exporting RAW photo of {filename} as {raw_name.name}")
results = self._export_photo(
raw_path,
raw_name,
@@ -629,15 +656,17 @@ def export2(
update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched)
else:
logging.debug(f"Skipping missing RAW photo for {filename}")
converted_to_jpeg_files.extend(results.converted_to_jpeg)
else:
# use_photo_export
exported = []
# export live_photo .mov file?
live_photo = True if live_photo and self.live_photo else False
if edited:
if edited or self.shared:
# exported edited version and not original
# shared photos (in shared albums) show up as not having adjustments (not edited)
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
# so tell Photos to export the current version in this case
if filename:
# use filename stem provided
filestem = dest.stem
@@ -645,33 +674,73 @@ def export2(
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}{edited_identifier}"
dest = dest.parent / f"{filestem}.jpeg"
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,
)
if use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self._info["burstUUID"], all=True
)
# 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 photo:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
)
else:
exported = []
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,
)
else:
# export original version and not edited
filestem = dest.stem
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 use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self._info["burstUUID"], all=True
)
# 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 photo:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
)
else:
exported = []
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:
if touch_file:
for exported_file in exported:
@@ -688,24 +757,47 @@ def export2(
)
# export metadata
sidecar_json_files_skipped = []
sidecar_json_files_written = []
if sidecar_json:
logging.debug("writing exiftool_json_sidecar")
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,
)
if not dry_run:
try:
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)
)
)
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)
except Exception as e:
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
raise e
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:
logging.debug("writing xmp_sidecar")
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,
@@ -714,12 +806,32 @@ def export2(
description_template=description_template,
extension=dest.suffix[1:] if dest.suffix else None,
)
if not dry_run:
try:
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)
)
)
if write_sidecar:
verbose(f"Writing XMP sidecar {sidecar_filename}")
sidecar_xmp_files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e
export_db.set_sidecar_for_file(
sidecar_filename,
sidecar_digest,
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
sidecar_xmp_files_skipped.append(str(sidecar_filename))
# if exiftool, write the metadata
if update:
@@ -730,7 +842,6 @@ def export2(
exif_files_updated = []
if exiftool and update and exif_files:
for exported_file in exif_files:
logging.debug(f"checking exif for {exported_file}")
files_are_different = False
old_data = export_db.get_exifdata_for_file(exported_file)
if old_data is not None:
@@ -741,6 +852,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
)[0]
if old_data != current_data:
@@ -749,6 +861,7 @@ def export2(
if old_data is None or files_are_different:
# didn't have old data, assume we need to write it
# or files were different
verbose(f"Writing metadata with exiftool for {exported_file}")
if not dry_run:
self._write_exif_data(
exported_file,
@@ -756,6 +869,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
export_db.set_exifdata_for_file(
exported_file,
@@ -764,14 +878,18 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
),
)
export_db.set_stat_exif_for_file(
exported_file, fileutil.file_sig(exported_file)
)
exif_files_updated.append(exported_file)
else:
verbose(f"Skipped up to date exiftool metadata for {exported_file}")
elif exiftool and exif_files:
for exported_file in exif_files:
verbose(f"Writing metadata with exiftool for {exported_file}")
if not dry_run:
self._write_exif_data(
exported_file,
@@ -779,6 +897,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
export_db.set_exifdata_for_file(
@@ -788,6 +907,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
),
)
export_db.set_stat_exif_for_file(
@@ -797,6 +917,7 @@ def export2(
if touch_file:
for exif_file in exif_files_updated:
verbose(f"Updating file modification time for {exif_file}")
touched_files.append(exif_file)
ts = int(self.date.timestamp())
fileutil.utime(exif_file, (ts, ts))
@@ -810,6 +931,11 @@ def export2(
update_skipped_files,
exif_files_updated,
touched_files,
converted_to_jpeg_files,
sidecar_json_files_written,
sidecar_json_files_skipped,
sidecar_xmp_files_written,
sidecar_xmp_files_skipped,
)
return results
@@ -866,6 +992,7 @@ def _export_photo(
update_new_files = []
update_skipped_files = []
touched_files = []
converted_to_jpeg_files = []
dest_str = str(dest)
dest_exists = dest.exists()
@@ -932,13 +1059,11 @@ def _export_photo(
else:
# update, destination doesn't exist (new file)
logging.debug(f"Update: exporting new file with {op_desc} {src} {dest}")
update_new_files.append(dest_str)
if touch_file:
touched_files.append(dest_str)
else:
# not update, export the file
logging.debug(f"Exporting file with {op_desc} {src} {dest}")
exported_files.append(dest_str)
if touch_file:
sig = fileutil.file_sig(src)
@@ -950,9 +1075,6 @@ def _export_photo(
edited_stat = fileutil.file_sig(src) if edited else (None, None, None)
if dest_exists and (update or overwrite):
# need to remove the destination first
logging.debug(
f"Update: removing existing file prior to {op_desc} {src} {dest}"
)
fileutil.unlink(dest)
if export_as_hardlink:
fileutil.hardlink(src, dest)
@@ -960,18 +1082,19 @@ def _export_photo(
# use convert_to_jpeg to export the file
fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality)
converted_stat = fileutil.file_sig(dest_str)
converted_to_jpeg_files.append(dest_str)
else:
fileutil.copy(src, dest_str, norsrc=no_xattr)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
converted_stat,
edited_stat,
self.json(),
None,
filename=dest_str,
uuid=self.uuid,
orig_stat=fileutil.file_sig(dest_str),
exif_stat=(None, None, None),
converted_stat=converted_stat,
edited_stat=edited_stat,
info_json=self.json(),
exif_json=None,
)
if touched_files:
@@ -985,6 +1108,11 @@ def _export_photo(
update_skipped_files,
[],
touched_files,
converted_to_jpeg_files,
[],
[],
[],
[],
)
@@ -995,62 +1123,78 @@ def _write_exif_data(
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" write exif data to image file at filepath
filepath: full path to the image file """
Args:
filepath: full path to the image file
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
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"Could not find file {filepath}")
exiftool = ExifTool(filepath)
exif_info = json.loads(
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,
)
)[0]
for exiftag, val in exif_info.items():
if type(val) == list:
# more than one, set first value the add additional values
exiftool.setvalue(exiftag, val.pop(0))
if val:
# add any remaining items
exiftool.addvalues(exiftag, *val)
else:
exiftool.setvalue(exiftag, val)
exif_info = self._exiftool_dict(
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,
)
with ExifTool(filepath) as exiftool:
for exiftag, val in exif_info.items():
if exiftag == "_CreatedBy":
continue
elif type(val) == list:
for v in val:
exiftool.setvalue(exiftag, v)
else:
exiftool.setvalue(exiftag, val)
def _exiftool_json_sidecar(
def _exiftool_dict(
self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" return json string of EXIF details in exiftool sidecar format
Does not include all the EXIF fields as those are likely already in the image
""" 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:
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
Exports the following:
FileName
ImageDescription
Description
Title
TagsList
Keywords (may include album name, person name, or template)
Subject
PersonInImage
GPSLatitude, GPSLongitude
GPSPosition
GPSLatitudeRef, GPSLongitudeRef
DateTimeOriginal
OffsetTimeOriginal
ModifyDate """
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
Returns: dict with exiftool tags / values
Exports the following:
EXIF:ImageDescription
XMP:Description (may include template)
XMP:Title
XMP:TagsList
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:PersonInImage
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate
IPTC:DateCreated
IPTC:TimeCreated
"""
exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
@@ -1112,46 +1256,112 @@ def _exiftool_json_sidecar(
keyword_list.extend(rendered_keywords)
if keyword_list:
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = keyword_list
exif["XMP:TagsList"] = keyword_list.copy()
exif["IPTC:Keywords"] = keyword_list.copy()
if person_list:
exif["XMP:PersonInImage"] = person_list
exif["XMP:PersonInImage"] = person_list.copy()
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
# only use Photos' keywords for subject
exif["XMP:Subject"] = list(self.keywords) + person_list
# only use Photos' keywords for subject (e.g. don't include template values)
exif["XMP:Subject"] = self.keywords.copy() + person_list.copy()
# if self.favorite():
# exif["Rating"] = 5
(lat, lon) = self.location
if lat is not None and lon is not None:
lat_str, lon_str = dd_to_dms_str(lat, lon)
exif["EXIF:GPSLatitude"] = lat_str
exif["EXIF:GPSLongitude"] = lon_str
lat_ref = "North" if lat >= 0 else "South"
lon_ref = "East" if lon >= 0 else "West"
exif["EXIF:GPSLatitude"] = lat
exif["EXIF:GPSLongitude"] = lon
lat_ref = "N" if lat >= 0 else "S"
lon_ref = "E" if lon >= 0 else "W"
exif["EXIF:GPSLatitudeRef"] = lat_ref
exif["EXIF:GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset
# Photos exports the following fields and sets modify date to creation date
# [EXIF] Modify Date : 2020:10:30 00:00:00
# [EXIF] Date/Time Original : 2020:10:30 00:00:00
# [EXIF] Create Date : 2020:10:30 00:00:00
# [IPTC] Digital Creation Date : 2020:10:30
# [IPTC] Date Created : 2020:10:30
#
# This code deviates from Photos in one regard:
# if photo has modification date, use it otherwise use creation date
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:CreateDate"] = datetimeoriginal
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:OffsetTimeOriginal"] = offsettime
if self.date_modified is not None:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
dateoriginal = date.strftime("%Y:%m:%d")
exif["IPTC:DateCreated"] = dateoriginal
json_str = json.dumps([exif])
return json_str
timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
exif["IPTC:TimeCreated"] = timeoriginal
if self.date_modified is not None and not ignore_date_modified:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
else:
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
return exif
def _exiftool_json_sidecar(
self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" 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:
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
Returns: dict with exiftool tags / values
Exports the following:
EXIF:ImageDescription
XMP:Description (may include template)
XMP:Title
XMP:TagsList
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:PersonInImage
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate
IPTC:DigitalCreationDate
IPTC:DateCreated
"""
exif = self._exiftool_dict(
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,
)
return json.dumps([exif])
def _xmp_sidecar(

View File

@@ -5,6 +5,7 @@ PhotosDB.photos() returns a list of PhotoInfo objects
"""
import dataclasses
import datetime
import json
import logging
import os
@@ -52,6 +53,7 @@ class PhotoInfo:
export,
export2,
_export_photo,
_exiftool_dict,
_exiftool_json_sidecar,
_write_exif_data,
_write_sidecar,
@@ -59,6 +61,7 @@ class PhotoInfo:
ExportResults,
)
from ._photoinfo_scoreinfo import score, ScoreInfo
from ._photoinfo_comments import comments, likes
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
@@ -68,7 +71,11 @@ class PhotoInfo:
@property
def filename(self):
""" filename of the picture """
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
if (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
and self.raw_original
):
# return the JPEG version as that's what Photos 5+ does
return self._info["raw_pair_info"]["filename"]
else:
@@ -78,11 +85,16 @@ class PhotoInfo:
def original_filename(self):
""" original filename of the picture
Photos 5 mangles filenames upon import """
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
if (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
and self.raw_original
):
# return the JPEG version as that's what Photos 5+ does
return self._info["raw_pair_info"]["originalFilename"]
original_name = self._info["raw_pair_info"]["originalFilename"]
else:
return self._info["originalFilename"]
original_name = self._info["originalFilename"]
return original_name or self.filename
@property
def date(self):
@@ -93,12 +105,20 @@ class PhotoInfo:
def date_modified(self):
""" image modification date as timezone aware datetime object
or None if no modification date set """
imagedate = self._info["lastmodifieddate"]
if imagedate:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return imagedate.astimezone(tz=tz)
# 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
else:
return None
@@ -145,6 +165,8 @@ class PhotoInfo:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
@@ -156,6 +178,8 @@ class PhotoInfo:
self._info["directory"],
self._info["filename"],
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
@@ -169,6 +193,8 @@ class PhotoInfo:
self._info["directory"],
self._info["filename"],
)
if not os.path.isfile(photopath):
photopath = None
self._path = photopath
return photopath
@@ -177,18 +203,15 @@ class PhotoInfo:
""" absolute path on disk of the edited picture """
""" None if photo has not been edited """
# TODO: break this code into a _path_edited_4 and _path_edited_5
# version to simplify the big if/then; same for path_live_photo
try:
return self._path_edited
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
self._path_edited = self._path_edited_4()
return self._path_edited
else:
self._path_edited = self._path_edited_5()
return self._path_edited
return self._path_edited
def _path_edited_5(self):
""" return path_edited for Photos >= 5 """
@@ -246,8 +269,6 @@ class PhotoInfo:
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
# logging.debug(photopath)
return photopath
def _path_edited_4(self):
@@ -280,7 +301,7 @@ class PhotoInfo:
# could be elsewhere--I haven't figured out this logic yet
# first see if it's in 00
photopath = os.path.join(
library, "resources", "media", "version", folder_id, "00", filename,
library, "resources", "media", "version", folder_id, "00", filename
)
if not os.path.isfile(photopath):
@@ -808,6 +829,9 @@ class PhotoInfo:
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
replacement=":",
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
@@ -820,6 +844,9 @@ class PhotoInfo:
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 = ":"
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
@@ -831,6 +858,9 @@ class PhotoInfo:
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
)
@property
@@ -940,22 +970,23 @@ class PhotoInfo:
}
return yaml.dump(info, sort_keys=False)
def json(self):
""" return JSON representation """
def asdict(self):
""" return dict representation """
date_modified_iso = (
self.date_modified.isoformat() if self.date_modified else None
)
folders = {album.title: album.folder_names for album in self.album_info}
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
place = self.place.as_dict() if self.place else {}
place = self.place.asdict() if self.place else {}
score = dataclasses.asdict(self.score) if self.score else {}
comments = [comment.asdict() for comment in self.comments]
likes = [like.asdict() for like in self.likes]
faces = [face.asdict() for face in self.face_info]
pic = {
return {
"library": self._db._library_path,
"uuid": self.uuid,
"filename": self.filename,
"original_filename": self.original_filename,
"date": self.date.isoformat(),
"date": self.date,
"description": self.description,
"title": self.title,
"keywords": self.keywords,
@@ -964,6 +995,7 @@ class PhotoInfo:
"albums": self.albums,
"folders": folders,
"persons": self.persons,
"faces": faces,
"path": self.path,
"ismissing": self.ismissing,
"hasadjustments": self.hasadjustments,
@@ -977,12 +1009,13 @@ class PhotoInfo:
"isphoto": self.isphoto,
"ismovie": self.ismovie,
"uti": self.uti,
"uti_original": self.uti_original,
"burst": self.burst,
"live_photo": self.live_photo,
"path_live_photo": self.path_live_photo,
"iscloudasset": self.iscloudasset,
"incloud": self.incloud,
"date_modified": date_modified_iso,
"date_modified": self.date_modified,
"portrait": self.portrait,
"screenshot": self.screenshot,
"slow_mo": self.slow_mo,
@@ -991,6 +1024,8 @@ class PhotoInfo:
"selfie": self.selfie,
"panorama": self.panorama,
"has_raw": self.has_raw,
"israw": self.israw,
"raw_original": self.raw_original,
"uti_raw": self.uti_raw,
"path_raw": self.path_raw,
"place": place,
@@ -1004,8 +1039,18 @@ class PhotoInfo:
"original_width": self.original_width,
"original_orientation": self.original_orientation,
"original_filesize": self.original_filesize,
"comments": comments,
"likes": likes,
}
return json.dumps(pic)
def json(self):
""" Return JSON representation """
def default(o):
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
return json.dumps(self.asdict(), sort_keys=True, default=default)
def __eq__(self, other):
""" Compare two PhotoInfo objects for equality """

1215
osxphotos/photokit.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
""" PhotosDB method for processing comments and likes on shared photos.
Do not import this module directly """
import dataclasses
import datetime
from dataclasses import dataclass
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION, TIME_DELTA
from ..utils import _open_sql_file, normalize_unicode
def _process_comments(self):
""" load the comments and likes data from the database
this is a PhotosDB method that should be imported in
the PhotosDB class definition in photosdb.py
"""
self._db_hashed_person_id = {}
self._db_comments_uuid = {}
if self._db_version <= _PHOTOS_4_VERSION:
_process_comments_4(self)
else:
_process_comments_5(self)
@dataclass
class CommentInfo:
""" Class for shared photo comments """
datetime: datetime.datetime
user: str
ismine: bool
text: str
def asdict(self):
return dataclasses.asdict(self)
@dataclass
class LikeInfo:
""" Class for shared photo likes """
datetime: datetime.datetime
user: str
ismine: bool
def asdict(self):
return dataclasses.asdict(self)
# The following methods do not get imported into PhotosDB
# but will get called by _process_comments
def _process_comments_4(photosdb):
""" process comments and likes info for Photos <= 4
photosdb: PhotosDB instance """
raise NotImplementedError(
f"Not implemented for database version {photosdb._db_version}."
)
def _process_comments_5(photosdb):
""" process comments and likes info for Photos >= 5
photosdb: PhotosDB instance """
db = photosdb._tmp_db
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
(conn, cursor) = _open_sql_file(db)
results = conn.execute(
"""
SELECT DISTINCT
ZINVITEEHASHEDPERSONID,
ZINVITEEFIRSTNAME,
ZINVITEELASTNAME,
ZINVITEEFULLNAME
FROM
ZCLOUDSHAREDALBUMINVITATIONRECORD
"""
)
# order of results
# 0: ZINVITEEHASHEDPERSONID,
# 1: ZINVITEEFIRSTNAME,
# 2: ZINVITEELASTNAME,
# 3: ZINVITEEFULLNAME
photosdb._db_hashed_person_id = {}
for row in results.fetchall():
person_id = row[0]
photosdb._db_hashed_person_id[person_id] = {
"first_name": normalize_unicode(row[1]),
"last_name": normalize_unicode(row[2]),
"full_name": normalize_unicode(row[3]),
}
results = conn.execute(
f"""
SELECT
{asset_table}.ZUUID, -- UUID of the photo
ZCLOUDSHAREDCOMMENT.ZISLIKE, -- comment is actually a "like"
ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, -- date of comment
ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, -- text of comment
ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, -- hashed ID of person who made comment/like
ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT -- is my (this user's) comment
FROM ZCLOUDSHAREDCOMMENT
JOIN {asset_table} ON
{asset_table}.Z_PK = ZCLOUDSHAREDCOMMENT.ZCOMMENTEDASSET
OR
{asset_table}.Z_PK = ZCLOUDSHAREDCOMMENT.ZLIKEDASSET
"""
)
# order of results
# 0: ZGENERICASSET.ZUUID, -- UUID of the photo
# 1: ZCLOUDSHAREDCOMMENT.ZISLIKE, -- comment is actually a "like"
# 2: ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, -- date of comment
# 3: ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, -- text of comment
# 4: ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, -- hashed ID of person who made comment/like
# 5: ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT -- is my (this user's) comment
photosdb._db_comments_uuid = {}
for row in results:
uuid = row[0]
is_like = bool(row[1])
text = normalize_unicode(row[3])
try:
user_name = photosdb._db_hashed_person_id[row[4]]["full_name"]
except KeyError:
user_name = None
try:
dt = datetime.datetime.fromtimestamp(row[2] + TIME_DELTA)
except:
dt = datetime.datetime(1970, 1, 1)
ismine = bool(row[5])
try:
db_comments = photosdb._db_comments_uuid[uuid]
except KeyError:
photosdb._db_comments_uuid[uuid] = {"likes": [], "comments": []}
db_comments = photosdb._db_comments_uuid[uuid]
if is_like:
db_comments["likes"].append(LikeInfo(dt, user_name, ismine))
elif text:
db_comments["comments"].append(CommentInfo(dt, user_name, ismine, text))
# sort results
for uuid in photosdb._db_comments_uuid:
if photosdb._db_comments_uuid[uuid]["likes"]:
photosdb._db_comments_uuid[uuid]["likes"].sort(key=lambda x: x.datetime)
if photosdb._db_comments_uuid[uuid]["comments"]:
photosdb._db_comments_uuid[uuid]["comments"].sort(key=lambda x: x.datetime)
conn.close()

View File

@@ -35,6 +35,7 @@ from .._constants import (
from .._version import __version__
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..fileutil import FileUtil
from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo
from ..utils import (
@@ -44,6 +45,7 @@ from ..utils import (
_get_os_version,
_open_sql_file,
get_last_library_path,
noop,
normalize_unicode,
)
from .photosdb_utils import get_db_model_version, get_db_version
@@ -67,12 +69,19 @@ class PhotosDB:
labels_normalized_as_dict,
)
from ._photosdb_process_scoreinfo import _process_scoreinfo
from ._photosdb_process_comments import _process_comments
def __init__(self, *dbfile_, dbfile=None):
""" create a new PhotosDB object
path to photos library or database may be specified EITHER as first argument or as named argument dbfile=path
specify full path to photos library or photos.db as first argument
specify path to photos library or photos.db using named argument dbfile=path """
def __init__(self, dbfile=None, verbose=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.
Raises:
FileNotFoundError if dbfile is not a valid Photos library.
TypeError if verbose is not None and not callable.
"""
# Check OS version
system = platform.system()
@@ -84,6 +93,12 @@ class PhotosDB:
f"you have {system}, OS version: {major}"
)
if verbose is None:
verbose = noop
elif not callable(verbose):
raise TypeError("verbose must be callable")
self._verbose = verbose
# create a temporary directory
# tempfile.TemporaryDirectory gets cleaned up when the object does
self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@@ -216,25 +231,7 @@ class PhotosDB:
if _debug():
logging.debug(f"dbfile = {dbfile}")
# get the path to photos library database
if dbfile_:
# got a library path as argument
if dbfile:
# shouldn't pass via both *args and dbfile=
raise TypeError(
f"photos database path must be specified as argument or "
f"named parameter dbfile but not both: args: {dbfile_}, dbfile: {dbfile}",
dbfile_,
dbfile,
)
elif len(dbfile_) == 1:
dbfile = dbfile_[0]
else:
raise TypeError(
f"__init__ takes only a single argument (photos database path): {dbfile_}",
dbfile_,
)
elif dbfile is None:
if dbfile is None:
dbfile = get_last_library_path()
if dbfile is None:
# get_last_library_path must have failed to find library
@@ -262,11 +259,14 @@ class PhotosDB:
# or photosanalysisd
self._dbfile = self._dbfile_actual = self._tmp_db = os.path.abspath(dbfile)
verbose(f"Processing database {self._dbfile}")
# if database is exclusively locked, make a copy of it and use the copy
# Photos maintains an exclusive lock on the database file while Photos is open
# photoanalysisd sometimes maintains this lock even after Photos is closed
# In those cases, make a temp copy of the file for sqlite3 to read
if _db_is_locked(self._dbfile):
verbose(f"Database locked, creating temporary copy.")
self._tmp_db = self._copy_db_file(self._dbfile)
self._db_version = get_db_version(self._tmp_db)
@@ -279,8 +279,10 @@ class PhotosDB:
raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile)
else:
self._dbfile_actual = self._tmp_db = dbfile
verbose(f"Processing database {self._dbfile_actual}")
# if database is exclusively locked, make a copy of it and use the copy
if _db_is_locked(self._dbfile_actual):
verbose(f"Database locked, creating temporary copy.")
self._tmp_db = self._copy_db_file(self._dbfile_actual)
if _debug():
@@ -545,14 +547,46 @@ class PhotosDB:
return dest_path
# NOTE: This method seems to cause problems with applescript
# Bummer...would'be been nice to avoid copying the DB
# def _link_db_file(self, fname):
# """ links the sqlite database file to a temp file """
# """ returns the name of the temp file """
# """ If sqlite shared memory and write-ahead log files exist, those are copied too """
# # required because python's sqlite3 implementation can't read a locked file
# # _, suffix = os.path.splitext(fname)
# dest_name = dest_path = ""
# try:
# dest_name = pathlib.Path(fname).name
# dest_path = os.path.join(self._tempdir_name, dest_name)
# FileUtil.hardlink(fname, dest_path)
# # link write-ahead log and shared memory files (-wal and -shm) files if they exist
# if os.path.exists(f"{fname}-wal"):
# FileUtil.hardlink(f"{fname}-wal", f"{dest_path}-wal")
# if os.path.exists(f"{fname}-shm"):
# FileUtil.hardlink(f"{fname}-shm", f"{dest_path}-shm")
# except:
# print("Error linking " + fname + " to " + dest_path, file=sys.stderr)
# raise Exception
# if _debug():
# logging.debug(dest_path)
# return dest_path
def _process_database4(self):
""" process the Photos database to extract info
works on Photos version <= 4.0 """
verbose = self._verbose
verbose("Processing database.")
verbose(f"Database version: {self._db_version}.")
(conn, c) = _open_sql_file(self._tmp_db)
# get info to associate persons with photos
# then get detected faces in each photo and link to persons
verbose("Processing persons in photos.")
c.execute(
""" SELECT
RKPerson.modelID,
@@ -618,6 +652,7 @@ class PhotosDB:
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
# get information on detected faces
verbose("Processing detected faces in photos.")
c.execute(
""" SELECT
RKPerson.modelID,
@@ -655,6 +690,7 @@ class PhotosDB:
logging.debug(pformat(self._dbfaces_uuid))
# Get info on albums
verbose("Processing albums.")
c.execute(
""" SELECT
RKAlbum.uuid,
@@ -797,6 +833,7 @@ class PhotosDB:
logging.debug(pformat(self._dbfolder_details))
# Get info on keywords
verbose("Processing keywords.")
c.execute(
""" SELECT
RKKeyword.name,
@@ -824,6 +861,7 @@ class PhotosDB:
self._dbvolumes[vol[0]] = vol[1]
# Get photo details
verbose("Processing photo details.")
if self._db_version < _PHOTOS_3_VERSION:
# Photos < 3.0 doesn't have RKVersion.selfPortrait (selfie)
c.execute(
@@ -1113,6 +1151,7 @@ class PhotosDB:
self._dbphotos[uuid]["fok_import_session"] = None
# get additional details from RKMaster, needed for RAW processing
verbose("Processing additional photo details.")
c.execute(
""" SELECT
RKMaster.uuid,
@@ -1286,6 +1325,7 @@ class PhotosDB:
self._dbphotos[uuid]["incloud"] = True if row[2] == 1 else False
# get location data
verbose("Processing location data.")
# get the country codes
country_codes = c.execute(
"SELECT modelID, countryCode "
@@ -1372,6 +1412,7 @@ class PhotosDB:
conn.close()
# process faces
verbose("Processing face details.")
self._process_faceinfo()
# add faces and keywords to photo data
@@ -1408,6 +1449,7 @@ class PhotosDB:
self._dbphotos[uuid]["volume"] = None
# done processing, dump debug data if requested
verbose("Done processing details from Photos library.")
if _debug():
logging.debug("Faces (_dbfaces_uuid):")
logging.debug(pformat(self._dbfaces_uuid))
@@ -1483,12 +1525,14 @@ class PhotosDB:
if _debug():
logging.debug(f"_process_database5")
verbose = self._verbose
verbose(f"Processing database.")
(conn, c) = _open_sql_file(self._tmp_db)
# some of the tables/columns have different names in different versions of Photos
photos_ver = get_db_model_version(self._tmp_db)
self._photos_ver = photos_ver
verbose(f"Database version: {self._db_version}, {photos_ver}.")
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
keyword_join = _DB_TABLE_NAMES[photos_ver]["KEYWORD_JOIN"]
album_join = _DB_TABLE_NAMES[photos_ver]["ALBUM_JOIN"]
@@ -1502,6 +1546,7 @@ class PhotosDB:
# get info to associate persons with photos
# then get detected faces in each photo and link to persons
verbose("Processing persons in photos.")
c.execute(
""" SELECT
ZPERSON.Z_PK,
@@ -1567,6 +1612,7 @@ class PhotosDB:
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
# get information on detected faces
verbose("Processing detected faces in photos.")
c.execute(
f""" SELECT
ZPERSON.Z_PK,
@@ -1601,6 +1647,7 @@ class PhotosDB:
logging.debug(pformat(self._dbfaces_uuid))
# get details about albums
verbose("Processing albums.")
c.execute(
f""" SELECT
ZGENERICALBUM.ZUUID,
@@ -1719,6 +1766,7 @@ class PhotosDB:
logging.debug(pformat(self._dbalbum_folders))
# get details on keywords
verbose("Processing keywords.")
c.execute(
f"""SELECT ZKEYWORD.ZTITLE, {asset_table}.ZUUID
FROM {asset_table}
@@ -1750,6 +1798,7 @@ class PhotosDB:
logging.debug(self._dbvolumes)
# get details about photos
verbose("Processing photo details.")
logging.debug(f"Getting information about photos")
c.execute(
f"""SELECT {asset_table}.ZUUID,
@@ -1788,7 +1837,8 @@ class PhotosDB:
ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE,
{depth_state}
{depth_state},
{asset_table}.ZADJUSTMENTTIMESTAMP
FROM {asset_table}
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
ORDER BY {asset_table}.ZUUID """
@@ -1832,6 +1882,7 @@ class PhotosDB:
# 34 ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
# 35 ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
# 36 ZGENERICASSET.ZDEPTHSTATES / ZASSET.ZDEPTHTYPE
# 37 ZGENERICASSET.ZADJUSTMENTTIMESTAMP -- when was photo edited?
for row in c:
uuid = row[0]
@@ -1845,9 +1896,9 @@ class PhotosDB:
# There are sometimes negative values for lastmodifieddate in the database
# I don't know what these mean but they will raise exception in datetime if
# not accounted for
info["lastmodifieddate_timestamp"] = row[4]
info["lastmodifieddate_timestamp"] = row[37]
try:
info["lastmodifieddate"] = datetime.fromtimestamp(row[4] + TIME_DELTA)
info["lastmodifieddate"] = datetime.fromtimestamp(row[37] + TIME_DELTA)
except ValueError:
info["lastmodifieddate"] = None
except TypeError:
@@ -1908,7 +1959,7 @@ class PhotosDB:
info["type"] = None
info["UTI"] = row[18]
info["UTI_original"] = None # filled in later
info["UTI_original"] = None # filled in later
# handle burst photos
# if burst photo, determine whether or not it's a selected burst photo
@@ -2040,6 +2091,7 @@ class PhotosDB:
# 1 ZGENERICASSET.ZIMPORTSESSION
# 2 ZGENERICASSET.Z_FOK_IMPORTSESSION
# 3 ZGENERICALBUM.ZUUID,
verbose("Processing import sessions.")
c.execute(
f"""SELECT
{asset_table}.ZUUID,
@@ -2062,6 +2114,7 @@ class PhotosDB:
logging.debug(f"No info record for uuid {uuid} for import session")
# Get extended description
verbose("Processing additional photo details.")
c.execute(
f"""SELECT {asset_table}.ZUUID,
ZASSETDESCRIPTION.ZLONGDESCRIPTION
@@ -2241,18 +2294,27 @@ class PhotosDB:
conn.close()
# process face info
verbose("Processing face details.")
self._process_faceinfo()
# process search info
verbose("Processing photo labels.")
self._process_searchinfo()
# process exif info
verbose("Processing EXIF details.")
self._process_exifinfo()
# process computed scores
verbose("Processing computed aesthetic scores.")
self._process_scoreinfo()
# process shared comments/likes
verbose("Processing comments and likes for shared photos.")
self._process_comments()
# done processing, dump debug data if requested
verbose("Done processing details from Photos library.")
if _debug():
logging.debug("Faces (_dbfaces_uuid):")
logging.debug(pformat(self._dbfaces_uuid))

File diff suppressed because it is too large Load Diff

View File

@@ -491,7 +491,7 @@ class PlaceInfo4(PlaceInfo):
}
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
def as_dict(self):
def asdict(self):
return {
"name": self.name,
"names": self.names._asdict(),
@@ -634,7 +634,7 @@ class PlaceInfo5(PlaceInfo):
}
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
def as_dict(self):
def asdict(self):
return {
"name": self.name,
"names": self.names._asdict(),

View File

@@ -12,7 +12,7 @@
% if desc is None:
<dc:description></dc:description>
% else:
<dc:description>${desc}</dc:description>
<dc:description>${desc | x}</dc:description>
% endif
</%def>
@@ -20,7 +20,7 @@
% if title is None:
<dc:title></dc:title>
% else:
<dc:title>${title}</dc:title>
<dc:title>${title | x}</dc:title>
% endif
</%def>
@@ -30,7 +30,7 @@
<dc:subject>
<rdf:Seq>
% for subj in subject:
<rdf:li>${subj}</rdf:li>
<rdf:li>${subj | x}</rdf:li>
% endfor
</rdf:Seq>
</dc:subject>
@@ -48,7 +48,7 @@
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
% for person in persons:
<rdf:li>${person}</rdf:li>
<rdf:li>${person | x}</rdf:li>
% endfor
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
@@ -60,7 +60,7 @@
<digiKam:TagsList>
<rdf:Seq>
% for keyword in keywords:
<rdf:li>${keyword}</rdf:li>
<rdf:li>${keyword | x}</rdf:li>
% endfor
</rdf:Seq>
</digiKam:TagsList>
@@ -81,10 +81,8 @@
<%def name="gps_info(latitude, longitude)">
% if latitude is not None and longitude is not None:
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef>
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude>
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
<exif:GPSLongitude>${int(abs(longitude))},${(abs(longitude) % 1) * 60}${"E" if longitude >= 0 else "W"}</exif:GPSLongitude>
<exif:GPSLatitude>${int(abs(latitude))},${(abs(latitude) % 1) * 60}${"N" if latitude >= 0 else "S"}</exif:GPSLatitude>
% endif
</%def>

View File

@@ -17,7 +17,6 @@ from plistlib import load as plistload
import CoreFoundation
import CoreServices
import objc
from Foundation import *
from ._constants import UNICODE_FORMAT
from .fileutil import FileUtil
@@ -58,6 +57,11 @@ def _debug():
return _DEBUG
def noop(*args, **kwargs):
""" do nothing (no operation) """
pass
def _get_os_version():
# returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6)
@@ -197,7 +201,7 @@ def get_last_library_path():
# pylint: disable=no-member
# pylint: disable=undefined-variable
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
CoreFoundation.kCFAllocatorDefault, photosurlref, 0, None, None, None, None
)
# the CFURLRef we got is a sruct that python treats as an array
@@ -358,9 +362,35 @@ def _db_is_locked(dbname):
def normalize_unicode(value):
""" normalize unicode data """
if value is not None:
if not isinstance(value, str):
raise ValueError("value must be str")
return unicodedata.normalize(UNICODE_FORMAT, value)
else:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("value must be str")
return unicodedata.normalize(UNICODE_FORMAT, value)
def increment_filename(filepath):
""" Return filename (1).ext, etc if filename.ext exists
If file exists in filename's parent folder with same stem as filename,
add (1), (2), etc. until a non-existing filename is found.
Args:
filepath: str; full path, including file name
Returns:
new filepath (or same if not incremented)
Note: This obviously is subject to race condition so using with caution.
"""
dest = pathlib.Path(str(filepath))
count = 1
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest.stem
while dest_new.lower() in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
return str(dest)

View File

@@ -47,6 +47,7 @@ parso==0.6.2
pathspec==0.7.0
pathvalidate==2.2.1
pexpect==4.8.0
photoscript==0.1.0
pickleshare==0.7.5
Pillow==7.2.0
pkginfo==1.5.0.1

View File

@@ -79,6 +79,7 @@ setup(
"pathvalidate==2.2.1",
"dataclasses==0.7;python_version<'3.7'",
"wurlitzer>=2.0.1",
"photoscript>=0.1.0",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-10-09T16:14:42Z</date>
<date>2020-11-01T02:34:49Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-10-10T05:21:03Z</date>
<date>2020-11-01T02:34:49Z</date>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-10-04T23:43:17Z</date>
<date>2020-11-01T02:34:46Z</date>
</dict>
</plist>

View File

@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key>
<date>2019-07-27T13:16:43Z</date>
<key>SnapshotLastValidated</key>
<date>2020-10-10T05:22:36Z</date>
<date>2020-11-01T02:34:46Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

View File

@@ -7,7 +7,7 @@
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>2964</integer>
<integer>36387</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2020-06-24T04:02:13Z</date>
<date>2020-10-17T23:45:25Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2020-06-24T04:02:12Z</date>
<date>2020-10-17T23:45:25Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2020-06-24T04:02:13Z</date>
<date>2020-10-17T23:45:25Z</date>
<key>BackgroundJobSearch</key>
<date>2020-06-24T04:02:13Z</date>
<date>2020-10-17T23:45:25Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2020-06-24T04:02:12Z</date>
<date>2020-10-17T23:45:25Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2020-06-24T04:02:13Z</date>
<date>2020-10-17T23:45:25Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-05-30T02:16:06Z</date>
<date>2020-10-17T23:45:33Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-05-29T04:31:37Z</date>
<date>2020-10-17T23:45:24Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-06-24T04:02:13Z</date>
<date>2020-10-17T23:45:26Z</date>
<key>SiriPortraitDonation</key>
<date>2020-06-24T04:02:13Z</date>
<date>2020-10-17T23:45:25Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>NumberOfFacesProcessedOnLastRun</key>
<integer>7</integer>
<integer>11</integer>
<key>ProcessedInQuiescentState</key>
<true/>
<key>SuggestedMeIdentifier</key>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>FaceIDModelLastGenerationKey</key>
<date>2020-05-29T03:44:04Z</date>
<date>2020-10-17T23:45:32Z</date>
<key>LastContactClassificationKey</key>
<date>2020-05-29T04:31:40Z</date>
<date>2020-10-17T23:45:54Z</date>
</dict>
</plist>

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>1797</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

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