Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f57abd23c | ||
|
|
f9a43b92c1 | ||
|
|
bf2a55d7f6 | ||
|
|
34bb7f2cdc | ||
|
|
3394c52768 | ||
|
|
27282af3b9 | ||
|
|
b7b06b9fdb | ||
|
|
29e424575a | ||
|
|
ea373c4197 | ||
|
|
f25a299309 | ||
|
|
5885b23d32 | ||
|
|
5dccdf7750 | ||
|
|
e9134f84df | ||
|
|
3872e7ae64 | ||
|
|
b3e86dffc8 | ||
|
|
4897fc4b05 | ||
|
|
1dbf22fdc9 | ||
|
|
fa58af8b88 | ||
|
|
9c9bcb08b3 | ||
|
|
b1cb99f83f | ||
|
|
d3605f6303 | ||
|
|
dce002cdfe | ||
|
|
7bd189e9b2 | ||
|
|
baa86c77f6 | ||
|
|
0d086bf851 | ||
|
|
ade98fc150 | ||
|
|
0d66759b1c | ||
|
|
d833c14ef4 | ||
|
|
34841f86c0 | ||
|
|
4cc40d24cf | ||
|
|
1ccf03e158 | ||
|
|
75888cd663 | ||
|
|
a08d0725b9 | ||
|
|
f9f699ba35 | ||
|
|
f469cccc4b | ||
|
|
4ece5c0d1c | ||
|
|
9ca5d8f0fd | ||
|
|
2a49255277 | ||
|
|
f3b7134af1 | ||
|
|
73716f12cd | ||
|
|
a4bbb6492d | ||
|
|
aca19f4063 | ||
|
|
2ebd4c33ff | ||
|
|
da2f91ffc7 | ||
|
|
ef94933dd8 | ||
|
|
e0e8850e56 | ||
|
|
8d1ccda0c8 | ||
|
|
6171c4d665 | ||
|
|
4678f15bc8 | ||
|
|
a7c688cfc2 | ||
|
|
880a9b67a1 | ||
|
|
d40b16a456 | ||
|
|
dcd2fde6d0 | ||
|
|
ad860b1500 | ||
|
|
7ad4db6c15 | ||
|
|
0f1cc7cc71 | ||
|
|
5e6a6cd5fb | ||
|
|
8237bc8267 | ||
|
|
e097f3aad5 | ||
|
|
3155045ec8 | ||
|
|
4f64eeb996 |
@@ -109,6 +109,15 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "finestream",
|
||||
"name": "finestream",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/16638513?v=4",
|
||||
"profile": "https://github.com/finestream",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7
|
||||
|
||||
142
CHANGELOG.md
@@ -4,6 +4,148 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.39.2](https://github.com/RhetTbull/osxphotos/compare/v0.39.1...v0.39.2)
|
||||
|
||||
> 31 December 2020
|
||||
|
||||
- Added --xattr-template, closes #242 [`#242`](https://github.com/RhetTbull/osxphotos/issues/242)
|
||||
|
||||
#### [v0.39.1](https://github.com/RhetTbull/osxphotos/compare/v0.39.0...v0.39.1)
|
||||
|
||||
> 31 December 2020
|
||||
|
||||
- Fixed --exiftool-path bug, issue #311, #313 [`3394c52`](https://github.com/RhetTbull/osxphotos/commit/3394c527682d8fdd2f20f4f778d802dab86b6372)
|
||||
|
||||
#### [v0.39.0](https://github.com/RhetTbull/osxphotos/compare/v0.38.22...v0.39.0)
|
||||
|
||||
> 30 December 2020
|
||||
|
||||
- Added Finder tags, partial implementation for issue #242 [`#310`](https://github.com/RhetTbull/osxphotos/pull/310)
|
||||
- Added tests for Finder tags [`29e4245`](https://github.com/RhetTbull/osxphotos/commit/29e424575a522ae03efe5a140be46bfd0a1346c5)
|
||||
- Initial implementation for Finder tags [`5885b23`](https://github.com/RhetTbull/osxphotos/commit/5885b23d3249cf91953092a6b1ce967da2667e29)
|
||||
- Updated README for finder tags [`f25a299`](https://github.com/RhetTbull/osxphotos/commit/f25a2993097ad7b2b8ab2d1c787db58c0d799a41)
|
||||
- Updated requirements.txt [`ea373c4`](https://github.com/RhetTbull/osxphotos/commit/ea373c4197ce1cce00e89157fe560d1366f7e764)
|
||||
|
||||
#### [v0.38.22](https://github.com/RhetTbull/osxphotos/compare/v0.38.21...v0.38.22)
|
||||
|
||||
> 30 December 2020
|
||||
|
||||
- Fixed --exiftool-path bug, issue #308 [`5dccdf7`](https://github.com/RhetTbull/osxphotos/commit/5dccdf7750611c78de5356bb02f6023d4fc382c5)
|
||||
|
||||
#### [v0.38.21](https://github.com/RhetTbull/osxphotos/compare/v0.38.20...v0.38.21)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Fixed --exiftool-path to work with --exiftool-merge-keywords/persons [`3872e7a`](https://github.com/RhetTbull/osxphotos/commit/3872e7ae649f42d849de472a7dbf78a241d54407)
|
||||
|
||||
#### [v0.38.20](https://github.com/RhetTbull/osxphotos/compare/v0.38.19...v0.38.20)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Added --exiftool-path to CLI [`4897fc4`](https://github.com/RhetTbull/osxphotos/commit/4897fc4b05cc7a3bea314f9cce8a2163bf3922b2)
|
||||
|
||||
#### [v0.38.19](https://github.com/RhetTbull/osxphotos/compare/v0.38.18...v0.38.19)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Added exiftool signature to JSON output, issue #303 [`fa58af8`](https://github.com/RhetTbull/osxphotos/commit/fa58af8b883da11fdfa723d2da75a600d927d46e)
|
||||
|
||||
#### [v0.38.18](https://github.com/RhetTbull/osxphotos/compare/v0.38.17...v0.38.18)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added --exiftool-merge-keywords/persons, issue #299, #292 [`b1cb99f`](https://github.com/RhetTbull/osxphotos/commit/b1cb99f83f55128a314d265d4588134cb79026c6)
|
||||
|
||||
#### [v0.38.17](https://github.com/RhetTbull/osxphotos/compare/v0.38.16...v0.38.17)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added --sidecar-drop-ext, issue #291 [`dce002c`](https://github.com/RhetTbull/osxphotos/commit/dce002cdfe12fa5fa4ada4d5097828a5375c2ecd)
|
||||
- Updated Template Substitution table [`7bd189e`](https://github.com/RhetTbull/osxphotos/commit/7bd189e9b22a2ad5a8a80deb7cb93c61be37c771)
|
||||
|
||||
#### [v0.38.16](https://github.com/RhetTbull/osxphotos/compare/v0.38.15...v0.38.16)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added searchinfo templates, issue #302 [`0d086bf`](https://github.com/RhetTbull/osxphotos/commit/0d086bf85102ce78b3111c64bfa88673fbc19559)
|
||||
|
||||
#### [v0.38.15](https://github.com/RhetTbull/osxphotos/compare/v0.38.14...v0.38.15)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added --sidecar exiftool, issue #303 [`d833c14`](https://github.com/RhetTbull/osxphotos/commit/d833c14ef4b3f9375a85034cf0fb0f85a68cabb4)
|
||||
- Refactored sidecar code [`ade98fc`](https://github.com/RhetTbull/osxphotos/commit/ade98fc15051684bfb54d0199d9c370481b70dcc)
|
||||
- Refactored export2 to use sidecar bit field [`0d66759`](https://github.com/RhetTbull/osxphotos/commit/0d66759b1c200f1ecda202e28c259f88fd3db599)
|
||||
|
||||
#### [v0.38.14](https://github.com/RhetTbull/osxphotos/compare/v0.38.13...v0.38.14)
|
||||
|
||||
> 27 December 2020
|
||||
|
||||
- Bug fix for --description-template, issue #304 [`4cc40d2`](https://github.com/RhetTbull/osxphotos/commit/4cc40d24cfb11ef8668c5d3c3bab40371fdd0436)
|
||||
|
||||
#### [v0.38.13](https://github.com/RhetTbull/osxphotos/compare/v0.38.12...v0.38.13)
|
||||
|
||||
> 27 December 2020
|
||||
|
||||
- Set XMP:Subject to match Keywords, issue #302 [`75888cd`](https://github.com/RhetTbull/osxphotos/commit/75888cd6633d3f0180d24fef4f6776986a136f0f)
|
||||
|
||||
#### [v0.38.12](https://github.com/RhetTbull/osxphotos/compare/v0.38.11...v0.38.12)
|
||||
|
||||
> 26 December 2020
|
||||
|
||||
- Fixed city/sub-locality for SearchInfo [`f9f699b`](https://github.com/RhetTbull/osxphotos/commit/f9f699ba3500d58494f955d4e5d8118e336e6a2c)
|
||||
|
||||
#### [v0.38.11](https://github.com/RhetTbull/osxphotos/compare/v0.38.9...v0.38.11)
|
||||
|
||||
> 26 December 2020
|
||||
|
||||
- Exposed SearchInfo, closes #121 [`#121`](https://github.com/RhetTbull/osxphotos/issues/121)
|
||||
- Added version to --verbose, closes #297 [`#297`](https://github.com/RhetTbull/osxphotos/issues/297)
|
||||
- Added --exportdb [`2a49255`](https://github.com/RhetTbull/osxphotos/commit/2a49255277d3c6bd3b0d5f8288afd7de7dab0320)
|
||||
- Updated README.md [`f469ccc`](https://github.com/RhetTbull/osxphotos/commit/f469cccc4b4561db7611c3e9abf5aefc3ab0f648)
|
||||
- Fixed help text [`f3b7134`](https://github.com/RhetTbull/osxphotos/commit/f3b7134af1e3d07fb956eaccccd9d60bd075d3bf)
|
||||
|
||||
#### [v0.38.9](https://github.com/RhetTbull/osxphotos/compare/v0.38.8...v0.38.9)
|
||||
|
||||
> 21 December 2020
|
||||
|
||||
- Added --exiftool-option to CLI, closes #298 [`#298`](https://github.com/RhetTbull/osxphotos/issues/298)
|
||||
|
||||
#### [v0.38.8](https://github.com/RhetTbull/osxphotos/compare/v0.38.7...v0.38.8)
|
||||
|
||||
> 21 December 2020
|
||||
|
||||
- remove duplicate keywords with --exiftool and --sidecar, closes #294 [`#294`](https://github.com/RhetTbull/osxphotos/issues/294)
|
||||
|
||||
#### [v0.38.7](https://github.com/RhetTbull/osxphotos/compare/v0.38.6...v0.38.7)
|
||||
|
||||
> 21 December 2020
|
||||
|
||||
- Added better exiftool error handling, closes #300 [`#300`](https://github.com/RhetTbull/osxphotos/issues/300)
|
||||
- README.md updates for tested versions [`8d1ccda`](https://github.com/RhetTbull/osxphotos/commit/8d1ccda0c897f84342caf612c1070d78bff421f5)
|
||||
- version bump [`ef94933`](https://github.com/RhetTbull/osxphotos/commit/ef94933dd87b9ad2a516163ca50a36753dacd55a)
|
||||
|
||||
#### [v0.38.6](https://github.com/RhetTbull/osxphotos/compare/v0.38.5...v0.38.6)
|
||||
|
||||
> 18 December 2020
|
||||
|
||||
- Documentation fix for #293. Thanks to @finestream [`#295`](https://github.com/RhetTbull/osxphotos/pull/295)
|
||||
- Added additional test cases for #286, --ignore-signature [`880a9b6`](https://github.com/RhetTbull/osxphotos/commit/880a9b67a14787ef23ae68ad3164d7eda1af16ec)
|
||||
- Add @finestream as a contributor [`ad860b1`](https://github.com/RhetTbull/osxphotos/commit/ad860b1500dffd846322e05562ba4f2019cd1017)
|
||||
- Fixed issue #296 [`a7c688c`](https://github.com/RhetTbull/osxphotos/commit/a7c688cfc2221833e0252d71bbe596eee5f9a6e8)
|
||||
- Updated README.md [`d40b16a`](https://github.com/RhetTbull/osxphotos/commit/d40b16a456c64014674505b7c715c80b977da76a)
|
||||
- Version bump [`4678f15`](https://github.com/RhetTbull/osxphotos/commit/4678f15bc86b5dedcb73c73f40e5fe11c1b51fa0)
|
||||
|
||||
#### [v0.38.5](https://github.com/RhetTbull/osxphotos/compare/v0.38.4...v0.38.5)
|
||||
|
||||
> 17 December 2020
|
||||
|
||||
- Patch 1 [`#1`](https://github.com/RhetTbull/osxphotos/pull/1)
|
||||
- Implemented --ignore-signature, issue #286 [`e394d8e`](https://github.com/RhetTbull/osxphotos/commit/e394d8e6be7607a1668029bcb37ccb30a4fa792f)
|
||||
- Update __main__.py [`e097f3a`](https://github.com/RhetTbull/osxphotos/commit/e097f3aad546b5be5eabab529bd2c35ce3056876)
|
||||
- Update README.md [`4f64eeb`](https://github.com/RhetTbull/osxphotos/commit/4f64eeb996d43953eb90618465d2bd046282c4bb)
|
||||
- Update README.md [`3155045`](https://github.com/RhetTbull/osxphotos/commit/3155045ec87d83285f2e66210559f4be0a10e3a2)
|
||||
|
||||
#### [v0.38.4](https://github.com/RhetTbull/osxphotos/compare/v0.38.3...v0.38.4)
|
||||
|
||||
> 14 December 2020
|
||||
|
||||
363
README.md
@@ -3,7 +3,7 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
- [OSXPhotos](#osxphotos)
|
||||
@@ -21,6 +21,7 @@
|
||||
+ [FolderInfo](#folderinfo)
|
||||
+ [PlaceInfo](#placeinfo)
|
||||
+ [ScoreInfo](#scoreinfo)
|
||||
+ [SearchInfo](#searchinfo)
|
||||
+ [PersonInfo](#personinfo)
|
||||
+ [FaceInfo](#faceinfo)
|
||||
+ [CommentInfo](#commentinfo)
|
||||
@@ -43,13 +44,13 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
|
||||
|
||||
## Supported operating systems
|
||||
|
||||
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.6 / Photos 5.0.
|
||||
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.7 / Photos 5.0.
|
||||
|
||||
Beta support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0.
|
||||
Beta support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0. Not tested on M1 / Apple silicon Macs.
|
||||
|
||||
Requires python >= 3.7.
|
||||
|
||||
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12.
|
||||
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running MacOS 10.12 and vice versa.
|
||||
|
||||
|
||||
## Installation instructions
|
||||
@@ -300,23 +301,45 @@ Options:
|
||||
the primary photo will be exported--
|
||||
associated burst images will be skipped.
|
||||
--sidecar FORMAT Create sidecar for each photo exported;
|
||||
valid FORMAT values: xmp, json; --sidecar
|
||||
json: create JSON sidecar useable by
|
||||
exiftool (https://exiftool.org/) The sidecar
|
||||
file can be used to apply metadata to the
|
||||
file with exiftool, for example: "exiftool
|
||||
valid FORMAT values: xmp, json, exiftool;
|
||||
--sidecar xmp: create XMP sidecar used by
|
||||
Adobe Lightroom, etc. The sidecar file is
|
||||
named in format photoname.ext.xmp The XMP
|
||||
sidecar exports the following tags:
|
||||
Description, Title, Keywords/Tags, Subject
|
||||
(set to Keywords + PersonInImage),
|
||||
PersonInImage, CreateDate, ModifyDate,
|
||||
GPSLongitude.
|
||||
--sidecar json: create JSON
|
||||
sidecar useable by exiftool
|
||||
(https://exiftool.org/) The sidecar file can
|
||||
be used to apply metadata to the file with
|
||||
exiftool, for example: "exiftool
|
||||
-j=photoname.jpg.json photoname.jpg" The
|
||||
sidecar file is named in format
|
||||
photoname.ext.json --sidecar xmp: create
|
||||
XMP sidecar used by Adobe Lightroom, etc.The
|
||||
sidecar file is named in format
|
||||
photoname.ext.xmpThe XMP sidecar exports the
|
||||
following tags: Description, Title,
|
||||
Keywords/Tags, Subject (set to Keywords +
|
||||
PersonInImage), PersonInImage, CreateDate,
|
||||
ModifyDate, GPSLongitude. For a list of tags
|
||||
exported in the JSON sidecar, see
|
||||
--exiftool.
|
||||
photoname.ext.json; format includes tag
|
||||
groups (equivalent to running 'exiftool -G
|
||||
-j').
|
||||
--sidecar exiftool: create JSON
|
||||
sidecar compatible with output of 'exiftool
|
||||
-j'. Unlike '--sidecar json', '--sidecar
|
||||
exiftool' does not export tag groups.
|
||||
Sidecar filename is in format
|
||||
photoname.ext.json; For a list of tags
|
||||
exported in the JSON and exiftool sidecar,
|
||||
see '--exiftool'.
|
||||
--sidecar-drop-ext Drop the photo's extension when naming
|
||||
sidecar files. By default, sidecar files are
|
||||
named in format
|
||||
'photo_filename.photo_ext.sidecar_ext', e.g.
|
||||
'IMG_1234.JPG.json'. Use '--sidecar-drop-
|
||||
ext' to ignore the photo extension.
|
||||
Resulting sidecar files will have name in
|
||||
format 'IMG_1234.json'. Warning: this may
|
||||
result in sidecar filename collisions if
|
||||
there are files of different types but the
|
||||
same name in the output directory, e.g.
|
||||
'IMG_1234.JPG' and 'IMG_1234.MOV'.
|
||||
--exiftool Use exiftool to write metadata directly to
|
||||
exported photos. To use this option,
|
||||
exiftool must be installed and in the path.
|
||||
@@ -326,14 +349,12 @@ Options:
|
||||
metadata: EXIF:ImageDescription,
|
||||
XMP:Description (see also --description-
|
||||
template); XMP:Title; XMP:TagsList,
|
||||
IPTC:Keywords (see also --keyword-template,
|
||||
--person-keyword, --album-keyword);
|
||||
XMP:Subject (set to keywords + person in
|
||||
image to mirror Photos' behavior);
|
||||
XMP:PersonInImage; EXIF:GPSLatitudeRef;
|
||||
EXIF:GPSLongitudeRef; EXIF:GPSLatitude;
|
||||
EXIF:GPSLongitude; EXIF:GPSPosition;
|
||||
EXIF:DateTimeOriginal;
|
||||
IPTC:Keywords, XMP:Subject (see also
|
||||
--keyword-template, --person-keyword,
|
||||
--album-keyword); XMP:PersonInImage;
|
||||
EXIF:GPSLatitudeRef; EXIF:GPSLongitudeRef;
|
||||
EXIF:GPSLatitude; EXIF:GPSLongitude;
|
||||
EXIF:GPSPosition; EXIF:DateTimeOriginal;
|
||||
EXIF:OffsetTimeOriginal; EXIF:ModifyDate
|
||||
(see --ignore-date-modified);
|
||||
IPTC:DateCreated; IPTC:TimeCreated; (video
|
||||
@@ -342,6 +363,24 @@ Options:
|
||||
(see also --ignore-date-modified);
|
||||
QuickTime:GPSCoordinates;
|
||||
UserData:GPSCoordinates.
|
||||
--exiftool-path EXIFTOOL_PATH Optionally specify path to exiftool; if not
|
||||
provided, will look for exiftool in $PATH.
|
||||
--exiftool-option OPTION Optional flag/option to pass to exiftool
|
||||
when using --exiftool. For example,
|
||||
--exiftool-option '-m' to ignore minor
|
||||
warnings. Specify these as you would on the
|
||||
exiftool command line. See exiftool docs at
|
||||
https://exiftool.org/exiftool_pod.html for
|
||||
full list of options. More than one option
|
||||
may be specified by repeating the option,
|
||||
e.g. --exiftool-option '-m' --exiftool-
|
||||
option '-F'.
|
||||
--exiftool-merge-keywords Merge any keywords found in the original
|
||||
file with keywords used for '--exiftool' and
|
||||
'--sidecar'.
|
||||
--exiftool-merge-persons Merge any persons found in the original file
|
||||
with persons used for '--exiftool' and '--
|
||||
sidecar'.
|
||||
--ignore-date-modified If used with --exiftool or --sidecar, will
|
||||
ignore the photo modification date and set
|
||||
EXIF:ModifyDate to EXIF:DateTimeOriginal;
|
||||
@@ -373,6 +412,30 @@ Options:
|
||||
could specify --description-template
|
||||
"{descr} exported with osxphotos on
|
||||
{today.date}" See Templating System below.
|
||||
--finder-tag-template TEMPLATE Set MacOS Finder tags to TEMPLATE. These
|
||||
tags can be searched in the Finder or
|
||||
Spotlight with 'tag:tagname' format. For
|
||||
example, '--finder-tag-template "{label}"'
|
||||
to set Finder tags to photo labels. You may
|
||||
specify multiple TEMPLATE values by using '
|
||||
--finder-tag-template' multiple times. See
|
||||
also '--finder-tag-keywords and Extended
|
||||
Attributes below.'.
|
||||
--finder-tag-keywords Set MacOS Finder tags to keywords; any
|
||||
keywords specified via '--keyword-template',
|
||||
'--person-keyword', etc. will also be used
|
||||
as Finder tags. See also '--finder-tag-
|
||||
template and Extended Attributes below.'.
|
||||
--xattr-template ATTRIBUTE TEMPLATE
|
||||
Set extended attribute ATTRIBUTE to TEMPLATE
|
||||
value. Valid attributes are: 'authors',
|
||||
'comment', 'copyright', 'description',
|
||||
'findercomment', 'headline', 'keywords'. For
|
||||
example, to set Finder comment to the
|
||||
photo's title and description: '--xattr-
|
||||
template findercomment "{title}; {descr}"
|
||||
See Extended Attributes below for additional
|
||||
details on this option.
|
||||
--directory DIRECTORY Optional template for specifying name of
|
||||
output directory in the form
|
||||
'{name,DEFAULT}'. See below for additional
|
||||
@@ -419,6 +482,14 @@ Options:
|
||||
set. For example, photos which had
|
||||
previously been exported and were
|
||||
subsequently deleted in Photos.
|
||||
--exportdb EXPORTDB_FILE Specify alternate name for database file
|
||||
which stores state information for export
|
||||
and --update. If --exportdb is not
|
||||
specified, export database will be saved to
|
||||
'.osxphotos_export.db' in the export
|
||||
directory. Must be specified as filename
|
||||
only, not a path, as export database will be
|
||||
saved in export directory.
|
||||
--load-config <config file path>
|
||||
Load options from file as written with
|
||||
--save-config. This allows you to save a
|
||||
@@ -467,6 +538,53 @@ option to re-export the entire library thus rebuilding the
|
||||
'.osxphotos_export.db' database.
|
||||
|
||||
|
||||
** Extended Attributes **
|
||||
|
||||
Some options (currently '--finder-tag-template', '--finder-tag-keywords',
|
||||
'-xattr-template') write additional metadata to extended attributes in the
|
||||
file. These options will only work if the destination filesystem supports
|
||||
extended attributes (most do). For example, --finder-tag-keyword writes all
|
||||
keywords (including any specified by '--keyword-template' or other options) to
|
||||
Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'.
|
||||
For example, if you have images with keyword "Travel" then using '--finder-
|
||||
tag-keywords' you could quickly find those images in the Finder by typing
|
||||
'tag:Travel' in the Spotlight search bar. Finder tags are written to the
|
||||
'com.apple.metadata:_kMDItemUserTags' extended attribute. Unlike EXIF
|
||||
metadata, extended attributes do not modify the actual file. Most cloud
|
||||
storage services do not synch extended attributes. Dropbox does sync them and
|
||||
any changes to a file's extended attributes will cause Dropbox to re-sync the
|
||||
files.
|
||||
|
||||
The following attributes may be used with '--xattr-template':
|
||||
|
||||
|
||||
authors The author, or authors, of the contents of the file. A list
|
||||
of strings. (com.apple.metadata:kMDItemAuthors)
|
||||
comment A comment related to the file. This differs from the Finder
|
||||
comment, kMDItemFinderComment. A string.
|
||||
(com.apple.metadata:kMDItemComment)
|
||||
copyright The copyright owner of the file contents. A string.
|
||||
(com.apple.metadata:kMDItemCopyright)
|
||||
description A description of the content of the resource. The
|
||||
description may include an abstract, table of contents,
|
||||
reference to a graphical representation of content or a free-
|
||||
text account of the content. A string.
|
||||
(com.apple.metadata:kMDItemDescription)
|
||||
findercomment Finder comments for this file. A string.
|
||||
(com.apple.metadata:kMDItemFinderComment)
|
||||
headline A publishable entry providing a synopsis of the contents of
|
||||
the file. A string. (com.apple.metadata:kMDItemHeadline)
|
||||
keywords Keywords associated with this file. For example, “Birthday”,
|
||||
“Important”, etc. This differs from Finder tags
|
||||
(_kMDItemUserTags) which are keywords/tags shown in the
|
||||
Finder and searchable in Spotlight using "tag:tag_name". A
|
||||
list of strings. (com.apple.metadata:kMDItemKeywords)
|
||||
|
||||
For additional information on extended attributes see: https://developer.apple
|
||||
.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute
|
||||
_keys
|
||||
|
||||
|
||||
** Templating System **
|
||||
|
||||
Several options, such as --directory, allow you to specify a template which
|
||||
@@ -474,6 +592,10 @@ will be rendered to substitute template fields with values from the photo.
|
||||
For example, '{created.month}' would be replaced with the month name of the
|
||||
photo creation date. e.g. 'November'.
|
||||
|
||||
Some options supporting templates may be repeated e.g., --keyword-template
|
||||
'{label}' --keyword-template '{media_type}' to add both labels and media
|
||||
types to the keywords.
|
||||
|
||||
The general format for a template is '{TEMPLATE_FIELD[,[DEFAULT]]}'. Some
|
||||
templates have optional modifiers in form
|
||||
'{[[DELIM]+]TEMPLATE_FIELD[(PATH_SEP)][?VALUE_IF_TRUE][,[DEFAULT]]}'
|
||||
@@ -618,27 +740,39 @@ Substitution Description
|
||||
https://strftime.org/ for help on strftime
|
||||
templates.
|
||||
{modified.date} Photo's modification date in ISO format,
|
||||
e.g. '2020-03-22'
|
||||
{modified.year} 4-digit year of photo modification time
|
||||
{modified.yy} 2-digit year of photo modification time
|
||||
e.g. '2020-03-22'; uses creation date if
|
||||
photo is not modified
|
||||
{modified.year} 4-digit year of photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
{modified.yy} 2-digit year of photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
{modified.mm} 2-digit month of the photo modification time
|
||||
(zero padded)
|
||||
(zero padded); uses creation date if photo
|
||||
is not modified
|
||||
{modified.month} Month name in user's locale of the photo
|
||||
modification time
|
||||
modification time; uses creation date if
|
||||
photo is not modified
|
||||
{modified.mon} Month abbreviation in the user's locale of
|
||||
the photo modification time
|
||||
the photo modification time; uses creation
|
||||
date if photo is not modified
|
||||
{modified.dd} 2-digit day of the month (zero padded) of
|
||||
the photo modification time
|
||||
the photo modification time; uses creation
|
||||
date if photo is not modified
|
||||
{modified.dow} Day of week in user's locale of the photo
|
||||
modification time
|
||||
modification time; uses creation date if
|
||||
photo is not modified
|
||||
{modified.doy} 3-digit day of year (e.g Julian day) of
|
||||
photo modification time, starting from 1
|
||||
(zero padded)
|
||||
{modified.hour} 2-digit hour of the photo modification time
|
||||
(zero padded); uses creation date if photo
|
||||
is not modified
|
||||
{modified.hour} 2-digit hour of the photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
{modified.min} 2-digit minute of the photo modification
|
||||
time
|
||||
time; uses creation date if photo is not
|
||||
modified
|
||||
{modified.sec} 2-digit second of the photo modification
|
||||
time
|
||||
time; uses creation date if photo is not
|
||||
modified
|
||||
{today.date} Current date in iso format, e.g.
|
||||
'2020-03-22'
|
||||
{today.year} 4-digit year of current date
|
||||
@@ -695,6 +829,10 @@ Substitution Description
|
||||
'United States'
|
||||
{place.address.country_code} ISO country code of the postal address, e.g.
|
||||
'US'
|
||||
{searchinfo.season} Season of the year associated with a photo,
|
||||
e.g. 'Summer'; (Photos 5+ only, applied
|
||||
automatically by Photos' image
|
||||
categorization algorithms).
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified
|
||||
for --directory these could result in multiple copies of a photo being being
|
||||
@@ -711,10 +849,10 @@ Substitution Description
|
||||
{keyword} Keyword(s) assigned to photo
|
||||
{person} Person(s) / face(s) in a photo
|
||||
{label} Image categorization label associated with a photo
|
||||
(Photos 5 only)
|
||||
{label_normalized} All lower case version of 'label' (Photos 5 only)
|
||||
(Photos 5+ only)
|
||||
{label_normalized} All lower case version of 'label' (Photos 5+ only)
|
||||
{comment} Comment(s) on shared Photos; format is 'Person
|
||||
name: comment text' (Photos 5 only)
|
||||
name: comment text' (Photos 5+ only)
|
||||
{exiftool:GROUP:TAGNAME} Use exiftool (https://exiftool.org) to extract
|
||||
metadata, in form GROUP:TAGNAME, from image. E.g.
|
||||
'{exiftool:EXIF:Make}' to get camera make, or
|
||||
@@ -723,6 +861,20 @@ Substitution Description
|
||||
tag names. You must specify group (e.g. EXIF,
|
||||
IPTC, etc) as used in `exiftool -G`. exiftool must
|
||||
be installed in the path to use this template.
|
||||
{searchinfo.holiday} Holiday names associated with a photo, e.g.
|
||||
'Christmas Day'; (Photos 5+ only, applied
|
||||
automatically by Photos' image categorization
|
||||
algorithms).
|
||||
{searchinfo.activity} Activities associated with a photo, e.g. 'Sporting
|
||||
Event'; (Photos 5+ only, applied automatically by
|
||||
Photos' image categorization algorithms).
|
||||
{searchinfo.venue} Venues associated with a photo, e.g. name of
|
||||
restaurant; (Photos 5+ only, applied automatically
|
||||
by Photos' image categorization algorithms).
|
||||
{searchinfo.venue_type} Venue types associated with a photo, e.g.
|
||||
'Restaurant'; (Photos 5+ only, applied
|
||||
automatically by Photos' image categorization
|
||||
algorithms).
|
||||
```
|
||||
|
||||
Example: export all photos to ~/Desktop/export group in folders by date created
|
||||
@@ -741,6 +893,10 @@ Example: export photos to file structure based on 4-digit year and full name of
|
||||
|
||||
`osxphotos export ~/Desktop/export --directory "{created.year}/{created.month}"`
|
||||
|
||||
Example: export photos to file structure based on 4-digit year of photo's creation date and add keywords for media type and labels (labels are only awailable on Photos 5 and higher):
|
||||
|
||||
`osxphotos export ~/Desktop/export --directory "{created.year}" --keyword-template "{label}" --keyword-template "{media_type}"`
|
||||
|
||||
Example: export default library using 'country name/year' as output directory (but use "NoCountry/year" if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput
|
||||
|
||||
`osxphotos export ~/Desktop/export --directory "{place.name.country,NoCountry}/{created.year}" --person-keyword --album-keyword --keyword-template "{created.year}" --exiftool --update --verbose`
|
||||
@@ -1444,6 +1600,7 @@ Returns image categorization labels associated with the photo as list of str.
|
||||
#### `labels_normalized`
|
||||
Returns image categorization labels associated with the photo as list of str. Labels are normalized (e.g. converted to lower case). Use of normalized strings makes it easier to search if you don't how Apple capitalizes a label. For example:
|
||||
|
||||
|
||||
```python
|
||||
import osxphotos
|
||||
|
||||
@@ -1453,12 +1610,23 @@ for photo in photosdb.photos():
|
||||
print(f"I found a statue! {photo.original_filename}")
|
||||
```
|
||||
|
||||
**Note**: Only valid on Photos 5; on earlier versions, returns empty list. In Photos 5, Photos runs machine learning image categorization against photos in the library and automatically assigns labels to photos such as "People", "Dog", "Water", etc. A photo may have zero or more labels associated with it. See also [labels](#labels).
|
||||
**Note**: Only valid on Photos 5+; on earlier versions, returns empty list. In Photos 5+, Photos runs machine learning image categorization against photos in the library and automatically assigns labels to photos such as "People", "Dog", "Water", etc. A photo may have zero or more labels associated with it. See also [labels](#labels).
|
||||
|
||||
|
||||
#### <a name="photosearchinfo">`search_info`</a>
|
||||
Returns [SearchInfo](#searchinfo) object that represents search metadata for the photo.
|
||||
|
||||
**Note**: Only valid on Photos 5+; on ealier versions, returns None.
|
||||
|
||||
#### <a name="photosearchinfo-normalized">`search_info_normalized`</a>
|
||||
Returns [SearchInfo](#searchinfo) object that represents normalized search metadata for the photo. This returns a SearchInfo object just as `search_info` but all the properties of the object return normalized text (converted to lowercase).
|
||||
|
||||
**Note**: Only valid on Photos 5+; on ealier versions, returns None.
|
||||
|
||||
#### `exif_info`
|
||||
Returns an [ExifInfo](#exifinfo) object with EXIF details from the Photos database. See [ExifInfo](#exifinfo) for additional details.
|
||||
|
||||
**Note**: Only valid on Photos 5; on earlier versions, returns `None`. The EXIF details returned are a subset of the actual EXIF data in a typical image. At import Photos stores this subset in the database and it's this stored data that `exif_info` returns.
|
||||
**Note**: Only valid on Photos 5+; on earlier versions, returns `None`. The EXIF details returned are a subset of the actual EXIF data in a typical image. At import Photos stores this subset in the database and it's this stored data that `exif_info` returns.
|
||||
|
||||
See also `exiftool`.
|
||||
|
||||
@@ -1517,7 +1685,7 @@ Returns a JSON representation of all photo info.
|
||||
Returns a dictionary representation of all photo info.
|
||||
|
||||
#### `export()`
|
||||
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
|
||||
`export(dest, *filename, edited=False, live_photo=False, export_as_hardlink=False, overwrite=False, increment=True, sidecar_json=False, sidecar_exiftool=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, use_albums_as_keywords=False, use_persons_as_keywords=False)`
|
||||
|
||||
Export photo from the Photos library to another destination on disk.
|
||||
- dest: must be valid destination path as str (or exception raised).
|
||||
@@ -1528,6 +1696,8 @@ Export photo from the Photos library to another destination on disk.
|
||||
- live_photo: boolean; if True (default=False), will also export the associted .mov for live photos; exported live photo will be named filename.mov
|
||||
- increment: boolean; if True (default=True), will increment file name until a non-existent name is found
|
||||
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name
|
||||
- sidecar_json: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will include tag group names (e.g. `exiftool -G -j`)
|
||||
- sidecar_exiftool: (boolean, default = False); if True will also write a json sidecar with metadata in format readable by exiftool; sidecar filename will be dest/filename.json where filename is the stem of the photo name; resulting json file will not include tag group names (e.g. `exiftool -j`)
|
||||
- sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with metadata; sidecar filename will be dest/filename.xmp where filename is the stem of the photo name
|
||||
- use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using.
|
||||
- timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||
@@ -1846,7 +2016,7 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
|
||||
'96753'
|
||||
```
|
||||
### ScoreInfo
|
||||
[PhotoInfo.score](#score) returns a ScoreInfo object that exposes the computed aesthetic scores for each photo (**Photos 5 only**). I have not yet reverse engineered the meaning of each score. The `overall` score seems to the most useful and appears to be a composite of the other scores. The following score properties are currently available:
|
||||
[PhotoInfo.score](#score) returns a ScoreInfo object that exposes the computed aesthetic scores for each photo (**Photos 5+ only**). I have not yet reverse engineered the meaning of each score. The `overall` score seems to the most useful and appears to be a composite of the other scores. The following score properties are currently available:
|
||||
|
||||
```python
|
||||
overall: float
|
||||
@@ -1885,6 +2055,71 @@ Example: find your "best" photo of food
|
||||
>>> best_food_photo = sorted([p for p in photos if "food" in p.labels_normalized], key=lambda p: p.score.overall, reverse=True)[0]
|
||||
```
|
||||
|
||||
### SearchInfo
|
||||
[PhotoInfo.search_info](#photosearchinfo) and [PhotoInfo.search_info_normalized](#photosearchinfo-normalized) return a SearchInfo object that exposes various metadata that Photos uses when searching for photos such as labels, associated holiday, etc. (**Photos 5+ only**).
|
||||
|
||||
The following properties are available:
|
||||
|
||||
#### `labels`
|
||||
Returns list of labels applied to photo by Photos image categorization algorithms.
|
||||
|
||||
#### `place_names`
|
||||
Returns list of place names associated with the photo.
|
||||
|
||||
#### `streets`
|
||||
Returns list of street names associated with the photo. (e.g. reverse geolocation of where the photo was taken)
|
||||
|
||||
#### `neighborhoods`
|
||||
Returns list of neighborhood names associated with the photo.
|
||||
|
||||
#### `locality_names`
|
||||
Returns list of locality names associated with the photo.
|
||||
|
||||
#### `city`
|
||||
Returns str of city/town/municipality associated with the photo.
|
||||
|
||||
#### `state`
|
||||
Returns str of state name associated with the photo.
|
||||
|
||||
#### `state_abbreviation`
|
||||
Returns str of state abbreviation associated with the photo.
|
||||
|
||||
#### `country`
|
||||
Returns str of country name associated with the photo.
|
||||
|
||||
#### `month`
|
||||
Returns str of month name associated witht the photo (e.g. month in which the photo was taken)
|
||||
|
||||
#### `year`
|
||||
Returns year associated with the photo.
|
||||
|
||||
#### `bodies_of_water`
|
||||
Returns list of bodies of water associated with the photo.
|
||||
|
||||
#### `holidays`
|
||||
Returns list of holiday names associated with the photo.
|
||||
|
||||
#### `activities`
|
||||
Returns list of activities associated with the photo.
|
||||
|
||||
#### `season`
|
||||
Returns str of season name associated with the photo.
|
||||
|
||||
#### `venues`
|
||||
Returns list of venue names associated with the photo.
|
||||
|
||||
#### `venue_types`
|
||||
Returns list of venue types associated with the photoo.
|
||||
|
||||
#### `media_types`
|
||||
Returns list of media types associated with the photo.
|
||||
|
||||
#### `all`
|
||||
Returns all search_info properties as a single list of strings.
|
||||
|
||||
#### `asdict()`
|
||||
Returns all associated search_info metadata as a dict.
|
||||
|
||||
### PersonInfo
|
||||
[PhotosDB.person_info](#dbpersoninfo) and [PhotoInfo.person_info](#photopersoninfo) return a list of PersonInfo objects represents persons in the database and in a photo, respectively. The PersonInfo class has the following properties and methods.
|
||||
|
||||
@@ -2074,18 +2309,18 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|
||||
|{created.min}|2-digit minute of the photo creation time|
|
||||
|{created.sec}|2-digit second of the photo creation time|
|
||||
|{created.strftime}|Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|
||||
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'|
|
||||
|{modified.year}|4-digit year of photo modification time|
|
||||
|{modified.yy}|2-digit year of photo modification time|
|
||||
|{modified.mm}|2-digit month of the photo modification time (zero padded)|
|
||||
|{modified.month}|Month name in user's locale of the photo modification time|
|
||||
|{modified.mon}|Month abbreviation in the user's locale of the photo modification time|
|
||||
|{modified.dd}|2-digit day of the month (zero padded) of the photo modification time|
|
||||
|{modified.dow}|Day of week in user's locale of the photo modification time|
|
||||
|{modified.doy}|3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)|
|
||||
|{modified.hour}|2-digit hour of the photo modification time|
|
||||
|{modified.min}|2-digit minute of the photo modification time|
|
||||
|{modified.sec}|2-digit second of the photo modification time|
|
||||
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified|
|
||||
|{modified.year}|4-digit year of photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.yy}|2-digit year of photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.mm}|2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified|
|
||||
|{modified.month}|Month name in user's locale of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.mon}|Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.dd}|2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.dow}|Day of week in user's locale of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.doy}|3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified|
|
||||
|{modified.hour}|2-digit hour of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.min}|2-digit minute of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.sec}|2-digit second of the photo modification time; uses creation date if photo is not modified|
|
||||
|{today.date}|Current date in iso format, e.g. '2020-03-22'|
|
||||
|{today.year}|4-digit year of current date|
|
||||
|{today.yy}|2-digit year of current date|
|
||||
@@ -2112,14 +2347,19 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|
||||
|{place.address.postal_code}|Postal code part of the postal address, e.g. '20009'|
|
||||
|{place.address.country}|Country name of the postal address, e.g. 'United States'|
|
||||
|{place.address.country_code}|ISO country code of the postal address, e.g. 'US'|
|
||||
|{searchinfo.season}|Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|{album}|Album(s) photo is contained in|
|
||||
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|
||||
|{keyword}|Keyword(s) assigned to photo|
|
||||
|{person}|Person(s) / face(s) in a photo|
|
||||
|{label}|Image categorization label associated with a photo (Photos 5 only)|
|
||||
|{label_normalized}|All lower case version of 'label' (Photos 5 only)|
|
||||
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)|
|
||||
|{label}|Image categorization label associated with a photo (Photos 5+ only)|
|
||||
|{label_normalized}|All lower case version of 'label' (Photos 5+ only)|
|
||||
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)|
|
||||
|{exiftool:GROUP:TAGNAME}|Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. exiftool must be installed in the path to use this template.|
|
||||
|{searchinfo.holiday}|Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|{searchinfo.activity}|Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|{searchinfo.venue_type}|Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|
||||
### Utility Functions
|
||||
|
||||
@@ -2242,6 +2482,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/jstrine"><img src="https://avatars1.githubusercontent.com/u/33943447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jonathan Strine</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jstrine" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/finestream"><img src="https://avatars1.githubusercontent.com/u/16638513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>finestream</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=finestream" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -102,6 +102,63 @@ _OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
|
||||
|
||||
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
|
||||
SEARCH_CATEGORY_LABEL = 2024
|
||||
SEARCH_CATEGORY_PLACE_NAME = 1
|
||||
SEARCH_CATEGORY_STREET = 2
|
||||
SEARCH_CATEGORY_NEIGHBORHOOD = 3
|
||||
SEARCH_CATEGORY_LOCALITY_4 = 4
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_5 = 5
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_6 = 6
|
||||
SEARCH_CATEGORY_CITY = 7
|
||||
SEARCH_CATEGORY_LOCALITY_8 = 8
|
||||
SEARCH_CATEGORY_NAMED_AREA = 9
|
||||
SEARCH_CATEGORY_ALL_LOCALITY = [
|
||||
SEARCH_CATEGORY_LOCALITY_4,
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_5,
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_6,
|
||||
SEARCH_CATEGORY_LOCALITY_8,
|
||||
SEARCH_CATEGORY_NAMED_AREA,
|
||||
]
|
||||
SEARCH_CATEGORY_STATE = 10
|
||||
SEARCH_CATEGORY_STATE_ABBREVIATION = 11
|
||||
SEARCH_CATEGORY_COUNTRY = 12
|
||||
SEARCH_CATEGORY_BODY_OF_WATER = 14
|
||||
SEARCH_CATEGORY_MONTH = 1014
|
||||
SEARCH_CATEGORY_YEAR = 1015
|
||||
SEARCH_CATEGORY_KEYWORDS = 2016
|
||||
SEARCH_CATEGORY_TITLE = 2017
|
||||
SEARCH_CATEGORY_DESCRIPTION = 2018
|
||||
SEARCH_CATEGORY_HOME = 2020
|
||||
SEARCH_CATEGORY_PERSON = 2021
|
||||
SEARCH_CATEGORY_ACTIVITY = 2027
|
||||
SEARCH_CATEGORY_HOLIDAY = 2029
|
||||
SEARCH_CATEGORY_SEASON = 2030
|
||||
SEARCH_CATEGORY_WORK = 2036
|
||||
SEARCH_CATEGORY_VENUE = 2038
|
||||
SEARCH_CATEGORY_VENUE_TYPE = 2039
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_VIDEO = 2044
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SLOMO = 2045
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_LIVE = 2046
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT = 2047
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA = 2048
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE = 2049
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_BURSTS = 2052
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT = 2053
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SELFIES = 2054
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES = 2055
|
||||
SEARCH_CATEGORY_MEDIA_TYPES = [
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_VIDEO,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SLOMO,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_LIVE,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_BURSTS,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SELFIES,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES,
|
||||
]
|
||||
SEARCH_CATEGORY_PHOTO_NAME = 2056
|
||||
|
||||
|
||||
# Max filename length on MacOS
|
||||
MAX_FILENAME_LEN = 255
|
||||
@@ -118,4 +175,23 @@ DEFAULT_EDITED_SUFFIX = "_edited"
|
||||
# Default suffix to add to original images
|
||||
DEFAULT_ORIGINAL_SUFFIX = ""
|
||||
|
||||
# Colors for print CLI messages
|
||||
CLI_COLOR_ERROR = "red"
|
||||
CLI_COLOR_WARNING = "yellow"
|
||||
|
||||
# Bit masks for --sidecar
|
||||
SIDECAR_JSON = 0x1
|
||||
SIDECAR_EXIFTOOL = 0x2
|
||||
SIDECAR_XMP = 0x4
|
||||
|
||||
# supported attributes for --xattr-template
|
||||
EXTENDED_ATTRIBUTE_NAMES = [
|
||||
"authors",
|
||||
"comment",
|
||||
"copyright",
|
||||
"description",
|
||||
"findercomment",
|
||||
"headline",
|
||||
"keywords",
|
||||
]
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.38.5"
|
||||
__version__ = "0.39.3"
|
||||
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ def get_exiftool_path():
|
||||
|
||||
|
||||
class _ExifToolProc:
|
||||
""" Runs exiftool in a subprocess via Popen
|
||||
Creates a singleton object """
|
||||
"""Runs exiftool in a subprocess via Popen
|
||||
Creates a singleton object"""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
@@ -44,20 +44,20 @@ class _ExifToolProc:
|
||||
return cls.instance
|
||||
|
||||
def __init__(self, exiftool=None):
|
||||
""" construct _ExifToolProc singleton object or return instance of already created object
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
|
||||
"""construct _ExifToolProc singleton object or return instance of already created object
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it)"""
|
||||
|
||||
if hasattr(self, "_process_running") and self._process_running:
|
||||
# already running
|
||||
if exiftool is not None:
|
||||
if exiftool is not None and exiftool != self._exiftool:
|
||||
logging.warning(
|
||||
f"exiftool subprocess already running, "
|
||||
f"ignoring exiftool={exiftool}"
|
||||
)
|
||||
return
|
||||
|
||||
self._exiftool = exiftool or get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._exiftool = exiftool or get_exiftool_path()
|
||||
self._start_proc()
|
||||
|
||||
@property
|
||||
@@ -106,8 +106,8 @@ class _ExifToolProc:
|
||||
|
||||
def _stop_proc(self):
|
||||
""" stop the exiftool process if it's running, otherwise, do nothing """
|
||||
|
||||
if not self._process_running:
|
||||
logging.warning("exiftool process is not running")
|
||||
return
|
||||
|
||||
self._process.stdin.write(b"-stay_open\n")
|
||||
@@ -132,19 +132,23 @@ class _ExifToolProc:
|
||||
class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True):
|
||||
""" Create ExifTool object
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
|
||||
"""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
|
||||
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
|
||||
flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
|
||||
|
||||
Returns:
|
||||
ExifTool instance
|
||||
"""
|
||||
self.file = filepath
|
||||
self.overwrite = overwrite
|
||||
self.flags = flags or []
|
||||
self.data = {}
|
||||
self.warning = None
|
||||
self.error = None
|
||||
# if running as a context manager, self._context_mgr will be True
|
||||
self._context_mgr = False
|
||||
@@ -153,16 +157,17 @@ class ExifTool:
|
||||
self._read_exif()
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
""" Set tag to value(s); if value is None, will delete tag
|
||||
"""Set tag to value(s); if value is None, will delete tag
|
||||
|
||||
Args:
|
||||
tag: str; name of tag to set
|
||||
value: str; value to set tag to
|
||||
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
"""
|
||||
|
||||
@@ -175,29 +180,30 @@ class ExifTool:
|
||||
self._commands.extend(command)
|
||||
return True
|
||||
else:
|
||||
_, self.error = self.run_commands(*command)
|
||||
return self.error is None
|
||||
_, _, error = self.run_commands(*command)
|
||||
return error is None
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
""" Add one or more value(s) to tag
|
||||
"""Add one or more value(s) to tag
|
||||
If more than one value is passed, each value will be added to the tag
|
||||
|
||||
Args:
|
||||
tag: str; tag to set
|
||||
*values: str; one or more values to set
|
||||
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
|
||||
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
"""
|
||||
if not values:
|
||||
@@ -216,11 +222,11 @@ class ExifTool:
|
||||
self._commands.extend(command)
|
||||
return True
|
||||
else:
|
||||
_, self.error = self.run_commands(*command)
|
||||
return self.error is None
|
||||
_, _, error = self.run_commands(*command)
|
||||
return error is None
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
""" Run commands in the exiftool process and return result.
|
||||
"""Run commands in the exiftool process and return result.
|
||||
|
||||
Args:
|
||||
*commands: exiftool commands to run
|
||||
@@ -228,11 +234,12 @@ class ExifTool:
|
||||
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, warning, errror)
|
||||
output: bytes is containing output of exiftool commands
|
||||
error: if exiftool generated an error, bytes containing error string otherwise None
|
||||
warning: if exiftool generated warnings, string containing warning otherwise empty string
|
||||
error: if exiftool generated errors, string containing otherwise empty string
|
||||
|
||||
Note: Also sets self.error if error generated.
|
||||
Note: Also sets self.warning and self.error if warning or error generated.
|
||||
"""
|
||||
if not (hasattr(self, "_process") and self._process):
|
||||
raise ValueError("exiftool process is not running")
|
||||
@@ -245,7 +252,14 @@ class ExifTool:
|
||||
commands.append("-overwrite_original")
|
||||
|
||||
filename = os.fsencode(self.file) if not no_file else b""
|
||||
command_str = (
|
||||
|
||||
if self.flags:
|
||||
command_str = b"\n".join([f.encode("utf-8") for f in self.flags])
|
||||
command_str += b"\n"
|
||||
else:
|
||||
command_str = b""
|
||||
|
||||
command_str += (
|
||||
b"\n".join([c.encode("utf-8") for c in commands])
|
||||
+ b"\n"
|
||||
+ filename
|
||||
@@ -259,16 +273,21 @@ class ExifTool:
|
||||
|
||||
# read the output
|
||||
output = b""
|
||||
warning = b""
|
||||
error = b""
|
||||
while EXIFTOOL_STAYOPEN_EOF not in str(output):
|
||||
line = self._process.stdout.readline()
|
||||
if line.startswith(b"Warning"):
|
||||
error += line
|
||||
warning += line.strip()
|
||||
elif line.startswith(b"Error"):
|
||||
error += line.strip()
|
||||
else:
|
||||
output += line.strip()
|
||||
error = None if error == b"" else error
|
||||
warning = "" if warning == b"" else warning.decode("utf-8")
|
||||
error = "" if error == b"" else error.decode("utf-8")
|
||||
self.warning = warning
|
||||
self.error = error
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], error
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], warning, error
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
@@ -278,14 +297,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 asdict(self):
|
||||
""" return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
"""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]
|
||||
@@ -294,7 +313,7 @@ class ExifTool:
|
||||
|
||||
def json(self):
|
||||
""" returns JSON string containing all EXIF tags and values from exiftool """
|
||||
json, _ = self.run_commands("-json")
|
||||
json, _, _ = self.run_commands("-json")
|
||||
return json
|
||||
|
||||
def _read_exif(self):
|
||||
@@ -314,4 +333,5 @@ class ExifTool:
|
||||
if exc_type:
|
||||
return False
|
||||
elif self._commands:
|
||||
_, self.error = self.run_commands(*self._commands)
|
||||
# run_commands sets self.warning and self.error as needed
|
||||
self.run_commands(*self._commands)
|
||||
|
||||
@@ -18,12 +18,11 @@ def exiftool(self):
|
||||
return self._exiftool
|
||||
except AttributeError:
|
||||
try:
|
||||
exiftool_path = get_exiftool_path()
|
||||
exiftool_path = self._db._exiftool_path or get_exiftool_path()
|
||||
if self.path is not None and os.path.isfile(self.path):
|
||||
exiftool = ExifTool(self.path)
|
||||
exiftool = ExifTool(self.path, exiftool=exiftool_path)
|
||||
else:
|
||||
exiftool = None
|
||||
logging.debug(f"exiftool: missing path {self.uuid}")
|
||||
except FileNotFoundError:
|
||||
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
||||
exiftool = None
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
_export_photo
|
||||
_write_exif_data
|
||||
_exiftool_json_sidecar
|
||||
_get_exif_keywords
|
||||
_get_exif_persons
|
||||
_exiftool_dict
|
||||
_xmp_sidecar
|
||||
_write_sidecar
|
||||
"""
|
||||
|
||||
# TODO: should this be its own PhotoExporter class?
|
||||
# TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
@@ -32,6 +35,9 @@ from .._constants import (
|
||||
_TEMPLATE_DIR,
|
||||
_UNKNOWN_PERSON,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
SIDECAR_EXIFTOOL,
|
||||
SIDECAR_JSON,
|
||||
SIDECAR_XMP,
|
||||
)
|
||||
from ..datetime_utils import datetime_tz_to_utc
|
||||
from ..exiftool import ExifTool
|
||||
@@ -40,29 +46,124 @@ from ..fileutil import FileUtil
|
||||
from ..photokit import (
|
||||
PHOTOS_VERSION_CURRENT,
|
||||
PHOTOS_VERSION_ORIGINAL,
|
||||
PhotoLibrary,
|
||||
PhotoKitFetchFailed,
|
||||
PhotoLibrary,
|
||||
)
|
||||
from ..utils import dd_to_dms_str, findfiles, noop
|
||||
|
||||
ExportResults = namedtuple(
|
||||
"ExportResults",
|
||||
[
|
||||
"exported",
|
||||
"new",
|
||||
"updated",
|
||||
"skipped",
|
||||
"exif_updated",
|
||||
"touched",
|
||||
"converted_to_jpeg",
|
||||
"sidecar_json_written",
|
||||
"sidecar_json_skipped",
|
||||
"sidecar_xmp_written",
|
||||
"sidecar_xmp_skipped",
|
||||
"missing",
|
||||
"error",
|
||||
],
|
||||
)
|
||||
|
||||
class ExportResults:
|
||||
""" holds export results for export2 """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
exported=None,
|
||||
new=None,
|
||||
updated=None,
|
||||
skipped=None,
|
||||
exif_updated=None,
|
||||
touched=None,
|
||||
converted_to_jpeg=None,
|
||||
sidecar_json_written=None,
|
||||
sidecar_json_skipped=None,
|
||||
sidecar_exiftool_written=None,
|
||||
sidecar_exiftool_skipped=None,
|
||||
sidecar_xmp_written=None,
|
||||
sidecar_xmp_skipped=None,
|
||||
missing=None,
|
||||
error=None,
|
||||
exiftool_warning=None,
|
||||
exiftool_error=None,
|
||||
xattr_written=None,
|
||||
xattr_skipped=None,
|
||||
):
|
||||
self.exported = exported or []
|
||||
self.new = new or []
|
||||
self.updated = updated or []
|
||||
self.skipped = skipped or []
|
||||
self.exif_updated = exif_updated or []
|
||||
self.touched = touched or []
|
||||
self.converted_to_jpeg = converted_to_jpeg or []
|
||||
self.sidecar_json_written = sidecar_json_written or []
|
||||
self.sidecar_json_skipped = sidecar_json_skipped or []
|
||||
self.sidecar_exiftool_written = sidecar_exiftool_written or []
|
||||
self.sidecar_exiftool_skipped = sidecar_exiftool_skipped or []
|
||||
self.sidecar_xmp_written = sidecar_xmp_written or []
|
||||
self.sidecar_xmp_skipped = sidecar_xmp_skipped or []
|
||||
self.missing = missing or []
|
||||
self.error = error or []
|
||||
self.exiftool_warning = exiftool_warning or []
|
||||
self.exiftool_error = exiftool_error or []
|
||||
self.xattr_written = xattr_written or []
|
||||
self.xattr_skipped = xattr_skipped or []
|
||||
|
||||
def all_files(self):
|
||||
""" return all filenames contained in results """
|
||||
files = (
|
||||
self.exported
|
||||
+ self.new
|
||||
+ self.updated
|
||||
+ self.skipped
|
||||
+ self.exif_updated
|
||||
+ self.touched
|
||||
+ self.converted_to_jpeg
|
||||
+ self.sidecar_json_written
|
||||
+ self.sidecar_json_skipped
|
||||
+ self.sidecar_exiftool_written
|
||||
+ self.sidecar_exiftool_skipped
|
||||
+ self.sidecar_xmp_written
|
||||
+ self.sidecar_xmp_skipped
|
||||
+ self.missing
|
||||
+ self.error
|
||||
)
|
||||
files += [x[0] for x in self.exiftool_warning]
|
||||
files += [x[0] for x in self.exiftool_error]
|
||||
|
||||
files = list(set(files))
|
||||
return files
|
||||
|
||||
def __iadd__(self, other):
|
||||
self.exported += other.exported
|
||||
self.new += other.new
|
||||
self.updated += other.updated
|
||||
self.skipped += other.skipped
|
||||
self.exif_updated += other.exif_updated
|
||||
self.touched += other.touched
|
||||
self.converted_to_jpeg += other.converted_to_jpeg
|
||||
self.sidecar_json_written += other.sidecar_json_written
|
||||
self.sidecar_json_skipped += other.sidecar_json_skipped
|
||||
self.sidecar_exiftool_written += other.sidecar_exiftool_written
|
||||
self.sidecar_exiftool_skipped += other.sidecar_exiftool_skipped
|
||||
self.sidecar_xmp_written += other.sidecar_xmp_written
|
||||
self.sidecar_xmp_skipped += other.sidecar_xmp_skipped
|
||||
self.missing += other.missing
|
||||
self.error += other.error
|
||||
self.exiftool_warning += other.exiftool_warning
|
||||
self.exiftool_error += other.exiftool_error
|
||||
return self
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
"ExportResults("
|
||||
+ f"exported={self.exported}"
|
||||
+ f",new={self.new}"
|
||||
+ f",updated={self.updated}"
|
||||
+ f",skipped={self.skipped}"
|
||||
+ f",exif_updated={self.exif_updated}"
|
||||
+ f",touched={self.touched}"
|
||||
+ f",converted_to_jpeg={self.converted_to_jpeg}"
|
||||
+ f",sidecar_json_written={self.sidecar_json_written}"
|
||||
+ f",sidecar_json_skipped={self.sidecar_json_skipped}"
|
||||
+ f",sidecar_exiftool_written={self.sidecar_exiftool_written}"
|
||||
+ f",sidecar_exiftool_skipped={self.sidecar_exiftool_skipped}"
|
||||
+ f",sidecar_xmp_written={self.sidecar_xmp_written}"
|
||||
+ f",sidecar_xmp_skipped={self.sidecar_xmp_skipped}"
|
||||
+ f",missing={self.missing}"
|
||||
+ f",error={self.error}"
|
||||
+ f",exiftool_warning={self.exiftool_warning}"
|
||||
+ f",exiftool_error={self.exiftool_error}"
|
||||
+ ")"
|
||||
)
|
||||
|
||||
|
||||
# hexdigest is not a class method, don't import this into PhotoInfo
|
||||
@@ -242,6 +343,7 @@ def export(
|
||||
overwrite=False,
|
||||
increment=True,
|
||||
sidecar_json=False,
|
||||
sidecar_exiftool=False,
|
||||
sidecar_xmp=False,
|
||||
use_photos_export=False,
|
||||
timeout=120,
|
||||
@@ -271,10 +373,12 @@ def export(
|
||||
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
||||
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
||||
if overwrite=False and increment=False, export will fail if destination file already exists
|
||||
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json
|
||||
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
|
||||
sidecar filename will be dest/filename.xmp
|
||||
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
|
||||
sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
|
||||
sidecar_xmp: if set will write an XMP sidecar with IPTC data
|
||||
sidecar filename will be dest/filename.xmp
|
||||
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
||||
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||
@@ -291,6 +395,14 @@ def export(
|
||||
|
||||
# Implementation note: calls export2 to actually do the work
|
||||
|
||||
sidecar = 0
|
||||
if sidecar_json:
|
||||
sidecar |= SIDECAR_JSON
|
||||
if sidecar_exiftool:
|
||||
sidecar |= SIDECAR_EXIFTOOL
|
||||
if sidecar_xmp:
|
||||
sidecar |= SIDECAR_XMP
|
||||
|
||||
results = self.export2(
|
||||
dest,
|
||||
*filename,
|
||||
@@ -300,8 +412,7 @@ def export(
|
||||
export_as_hardlink=export_as_hardlink,
|
||||
overwrite=overwrite,
|
||||
increment=increment,
|
||||
sidecar_json=sidecar_json,
|
||||
sidecar_xmp=sidecar_xmp,
|
||||
sidecar=sidecar,
|
||||
use_photos_export=use_photos_export,
|
||||
timeout=timeout,
|
||||
exiftool=exiftool,
|
||||
@@ -324,8 +435,8 @@ def export2(
|
||||
export_as_hardlink=False,
|
||||
overwrite=False,
|
||||
increment=True,
|
||||
sidecar_json=False,
|
||||
sidecar_xmp=False,
|
||||
sidecar=0,
|
||||
sidecar_drop_ext=False,
|
||||
use_photos_export=False,
|
||||
timeout=120,
|
||||
exiftool=False,
|
||||
@@ -344,6 +455,9 @@ def export2(
|
||||
ignore_date_modified=False,
|
||||
use_photokit=False,
|
||||
verbose=None,
|
||||
exiftool_flags=None,
|
||||
merge_exif_keywords=False,
|
||||
merge_exif_persons=False,
|
||||
):
|
||||
"""export photo, like export but with update and dry_run options
|
||||
dest: must be valid destination path or exception raised
|
||||
@@ -363,10 +477,14 @@ def export2(
|
||||
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
||||
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
||||
if overwrite=False and increment=False, export will fail if destination file already exists
|
||||
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json
|
||||
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
|
||||
sidecar: bit field: set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
|
||||
SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
|
||||
SIDECAR_EXIFTOOL: if set will write a json sidecar with data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
|
||||
SIDECAR_XMP: if set will write an XMP sidecar with IPTC data
|
||||
sidecar filename will be dest/filename.xmp
|
||||
sidecar_drop_ext: (boolean, default=False); if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
|
||||
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
||||
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||
@@ -388,8 +506,12 @@ def export2(
|
||||
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.
|
||||
exiftool_flags: optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
|
||||
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
|
||||
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
|
||||
|
||||
Returns: ExportResults namedtuple with fields:
|
||||
Returns: ExportResults class
|
||||
ExportResults has attributes:
|
||||
"exported",
|
||||
"new",
|
||||
"updated",
|
||||
@@ -399,10 +521,15 @@ def export2(
|
||||
"converted_to_jpeg",
|
||||
"sidecar_json_written",
|
||||
"sidecar_json_skipped",
|
||||
"sidecar_exiftool_written",
|
||||
"sidecar_exiftool_skipped",
|
||||
"sidecar_xmp_written",
|
||||
"sidecar_xmp_skipped",
|
||||
"missing",
|
||||
"error"
|
||||
"error",
|
||||
"exiftool_warning",
|
||||
"exiftool_error",
|
||||
|
||||
|
||||
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
|
||||
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
|
||||
@@ -770,48 +897,62 @@ def export2(
|
||||
)
|
||||
|
||||
# export metadata
|
||||
sidecars = []
|
||||
sidecar_json_files_skipped = []
|
||||
sidecar_json_files_written = []
|
||||
if sidecar_json:
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json")
|
||||
sidecar_exiftool_files_skipped = []
|
||||
sidecar_exiftool_files_written = []
|
||||
sidecar_xmp_files_skipped = []
|
||||
sidecar_xmp_files_written = []
|
||||
|
||||
dest_suffix = "" if sidecar_drop_ext else dest.suffix
|
||||
if sidecar & SIDECAR_JSON:
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
|
||||
sidecar_str = self._exiftool_json_sidecar(
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
filename=dest.name,
|
||||
)
|
||||
sidecar_digest = hexdigest(sidecar_str)
|
||||
old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
|
||||
sidecar_filename
|
||||
)
|
||||
write_sidecar = (
|
||||
not update
|
||||
or (update and not sidecar_filename.exists())
|
||||
or (
|
||||
update
|
||||
and (sidecar_digest != old_sidecar_digest)
|
||||
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
|
||||
sidecars.append(
|
||||
(
|
||||
sidecar_filename,
|
||||
sidecar_str,
|
||||
sidecar_json_files_written,
|
||||
sidecar_json_files_skipped,
|
||||
"JSON",
|
||||
)
|
||||
)
|
||||
if write_sidecar:
|
||||
verbose(f"Writing exiftool JSON sidecar {sidecar_filename}")
|
||||
sidecar_json_files_written.append(str(sidecar_filename))
|
||||
if not dry_run:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
export_db.set_sidecar_for_file(
|
||||
sidecar_filename,
|
||||
sidecar_digest,
|
||||
fileutil.file_sig(sidecar_filename),
|
||||
)
|
||||
else:
|
||||
verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}")
|
||||
sidecar_json_files_skipped.append(str(sidecar_filename))
|
||||
|
||||
sidecar_xmp_files_skipped = []
|
||||
sidecar_xmp_files_written = []
|
||||
if sidecar_xmp:
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
|
||||
if sidecar & SIDECAR_EXIFTOOL:
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.json")
|
||||
sidecar_str = self._exiftool_json_sidecar(
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
tag_groups=False,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
filename=dest.name,
|
||||
)
|
||||
sidecars.append(
|
||||
(
|
||||
sidecar_filename,
|
||||
sidecar_str,
|
||||
sidecar_exiftool_files_written,
|
||||
sidecar_exiftool_files_skipped,
|
||||
"exiftool",
|
||||
)
|
||||
)
|
||||
|
||||
if sidecar & SIDECAR_XMP:
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest_suffix}.xmp")
|
||||
sidecar_str = self._xmp_sidecar(
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
@@ -819,6 +960,23 @@ def export2(
|
||||
description_template=description_template,
|
||||
extension=dest.suffix[1:] if dest.suffix else None,
|
||||
)
|
||||
sidecars.append(
|
||||
(
|
||||
sidecar_filename,
|
||||
sidecar_str,
|
||||
sidecar_xmp_files_written,
|
||||
sidecar_xmp_files_skipped,
|
||||
"XMP",
|
||||
)
|
||||
)
|
||||
|
||||
for data in sidecars:
|
||||
sidecar_filename = data[0]
|
||||
sidecar_str = data[1]
|
||||
files_written = data[2]
|
||||
files_skipped = data[3]
|
||||
sidecar_type = data[4]
|
||||
|
||||
sidecar_digest = hexdigest(sidecar_str)
|
||||
old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
|
||||
sidecar_filename
|
||||
@@ -833,8 +991,8 @@ def export2(
|
||||
)
|
||||
)
|
||||
if write_sidecar:
|
||||
verbose(f"Writing XMP sidecar {sidecar_filename}")
|
||||
sidecar_xmp_files_written.append(str(sidecar_filename))
|
||||
verbose(f"Writing {sidecar_type} sidecar {sidecar_filename}")
|
||||
files_written.append(str(sidecar_filename))
|
||||
if not dry_run:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
export_db.set_sidecar_for_file(
|
||||
@@ -843,8 +1001,8 @@ def export2(
|
||||
fileutil.file_sig(sidecar_filename),
|
||||
)
|
||||
else:
|
||||
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
|
||||
sidecar_xmp_files_skipped.append(str(sidecar_filename))
|
||||
verbose(f"Skipped up to date {sidecar_type} sidecar {sidecar_filename}")
|
||||
files_skipped.append(str(sidecar_filename))
|
||||
|
||||
# if exiftool, write the metadata
|
||||
if update:
|
||||
@@ -853,6 +1011,10 @@ def export2(
|
||||
exif_files = exported_files
|
||||
|
||||
exif_files_updated = []
|
||||
exiftool_warning = []
|
||||
exiftool_error = []
|
||||
errors = []
|
||||
# TODO: remove duplicative code from below
|
||||
if exiftool and update and exif_files:
|
||||
for exported_file in exif_files:
|
||||
files_are_different = False
|
||||
@@ -866,6 +1028,8 @@ def export2(
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
)
|
||||
)[0]
|
||||
if old_data != current_data:
|
||||
@@ -876,14 +1040,23 @@ def export2(
|
||||
# or files were different
|
||||
verbose(f"Writing metadata with exiftool for {exported_file}")
|
||||
if not dry_run:
|
||||
self._write_exif_data(
|
||||
warning_, error_ = self._write_exif_data(
|
||||
exported_file,
|
||||
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,
|
||||
flags=exiftool_flags,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
)
|
||||
if warning_:
|
||||
exiftool_warning.append((exported_file, warning_))
|
||||
if error_:
|
||||
exiftool_error.append((exported_file, error_))
|
||||
errors.append(exported_file)
|
||||
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
self._exiftool_json_sidecar(
|
||||
@@ -892,6 +1065,8 @@ def export2(
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
),
|
||||
)
|
||||
export_db.set_stat_exif_for_file(
|
||||
@@ -904,14 +1079,22 @@ def export2(
|
||||
for exported_file in exif_files:
|
||||
verbose(f"Writing metadata with exiftool for {exported_file}")
|
||||
if not dry_run:
|
||||
self._write_exif_data(
|
||||
warning_, error_ = self._write_exif_data(
|
||||
exported_file,
|
||||
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,
|
||||
flags=exiftool_flags,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
)
|
||||
if warning_:
|
||||
exiftool_warning.append((exported_file, warning_))
|
||||
if error_:
|
||||
exiftool_error.append((exported_file, error_))
|
||||
errors.append(exported_file)
|
||||
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
@@ -921,6 +1104,8 @@ def export2(
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
),
|
||||
)
|
||||
export_db.set_stat_exif_for_file(
|
||||
@@ -947,10 +1132,13 @@ def export2(
|
||||
converted_to_jpeg=converted_to_jpeg_files,
|
||||
sidecar_json_written=sidecar_json_files_written,
|
||||
sidecar_json_skipped=sidecar_json_files_skipped,
|
||||
sidecar_exiftool_written=sidecar_exiftool_files_written,
|
||||
sidecar_exiftool_skipped=sidecar_exiftool_files_skipped,
|
||||
sidecar_xmp_written=sidecar_xmp_files_written,
|
||||
sidecar_xmp_skipped=sidecar_xmp_files_skipped,
|
||||
missing=[],
|
||||
error=[],
|
||||
error=errors,
|
||||
exiftool_error=exiftool_error,
|
||||
exiftool_warning=exiftool_warning,
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -1128,6 +1316,8 @@ def _export_photo(
|
||||
converted_to_jpeg=converted_to_jpeg_files,
|
||||
sidecar_json_written=[],
|
||||
sidecar_json_skipped=[],
|
||||
sidecar_exiftool_written=[],
|
||||
sidecar_exiftool_skipped=[],
|
||||
sidecar_xmp_written=[],
|
||||
sidecar_xmp_skipped=[],
|
||||
missing=[],
|
||||
@@ -1143,6 +1333,9 @@ def _write_exif_data(
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
ignore_date_modified=False,
|
||||
flags=None,
|
||||
merge_exif_keywords=False,
|
||||
merge_exif_persons=False,
|
||||
):
|
||||
"""write exif data to image file at filepath
|
||||
|
||||
@@ -1152,6 +1345,10 @@ def _write_exif_data(
|
||||
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
|
||||
flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
|
||||
|
||||
Returns:
|
||||
(warning, error) of warning and error strings if exiftool produces warnings or errors
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Could not find file {filepath}")
|
||||
@@ -1161,15 +1358,18 @@ def _write_exif_data(
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
)
|
||||
|
||||
with ExifTool(filepath) as exiftool:
|
||||
with ExifTool(filepath, flags=flags, exiftool=self._db._exiftool_path) as exiftool:
|
||||
for exiftag, val in exif_info.items():
|
||||
if type(val) == list:
|
||||
for v in val:
|
||||
exiftool.setvalue(exiftag, v)
|
||||
else:
|
||||
exiftool.setvalue(exiftag, val)
|
||||
return exiftool.warning, exiftool.error
|
||||
|
||||
|
||||
def _exiftool_dict(
|
||||
@@ -1179,16 +1379,22 @@ def _exiftool_dict(
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
ignore_date_modified=False,
|
||||
merge_exif_keywords=False,
|
||||
merge_exif_persons=False,
|
||||
filename=None,
|
||||
):
|
||||
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||
Does not include all the EXIF fields as those are likely already in the image.
|
||||
|
||||
Args:
|
||||
filename: name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included
|
||||
use_albums_as_keywords: treat album names as keywords
|
||||
use_persons_as_keywords: treat person names as keywords
|
||||
keyword_template: (list of strings); list of template strings to render as keywords
|
||||
description_template: (list of strings); list of template strings to render for the description
|
||||
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
||||
merge_exif_keywords: merge keywords in the file's exif metadata (requires exiftool)
|
||||
merge_exif_persons: merge persons in the file's exif metadata (requires exiftool)
|
||||
|
||||
Returns: dict with exiftool tags / values
|
||||
|
||||
@@ -1215,11 +1421,21 @@ def _exiftool_dict(
|
||||
UserData:GPSCoordinates
|
||||
"""
|
||||
|
||||
exif = {}
|
||||
exif = (
|
||||
{
|
||||
"SourceFile": filename,
|
||||
"ExifTool:ExifToolVersion": "12.00",
|
||||
"File:FileName": filename,
|
||||
}
|
||||
if filename is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
if description_template is not None:
|
||||
description = self.render_template(
|
||||
rendered = self.render_template(
|
||||
description_template, expand_inplace=True, inplace_sep=", "
|
||||
)[0]
|
||||
description = " ".join(rendered) if rendered else ""
|
||||
exif["EXIF:ImageDescription"] = description
|
||||
exif["XMP:Description"] = description
|
||||
elif self.description:
|
||||
@@ -1230,19 +1446,25 @@ def _exiftool_dict(
|
||||
exif["XMP:Title"] = self.title
|
||||
|
||||
keyword_list = []
|
||||
if merge_exif_keywords:
|
||||
keyword_list.extend(self._get_exif_keywords())
|
||||
|
||||
if self.keywords:
|
||||
keyword_list.extend(self.keywords)
|
||||
|
||||
person_list = []
|
||||
if merge_exif_persons:
|
||||
person_list.extend(self._get_exif_persons())
|
||||
|
||||
if self.persons:
|
||||
# filter out _UNKNOWN_PERSON
|
||||
person_list = sorted([p for p in self.persons if p != _UNKNOWN_PERSON])
|
||||
person_list.extend([p for p in self.persons if p != _UNKNOWN_PERSON])
|
||||
|
||||
if use_persons_as_keywords and person_list:
|
||||
keyword_list.extend(sorted(person_list))
|
||||
keyword_list.extend(person_list)
|
||||
|
||||
if use_albums_as_keywords and self.albums:
|
||||
keyword_list.extend(sorted(self.albums))
|
||||
keyword_list.extend(self.albums)
|
||||
|
||||
if keyword_template:
|
||||
rendered_keywords = []
|
||||
@@ -1277,17 +1499,16 @@ def _exiftool_dict(
|
||||
keyword_list.extend(rendered_keywords)
|
||||
|
||||
if keyword_list:
|
||||
exif["XMP:TagsList"] = keyword_list.copy()
|
||||
# remove duplicates
|
||||
keyword_list = sorted(list(set(keyword_list)))
|
||||
exif["IPTC:Keywords"] = keyword_list.copy()
|
||||
exif["XMP:Subject"] = keyword_list.copy()
|
||||
exif["XMP:TagsList"] = keyword_list.copy()
|
||||
|
||||
if person_list:
|
||||
person_list = sorted(list(set(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 (e.g. don't include template values)
|
||||
exif["XMP:Subject"] = self.keywords.copy() + person_list.copy()
|
||||
|
||||
# if self.favorite():
|
||||
# exif["Rating"] = 5
|
||||
|
||||
@@ -1355,16 +1576,48 @@ def _exiftool_dict(
|
||||
date_utc = datetime_tz_to_utc(date)
|
||||
creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S")
|
||||
exif["QuickTime:CreateDate"] = creationdate
|
||||
if self.date_modified is not None and not ignore_date_modified:
|
||||
if self.date_modified is None or ignore_date_modified:
|
||||
exif["QuickTime:ModifyDate"] = creationdate
|
||||
else:
|
||||
exif["QuickTime:ModifyDate"] = datetime_tz_to_utc(
|
||||
self.date_modified
|
||||
).strftime("%Y:%m:%d %H:%M:%S")
|
||||
else:
|
||||
exif["QuickTime:ModifyDate"] = creationdate
|
||||
|
||||
return exif
|
||||
|
||||
|
||||
def _get_exif_keywords(self):
|
||||
""" returns list of keywords found in the file's exif metadata """
|
||||
keywords = []
|
||||
exif = self.exiftool
|
||||
if exif:
|
||||
exifdict = exif.asdict()
|
||||
for field in ["IPTC:Keywords", "XMP:TagsList", "XMP:Subject"]:
|
||||
try:
|
||||
kw = exifdict[field]
|
||||
if kw and type(kw) != list:
|
||||
kw = [kw]
|
||||
keywords.extend(kw)
|
||||
except KeyError:
|
||||
pass
|
||||
return keywords
|
||||
|
||||
|
||||
def _get_exif_persons(self):
|
||||
""" returns list of persons found in the file's exif metadata """
|
||||
persons = []
|
||||
exif = self.exiftool
|
||||
if exif:
|
||||
exifdict = exif.asdict()
|
||||
try:
|
||||
p = exifdict["XMP:PersonInImage"]
|
||||
if p and type(p) != list:
|
||||
p = [p]
|
||||
persons.extend(p)
|
||||
except KeyError:
|
||||
pass
|
||||
return persons
|
||||
|
||||
|
||||
def _exiftool_json_sidecar(
|
||||
self,
|
||||
use_albums_as_keywords=False,
|
||||
@@ -1372,6 +1625,10 @@ def _exiftool_json_sidecar(
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
ignore_date_modified=False,
|
||||
tag_groups=True,
|
||||
merge_exif_keywords=False,
|
||||
merge_exif_persons=False,
|
||||
filename=None,
|
||||
):
|
||||
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||
Does not include all the EXIF fields as those are likely already in the image.
|
||||
@@ -1382,6 +1639,10 @@ def _exiftool_json_sidecar(
|
||||
keyword_template: (list of strings); list of template strings to render as keywords
|
||||
description_template: (list of strings); list of template strings to render for the description
|
||||
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
||||
tag_groups: if True, tags are in form Group:TagName, e.g. IPTC:Keywords, otherwise group name is omitted, e.g. Keywords
|
||||
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
|
||||
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
|
||||
filename: filename of the destination image file for including in exiftool signature in JSON sidecar
|
||||
|
||||
Returns: dict with exiftool tags / values
|
||||
|
||||
@@ -1413,7 +1674,19 @@ def _exiftool_json_sidecar(
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
if not tag_groups:
|
||||
# strip tag groups
|
||||
exif_new = {}
|
||||
for k, v in exif.items():
|
||||
k = re.sub(r".*:", "", k)
|
||||
exif_new[k] = v
|
||||
exif = exif_new
|
||||
|
||||
return json.dumps([exif])
|
||||
|
||||
|
||||
@@ -1424,12 +1697,17 @@ def _xmp_sidecar(
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
extension=None,
|
||||
merge_exif_keywords=False,
|
||||
merge_exif_persons=False,
|
||||
):
|
||||
"""returns string for XMP sidecar
|
||||
use_albums_as_keywords: treat album names as keywords
|
||||
use_persons_as_keywords: treat person names as keywords
|
||||
keyword_template: (list of strings); list of template strings to render as keywords
|
||||
description_template: string; optional template string that will be rendered for use as photo description"""
|
||||
description_template: string; optional template string that will be rendered for use as photo description
|
||||
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
|
||||
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
|
||||
"""
|
||||
|
||||
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
|
||||
|
||||
@@ -1438,13 +1716,17 @@ def _xmp_sidecar(
|
||||
extension = extension.suffix[1:] if extension.suffix else None
|
||||
|
||||
if description_template is not None:
|
||||
description = self.render_template(
|
||||
rendered = self.render_template(
|
||||
description_template, expand_inplace=True, inplace_sep=", "
|
||||
)[0]
|
||||
description = " ".join(rendered) if rendered else ""
|
||||
else:
|
||||
description = self.description if self.description is not None else ""
|
||||
|
||||
keyword_list = []
|
||||
if merge_exif_keywords:
|
||||
keyword_list.extend(self._get_exif_keywords())
|
||||
|
||||
if self.keywords:
|
||||
keyword_list.extend(self.keywords)
|
||||
|
||||
@@ -1452,9 +1734,12 @@ def _xmp_sidecar(
|
||||
# good candidate for pulling out in a function
|
||||
|
||||
person_list = []
|
||||
if merge_exif_persons:
|
||||
person_list.extend(self._get_exif_persons())
|
||||
|
||||
if self.persons:
|
||||
# filter out _UNKNOWN_PERSON
|
||||
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
|
||||
person_list.extend([p for p in self.persons if p != _UNKNOWN_PERSON])
|
||||
|
||||
if use_persons_as_keywords and person_list:
|
||||
keyword_list.extend(person_list)
|
||||
@@ -1494,10 +1779,14 @@ def _xmp_sidecar(
|
||||
|
||||
keyword_list.extend(rendered_keywords)
|
||||
|
||||
subject_list = []
|
||||
if self.keywords or person_list:
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
subject_list = list(self.keywords) + person_list
|
||||
# remove duplicates
|
||||
# sorted mainly to make testing the XMP file easier
|
||||
if keyword_list:
|
||||
keyword_list = sorted(list(set(keyword_list)))
|
||||
if person_list:
|
||||
person_list = sorted(list(set(person_list)))
|
||||
|
||||
subject_list = keyword_list
|
||||
|
||||
xmp_str = xmp_template.render(
|
||||
photo=self,
|
||||
@@ -1509,7 +1798,7 @@ def _xmp_sidecar(
|
||||
)
|
||||
|
||||
# remove extra lines that mako inserts from template
|
||||
xmp_str = "\n".join([line for line in xmp_str.split("\n") if line.strip() != ""])
|
||||
xmp_str = "\n".join(line for line in xmp_str.split("\n") if line.strip() != "")
|
||||
return xmp_str
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
|
||||
Adds the following properties to PhotoInfo (valid only for Photos 5):
|
||||
search_info: returns a SearchInfo object
|
||||
search_info_normalized: returns a SearchInfo object with properties that produce normalized results
|
||||
labels: returns list of labels
|
||||
labels_normalized: returns list of normalized labels
|
||||
"""
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
|
||||
from .._constants import (
|
||||
_PHOTOS_4_VERSION,
|
||||
SEARCH_CATEGORY_CITY,
|
||||
SEARCH_CATEGORY_LABEL,
|
||||
SEARCH_CATEGORY_NEIGHBORHOOD,
|
||||
SEARCH_CATEGORY_PLACE_NAME,
|
||||
SEARCH_CATEGORY_STREET,
|
||||
SEARCH_CATEGORY_ALL_LOCALITY,
|
||||
SEARCH_CATEGORY_COUNTRY,
|
||||
SEARCH_CATEGORY_STATE,
|
||||
SEARCH_CATEGORY_STATE_ABBREVIATION,
|
||||
SEARCH_CATEGORY_BODY_OF_WATER,
|
||||
SEARCH_CATEGORY_MONTH,
|
||||
SEARCH_CATEGORY_YEAR,
|
||||
SEARCH_CATEGORY_HOLIDAY,
|
||||
SEARCH_CATEGORY_ACTIVITY,
|
||||
SEARCH_CATEGORY_SEASON,
|
||||
SEARCH_CATEGORY_VENUE,
|
||||
SEARCH_CATEGORY_VENUE_TYPE,
|
||||
SEARCH_CATEGORY_MEDIA_TYPES,
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
@@ -24,6 +45,22 @@ def search_info(self):
|
||||
return self._search_info
|
||||
|
||||
|
||||
@property
|
||||
def search_info_normalized(self):
|
||||
""" returns SearchInfo object for photo that produces normalized results
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info_normalized
|
||||
except AttributeError:
|
||||
self._search_info_normalized = SearchInfo(self, normalized=True)
|
||||
return self._search_info_normalized
|
||||
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" returns list of labels applied to photo by Photos image categorization
|
||||
@@ -43,14 +80,15 @@ def labels_normalized(self):
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info.labels_normalized
|
||||
return self.search_info_normalized.labels
|
||||
|
||||
|
||||
class SearchInfo:
|
||||
""" Info about search terms such as machine learning labels that Photos knows about a photo """
|
||||
|
||||
def __init__(self, photo):
|
||||
""" photo: PhotoInfo object """
|
||||
def __init__(self, photo, normalized=False):
|
||||
""" photo: PhotoInfo object
|
||||
normalized: if True, all properties return normalized (lower case) results """
|
||||
|
||||
if photo._db._db_version <= _PHOTOS_4_VERSION:
|
||||
raise NotImplementedError(
|
||||
@@ -58,6 +96,7 @@ class SearchInfo:
|
||||
)
|
||||
|
||||
self._photo = photo
|
||||
self._normalized = normalized
|
||||
self.uuid = photo.uuid
|
||||
try:
|
||||
# get search info for this UUID
|
||||
@@ -69,25 +108,170 @@ class SearchInfo:
|
||||
@property
|
||||
def labels(self):
|
||||
""" return list of labels associated with Photo """
|
||||
if self._db_searchinfo:
|
||||
labels = [
|
||||
rec["content_string"]
|
||||
for rec in self._db_searchinfo
|
||||
if rec["category"] == SEARCH_CATEGORY_LABEL
|
||||
]
|
||||
else:
|
||||
labels = []
|
||||
return labels
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
""" return list of normalized labels associated with Photo """
|
||||
def place_names(self):
|
||||
""" returns list of place names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
|
||||
|
||||
@property
|
||||
def streets(self):
|
||||
""" returns list of street names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_STREET)
|
||||
|
||||
@property
|
||||
def neighborhoods(self):
|
||||
""" returns list of neighborhoods """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
|
||||
|
||||
@property
|
||||
def locality_names(self):
|
||||
""" returns list of other locality names """
|
||||
locality = []
|
||||
for category in SEARCH_CATEGORY_ALL_LOCALITY:
|
||||
locality += self._get_text_for_category(category)
|
||||
return locality
|
||||
|
||||
@property
|
||||
def city(self):
|
||||
""" returns city/town """
|
||||
city = self._get_text_for_category(SEARCH_CATEGORY_CITY)
|
||||
return city[0] if city else ""
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" returns state name """
|
||||
state = self._get_text_for_category(SEARCH_CATEGORY_STATE)
|
||||
return state[0] if state else ""
|
||||
|
||||
@property
|
||||
def state_abbreviation(self):
|
||||
""" returns state abbreviation """
|
||||
abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION)
|
||||
return abbrev[0] if abbrev else ""
|
||||
|
||||
@property
|
||||
def country(self):
|
||||
""" returns country name """
|
||||
country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY)
|
||||
return country[0] if country else ""
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" returns month name """
|
||||
month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
|
||||
return month[0] if month else ""
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" returns year """
|
||||
year = self._get_text_for_category(SEARCH_CATEGORY_YEAR)
|
||||
return year[0] if year else ""
|
||||
|
||||
@property
|
||||
def bodies_of_water(self):
|
||||
""" returns list of body of water names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER)
|
||||
|
||||
@property
|
||||
def holidays(self):
|
||||
""" returns list of holiday names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
|
||||
|
||||
@property
|
||||
def activities(self):
|
||||
""" returns list of activity names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
|
||||
|
||||
@property
|
||||
def season(self):
|
||||
""" returns season name """
|
||||
season = self._get_text_for_category(SEARCH_CATEGORY_SEASON)
|
||||
return season[0] if season else ""
|
||||
|
||||
@property
|
||||
def venues(self):
|
||||
""" returns list of venue names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
|
||||
|
||||
@property
|
||||
def venue_types(self):
|
||||
""" returns list of venue types """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE)
|
||||
|
||||
@property
|
||||
def media_types(self):
|
||||
""" returns list of media types (photo, video, panorama, etc) """
|
||||
types = []
|
||||
for category in SEARCH_CATEGORY_MEDIA_TYPES:
|
||||
types += self._get_text_for_category(category)
|
||||
return types
|
||||
|
||||
@property
|
||||
def all(self):
|
||||
""" return all search info properties in a single list """
|
||||
all = (
|
||||
self.labels
|
||||
+ self.place_names
|
||||
+ self.streets
|
||||
+ self.neighborhoods
|
||||
+ self.locality_names
|
||||
+ self.bodies_of_water
|
||||
+ self.holidays
|
||||
+ self.activities
|
||||
+ self.venues
|
||||
+ self.venue_types
|
||||
+ self.media_types
|
||||
)
|
||||
if self.city:
|
||||
all += [self.city]
|
||||
if self.state:
|
||||
all += [self.state]
|
||||
if self.state_abbreviation:
|
||||
all += [self.state_abbreviation]
|
||||
if self.country:
|
||||
all += [self.country]
|
||||
if self.month:
|
||||
all += [self.month]
|
||||
if self.year:
|
||||
all += [self.year]
|
||||
if self.season:
|
||||
all += [self.season]
|
||||
|
||||
return all
|
||||
|
||||
def asdict(self):
|
||||
""" return dict of search info """
|
||||
return {
|
||||
"labels": self.labels,
|
||||
"place_names": self.place_names,
|
||||
"streets": self.streets,
|
||||
"neighborhoods": self.neighborhoods,
|
||||
"city": self.city,
|
||||
"locality_names": self.locality_names,
|
||||
"state": self.state,
|
||||
"state_abbreviation": self.state_abbreviation,
|
||||
"country": self.country,
|
||||
"bodies_of_water": self.bodies_of_water,
|
||||
"month": self.month,
|
||||
"year": self.year,
|
||||
"holidays": self.holidays,
|
||||
"activities": self.activities,
|
||||
"season": self.season,
|
||||
"venues": self.venues,
|
||||
"venue_types": self.venue_types,
|
||||
"media_types": self.media_types,
|
||||
}
|
||||
|
||||
def _get_text_for_category(self, category):
|
||||
""" return list of text for a specified category ID """
|
||||
if self._db_searchinfo:
|
||||
labels = [
|
||||
rec["normalized_string"]
|
||||
content = "normalized_string" if self._normalized else "content_string"
|
||||
return [
|
||||
rec[content]
|
||||
for rec in self._db_searchinfo
|
||||
if rec["category"] == SEARCH_CATEGORY_LABEL
|
||||
if rec["category"] == category
|
||||
]
|
||||
else:
|
||||
labels = []
|
||||
return labels
|
||||
return []
|
||||
|
||||
@@ -21,11 +21,11 @@ from .._constants import (
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
_PHOTOS_4_ROOT_FOLDER,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND,
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
_PHOTOS_5_VERSION,
|
||||
)
|
||||
from ..albuminfo import AlbumInfo, ImportInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
@@ -43,6 +43,7 @@ class PhotoInfo:
|
||||
# import additional methods
|
||||
from ._photoinfo_searchinfo import (
|
||||
search_info,
|
||||
search_info_normalized,
|
||||
labels,
|
||||
labels_normalized,
|
||||
SearchInfo,
|
||||
@@ -55,6 +56,8 @@ class PhotoInfo:
|
||||
_export_photo,
|
||||
_exiftool_dict,
|
||||
_exiftool_json_sidecar,
|
||||
_get_exif_keywords,
|
||||
_get_exif_persons,
|
||||
_write_exif_data,
|
||||
_write_sidecar,
|
||||
_xmp_sidecar,
|
||||
@@ -83,8 +86,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def original_filename(self):
|
||||
""" original filename of the picture
|
||||
Photos 5 mangles filenames upon import """
|
||||
"""original filename of the picture
|
||||
Photos 5 mangles filenames upon import"""
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
@@ -103,8 +106,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def date_modified(self):
|
||||
""" image modification date as timezone aware datetime object
|
||||
or None if no modification date set """
|
||||
"""image modification date as timezone aware datetime object
|
||||
or None if no modification date set"""
|
||||
|
||||
# Photos <= 4 provides no way to get date of adjustment and will update
|
||||
# lastmodifieddate anytime photo database record is updated (e.g. adding tags)
|
||||
@@ -489,9 +492,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def ismissing(self):
|
||||
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
|
||||
"""returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
|
||||
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
|
||||
do not immediately get written to disk. In particular, I've noticed that downloading
|
||||
do not immediately get written to disk. In particular, I've noticed that downloading
|
||||
an image from the cloud does not force the database to be updated until something else
|
||||
e.g. an edit, keyword, etc. occurs forcing a database synch
|
||||
The exact process / timing is a mystery to be but be aware that if some photos were recently
|
||||
@@ -536,8 +539,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def shared(self):
|
||||
""" returns True if photos is in a shared iCloud album otherwise false
|
||||
Only valid on Photos 5; returns None on older versions """
|
||||
"""returns True if photos is in a shared iCloud album otherwise false
|
||||
Only valid on Photos 5; returns None on older versions"""
|
||||
if self._db._db_version > _PHOTOS_4_VERSION:
|
||||
return self._info["shared"]
|
||||
else:
|
||||
@@ -545,8 +548,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""Returns Uniform Type Identifier (UTI) for the image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self.hasadjustments:
|
||||
@@ -561,8 +564,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti_original(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the original image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""Returns Uniform Type Identifier (UTI) for the original image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
|
||||
return self._info["raw_pair_info"]["UTI"]
|
||||
@@ -574,9 +577,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti_edited(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the edited image
|
||||
if the photo has been edited, otherwise None;
|
||||
for example: public.jpeg
|
||||
"""Returns Uniform Type Identifier (UTI) for the edited image
|
||||
if the photo has been edited, otherwise None;
|
||||
for example: public.jpeg
|
||||
"""
|
||||
if self._db._db_version >= _PHOTOS_5_VERSION:
|
||||
return self.uti if self.hasadjustments else None
|
||||
@@ -585,36 +588,34 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti_raw(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""Returns Uniform Type Identifier (UTI) for the RAW image if there is one
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""
|
||||
return self._info["UTI_raw"]
|
||||
|
||||
@property
|
||||
def ismovie(self):
|
||||
""" Returns True if file is a movie, otherwise False
|
||||
"""
|
||||
"""Returns True if file is a movie, otherwise False"""
|
||||
return True if self._info["type"] == _MOVIE_TYPE else False
|
||||
|
||||
@property
|
||||
def isphoto(self):
|
||||
""" Returns True if file is an image, otherwise False
|
||||
"""
|
||||
"""Returns True if file is an image, otherwise False"""
|
||||
return True if self._info["type"] == _PHOTO_TYPE else False
|
||||
|
||||
@property
|
||||
def incloud(self):
|
||||
""" Returns True if photo is cloud asset and is synched to cloud
|
||||
False if photo is cloud asset and not yet synched to cloud
|
||||
None if photo is not cloud asset
|
||||
"""Returns True if photo is cloud asset and is synched to cloud
|
||||
False if photo is cloud asset and not yet synched to cloud
|
||||
None if photo is not cloud asset
|
||||
"""
|
||||
return self._info["incloud"]
|
||||
|
||||
@property
|
||||
def iscloudasset(self):
|
||||
""" Returns True if photo is a cloud asset (in an iCloud library),
|
||||
otherwise False
|
||||
"""Returns True if photo is a cloud asset (in an iCloud library),
|
||||
otherwise False
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return (
|
||||
@@ -633,9 +634,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def burst_photos(self):
|
||||
""" If photo is a burst photo, returns list of PhotoInfo objects
|
||||
that are part of the same burst photo set; otherwise returns empty list.
|
||||
self is not included in the returned list """
|
||||
"""If photo is a burst photo, returns list of PhotoInfo objects
|
||||
that are part of the same burst photo set; otherwise returns empty list.
|
||||
self is not included in the returned list"""
|
||||
if self._info["burst"]:
|
||||
burst_uuid = self._info["burstUUID"]
|
||||
return [
|
||||
@@ -653,9 +654,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def path_live_photo(self):
|
||||
""" Returns path to the associated video file for a live photo
|
||||
If photo is not a live photo, returns None
|
||||
If photo is missing, returns None """
|
||||
"""Returns path to the associated video file for a live photo
|
||||
If photo is not a live photo, returns None
|
||||
If photo is missing, returns None"""
|
||||
|
||||
photopath = None
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
@@ -782,9 +783,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def raw_original(self):
|
||||
""" returns True if associated raw image and the raw image is selected in Photos
|
||||
via "Use RAW as Original "
|
||||
otherwise returns False """
|
||||
"""returns True if associated raw image and the raw image is selected in Photos
|
||||
via "Use RAW as Original "
|
||||
otherwise returns False"""
|
||||
return self._info["raw_is_original"]
|
||||
|
||||
@property
|
||||
@@ -838,20 +839,20 @@ class PhotoInfo:
|
||||
Args:
|
||||
template_str: a template string with fields to render
|
||||
none_str: a str to use if template field renders to None, default is "_".
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
fields like folder_album; if not provided, defaults to os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
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
|
||||
"""
|
||||
template = PhotoTemplate(self)
|
||||
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
|
||||
return template.render(
|
||||
template_str,
|
||||
none_str=none_str,
|
||||
@@ -874,11 +875,11 @@ class PhotoInfo:
|
||||
return self._info["latitude"]
|
||||
|
||||
def _get_album_uuids(self):
|
||||
""" Return list of album UUIDs this photo is found in
|
||||
|
||||
"""Return list of album UUIDs this photo is found in
|
||||
|
||||
Filters out albums in the trash and any special album types
|
||||
|
||||
Returns: list of album UUIDs
|
||||
Returns: list of album UUIDs
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
version4 = True
|
||||
@@ -980,6 +981,7 @@ class PhotoInfo:
|
||||
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]
|
||||
search_info = self.search_info.asdict() if self.search_info else {}
|
||||
|
||||
return {
|
||||
"library": self._db._library_path,
|
||||
@@ -1041,6 +1043,7 @@ class PhotoInfo:
|
||||
"original_filesize": self.original_filesize,
|
||||
"comments": comments,
|
||||
"likes": likes,
|
||||
"search_info": search_info,
|
||||
}
|
||||
|
||||
def json(self):
|
||||
|
||||
@@ -104,17 +104,19 @@ def _process_searchinfo(self):
|
||||
for row in c:
|
||||
uuid = ints_to_uuid(row[1], row[2])
|
||||
# strings have null character appended, so strip it
|
||||
record = {}
|
||||
record["uuid"] = uuid
|
||||
record["rowid"] = row[0]
|
||||
record["uuid_0"] = row[1]
|
||||
record["uuid_1"] = row[2]
|
||||
record["groupid"] = row[3]
|
||||
record["category"] = row[4]
|
||||
record["owning_groupid"] = row[5]
|
||||
record["content_string"] = normalize_unicode(row[6].replace("\x00", ""))
|
||||
record = {
|
||||
"uuid": uuid,
|
||||
"rowid": row[0],
|
||||
"uuid_0": row[1],
|
||||
"uuid_1": row[2],
|
||||
"groupid": row[3],
|
||||
"category": row[4],
|
||||
"owning_groupid": row[5],
|
||||
"content_string": normalize_unicode(row[6].replace("\x00", "")),
|
||||
}
|
||||
|
||||
record["normalized_string"] = normalize_unicode(row[7].replace("\x00", ""))
|
||||
record["lookup_identifier"] = row[8]
|
||||
record["lookup_identifier"] = normalize_unicode(row[8].replace("\x00", ""))
|
||||
|
||||
try:
|
||||
_db_searchinfo_uuid[uuid].append(record)
|
||||
|
||||
@@ -70,12 +70,13 @@ class PhotosDB:
|
||||
from ._photosdb_process_scoreinfo import _process_scoreinfo
|
||||
from ._photosdb_process_comments import _process_comments
|
||||
|
||||
def __init__(self, dbfile=None, verbose=None):
|
||||
def __init__(self, dbfile=None, verbose=None, exiftool=None):
|
||||
""" Create a new PhotosDB object.
|
||||
|
||||
Args:
|
||||
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
|
||||
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if dbfile is not a valid Photos library.
|
||||
@@ -98,6 +99,8 @@ class PhotosDB:
|
||||
raise TypeError("verbose must be callable")
|
||||
self._verbose = verbose
|
||||
|
||||
self._exiftool_path = exiftool
|
||||
|
||||
# create a temporary directory
|
||||
# tempfile.TemporaryDirectory gets cleaned up when the object does
|
||||
self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
|
||||
@@ -70,18 +70,18 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
+ "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
+ "If used with no template will return null value. "
|
||||
+ "See https://strftime.org/ for help on strftime templates.",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
|
||||
"{modified.year}": "4-digit year of photo modification time",
|
||||
"{modified.yy}": "2-digit year of photo modification time",
|
||||
"{modified.mm}": "2-digit month of the photo modification time (zero padded)",
|
||||
"{modified.month}": "Month name in user's locale of the photo modification time",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the photo modification time",
|
||||
"{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time",
|
||||
"{modified.dow}": "Day of week in user's locale of the photo modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)",
|
||||
"{modified.hour}": "2-digit hour of the photo modification time",
|
||||
"{modified.min}": "2-digit minute of the photo modification time",
|
||||
"{modified.sec}": "2-digit second of the photo modification time",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified",
|
||||
"{modified.year}": "4-digit year of photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.yy}": "2-digit year of photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.mm}": "2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified",
|
||||
"{modified.month}": "Month name in user's locale of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.dow}": "Day of week in user's locale of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified",
|
||||
"{modified.hour}": "2-digit hour of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.min}": "2-digit minute of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.sec}": "2-digit second of the photo modification time; uses creation date if photo is not modified",
|
||||
# "{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
|
||||
# + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
|
||||
# + "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
@@ -117,6 +117,7 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
|
||||
"{place.address.country}": "Country name of the postal address, e.g. 'United States'",
|
||||
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
|
||||
"{searchinfo.season}": "Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
|
||||
}
|
||||
|
||||
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
|
||||
@@ -125,13 +126,17 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
|
||||
"{keyword}": "Keyword(s) assigned to photo",
|
||||
"{person}": "Person(s) / face(s) in a photo",
|
||||
"{label}": "Image categorization label associated with a photo (Photos 5 only)",
|
||||
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
|
||||
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)",
|
||||
"{label}": "Image categorization label associated with a photo (Photos 5+ only)",
|
||||
"{label_normalized}": "All lower case version of 'label' (Photos 5+ only)",
|
||||
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)",
|
||||
"{exiftool:GROUP:TAGNAME}": "Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. "
|
||||
"E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. "
|
||||
"See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) "
|
||||
"as used in `exiftool -G`. exiftool must be installed in the path to use this template.",
|
||||
"{searchinfo.holiday}": "Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
|
||||
"{searchinfo.activity}": "Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
|
||||
"{searchinfo.venue}": "Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
|
||||
"{searchinfo.venue_type}": "Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
|
||||
}
|
||||
|
||||
# Just the multi-valued substitution names without the braces
|
||||
@@ -144,13 +149,15 @@ MULTI_VALUE_SUBSTITUTIONS = [
|
||||
class PhotoTemplate:
|
||||
""" PhotoTemplate class to render a template string from a PhotoInfo object """
|
||||
|
||||
def __init__(self, photo):
|
||||
""" Inits PhotoTemplate class with photo, non_str, and path_sep
|
||||
def __init__(self, photo, exiftool_path=None):
|
||||
""" Inits PhotoTemplate class with photo
|
||||
|
||||
Args:
|
||||
photo: a PhotoInfo instance.
|
||||
exiftool_path: optional path to exiftool for use with {exiftool:} template; if not provided, will look for exiftool in $PATH
|
||||
"""
|
||||
self.photo = photo
|
||||
self.exiftool_path = exiftool_path
|
||||
|
||||
# holds value of current date/time for {today.x} fields
|
||||
# gets initialized in get_template_value
|
||||
@@ -510,9 +517,9 @@ class PhotoTemplate:
|
||||
subfield = field[9:]
|
||||
|
||||
if not self.photo.path:
|
||||
values = []
|
||||
values = [None]
|
||||
else:
|
||||
exif = ExifTool(self.photo.path)
|
||||
exif = ExifTool(self.photo.path, exiftool=self.exiftool_path)
|
||||
exifdict = exif.asdict()
|
||||
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
|
||||
subfield = subfield.lower()
|
||||
@@ -672,73 +679,73 @@ class PhotoTemplate:
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).date
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).date
|
||||
)
|
||||
elif field == "modified.year":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).year
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).year
|
||||
)
|
||||
elif field == "modified.yy":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).yy
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).yy
|
||||
)
|
||||
elif field == "modified.mm":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).mm
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).mm
|
||||
)
|
||||
elif field == "modified.month":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).month
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).month
|
||||
)
|
||||
elif field == "modified.mon":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).mon
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).mon
|
||||
)
|
||||
elif field == "modified.dd":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).dd
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).dd
|
||||
)
|
||||
elif field == "modified.dow":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).dow
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).dow
|
||||
)
|
||||
elif field == "modified.doy":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).doy
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).doy
|
||||
)
|
||||
elif field == "modified.hour":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).hour
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).hour
|
||||
)
|
||||
elif field == "modified.min":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).min
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).min
|
||||
)
|
||||
elif field == "modified.sec":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).sec
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).sec
|
||||
)
|
||||
elif field == "today.date":
|
||||
value = DateTimeFormatter(self.today).date
|
||||
@@ -842,6 +849,8 @@ class PhotoTemplate:
|
||||
if self.photo.place and self.photo.place.address.iso_country_code
|
||||
else None
|
||||
)
|
||||
elif field == "searchinfo.season":
|
||||
value = self.photo.search_info.season if self.photo.search_info else None
|
||||
else:
|
||||
# if here, didn't get a match
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
@@ -870,7 +879,7 @@ class PhotoTemplate:
|
||||
"""
|
||||
|
||||
""" return list of values for a multi-valued template field """
|
||||
values = []
|
||||
values = []
|
||||
if field == "album":
|
||||
values = self.photo.albums
|
||||
elif field == "keyword":
|
||||
@@ -914,6 +923,16 @@ class PhotoTemplate:
|
||||
values = [
|
||||
f"{comment.user}: {comment.text}" for comment in self.photo.comments
|
||||
]
|
||||
elif field == "searchinfo.holiday":
|
||||
values = self.photo.search_info.holidays if self.photo.search_info else []
|
||||
elif field == "searchinfo.activity":
|
||||
values = self.photo.search_info.activities if self.photo.search_info else []
|
||||
elif field == "searchinfo.venue":
|
||||
values = self.photo.search_info.venues if self.photo.search_info else []
|
||||
elif field == "searchinfo.venue_type":
|
||||
values = (
|
||||
self.photo.search_info.venue_types if self.photo.search_info else []
|
||||
)
|
||||
elif not field.startswith("exiftool:"):
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
|
||||
<%def name="dc_subject(subject)">
|
||||
% if subject:
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
% for subj in subject:
|
||||
|
||||
@@ -42,6 +42,7 @@ mccabe==0.6.1
|
||||
modulegraph==0.18
|
||||
more-itertools==7.2.0
|
||||
multidict==4.7.6
|
||||
osxmetadata>=0.99.11
|
||||
packaging==19.0
|
||||
parso==0.6.2
|
||||
pathspec==0.7.0
|
||||
|
||||
1
setup.py
@@ -81,6 +81,7 @@ setup(
|
||||
"wurlitzer>=2.0.1",
|
||||
"photoscript>=0.1.0",
|
||||
"toml>=0.10.0",
|
||||
"osxmetadata>=0.99.13",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>485</integer>
|
||||
<integer>55247</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
|
Before Width: | Height: | Size: 574 KiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 550 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
1
tests/search_info_test_data_10_15_7.json
Normal file
BIN
tests/test-images/badimage.jpeg
Normal file
|
After Width: | Height: | Size: 500 KiB |
BIN
tests/test-images/exiftool_warning.heic
Normal file
BIN
tests/test-images/screenshot-really-a-png.jpeg
Normal file
|
After Width: | Height: | Size: 550 KiB |
@@ -18,10 +18,10 @@ PHOTOS_DB = "tests/Test-10.15.7.photoslibrary/database/photos.db"
|
||||
PHOTOS_DB_PATH = "/Test-10.15.7.photoslibrary/database/photos.db"
|
||||
PHOTOS_LIBRARY_PATH = "/Test-10.15.7.photoslibrary"
|
||||
|
||||
PHOTOS_DB_LEN = 18
|
||||
PHOTOS_NOT_IN_TRASH_LEN = 16
|
||||
PHOTOS_DB_LEN = 19
|
||||
PHOTOS_NOT_IN_TRASH_LEN = 17
|
||||
PHOTOS_IN_TRASH_LEN = 2
|
||||
PHOTOS_DB_IMPORT_SESSIONS = 13
|
||||
PHOTOS_DB_IMPORT_SESSIONS = 14
|
||||
|
||||
KEYWORDS = [
|
||||
"Kids",
|
||||
@@ -35,6 +35,7 @@ KEYWORDS = [
|
||||
"United Kingdom",
|
||||
"foo/bar",
|
||||
"Travel",
|
||||
"Maria",
|
||||
]
|
||||
# Photos 5 includes blank person for detected face
|
||||
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
||||
@@ -60,6 +61,7 @@ KEYWORDS_DICT = {
|
||||
"United Kingdom": 1,
|
||||
"foo/bar": 1,
|
||||
"Travel": 2,
|
||||
"Maria": 1,
|
||||
}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 2, _UNKNOWN_PERSON: 1}
|
||||
ALBUM_DICT = {
|
||||
@@ -339,7 +341,7 @@ def test_attributes_2(photosdb):
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
assert p.keywords == ["wedding"]
|
||||
assert sorted(p.keywords) == ["Maria", "wedding"]
|
||||
assert p.original_filename == "wedding.jpg"
|
||||
assert p.filename == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg"
|
||||
assert p.date == datetime.datetime(
|
||||
@@ -971,7 +973,7 @@ def test_from_to_date(photosdb):
|
||||
time.tzset()
|
||||
|
||||
photos = photosdb.photos(from_date=datetime.datetime(2018, 10, 28))
|
||||
assert len(photos) == 9
|
||||
assert len(photos) == 10
|
||||
|
||||
photos = photosdb.photos(to_date=datetime.datetime(2018, 10, 28))
|
||||
assert len(photos) == 7
|
||||
|
||||
1047
tests/test_cli.py
@@ -2,6 +2,8 @@ import pytest
|
||||
from osxphotos.exiftool import get_exiftool_path
|
||||
|
||||
TEST_FILE_ONE_KEYWORD = "tests/test-images/wedding.jpg"
|
||||
TEST_FILE_BAD_IMAGE = "tests/test-images/badimage.jpeg"
|
||||
TEST_FILE_WARNING = "tests/test-images/exiftool_warning.heic"
|
||||
TEST_FILE_MULTI_KEYWORD = "tests/test-images/Tulips.jpg"
|
||||
TEST_MULTI_KEYWORDS = [
|
||||
"Top Shot",
|
||||
@@ -109,8 +111,8 @@ def test_setvalue_1():
|
||||
assert exif.data["IPTC:Keywords"] == "test"
|
||||
|
||||
|
||||
def test_setvalue_error():
|
||||
# test setting illegal tag value generates error
|
||||
def test_setvalue_warning():
|
||||
# test setting illegal tag value generates warning
|
||||
import os.path
|
||||
import tempfile
|
||||
import osxphotos.exiftool
|
||||
@@ -122,6 +124,22 @@ def test_setvalue_error():
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(tempfile)
|
||||
exif.setvalue("IPTC:Foo", "test")
|
||||
assert exif.warning
|
||||
|
||||
|
||||
def test_setvalue_error():
|
||||
# test setting tag on bad image generates error
|
||||
import os.path
|
||||
import tempfile
|
||||
import osxphotos.exiftool
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_BAD_IMAGE))
|
||||
FileUtil.copy(TEST_FILE_BAD_IMAGE, tempfile)
|
||||
|
||||
exif = osxphotos.exiftool.ExifTool(tempfile)
|
||||
exif.setvalue("IPTC:Keywords", "test")
|
||||
assert exif.error
|
||||
|
||||
|
||||
@@ -142,7 +160,7 @@ def test_setvalue_context_manager():
|
||||
exif.setvalue("XMP:Title", "title")
|
||||
exif.setvalue("XMP:Subject", "subject")
|
||||
|
||||
assert exif.error is None
|
||||
assert not exif.error
|
||||
|
||||
exif2 = osxphotos.exiftool.ExifTool(tempfile)
|
||||
exif2._read_exif()
|
||||
@@ -151,8 +169,8 @@ def test_setvalue_context_manager():
|
||||
assert exif2.data["XMP:Subject"] == "subject"
|
||||
|
||||
|
||||
def test_setvalue_context_manager_error():
|
||||
# test setting a tag value as context manager when error generated
|
||||
def test_setvalue_context_manager_warning():
|
||||
# test setting a tag value as context manager when warning generated
|
||||
import os.path
|
||||
import tempfile
|
||||
import osxphotos.exiftool
|
||||
@@ -164,9 +182,48 @@ def test_setvalue_context_manager_error():
|
||||
|
||||
with osxphotos.exiftool.ExifTool(tempfile) as exif:
|
||||
exif.setvalue("Foo:Bar", "test1")
|
||||
assert exif.warning
|
||||
|
||||
|
||||
def test_setvalue_context_manager_error():
|
||||
# test setting a tag value as context manager when error generated
|
||||
import os.path
|
||||
import tempfile
|
||||
import osxphotos.exiftool
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_BAD_IMAGE))
|
||||
FileUtil.copy(TEST_FILE_BAD_IMAGE, tempfile)
|
||||
|
||||
with osxphotos.exiftool.ExifTool(tempfile) as exif:
|
||||
exif.setvalue("IPTC:Keywords", "test1")
|
||||
assert exif.error
|
||||
|
||||
|
||||
def test_flags():
|
||||
# test that flags work
|
||||
import os.path
|
||||
import tempfile
|
||||
import osxphotos.exiftool
|
||||
from osxphotos.fileutil import FileUtil
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_WARNING))
|
||||
FileUtil.copy(TEST_FILE_WARNING, tempfile)
|
||||
|
||||
with osxphotos.exiftool.ExifTool(tempfile) as exif:
|
||||
exif.setvalue("XMP:Subject", "foo/bar")
|
||||
assert exif.warning
|
||||
|
||||
# test again with -m: ignore minor warnings
|
||||
FileUtil.unlink(tempfile)
|
||||
FileUtil.copy(TEST_FILE_WARNING, tempfile)
|
||||
with osxphotos.exiftool.ExifTool(tempfile, flags=["-m"]) as exif:
|
||||
exif.setvalue("XMP:Subject", "foo/bar")
|
||||
assert not exif.warning
|
||||
|
||||
|
||||
def test_clear_value():
|
||||
# test clearing a tag value
|
||||
import os.path
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
from osxphotos.exiftool import get_exiftool_path
|
||||
from osxphotos.utils import dd_to_dms_str
|
||||
@@ -12,6 +13,12 @@ except:
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.15.7.photoslibrary/database/photos.db"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def photosdb():
|
||||
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
|
||||
|
||||
KEYWORDS = [
|
||||
"Kids",
|
||||
"wedding",
|
||||
@@ -22,6 +29,7 @@ KEYWORDS = [
|
||||
"St. James's Park",
|
||||
"UK",
|
||||
"United Kingdom",
|
||||
"Maria",
|
||||
]
|
||||
# Photos 5 includes blank person for detected face
|
||||
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
||||
@@ -39,6 +47,7 @@ KEYWORDS_DICT = {
|
||||
"St. James's Park": 1,
|
||||
"UK": 1,
|
||||
"United Kingdom": 1,
|
||||
"Maria": 1,
|
||||
}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
|
||||
ALBUM_DICT = {
|
||||
@@ -70,8 +79,8 @@ EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
|
||||
EXIF_JSON_EXPECTED = """
|
||||
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||
"XMP:Description": "Bride Wedding day",
|
||||
"XMP:TagsList": ["wedding"],
|
||||
"IPTC:Keywords": ["wedding"],
|
||||
"XMP:TagsList": ["Maria", "wedding"],
|
||||
"IPTC:Keywords": ["Maria", "wedding"],
|
||||
"XMP:PersonInImage": ["Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
@@ -82,11 +91,26 @@ EXIF_JSON_EXPECTED = """
|
||||
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
|
||||
"""
|
||||
|
||||
EXIFTOOL_SIDECAR_EXPECTED = """
|
||||
[{"ImageDescription": "Bride Wedding day",
|
||||
"Description": "Bride Wedding day",
|
||||
"TagsList": ["Maria", "wedding"],
|
||||
"Keywords": ["Maria", "wedding"],
|
||||
"PersonInImage": ["Maria"],
|
||||
"Subject": ["wedding", "Maria"],
|
||||
"DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
"CreateDate": "2019:04:15 14:40:24",
|
||||
"OffsetTimeOriginal": "-04:00",
|
||||
"DateCreated": "2019:04:15",
|
||||
"TimeCreated": "14:40:24-04:00",
|
||||
"ModifyDate": "2019:07:27 17:33:28"}]
|
||||
"""
|
||||
|
||||
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
|
||||
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||
"XMP:Description": "Bride Wedding day",
|
||||
"XMP:TagsList": ["wedding"],
|
||||
"IPTC:Keywords": ["wedding"],
|
||||
"XMP:TagsList": ["Maria", "wedding"],
|
||||
"IPTC:Keywords": ["Maria", "wedding"],
|
||||
"XMP:PersonInImage": ["Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
@@ -98,18 +122,15 @@ EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
|
||||
"""
|
||||
|
||||
|
||||
def test_export_1():
|
||||
def test_export_1(photosdb):
|
||||
# test basic export
|
||||
# get an unedited image and export it using default filename
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -120,18 +141,15 @@ def test_export_1():
|
||||
assert os.path.isfile(got_dest)
|
||||
|
||||
|
||||
def test_export_2():
|
||||
def test_export_2(photosdb):
|
||||
# test export with user provided filename
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -143,18 +161,15 @@ def test_export_2():
|
||||
assert os.path.isfile(got_dest)
|
||||
|
||||
|
||||
def test_export_3():
|
||||
def test_export_3(photosdb):
|
||||
# test file already exists and test increment=True (default)
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -170,7 +185,7 @@ def test_export_3():
|
||||
assert os.path.isfile(got_dest_2)
|
||||
|
||||
|
||||
def test_export_4():
|
||||
def test_export_4(photosdb):
|
||||
# test user supplied file already exists and test increment=True (default)
|
||||
import os
|
||||
import os.path
|
||||
@@ -178,11 +193,8 @@ def test_export_4():
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -198,18 +210,15 @@ def test_export_4():
|
||||
assert os.path.isfile(got_dest_2)
|
||||
|
||||
|
||||
def test_export_5():
|
||||
def test_export_5(photosdb):
|
||||
# test file already exists and test increment=True (default)
|
||||
# and overwrite = True
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -223,7 +232,7 @@ def test_export_5():
|
||||
assert os.path.isfile(got_dest_2)
|
||||
|
||||
|
||||
def test_export_6():
|
||||
def test_export_6(photosdb):
|
||||
# test user supplied file already exists and test increment=True (default)
|
||||
# and overwrite = True
|
||||
import os
|
||||
@@ -232,11 +241,8 @@ def test_export_6():
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -251,18 +257,15 @@ def test_export_6():
|
||||
assert os.path.isfile(got_dest_2)
|
||||
|
||||
|
||||
def test_export_7():
|
||||
def test_export_7(photosdb):
|
||||
# test file already exists and test increment=False (not default), overwrite=False (default)
|
||||
# should raise exception
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -275,18 +278,15 @@ def test_export_7():
|
||||
assert e.type == type(FileExistsError())
|
||||
|
||||
|
||||
def test_export_8():
|
||||
def test_export_8(photosdb):
|
||||
# try to export missing file
|
||||
# should raise exception
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -297,18 +297,15 @@ def test_export_8():
|
||||
assert e.type == type(FileNotFoundError())
|
||||
|
||||
|
||||
def test_export_9():
|
||||
def test_export_9(photosdb):
|
||||
# try to export edited file that's not edited
|
||||
# should raise exception
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
|
||||
|
||||
with pytest.raises(Exception) as e:
|
||||
@@ -316,7 +313,7 @@ def test_export_9():
|
||||
assert e.type == ValueError
|
||||
|
||||
|
||||
def test_export_10():
|
||||
def test_export_10(photosdb):
|
||||
# try to export edited file that's not edited and name provided
|
||||
# should raise exception
|
||||
import os
|
||||
@@ -324,11 +321,8 @@ def test_export_10():
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -340,18 +334,15 @@ def test_export_10():
|
||||
assert e.type == ValueError
|
||||
|
||||
|
||||
def test_export_11():
|
||||
def test_export_11(photosdb):
|
||||
# export edited file with name provided
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -362,18 +353,15 @@ def test_export_11():
|
||||
assert got_dest == expected_dest
|
||||
|
||||
|
||||
def test_export_12():
|
||||
def test_export_12(photosdb):
|
||||
# export edited file with default name
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
|
||||
|
||||
edited_name = pathlib.Path(photos[0].path_edited).name
|
||||
@@ -385,15 +373,13 @@ def test_export_12():
|
||||
assert got_dest == expected_dest
|
||||
|
||||
|
||||
def test_export_13():
|
||||
def test_export_13(photosdb):
|
||||
# export to invalid destination
|
||||
# should raise exception
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
|
||||
@@ -403,7 +389,6 @@ def test_export_13():
|
||||
dest = os.path.join(dest, str(i))
|
||||
i += 1
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -415,7 +400,6 @@ def test_export_13():
|
||||
|
||||
|
||||
def test_dd_to_dms_str_1():
|
||||
import osxphotos
|
||||
|
||||
lat_str, lon_str = dd_to_dms_str(
|
||||
34.559331096, 69.206499174
|
||||
@@ -426,7 +410,6 @@ def test_dd_to_dms_str_1():
|
||||
|
||||
|
||||
def test_dd_to_dms_str_2():
|
||||
import osxphotos
|
||||
|
||||
lat_str, lon_str = dd_to_dms_str(
|
||||
-34.601997592, -58.375665164
|
||||
@@ -437,7 +420,6 @@ def test_dd_to_dms_str_2():
|
||||
|
||||
|
||||
def test_dd_to_dms_str_3():
|
||||
import osxphotos
|
||||
|
||||
lat_str, lon_str = dd_to_dms_str(
|
||||
-1.2666656, 36.7999968
|
||||
@@ -448,7 +430,6 @@ def test_dd_to_dms_str_3():
|
||||
|
||||
|
||||
def test_dd_to_dms_str_4():
|
||||
import osxphotos
|
||||
|
||||
lat_str, lon_str = dd_to_dms_str(
|
||||
38.889248, -77.050636
|
||||
@@ -458,11 +439,9 @@ def test_dd_to_dms_str_4():
|
||||
assert lon_str == "77 deg 3' 2.29\" W"
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar():
|
||||
import osxphotos
|
||||
def test_exiftool_json_sidecar(photosdb):
|
||||
import json
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
|
||||
|
||||
json_expected = json.loads(EXIF_JSON_EXPECTED)[0]
|
||||
@@ -484,11 +463,9 @@ def test_exiftool_json_sidecar():
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar_ignore_date_modified():
|
||||
import osxphotos
|
||||
def test_exiftool_json_sidecar_ignore_date_modified(photosdb):
|
||||
import json
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
|
||||
|
||||
json_expected = json.loads(EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED)[0]
|
||||
@@ -510,22 +487,20 @@ def test_exiftool_json_sidecar_ignore_date_modified():
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar_keyword_template_long(caplog):
|
||||
import osxphotos
|
||||
def test_exiftool_json_sidecar_keyword_template_long(caplog, photosdb):
|
||||
from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN
|
||||
import json
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
|
||||
|
||||
json_expected = json.loads(
|
||||
"""
|
||||
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||
"XMP:Description": "Bride Wedding day",
|
||||
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||
"XMP:TagsList": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||
"IPTC:Keywords": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||
"XMP:Subject": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||
"XMP:PersonInImage": ["Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
"EXIF:CreateDate": "2019:04:15 14:40:24",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
@@ -560,21 +535,19 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar_keyword_template():
|
||||
import osxphotos
|
||||
def test_exiftool_json_sidecar_keyword_template(photosdb):
|
||||
import json
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
|
||||
|
||||
json_expected = json.loads(
|
||||
"""
|
||||
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||
"XMP:Description": "Bride Wedding day",
|
||||
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||
"XMP:TagsList": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||
"IPTC:Keywords": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||
"XMP:Subject": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||
"XMP:PersonInImage": ["Maria"],
|
||||
"XMP:Subject": ["wedding", "Maria"],
|
||||
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
|
||||
"EXIF:CreateDate": "2019:04:15 14:40:24",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
@@ -620,11 +593,9 @@ def test_exiftool_json_sidecar_keyword_template():
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar_use_persons_keyword():
|
||||
import osxphotos
|
||||
def test_exiftool_json_sidecar_use_persons_keyword(photosdb):
|
||||
import json
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
json_expected = json.loads(
|
||||
@@ -662,11 +633,9 @@ def test_exiftool_json_sidecar_use_persons_keyword():
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar_use_albums_keyword():
|
||||
import osxphotos
|
||||
def test_exiftool_json_sidecar_use_albums_keyword(photosdb):
|
||||
import json
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
json_expected = json.loads(
|
||||
@@ -677,7 +646,7 @@ def test_exiftool_json_sidecar_use_albums_keyword():
|
||||
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
|
||||
"IPTC:Keywords": ["Kids", "Pumpkin Farm", "Test Album"],
|
||||
"XMP:PersonInImage": ["Suzy", "Katie"],
|
||||
"XMP:Subject": ["Kids", "Suzy", "Katie"],
|
||||
"XMP:Subject": ["Kids", "Pumpkin Farm", "Test Album"],
|
||||
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
|
||||
"EXIF:CreateDate": "2018:09:28 15:35:49",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
@@ -704,26 +673,46 @@ def test_exiftool_json_sidecar_use_albums_keyword():
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_exiftool_sidecar(photosdb):
|
||||
import json
|
||||
|
||||
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
|
||||
|
||||
json_expected = json.loads(EXIFTOOL_SIDECAR_EXPECTED)[0]
|
||||
|
||||
json_got = photos[0]._exiftool_json_sidecar(tag_groups=False)
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
# some gymnastics to account for different sort order in different pythons
|
||||
for k, v in json_got.items():
|
||||
if type(v) in (list, tuple):
|
||||
assert sorted(json_expected[k]) == sorted(v)
|
||||
else:
|
||||
assert json_expected[k] == v
|
||||
|
||||
for k, v in json_expected.items():
|
||||
if type(v) in (list, tuple):
|
||||
assert sorted(json_got[k]) == sorted(v)
|
||||
else:
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||
def test_xmp_sidecar_is_valid(tmp_path):
|
||||
def test_xmp_sidecar_is_valid(tmp_path, photosdb):
|
||||
""" validate XMP sidecar file with exiftool """
|
||||
import osxphotos
|
||||
from osxphotos.exiftool import ExifTool
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
photos[0].export(str(tmp_path), XMP_JPG_FILENAME, sidecar_xmp=True)
|
||||
xmp_file = tmp_path / XMP_FILENAME
|
||||
assert xmp_file.is_file()
|
||||
exiftool = ExifTool(str(xmp_file))
|
||||
output, _ = exiftool.run_commands("-validate", "-warning")
|
||||
output, _, _ = exiftool.run_commands("-validate", "-warning")
|
||||
assert output == b"[ExifTool] Validate : 0 0 0"
|
||||
|
||||
|
||||
def test_xmp_sidecar():
|
||||
import osxphotos
|
||||
def test_xmp_sidecar(photosdb):
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
@@ -736,12 +725,9 @@ def test_xmp_sidecar():
|
||||
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
|
||||
<dc:description>Girls with pumpkins</dc:description>
|
||||
<dc:title>Can we carry this?</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>Kids</rdf:li>
|
||||
<rdf:li>Suzy</rdf:li>
|
||||
<rdf:li>Katie</rdf:li>
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
@@ -785,11 +771,9 @@ def test_xmp_sidecar():
|
||||
assert line_expected == line_got
|
||||
|
||||
|
||||
def test_xmp_sidecar_extension():
|
||||
def test_xmp_sidecar_extension(photosdb):
|
||||
""" test XMP sidecar when no extension is passed """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
@@ -802,12 +786,9 @@ def test_xmp_sidecar_extension():
|
||||
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
|
||||
<dc:description>Girls with pumpkins</dc:description>
|
||||
<dc:title>Can we carry this?</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>Kids</rdf:li>
|
||||
<rdf:li>Suzy</rdf:li>
|
||||
<rdf:li>Katie</rdf:li>
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
@@ -851,10 +832,8 @@ def test_xmp_sidecar_extension():
|
||||
assert line_expected == line_got
|
||||
|
||||
|
||||
def test_xmp_sidecar_use_persons_keyword():
|
||||
import osxphotos
|
||||
def test_xmp_sidecar_use_persons_keyword(photosdb):
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
@@ -867,7 +846,6 @@ def test_xmp_sidecar_use_persons_keyword():
|
||||
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
|
||||
<dc:description>Girls with pumpkins</dc:description>
|
||||
<dc:title>Can we carry this?</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>Kids</rdf:li>
|
||||
@@ -918,10 +896,8 @@ def test_xmp_sidecar_use_persons_keyword():
|
||||
assert line_expected == line_got
|
||||
|
||||
|
||||
def test_xmp_sidecar_use_albums_keyword():
|
||||
import osxphotos
|
||||
def test_xmp_sidecar_use_albums_keyword(photosdb):
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
@@ -934,12 +910,11 @@ def test_xmp_sidecar_use_albums_keyword():
|
||||
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
|
||||
<dc:description>Girls with pumpkins</dc:description>
|
||||
<dc:title>Can we carry this?</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>Kids</rdf:li>
|
||||
<rdf:li>Suzy</rdf:li>
|
||||
<rdf:li>Katie</rdf:li>
|
||||
<rdf:li>Pumpkin Farm</rdf:li>
|
||||
<rdf:li>Test Album</rdf:li>
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
@@ -985,11 +960,9 @@ def test_xmp_sidecar_use_albums_keyword():
|
||||
assert line_expected == line_got
|
||||
|
||||
|
||||
def test_xmp_sidecar_gps():
|
||||
def test_xmp_sidecar_gps(photosdb):
|
||||
""" Test export XMP sidecar with GPS info """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
@@ -1002,7 +975,6 @@ def test_xmp_sidecar_gps():
|
||||
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
|
||||
<dc:description></dc:description>
|
||||
<dc:title>St. James's Park</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>UK</rdf:li>
|
||||
@@ -1055,10 +1027,8 @@ def test_xmp_sidecar_gps():
|
||||
assert line_expected == line_got
|
||||
|
||||
|
||||
def test_xmp_sidecar_keyword_template():
|
||||
import osxphotos
|
||||
def test_xmp_sidecar_keyword_template(photosdb):
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
@@ -1071,12 +1041,12 @@ def test_xmp_sidecar_keyword_template():
|
||||
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
|
||||
<dc:description>Girls with pumpkins</dc:description>
|
||||
<dc:title>Can we carry this?</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>Kids</rdf:li>
|
||||
<rdf:li>Suzy</rdf:li>
|
||||
<rdf:li>Katie</rdf:li>
|
||||
<rdf:li>Pumpkin Farm</rdf:li>
|
||||
<rdf:li>Test Album</rdf:li>
|
||||
<rdf:li>2018</rdf:li>
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import pytest
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
|
||||
# TODO: put some of this code into a pre-function
|
||||
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
|
||||
PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db"
|
||||
@@ -63,18 +62,20 @@ EXIF_JSON_EXPECTED = """
|
||||
"""
|
||||
|
||||
|
||||
def test_export_1():
|
||||
@pytest.fixture(scope="module")
|
||||
def photosdb():
|
||||
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
|
||||
|
||||
def test_export_1(photosdb):
|
||||
# test basic export
|
||||
# get an unedited image and export it using default filename
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -85,18 +86,15 @@ def test_export_1():
|
||||
assert os.path.isfile(got_dest)
|
||||
|
||||
|
||||
def test_export_2():
|
||||
def test_export_2(photosdb):
|
||||
# test export with user provided filename
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -108,18 +106,15 @@ def test_export_2():
|
||||
assert os.path.isfile(got_dest)
|
||||
|
||||
|
||||
def test_export_3():
|
||||
def test_export_3(photosdb):
|
||||
# test file already exists and test increment=True (default)
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -135,7 +130,7 @@ def test_export_3():
|
||||
assert os.path.isfile(got_dest_2)
|
||||
|
||||
|
||||
def test_export_4():
|
||||
def test_export_4(photosdb):
|
||||
# test user supplied file already exists and test increment=True (default)
|
||||
import os
|
||||
import os.path
|
||||
@@ -143,11 +138,8 @@ def test_export_4():
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -163,18 +155,15 @@ def test_export_4():
|
||||
assert os.path.isfile(got_dest_2)
|
||||
|
||||
|
||||
def test_export_5():
|
||||
def test_export_5(photosdb):
|
||||
# test file already exists and test increment=True (default)
|
||||
# and overwrite = True
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -188,7 +177,7 @@ def test_export_5():
|
||||
assert os.path.isfile(got_dest_2)
|
||||
|
||||
|
||||
def test_export_6():
|
||||
def test_export_6(photosdb):
|
||||
# test user supplied file already exists and test increment=True (default)
|
||||
# and overwrite = True
|
||||
import os
|
||||
@@ -197,11 +186,8 @@ def test_export_6():
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -216,18 +202,15 @@ def test_export_6():
|
||||
assert os.path.isfile(got_dest_2)
|
||||
|
||||
|
||||
def test_export_7():
|
||||
def test_export_7(photosdb):
|
||||
# test file already exists and test increment=False (not default), overwrite=False (default)
|
||||
# should raise exception
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -240,18 +223,15 @@ def test_export_7():
|
||||
assert e.type == type(FileExistsError())
|
||||
|
||||
|
||||
def test_export_8():
|
||||
def test_export_8(photosdb):
|
||||
# try to export missing file
|
||||
# should raise exception
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["missing"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -262,18 +242,15 @@ def test_export_8():
|
||||
assert e.type == type(FileNotFoundError())
|
||||
|
||||
|
||||
def test_export_9():
|
||||
def test_export_9(photosdb):
|
||||
# try to export edited file that's not edited
|
||||
# should raise exception
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -284,7 +261,7 @@ def test_export_9():
|
||||
assert e.type == ValueError
|
||||
|
||||
|
||||
def test_export_10():
|
||||
def test_export_10(photosdb):
|
||||
# try to export edited file that's not edited and name provided
|
||||
# should raise exception
|
||||
import os
|
||||
@@ -292,11 +269,8 @@ def test_export_10():
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -308,18 +282,15 @@ def test_export_10():
|
||||
assert e.type == ValueError
|
||||
|
||||
|
||||
def test_export_11():
|
||||
def test_export_11(photosdb):
|
||||
# export edited file with name provided
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
|
||||
|
||||
timestamp = time.time()
|
||||
@@ -330,18 +301,15 @@ def test_export_11():
|
||||
assert got_dest == expected_dest
|
||||
|
||||
|
||||
def test_export_12():
|
||||
def test_export_12(photosdb):
|
||||
# export edited file with default name
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
|
||||
|
||||
edited_name = pathlib.Path(photos[0].path_edited).name
|
||||
@@ -353,15 +321,13 @@ def test_export_12():
|
||||
assert got_dest == expected_dest
|
||||
|
||||
|
||||
def test_export_13():
|
||||
def test_export_13(photosdb):
|
||||
# export to invalid destination
|
||||
# should raise exception
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dest = tempdir.name
|
||||
|
||||
@@ -371,7 +337,6 @@ def test_export_13():
|
||||
dest = os.path.join(dest, str(i))
|
||||
i += 1
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
|
||||
filename = photos[0].filename
|
||||
@@ -382,11 +347,9 @@ def test_export_13():
|
||||
assert e.type == type(FileNotFoundError())
|
||||
|
||||
|
||||
def test_exiftool_json_sidecar():
|
||||
import osxphotos
|
||||
def test_exiftool_json_sidecar(photosdb):
|
||||
import json
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
|
||||
|
||||
json_expected = json.loads(EXIF_JSON_EXPECTED)[0]
|
||||
@@ -408,10 +371,8 @@ def test_exiftool_json_sidecar():
|
||||
assert json_got[k] == v
|
||||
|
||||
|
||||
def test_xmp_sidecar():
|
||||
import osxphotos
|
||||
def test_xmp_sidecar(photosdb):
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
@@ -424,12 +385,9 @@ def test_xmp_sidecar():
|
||||
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
|
||||
<dc:description>Girls with pumpkins</dc:description>
|
||||
<dc:title>Can we carry this?</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>Kids</rdf:li>
|
||||
<rdf:li>Suzy</rdf:li>
|
||||
<rdf:li>Katie</rdf:li>
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
@@ -438,8 +396,8 @@ def test_xmp_sidecar():
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
<rdf:li>Suzy</rdf:li>
|
||||
<rdf:li>Katie</rdf:li>
|
||||
<rdf:li>Suzy</rdf:li>
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
</rdf:Description>
|
||||
@@ -471,10 +429,8 @@ def test_xmp_sidecar():
|
||||
assert line_expected == line_got
|
||||
|
||||
|
||||
def test_xmp_sidecar_keyword_template():
|
||||
import osxphotos
|
||||
def test_xmp_sidecar_keyword_template(photosdb):
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
@@ -487,12 +443,12 @@ def test_xmp_sidecar_keyword_template():
|
||||
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
|
||||
<dc:description>Girls with pumpkins</dc:description>
|
||||
<dc:title>Can we carry this?</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>Kids</rdf:li>
|
||||
<rdf:li>Suzy</rdf:li>
|
||||
<rdf:li>Katie</rdf:li>
|
||||
<rdf:li>Test Album</rdf:li>
|
||||
<rdf:li>Pumpkin Farm</rdf:li>
|
||||
<rdf:li>2018</rdf:li>
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
|
||||
99
tests/test_exportresults.py
Normal file
@@ -0,0 +1,99 @@
|
||||
""" test ExportResults class """
|
||||
|
||||
import pytest
|
||||
from osxphotos.photoinfo import ExportResults
|
||||
|
||||
EXPORT_RESULT_ATTRIBUTES = [
|
||||
"exported",
|
||||
"new",
|
||||
"updated",
|
||||
"skipped",
|
||||
"exif_updated",
|
||||
"touched",
|
||||
"converted_to_jpeg",
|
||||
"sidecar_json_written",
|
||||
"sidecar_json_skipped",
|
||||
"sidecar_exiftool_written",
|
||||
"sidecar_exiftool_skipped",
|
||||
"sidecar_xmp_written",
|
||||
"sidecar_xmp_skipped",
|
||||
"missing",
|
||||
"error",
|
||||
"exiftool_warning",
|
||||
"exiftool_error",
|
||||
]
|
||||
|
||||
|
||||
def test_exportresults_init():
|
||||
results = ExportResults()
|
||||
assert results.exported == []
|
||||
assert results.new == []
|
||||
assert results.updated == []
|
||||
assert results.skipped == []
|
||||
assert results.exif_updated == []
|
||||
assert results.touched == []
|
||||
assert results.converted_to_jpeg == []
|
||||
assert results.sidecar_json_written == []
|
||||
assert results.sidecar_json_skipped == []
|
||||
assert results.sidecar_exiftool_written == []
|
||||
assert results.sidecar_exiftool_skipped == []
|
||||
assert results.sidecar_xmp_written == []
|
||||
assert results.sidecar_xmp_skipped == []
|
||||
assert results.missing == []
|
||||
assert results.error == []
|
||||
assert results.exiftool_warning == []
|
||||
assert results.exiftool_error == []
|
||||
|
||||
|
||||
def test_exportresults_iadd():
|
||||
results1 = ExportResults()
|
||||
results2 = ExportResults()
|
||||
for x in EXPORT_RESULT_ATTRIBUTES:
|
||||
setattr(results1, x, [f"{x}1"])
|
||||
setattr(results2, x, [f"{x}2"])
|
||||
|
||||
results1 += results2
|
||||
for x in EXPORT_RESULT_ATTRIBUTES:
|
||||
assert getattr(results1, x) == [f"{x}1", f"{x}2"]
|
||||
|
||||
# exiftool_warning and exiftool_error are lists of tuples
|
||||
results1 = ExportResults()
|
||||
results2 = ExportResults()
|
||||
results1.exiftool_warning = [("exiftool_warning1", "foo")]
|
||||
results2.exiftool_warning = [("exiftool_warning2", "bar")]
|
||||
results1.exiftool_error = [("exiftool_error1", "foo")]
|
||||
results2.exiftool_error = [("exiftool_error2", "bar")]
|
||||
|
||||
results1 += results2
|
||||
|
||||
assert results1.exiftool_warning == [
|
||||
("exiftool_warning1", "foo"),
|
||||
("exiftool_warning2", "bar"),
|
||||
]
|
||||
assert results1.exiftool_error == [
|
||||
("exiftool_error1", "foo"),
|
||||
("exiftool_error2", "bar"),
|
||||
]
|
||||
|
||||
|
||||
def test_all_files():
|
||||
""" test ExportResults.all_files() """
|
||||
results = ExportResults()
|
||||
for x in EXPORT_RESULT_ATTRIBUTES:
|
||||
setattr(results, x, [f"{x}1"])
|
||||
results.exiftool_warning = [("exiftool_warning1", "foo")]
|
||||
results.exiftool_error = [("exiftool_error1", "foo")]
|
||||
|
||||
assert sorted(results.all_files()) == sorted(
|
||||
[f"{x}1" for x in EXPORT_RESULT_ATTRIBUTES]
|
||||
)
|
||||
|
||||
|
||||
def test_str():
|
||||
""" test ExportResults.__str__ """
|
||||
results = ExportResults()
|
||||
assert (
|
||||
str(results)
|
||||
== "ExportResults(exported=[],new=[],updated=[],skipped=[],exif_updated=[],touched=[],converted_to_jpeg=[],sidecar_json_written=[],sidecar_json_skipped=[],sidecar_exiftool_written=[],sidecar_exiftool_skipped=[],sidecar_xmp_written=[],sidecar_xmp_skipped=[],missing=[],error=[],exiftool_warning=[],exiftool_error=[])"
|
||||
)
|
||||
|
||||
@@ -210,7 +210,7 @@ def test_search_info(photosdb):
|
||||
def test_labels_normalized(photosdb):
|
||||
for uuid in LABELS_NORMALIZED_DICT:
|
||||
photo = photosdb.photos(uuid=[uuid])[0]
|
||||
assert sorted(photo.search_info.labels_normalized) == sorted(
|
||||
assert sorted(photo.search_info_normalized.labels) == sorted(
|
||||
LABELS_NORMALIZED_DICT[uuid]
|
||||
)
|
||||
assert sorted(photo.labels_normalized) == sorted(LABELS_NORMALIZED_DICT[uuid])
|
||||
|
||||
@@ -349,7 +349,7 @@ def test_labels_normalized(photosdb):
|
||||
for uuid in LABELS_NORMALIZED_DICT:
|
||||
photo = photosdb.photos(uuid=[uuid])[0]
|
||||
logging.warning(f"uuid = {uuid}")
|
||||
assert sorted(photo.search_info.labels_normalized) == sorted(
|
||||
assert sorted(photo.search_info_normalized.labels) == sorted(
|
||||
LABELS_NORMALIZED_DICT[uuid]
|
||||
)
|
||||
assert sorted(photo.labels_normalized) == sorted(LABELS_NORMALIZED_DICT[uuid])
|
||||
|
||||
65
tests/test_search_info_10_15_7.py
Normal file
@@ -0,0 +1,65 @@
|
||||
""" test SearchInfo class """
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import osxphotos
|
||||
|
||||
# These tests must be run against the author's personal photo library
|
||||
skip_test = "OSXPHOTOS_TEST_EXPORT" not in os.environ
|
||||
pytestmark = pytest.mark.skipif(
|
||||
skip_test, reason="These tests only run against system Photos library"
|
||||
)
|
||||
|
||||
PHOTOS_DB = "/Users/rhet/Pictures/Photos Library.photoslibrary"
|
||||
|
||||
with open("tests/search_info_test_data_10_15_7.json") as fp:
|
||||
test_data = json.load(fp)
|
||||
|
||||
UUID_SEARCH_INFO = test_data["UUID_SEARCH_INFO"]
|
||||
UUID_SEARCH_INFO_NORMALIZED = test_data["UUID_SEARCH_INFO_NORMALIZED"]
|
||||
UUID_SEARCH_INFO_ALL = test_data["UUID_SEARCH_INFO_ALL"]
|
||||
UUID_SEARCH_INFO_ALL_NORMALIZED = test_data["UUID_SEARCH_INFO_ALL_NORMALIZED"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def photosdb():
|
||||
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
|
||||
|
||||
def test_search_info(photosdb):
|
||||
for uuid in UUID_SEARCH_INFO:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
search_dict = photo.search_info.asdict()
|
||||
for k, v in search_dict.items():
|
||||
if type(v) == list:
|
||||
assert sorted(v) == sorted(UUID_SEARCH_INFO[uuid][k])
|
||||
else:
|
||||
assert v == UUID_SEARCH_INFO[uuid][k]
|
||||
|
||||
|
||||
def test_search_info_normalized(photosdb):
|
||||
for uuid in UUID_SEARCH_INFO_NORMALIZED:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
search_dict = photo.search_info_normalized.asdict()
|
||||
for k, v in search_dict.items():
|
||||
if type(v) == list:
|
||||
assert sorted(v) == sorted(UUID_SEARCH_INFO_NORMALIZED[uuid][k])
|
||||
else:
|
||||
assert v == UUID_SEARCH_INFO_NORMALIZED[uuid][k]
|
||||
|
||||
|
||||
def test_search_info_all(photosdb):
|
||||
for uuid in UUID_SEARCH_INFO_ALL:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
assert sorted(photo.search_info.all) == sorted(UUID_SEARCH_INFO_ALL[uuid])
|
||||
|
||||
|
||||
def test_search_info_all_normalized(photosdb):
|
||||
for uuid in UUID_SEARCH_INFO_ALL_NORMALIZED:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
assert sorted(photo.search_info_normalized.all) == sorted(
|
||||
UUID_SEARCH_INFO_ALL_NORMALIZED[uuid]
|
||||
)
|
||||
@@ -170,17 +170,21 @@ TEMPLATE_VALUES_DATE_MODIFIED = {
|
||||
}
|
||||
|
||||
TEMPLATE_VALUES_DATE_NOT_MODIFIED = {
|
||||
# uses creation date instead of modified date
|
||||
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
|
||||
"{original_name}": "IMG_1064",
|
||||
"{modified.date}": "_",
|
||||
"{modified.year}": "_",
|
||||
"{modified.yy}": "_",
|
||||
"{modified.mm}": "_",
|
||||
"{modified.month}": "_",
|
||||
"{modified.mon}": "_",
|
||||
"{modified.dd}": "_",
|
||||
"{modified.doy}": "_",
|
||||
"{modified.dow}": "_",
|
||||
"{modified.date}": "2020-02-04",
|
||||
"{modified.year}": "2020",
|
||||
"{modified.yy}": "20",
|
||||
"{modified.mm}": "02",
|
||||
"{modified.month}": "February",
|
||||
"{modified.mon}": "Feb",
|
||||
"{modified.dd}": "04",
|
||||
"{modified.dow}": "Tuesday",
|
||||
"{modified.doy}": "035",
|
||||
"{modified.hour}": "19",
|
||||
"{modified.min}": "07",
|
||||
"{modified.sec}": "38",
|
||||
}
|
||||
|
||||
|
||||
|
||||
34
utils/generate_search_info_test_data.py
Normal file
@@ -0,0 +1,34 @@
|
||||
""" Create the test data needed for test_search_info_10_15_7.py """
|
||||
|
||||
# reads data from the author's system photo library to build the test data
|
||||
# used to test SearchInfo
|
||||
|
||||
import json
|
||||
|
||||
import osxphotos
|
||||
|
||||
UUID = [
|
||||
"C8EAF50A-D891-4E0C-8086-C417E1284153",
|
||||
"71DFB4C3-E868-4BE4-906E-D96BD8692D7E",
|
||||
"2C151013-5BBA-4D00-B70F-1C9420418B86",
|
||||
]
|
||||
|
||||
data = {
|
||||
"UUID_SEARCH_INFO": {},
|
||||
"UUID_SEARCH_INFO_NORMALIZED": {},
|
||||
"UUID_SEARCH_INFO_ALL": {},
|
||||
"UUID_SEARCH_INFO_ALL_NORMALIZED": {},
|
||||
}
|
||||
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
|
||||
for uuid in UUID:
|
||||
photo = photosdb.get_photo(uuid)
|
||||
search = photo.search_info
|
||||
search_norm = photo.search_info_normalized
|
||||
data["UUID_SEARCH_INFO"][uuid] = search.asdict()
|
||||
data["UUID_SEARCH_INFO_NORMALIZED"][uuid] = search_norm.asdict()
|
||||
data["UUID_SEARCH_INFO_ALL"][uuid] = search.all
|
||||
data["UUID_SEARCH_INFO_ALL_NORMALIZED"][uuid] = search_norm.all
|
||||
|
||||
print(json.dumps(data))
|
||||