diff --git a/README.md b/README.md index c9c8a7a1..75da1621 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,10 @@ Options: photos if the RAW photo does not have an associated jpeg image (e.g. the RAW file was imported to Photos without a jpeg preview). + --person-keyword Use person in image as keyword/tag when + exporting metadata. + --album-keyword Use album name as keyword/tag when exporting + metadata. --current-name Use photo's current filename instead of original filename for export. Note: Starting with Photos 5, all photos are @@ -814,6 +818,12 @@ For example, in my library, Photos says I have 19,386 photos and 474 movies. Ho >>> ``` +#### `use_persons_as_keywords` +If True, person names (face/person in image) will be used as keywords when exporting metadata with [PhotoInfo](#PhotoInfo) [export()](#export). + +#### `use_albums_as_keywords` +If True, album names will be used as keywords when exporting metadata with [PhotoInfo](#PhotoInfo) [export()](#export). + ### PhotoInfo PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library. @@ -958,7 +968,8 @@ Returns True if photo is a panorama, otherwise False. #### `json()` Returns a JSON representation of all photo info -#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False)` +#### `export()` +`export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False)` Export photo from the Photos library to another destination on disk. - dest: must be valid destination path as str (or exception raised). diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 4a1f2995..105a274e 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -893,6 +893,16 @@ def query( "Note: this does not skip RAW photos if the RAW photo does not have an associated jpeg image " "(e.g. the RAW file was imported to Photos without a jpeg preview).", ) +@click.option( + "--person-keyword", + is_flag = True, + help = "Use person in image as keyword/tag when exporting metadata." +) +@click.option( + "--album-keyword", + is_flag = True, + help = "Use album name as keyword/tag when exporting metadata." +) @click.option( "--current-name", is_flag=True, @@ -983,6 +993,8 @@ def export( skip_bursts, skip_live, skip_raw, + person_keyword, + album_keyword, current_name, sidecar, only_photos, @@ -1145,6 +1157,16 @@ def export( ) if photos: + # set the persons_as_keywords and albums_as_keywords on the database object + # to control metadata export + photosdb = photos[0]._db + if person_keyword: + # add persons as keywords + photosdb.use_persons_as_keywords = True + if album_keyword: + # add albums as keywords + photosdb.use_albums_as_keywords = True + if export_bursts: # add the burst_photos to the export set photos_burst = [p for p in photos if p.burst] diff --git a/osxphotos/_version.py b/osxphotos/_version.py index e33bb353..8f257e08 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.28.6" +__version__ = "0.28.7" diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 8e44ac1a..307e407f 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -24,6 +24,7 @@ from ._constants import ( _PHOTOS_4_VERSION, _PHOTOS_5_SHARED_PHOTO_PATH, _TEMPLATE_DIR, + _UNKNOWN_PERSON, _XMP_TEMPLATE_NAME, ) from .exiftool import ExifTool @@ -304,9 +305,7 @@ class PhotoInfo: # Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library # that are missing do not always trigger is_missing = True as happens # in earlier version so it's possible for this check to fail, if so, return None - logging.debug( - f"Error getting path to RAW file: {filepath}/{glob_str}" - ) + logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}") photopath = None else: photopath = os.path.join(filepath, raw_file[0]) @@ -934,18 +933,30 @@ class PhotoInfo: if self.title: exif["XMP:Title"] = self.title + keyword_list = [] if self.keywords: - exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords) - # Photos puts both keywords and persons in Subject when using "Export IPTC as XMP" - exif["XMP:Subject"] = list(self.keywords) + keyword_list.extend(self.keywords) + person_list = [] if self.persons: - exif["XMP:PersonInImage"] = self.persons + # filter out _UNKNOWN_PERSON + person_list = [p for p in self.persons if p != _UNKNOWN_PERSON] + + if self._db.use_persons_as_keywords and person_list: + keyword_list.extend(person_list) + + if self._db.use_albums_as_keywords and self.albums: + keyword_list.extend(self.albums) + + if keyword_list: + exif["XMP:TagsList"] = exif["IPTC:Keywords"] = keyword_list + + if person_list: + exif["XMP:PersonInImage"] = person_list + + if self.keywords or person_list: # Photos puts both keywords and persons in Subject when using "Export IPTC as XMP" - if "XMP:Subject" in exif: - exif["XMP:Subject"].extend(self.persons) - else: - exif["XMP:Subject"] = self.persons + exif["XMP:Subject"] = list(self.keywords) + person_list # if self.favorite(): # exif["Rating"] = 5 @@ -986,7 +997,34 @@ class PhotoInfo: xmp_template = Template( filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME) ) - xmp_str = xmp_template.render(photo=self) + + keyword_list = [] + if self.keywords: + keyword_list.extend(self.keywords) + + person_list = [] + if self.persons: + # filter out _UNKNOWN_PERSON + person_list = [p for p in self.persons if p != _UNKNOWN_PERSON] + + if self._db.use_persons_as_keywords and person_list: + keyword_list.extend(person_list) + + if self._db.use_albums_as_keywords and self.albums: + keyword_list.extend(self.albums) + + 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 + + xmp_str = xmp_template.render( + photo=self, + keywords=keyword_list, + persons=person_list, + subjects=subject_list, + ) + # remove extra lines that mako inserts from template xmp_str = "\n".join( [line for line in xmp_str.split("\n") if line.strip() != ""] diff --git a/osxphotos/photosdb.py b/osxphotos/photosdb.py index 9f251c93..d2f18ab0 100644 --- a/osxphotos/photosdb.py +++ b/osxphotos/photosdb.py @@ -77,6 +77,12 @@ class PhotosDB: # set up the data structures used to store all the Photo database info + # if True, will treat persons as keywords when exporting metadata + self.use_persons_as_keywords = False + + # if True, will treat albums as keywords when exporting metadata + self.use_albums_as_keywords = False + # Path to the Photos library database file # photos.db in the photos library database/ directory self._dbfile = None diff --git a/osxphotos/templates/xmp_sidecar.mako b/osxphotos/templates/xmp_sidecar.mako index 1f2e1a8c..792cc831 100644 --- a/osxphotos/templates/xmp_sidecar.mako +++ b/osxphotos/templates/xmp_sidecar.mako @@ -79,16 +79,16 @@ xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"> ${dc_description(photo.description)} ${dc_title(photo.title)} - ${dc_subject(photo.keywords + photo.persons)} + ${dc_subject(subjects)} ${dc_datecreated(photo.date)} - ${iptc_personinimage(photo.persons)} + ${iptc_personinimage(persons)} - ${dk_tagslist(photo.keywords)} + ${dk_tagslist(keywords)} diff --git a/tests/test_export_catalina_10_15_1.py b/tests/test_export_catalina_10_15_1.py index fc8b06d0..590f9ef7 100644 --- a/tests/test_export_catalina_10_15_1.py +++ b/tests/test_export_catalina_10_15_1.py @@ -482,6 +482,90 @@ def test_exiftool_json_sidecar(): assert json_got[k] == v +def test_exiftool_json_sidecar_use_persons_keyword(): + import osxphotos + import json + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photosdb.use_persons_as_keywords = True + photos = photosdb.photos(uuid=[UUID_DICT["xmp"]]) + + json_expected = json.loads( + """ + [{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", + "EXIF:ImageDescription": "Girls with pumpkins", + "XMP:Description": "Girls with pumpkins", + "XMP:Title": "Can we carry this?", + "XMP:TagsList": ["Kids", "Suzy", "Katie"], + "IPTC:Keywords": ["Kids", "Suzy", "Katie"], + "XMP:PersonInImage": ["Suzy", "Katie"], + "XMP:Subject": ["Kids", "Suzy", "Katie"], + "EXIF:DateTimeOriginal": "2018:09:28 15:35:49", + "EXIF:OffsetTimeOriginal": "-04:00", + "EXIF:ModifyDate": "2019:11:24 13:09:17"}] + """ + )[0] + + json_got = photos[0]._exiftool_json_sidecar() + json_got = json.loads(json_got)[0] + + # some gymnastics to account for different sort order in different pythons + # 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 + + +def test_exiftool_json_sidecar_use_albums_keyword(): + import osxphotos + import json + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photosdb.use_albums_as_keywords = True + photos = photosdb.photos(uuid=[UUID_DICT["xmp"]]) + + json_expected = json.loads( + """ + [{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", + "EXIF:ImageDescription": "Girls with pumpkins", + "XMP:Description": "Girls with pumpkins", + "XMP:Title": "Can we carry this?", + "XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"], + "IPTC:Keywords": ["Kids", "Pumpkin Farm", "Test Album"], + "XMP:PersonInImage": ["Suzy", "Katie"], + "XMP:Subject": ["Kids", "Suzy", "Katie"], + "EXIF:DateTimeOriginal": "2018:09:28 15:35:49", + "EXIF:OffsetTimeOriginal": "-04:00", + "EXIF:ModifyDate": "2019:11:24 13:09:17"}] + """ + )[0] + + json_got = photos[0]._exiftool_json_sidecar() + json_got = json.loads(json_got)[0] + + # some gymnastics to account for different sort order in different pythons + # 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 + + def test_xmp_sidecar(): import osxphotos @@ -539,3 +623,125 @@ def test_xmp_sidecar(): for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines): assert line_expected == line_got + +def test_xmp_sidecar_use_persons_keyword(): + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photosdb.use_persons_as_keywords = True + photos = photosdb.photos(uuid=[UUID_DICT["xmp"]]) + + xmp_expected = """ + + + + + Girls with pumpkins + Can we carry this? + + + + Kids + Suzy + Katie + + + 2018-09-28T15:35:49.063000-04:00 + + + + + Suzy + Katie + + + + + + + Kids + Suzy + Katie + + + + + 2018-09-28T15:35:49 + 2018-09-28T15:35:49 + + + """ + + xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")] + + xmp_got = photos[0]._xmp_sidecar() + xmp_got_lines = [line.strip() for line in xmp_got.split("\n")] + + for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines): + assert line_expected == line_got + +def test_xmp_sidecar_use_albums_keyword(): + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photosdb.use_albums_as_keywords = True + photos = photosdb.photos(uuid=[UUID_DICT["xmp"]]) + + xmp_expected = """ + + + + + Girls with pumpkins + Can we carry this? + + + + Kids + Suzy + Katie + + + 2018-09-28T15:35:49.063000-04:00 + + + + + Suzy + Katie + + + + + + + Kids + Pumpkin Farm + Test Album + + + + + 2018-09-28T15:35:49 + 2018-09-28T15:35:49 + + + """ + + xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")] + + xmp_got = photos[0]._xmp_sidecar() + xmp_got_lines = [line.strip() for line in xmp_got.split("\n")] + + for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines): + assert line_expected == line_got \ No newline at end of file