Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbd005bcd | ||
|
|
1bf7105737 | ||
|
|
6e5ea8e013 | ||
|
|
9f64262757 | ||
|
|
6c11e3fa5b | ||
|
|
c9c9202205 | ||
|
|
ebd878a075 | ||
|
|
2cf3b6bb67 | ||
|
|
beb7970b3b | ||
|
|
2567974f5b | ||
|
|
78d494ff2c | ||
|
|
eefa1f181f | ||
|
|
2bf5fae093 | ||
|
|
9b13d1e00b | ||
|
|
f2df6f1a12 | ||
|
|
98e417023e | ||
|
|
360c8d8e1b | ||
|
|
868cda8482 | ||
|
|
fa149dc7e1 | ||
|
|
7467bbf62b | ||
|
|
d2deefff83 | ||
|
|
f474dcd2cb | ||
|
|
6acf9acd63 |
36
CHANGELOG.md
@@ -4,6 +4,42 @@ 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.33.0](https://github.com/RhetTbull/osxphotos/compare/v0.32.0...v0.33.0)
|
||||
|
||||
> 17 August 2020
|
||||
|
||||
- Replaced call to which, closes #171 [`#171`](https://github.com/RhetTbull/osxphotos/issues/171)
|
||||
- Added contributors to README.md, closes #200 [`#200`](https://github.com/RhetTbull/osxphotos/issues/200)
|
||||
- Added tests for 10.15.6 [`d2deeff`](https://github.com/RhetTbull/osxphotos/commit/d2deefff834e46e1a26adc01b1b025ac839dbc78)
|
||||
- Added ImportInfo for Photos 5+ [`98e4170`](https://github.com/RhetTbull/osxphotos/commit/98e417023ec5bd8292b25040d0844f3706645950)
|
||||
- Update README.md [`360c8d8`](https://github.com/RhetTbull/osxphotos/commit/360c8d8e1b4760e95a8b71b3a0bf0df4fb5adaf5)
|
||||
- Update README.md [`868cda8`](https://github.com/RhetTbull/osxphotos/commit/868cda8482ce6b29dd00e04a209d40550e6b128b)
|
||||
|
||||
#### [v0.32.0](https://github.com/RhetTbull/osxphotos/compare/v0.31.2...v0.32.0)
|
||||
|
||||
> 9 August 2020
|
||||
|
||||
- Alpha support for MacOS Big Sur/10.16, see issue #187 [`6acf9ac`](https://github.com/RhetTbull/osxphotos/commit/6acf9acd6364e1996158179493d128ec0958e652)
|
||||
|
||||
#### [v0.31.2](https://github.com/RhetTbull/osxphotos/compare/v0.31.0...v0.31.2)
|
||||
|
||||
> 9 August 2020
|
||||
|
||||
- Fixed from_date and to_date to be timezone aware, closes #193 [`#193`](https://github.com/RhetTbull/osxphotos/issues/193)
|
||||
- Added test for valid XMP file, closes #197 [`#197`](https://github.com/RhetTbull/osxphotos/issues/197)
|
||||
- Dropped py36 due to datetime.fromisoformat [`a714ae0`](https://github.com/RhetTbull/osxphotos/commit/a714ae0af089b13acf70c4f29934393aa48ed222)
|
||||
- Added --uuid-from-file to CLI [`840e993`](https://github.com/RhetTbull/osxphotos/commit/840e9937bede407ef55972a361618683245e086b)
|
||||
- Added write_uuid_to_file.applescript to utils [`bea770b`](https://github.com/RhetTbull/osxphotos/commit/bea770b322d21cf3f8245d20e182006247cb71d6)
|
||||
- Updated README.md [`002fce8`](https://github.com/RhetTbull/osxphotos/commit/002fce8e93edd936d4b866118ae6d4c94e5d6744)
|
||||
- Added py37 [`d0ec862`](https://github.com/RhetTbull/osxphotos/commit/d0ec8620c721fe7576ab7d519a5eaac4d17a317e)
|
||||
|
||||
#### [v0.31.0](https://github.com/RhetTbull/osxphotos/compare/v0.30.13...v0.31.0)
|
||||
|
||||
> 27 July 2020
|
||||
|
||||
- Initial FaceInfo support for Issue #21 [`6f29cda`](https://github.com/RhetTbull/osxphotos/commit/6f29cda99f1b8d94a95597c7046620cf21fecae4)
|
||||
- Updated Github Actions to run on PR [`9fc4f76`](https://github.com/RhetTbull/osxphotos/commit/9fc4f762193699dd45b586b51aa2d3066928aab1)
|
||||
|
||||
#### [v0.30.13](https://github.com/RhetTbull/osxphotos/compare/v0.30.12...v0.30.13)
|
||||
|
||||
> 23 July 2020
|
||||
|
||||
72
README.md
@@ -15,6 +15,7 @@
|
||||
+ [PhotoInfo](#photoinfo)
|
||||
+ [ExifInfo](#exifinfo)
|
||||
+ [AlbumInfo](#albuminfo)
|
||||
+ [ImportInfo](#importinfo)
|
||||
+ [FolderInfo](#folderinfo)
|
||||
+ [PlaceInfo](#placeinfo)
|
||||
+ [ScoreInfo](#scoreinfo)
|
||||
@@ -37,7 +38,9 @@ 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.6 / Photos 5.0.
|
||||
|
||||
Alpha support for MacOS 10.16/MacOS 11 Big Sur Beta / Photos 6.0.
|
||||
|
||||
Requires python >= 3.7.
|
||||
|
||||
@@ -200,14 +203,14 @@ Options:
|
||||
both images and movies).
|
||||
--only-photos Search only for photos/images (default
|
||||
searches both images and movies).
|
||||
--from-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]
|
||||
Search by start item date, e.g.
|
||||
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601
|
||||
w/o TZ).
|
||||
--to-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]
|
||||
Search by end item date, e.g.
|
||||
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601
|
||||
w/o TZ).
|
||||
--from-date DATETIME Search by start item date, e.g.
|
||||
2000-01-12T12:00:00,
|
||||
2001-01-12T12:00:00-07:00, or 2000-12-31
|
||||
(ISO 8601).
|
||||
--to-date DATETIME Search by end item date, e.g.
|
||||
2000-01-12T12:00:00,
|
||||
2001-01-12T12:00:00-07:00, or 2000-12-31
|
||||
(ISO 8601).
|
||||
--deleted Include photos from the 'Recently Deleted'
|
||||
folder.
|
||||
--deleted-only Include only photos from the 'Recently
|
||||
@@ -219,6 +222,8 @@ Options:
|
||||
--export-as-hardlink Hardlink files instead of copying them.
|
||||
Cannot be used with --exiftool which creates
|
||||
copies of the files with embedded EXIF data.
|
||||
--touch-file Sets the file's modification time to match
|
||||
photo date.
|
||||
--overwrite Overwrite existing files. Default behavior
|
||||
is to add (1), (2), etc to filename if file
|
||||
already exists. Use this with caution as it
|
||||
@@ -765,6 +770,10 @@ Returns list of shared album names found in photos database (e.g. albums shared
|
||||
|
||||
**Note**: *Only valid for Photos 5 / MacOS 10.15*; on Photos <= 4, prints warning and returns empty list.
|
||||
|
||||
#### `import_info`
|
||||
|
||||
Returns a list of [ImportInfo](#importinfo) objects representing the import sessions for the database.
|
||||
|
||||
#### `folder_info`
|
||||
```python
|
||||
# assumes photosdb is a PhotosDB object (see above)
|
||||
@@ -1051,6 +1060,9 @@ Returns a list of albums the photo is contained in. See also [album_info](#album
|
||||
#### `album_info`
|
||||
Returns a list of [AlbumInfo](#AlbumInfo) objects representing the albums the photo is contained in. See also [albums](#albums).
|
||||
|
||||
#### `import_info`
|
||||
Returns an [ImportInfo](#importinfo) object representing the import session associated with the photo or `None` if there is no associated import session.
|
||||
|
||||
#### `persons`
|
||||
Returns a list of the names of the persons in the photo
|
||||
|
||||
@@ -1376,6 +1388,15 @@ Returns the title or name of the album.
|
||||
#### <a name="albumphotos">`photos`</a>
|
||||
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album sorted in the same order as in Photos. (e.g. if photos were manually sorted in the Photos albums, photos returned by `photos` will be in same order as they appear in the Photos album)
|
||||
|
||||
#### `creation_date`
|
||||
Returns the creation date as a timezone aware datetime.datetime object of the album.
|
||||
|
||||
#### `start_date`
|
||||
Returns the date of earliest photo in the album as a timezone aware datetime.datetime object.
|
||||
|
||||
#### `end_date`
|
||||
Returns the date of latest photo in the album as a timezone aware datetime.datetime object.
|
||||
|
||||
#### `folder_list`
|
||||
Returns a hierarchical list of [FolderInfo](#FolderInfo) objects representing the folders the album is contained in. For example, if album "AlbumInFolder" is in SubFolder2 of Folder1 as illustrated below, would return a list of `FolderInfo` objects representing ["Folder1", "SubFolder2"]
|
||||
|
||||
@@ -1401,6 +1422,25 @@ Photos Library
|
||||
#### `parent`
|
||||
Returns a [FolderInfo](#FolderInfo) object representing the albums parent folder or `None` if album is not a in a folder.
|
||||
|
||||
### ImportInfo
|
||||
PhotosDB.import_info returns a list of ImportInfo objects. Each ImportInfo object represents an import session in the library. PhotoInfo.import_info returns a single ImportInfo object representing the import session for the photo (or `None` if no associated import session).
|
||||
|
||||
**Note**: Photos 5+ only. Not implemented for Photos version <= 4.
|
||||
|
||||
#### `uuid`
|
||||
Returns the universally unique identifier (uuid) of the import session. This is how Photos keeps track of individual objects within the database.
|
||||
|
||||
#### <a name="importphotos">`photos`</a>
|
||||
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the import session.
|
||||
|
||||
#### `creation_date`
|
||||
Returns the creation date as a timezone aware datetime.datetime object of the import session.
|
||||
|
||||
#### `start_date`
|
||||
Returns the start date as a timezone aware datetime.datetime object for when the import session bega.
|
||||
|
||||
#### `end_date`
|
||||
Returns the end date as a timezone aware datetime.datetime object for when the import session completed.
|
||||
|
||||
### FolderInfo
|
||||
PhotosDB.folder_info returns a list of FolderInfo objects representing the top level folders in the library. Each FolderInfo object represents a single folder in the Photos library.
|
||||
@@ -1805,6 +1845,7 @@ if __name__ == "__main__":
|
||||
## Related Projects
|
||||
|
||||
- [rhettbull/photosmeta](https://github.com/rhettbull/photosmeta): uses osxphotos and [exiftool](https://exiftool.org/) to apply metadata from Photos as exif data in the photo files. Can also export photos while preserving metadata and also apply Photos keywords as spotlight tags to make it easier to search for photos using spotlight. This is mostly made obsolete by osxphotos. The one feature that photosmeta has that osxphotos does not is ability to update the metadata of the actual photo files in the Photos library without exporting them. (Use with caution!)
|
||||
- [rhettbull/PhotoScript](https://github.com/RhetTbull/PhotoScript): python wrapper around Photos' applescript API allowing automation of Photos (including creation/deletion of items) from python.
|
||||
- [patrikhson/photo-export](https://github.com/patrikhson/photo-export): Exports older versions of Photos databases. Provided the inspiration for osxphotos.
|
||||
- [orangeturtle739/photos-export](https://github.com/orangeturtle739/photos-export): Set of scripts to export Photos libraries.
|
||||
- [ndbroadbent/icloud_photos_downloader](https://github.com/ndbroadbent/icloud_photos_downloader): Download photos from iCloud. Currently unmaintained.
|
||||
@@ -1822,6 +1863,19 @@ If you have an interesting example that shows usage of this package, submit an i
|
||||
|
||||
Testing against "real world" Photos libraries would be especially helpful. If you discover issues in testing against your Photos libraries, please open an issue. I've done extensive testing against my own Photos library but that's a since data point and I'm certain there are issues lurking in various edge cases I haven't discovered yet.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank-you to the following people who have contributed to improving osxphotos! If I've inadvertently left you off, please open an issue or send me a note.
|
||||
|
||||
- [britiscurious](https://github.com/britiscurious)
|
||||
- [Michel Wortmann](https://github.com/mwort)
|
||||
- [hshore29](https://github.com/hshore29)
|
||||
- [Pablo 'merKur' Kohan](https://github.com/PabloKohan)
|
||||
- [Jean-Yves Stervinou](https://github.com/jystervinou)
|
||||
- [Thibault Deutsch](https://github.com/dethi)
|
||||
- [grundsch](https://github.com/grundsch)
|
||||
- [Ag Primatic](https://github.com/agprimatic)
|
||||
|
||||
## Known Bugs
|
||||
|
||||
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 600 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
|
||||
|
||||
@@ -1121,6 +1121,11 @@ def query(
|
||||
help="Hardlink files instead of copying them. "
|
||||
"Cannot be used with --exiftool which creates copies of the files with embedded EXIF data.",
|
||||
)
|
||||
@click.option(
|
||||
"--touch-file",
|
||||
is_flag=True,
|
||||
help="Sets the file's modification time to match photo date.",
|
||||
)
|
||||
@click.option(
|
||||
"--overwrite",
|
||||
is_flag=True,
|
||||
@@ -1264,6 +1269,13 @@ def query(
|
||||
"to a filesystem that doesn't support Mac OS extended attributes. Only use this if you get "
|
||||
"an error while exporting.",
|
||||
)
|
||||
@click.option(
|
||||
"--use-photos-export",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
hidden=True,
|
||||
help="Force the use of AppleScript to export even if not missing (see also --download-missing).",
|
||||
)
|
||||
@DB_ARGUMENT
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -1299,6 +1311,7 @@ def export(
|
||||
update,
|
||||
dry_run,
|
||||
export_as_hardlink,
|
||||
touch_file,
|
||||
overwrite,
|
||||
export_by_date,
|
||||
skip_edited,
|
||||
@@ -1344,6 +1357,7 @@ def export(
|
||||
label,
|
||||
deleted,
|
||||
deleted_only,
|
||||
use_photos_export,
|
||||
):
|
||||
""" Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1514,7 +1528,6 @@ def export(
|
||||
deleted_only=deleted_only,
|
||||
)
|
||||
|
||||
results_exported = []
|
||||
if photos:
|
||||
if export_bursts:
|
||||
# add the burst_photos to the export set
|
||||
@@ -1533,10 +1546,12 @@ def export(
|
||||
# because the original code used --original-name as an option
|
||||
original_name = not current_name
|
||||
|
||||
results_exported = []
|
||||
results_new = []
|
||||
results_updated = []
|
||||
results_skipped = []
|
||||
results_exif_updated = []
|
||||
results_touched = []
|
||||
if verbose_:
|
||||
for p in photos:
|
||||
results = export_photo(
|
||||
@@ -1564,13 +1579,16 @@ def export(
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run=dry_run,
|
||||
touch_file=touch_file,
|
||||
edited_suffix=edited_suffix,
|
||||
use_photos_export=use_photos_export,
|
||||
)
|
||||
results_exported.extend(results.exported)
|
||||
results_new.extend(results.new)
|
||||
results_updated.extend(results.updated)
|
||||
results_skipped.extend(results.skipped)
|
||||
results_exif_updated.extend(results.exif_updated)
|
||||
results_touched.extend(results.touched)
|
||||
|
||||
else:
|
||||
# show progress bar
|
||||
@@ -1601,31 +1619,42 @@ def export(
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run=dry_run,
|
||||
touch_file=touch_file,
|
||||
edited_suffix=edited_suffix,
|
||||
use_photos_export=use_photos_export,
|
||||
)
|
||||
results_exported.extend(results.exported)
|
||||
results_new.extend(results.new)
|
||||
results_updated.extend(results.updated)
|
||||
results_skipped.extend(results.skipped)
|
||||
results_exif_updated.extend(results.exif_updated)
|
||||
results_touched.extend(results.touched)
|
||||
stop_time = time.perf_counter()
|
||||
# print summary results
|
||||
if update:
|
||||
photo_str_new = "photos" if len(results_new) != 1 else "photo"
|
||||
photo_str_updated = "photos" if len(results_new) != 1 else "photo"
|
||||
photo_str_updated = "photos" if len(results_updated) != 1 else "photo"
|
||||
photo_str_skipped = "photos" if len(results_skipped) != 1 else "photo"
|
||||
photo_str_touched = "photos" if len(results_touched) != 1 else "photo"
|
||||
photo_str_exif_updated = (
|
||||
"photos" if len(results_exif_updated) != 1 else "photo"
|
||||
)
|
||||
click.echo(
|
||||
summary = (
|
||||
f"Exported: {len(results_new)} {photo_str_new}, "
|
||||
+ f"updated: {len(results_updated)} {photo_str_updated}, "
|
||||
+ f"skipped: {len(results_skipped)} {photo_str_skipped}, "
|
||||
+ f"updated EXIF data: {len(results_exif_updated)} {photo_str_exif_updated}"
|
||||
f"updated: {len(results_updated)} {photo_str_updated}, "
|
||||
f"skipped: {len(results_skipped)} {photo_str_skipped}, "
|
||||
f"updated EXIF data: {len(results_exif_updated)} {photo_str_exif_updated}"
|
||||
)
|
||||
if touch_file:
|
||||
summary += f", touched date: {len(results_touched)} {photo_str_touched}"
|
||||
click.echo(summary)
|
||||
else:
|
||||
photo_str = "photos" if len(results_exported) != 1 else "photo"
|
||||
click.echo(f"Exported: {len(results_exported)} {photo_str}")
|
||||
photo_str_touched = "photos" if len(results_touched) != 1 else "photo"
|
||||
summary = f"Exported: {len(results_exported)} {photo_str}"
|
||||
if touch_file:
|
||||
summary += f", touched date: {len(results_touched)} {photo_str_touched}"
|
||||
click.echo(summary)
|
||||
click.echo(f"Elapsed time: {(stop_time-start_time):.3f} seconds")
|
||||
else:
|
||||
click.echo("Did not find any photos to export")
|
||||
@@ -2073,7 +2102,9 @@ def export_photo(
|
||||
export_db=None,
|
||||
fileutil=FileUtil,
|
||||
dry_run=None,
|
||||
touch_file=None,
|
||||
edited_suffix="_edited",
|
||||
use_photos_export=False,
|
||||
):
|
||||
""" Helper function for export that does the actual export
|
||||
|
||||
@@ -2101,6 +2132,8 @@ def export_photo(
|
||||
export_db: export database instance compatible with ExportDB_ABC
|
||||
fileutil: file util class compatible with FileUtilABC
|
||||
dry_run: boolean; if True, doesn't actually export or update any files
|
||||
touch_file: boolean; sets file's modification time to match photo date
|
||||
use_photos_export: boolean; if True forces the use of AppleScript to export even if photo not missing
|
||||
|
||||
Returns:
|
||||
list of path(s) of exported photo or None if photo was missing
|
||||
@@ -2115,25 +2148,26 @@ def export_photo(
|
||||
if photo.ismissing:
|
||||
space = " " if not verbose_ else ""
|
||||
verbose(f"{space}Skipping missing photo {photo.filename}")
|
||||
return ExportResults([], [], [], [], [])
|
||||
return ExportResults([], [], [], [], [], [])
|
||||
elif not os.path.exists(photo.path):
|
||||
space = " " if not verbose_ else ""
|
||||
verbose(
|
||||
f"{space}WARNING: file {photo.path} is missing but ismissing=False, "
|
||||
f"skipping {photo.filename}"
|
||||
)
|
||||
return ExportResults([], [], [], [], [])
|
||||
return ExportResults([], [], [], [], [], [])
|
||||
elif photo.ismissing and not photo.iscloudasset or not photo.incloud:
|
||||
verbose(
|
||||
f"Skipping missing {photo.filename}: not iCloud asset or missing from cloud"
|
||||
)
|
||||
return ExportResults([], [], [], [], [])
|
||||
return ExportResults([], [], [], [], [], [])
|
||||
|
||||
results_exported = []
|
||||
results_new = []
|
||||
results_updated = []
|
||||
results_skipped = []
|
||||
results_exif_updated = []
|
||||
results_touched = []
|
||||
|
||||
filenames = get_filenames_from_template(photo, filename_template, original_name)
|
||||
for filename in filenames:
|
||||
@@ -2152,8 +2186,10 @@ def export_photo(
|
||||
|
||||
# if download_missing and the photo is missing or path doesn't exist,
|
||||
# try to download with Photos
|
||||
use_photos_export = download_missing and (
|
||||
photo.ismissing or not os.path.exists(photo.path)
|
||||
use_photos_export = (
|
||||
download_missing and (photo.ismissing or not os.path.exists(photo.path))
|
||||
if not use_photos_export
|
||||
else True
|
||||
)
|
||||
|
||||
# export the photo to each path in dest_paths
|
||||
@@ -2178,6 +2214,7 @@ def export_photo(
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run=dry_run,
|
||||
touch_file=touch_file,
|
||||
)
|
||||
|
||||
results_exported.extend(export_results.exported)
|
||||
@@ -2185,6 +2222,7 @@ def export_photo(
|
||||
results_updated.extend(export_results.updated)
|
||||
results_skipped.extend(export_results.skipped)
|
||||
results_exif_updated.extend(export_results.exif_updated)
|
||||
results_touched.extend(export_results.touched)
|
||||
|
||||
if verbose_:
|
||||
for exported in export_results.exported:
|
||||
@@ -2195,13 +2233,14 @@ def export_photo(
|
||||
verbose(f"Exported updated file {updated}")
|
||||
for skipped in export_results.skipped:
|
||||
verbose(f"Skipped up to date file {skipped}")
|
||||
for touched in export_results.touched:
|
||||
verbose(f"Touched date on file {touched}")
|
||||
|
||||
# if export-edited, also export the edited version
|
||||
# verify the photo has adjustments and valid path to avoid raising an exception
|
||||
if export_edited and photo.hasadjustments:
|
||||
# if download_missing and the photo is missing or path doesn't exist,
|
||||
# try to download with Photos
|
||||
use_photos_export = download_missing and photo.path_edited is None
|
||||
if not download_missing and photo.path_edited is None:
|
||||
verbose(f"Skipping missing edited photo for {filename}")
|
||||
else:
|
||||
@@ -2234,6 +2273,7 @@ def export_photo(
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run=dry_run,
|
||||
touch_file=touch_file,
|
||||
)
|
||||
|
||||
results_exported.extend(export_results_edited.exported)
|
||||
@@ -2241,6 +2281,7 @@ def export_photo(
|
||||
results_updated.extend(export_results_edited.updated)
|
||||
results_skipped.extend(export_results_edited.skipped)
|
||||
results_exif_updated.extend(export_results_edited.exif_updated)
|
||||
results_touched.extend(export_results_edited.touched)
|
||||
|
||||
if verbose_:
|
||||
for exported in export_results_edited.exported:
|
||||
@@ -2251,6 +2292,8 @@ def export_photo(
|
||||
verbose(f"Exported updated file {updated}")
|
||||
for skipped in export_results_edited.skipped:
|
||||
verbose(f"Skipped up to date file {skipped}")
|
||||
for touched in export_results_edited.touched:
|
||||
verbose(f"Touched date on file {touched}")
|
||||
|
||||
return ExportResults(
|
||||
results_exported,
|
||||
@@ -2258,6 +2301,7 @@ def export_photo(
|
||||
results_updated,
|
||||
results_skipped,
|
||||
results_exif_updated,
|
||||
results_touched,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ Constants used by osxphotos
|
||||
"""
|
||||
|
||||
import os.path
|
||||
from datetime import datetime
|
||||
|
||||
# Time delta: add this to Photos times to get unix time
|
||||
# Apple Epoch is Jan 1, 2001
|
||||
TIME_DELTA = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
|
||||
|
||||
# which Photos library database versions have been tested
|
||||
# Photos 2.0 (10.12.6) == 2622
|
||||
@@ -10,18 +15,46 @@ import os.path
|
||||
# Photos 4.0 (10.14.5) == 4016
|
||||
# Photos 4.0 (10.14.6) == 4025
|
||||
# Photos 5.0 (10.15.0) == 6000
|
||||
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
|
||||
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
|
||||
|
||||
# database model versions (applies to Photos 5, Photos 6)
|
||||
# these come from PLModelVersion key in binary plist in Z_METADATA.Z_PLIST
|
||||
# Photos 5 (10.15.1) == 13537
|
||||
# Photos 5 (10.15.4, 10.15.5, 10.15.6) == 13703
|
||||
# Photos 6 (10.16.0 Beta) == 14104
|
||||
_TEST_MODEL_VERSIONS = ["13537", "13703", "14104"]
|
||||
|
||||
# only version 3 - 4 have RKVersion.selfPortrait
|
||||
_PHOTOS_3_VERSION = "3301"
|
||||
|
||||
# versions 5.0 and later have a different database structure
|
||||
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
|
||||
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.5
|
||||
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.6
|
||||
|
||||
# Ranges for model version by Photos version
|
||||
_PHOTOS_5_MODEL_VERSION = [13000, 13999]
|
||||
_PHOTOS_6_MODEL_VERSION = [14000, 14999]
|
||||
|
||||
# some table names differ between Photos 5 and Photos 6
|
||||
_DB_TABLE_NAMES = {
|
||||
5: {
|
||||
"ASSET": "ZGENERICASSET",
|
||||
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_37KEYWORDS",
|
||||
"ALBUM_JOIN": "Z_26ASSETS.Z_34ASSETS",
|
||||
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_34ASSETS",
|
||||
"IMPORT_FOK": "ZGENERICASSET.Z_FOK_IMPORTSESSION",
|
||||
},
|
||||
6: {
|
||||
"ASSET": "ZASSET",
|
||||
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_36KEYWORDS",
|
||||
"ALBUM_JOIN": "Z_26ASSETS.Z_3ASSETS",
|
||||
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_3ASSETS",
|
||||
"IMPORT_FOK": "null",
|
||||
},
|
||||
}
|
||||
|
||||
# which major version operating systems have been tested
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14", "15", "16"]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
_UNKNOWN_PERSON = "_UNKNOWN_"
|
||||
@@ -47,6 +80,7 @@ _PHOTOS_5_ALBUM_KIND = 2 # normal user album
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
|
||||
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
|
||||
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
|
||||
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND = 1506 # import session
|
||||
|
||||
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
|
||||
|
||||
@@ -189,7 +189,12 @@ class ExportDB(ExportDB_ABC):
|
||||
(filename,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
stats = results[0:3] if results else None
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
@@ -232,7 +237,12 @@ class ExportDB(ExportDB_ABC):
|
||||
(filename,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
stats = results[0:3] if results else None
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.31.2"
|
||||
__version__ = "0.33.2"
|
||||
|
||||
@@ -10,7 +10,7 @@ Represents a single Folder in the Photos library and provides access to the fold
|
||||
PhotosDB.folders() returns a list of FolderInfo objects
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ._constants import (
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
@@ -18,11 +18,34 @@ from ._constants import (
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_FOLDER_KIND,
|
||||
TIME_DELTA,
|
||||
)
|
||||
from .datetime_utils import get_local_tz
|
||||
|
||||
|
||||
class AlbumInfo:
|
||||
def sort_list_by_keys(values, sort_keys):
|
||||
""" Sorts list values by a second list sort_keys
|
||||
e.g. given ["a","c","b"], [1, 3, 2], returns ["a", "b", "c"]
|
||||
|
||||
Args:
|
||||
values: a list of values to be sorted
|
||||
sort_keys: a list of keys to sort values by
|
||||
|
||||
Returns:
|
||||
list of values, sorted by sort_keys
|
||||
|
||||
Raises:
|
||||
ValueError: raised if len(values) != len(sort_keys)
|
||||
"""
|
||||
if len(values) != len(sort_keys):
|
||||
return ValueError("values and sort_keys must have same length")
|
||||
|
||||
return list(zip(*sorted(zip(sort_keys, values))))[1]
|
||||
|
||||
|
||||
class AlbumInfoBaseClass:
|
||||
"""
|
||||
Base class for AlbumInfo, ImportInfo
|
||||
Info about a specific Album, contains all the details about the album
|
||||
including folders, photos, etc.
|
||||
"""
|
||||
@@ -31,33 +54,107 @@ class AlbumInfo:
|
||||
self._uuid = uuid
|
||||
self._db = db
|
||||
self._title = self._db._dbalbum_details[uuid]["title"]
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of album """
|
||||
return self._title
|
||||
self._creation_date_timestamp = self._db._dbalbum_details[uuid]["creation_date"]
|
||||
self._start_date_timestamp = self._db._dbalbum_details[uuid]["start_date"]
|
||||
self._end_date_timestamp = self._db._dbalbum_details[uuid]["end_date"]
|
||||
self._local_tz = get_local_tz()
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of album """
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def creation_date(self):
|
||||
""" return creation date of album """
|
||||
try:
|
||||
return self._creation_date
|
||||
except AttributeError:
|
||||
try:
|
||||
self._creation_date = (
|
||||
datetime.fromtimestamp(
|
||||
self._creation_date_timestamp + TIME_DELTA
|
||||
).astimezone(tz=self._local_tz)
|
||||
if self._creation_date_timestamp
|
||||
else datetime(1970, 1, 1, 0, 0, 0).astimezone(
|
||||
tz=timezone(timedelta(0))
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
self._creation_date = datetime(1970, 1, 1, 0, 0, 0).astimezone(
|
||||
tz=timezone(timedelta(0))
|
||||
)
|
||||
return self._creation_date
|
||||
|
||||
@property
|
||||
def start_date(self):
|
||||
""" For Albums, return start date (earliest image) of album or None for albums with no images
|
||||
For Import Sessions, return start date of import session (when import began) """
|
||||
try:
|
||||
return self._start_date
|
||||
except AttributeError:
|
||||
try:
|
||||
self._start_date = (
|
||||
datetime.fromtimestamp(
|
||||
self._start_date_timestamp + TIME_DELTA
|
||||
).astimezone(tz=self._local_tz)
|
||||
if self._start_date_timestamp
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
self._start_date = None
|
||||
return self._start_date
|
||||
|
||||
@property
|
||||
def end_date(self):
|
||||
""" For Albums, return end date (most recent image) of album or None for albums with no images
|
||||
For Import Sessions, return end date of import sessions (when import was completed) """
|
||||
try:
|
||||
return self._end_date
|
||||
except AttributeError:
|
||||
try:
|
||||
self._end_date = (
|
||||
datetime.fromtimestamp(
|
||||
self._end_date_timestamp + TIME_DELTA
|
||||
).astimezone(tz=self._local_tz)
|
||||
if self._end_date_timestamp
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
self._end_date = None
|
||||
return self._end_date
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in album """
|
||||
return []
|
||||
|
||||
def __len__(self):
|
||||
""" return number of photos contained in album """
|
||||
return len(self.photos)
|
||||
|
||||
|
||||
class AlbumInfo(AlbumInfoBaseClass):
|
||||
"""
|
||||
Base class for AlbumInfo, ImportInfo
|
||||
Info about a specific Album, contains all the details about the album
|
||||
including folders, photos, etc.
|
||||
"""
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of album """
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in album sorted in same sort order as Photos """
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
if self.uuid in self._db._dbalbums_album:
|
||||
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
|
||||
self._photos = self._db.photos(uuid=uuid)
|
||||
# PhotosDB.photos does not preserve order when passing in list of uuids
|
||||
# so need to build photo list one a time
|
||||
# sort uuids by sort order
|
||||
sorted_uuid = sorted(zip(sort_order, uuid))
|
||||
self._photos = [
|
||||
self._db.photos(uuid=[uuid])[0] for _, uuid in sorted_uuid
|
||||
]
|
||||
sorted_uuid = sort_list_by_keys(uuid, sort_order)
|
||||
self._photos = self._db.photos_by_uuid(sorted_uuid)
|
||||
else:
|
||||
self._photos = []
|
||||
return self._photos
|
||||
@@ -110,9 +207,24 @@ class AlbumInfo:
|
||||
)
|
||||
return self._parent
|
||||
|
||||
def __len__(self):
|
||||
""" return number of photos contained in album """
|
||||
return len(self.photos)
|
||||
|
||||
class ImportInfo(AlbumInfoBaseClass):
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in import session """
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
uuid_list, sort_order = zip(
|
||||
*[
|
||||
(uuid, self._db._dbphotos[uuid]["fok_import_session"])
|
||||
for uuid in self._db._dbphotos
|
||||
if self._db._dbphotos[uuid]["import_uuid"] == self.uuid
|
||||
]
|
||||
)
|
||||
sorted_uuid = sort_list_by_keys(uuid_list, sort_order)
|
||||
self._photos = self._db.photos_by_uuid(sorted_uuid)
|
||||
return self._photos
|
||||
|
||||
|
||||
class FolderInfo:
|
||||
|
||||
@@ -20,8 +20,7 @@ def datetime_remove_tz(dt):
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
dt_new = dt.replace(tzinfo=None)
|
||||
return dt_new
|
||||
return dt.replace(tzinfo=None)
|
||||
|
||||
|
||||
def datetime_has_tz(dt):
|
||||
@@ -32,9 +31,7 @@ def datetime_has_tz(dt):
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||
return True
|
||||
return False
|
||||
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
||||
|
||||
|
||||
def datetime_naive_to_local(dt):
|
||||
@@ -53,5 +50,4 @@ def datetime_naive_to_local(dt):
|
||||
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tizinfo.utcoffset(dt)}"
|
||||
)
|
||||
|
||||
dt_local = dt.replace(tzinfo=get_local_tz())
|
||||
return dt_local
|
||||
return dt.replace(tzinfo=get_local_tz())
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
@@ -22,8 +23,7 @@ EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
result = subprocess.run(["which", "exiftool"], stdout=subprocess.PIPE)
|
||||
exiftool_path = result.stdout.decode("utf-8")
|
||||
exiftool_path = shutil.which('exiftool')
|
||||
if _debug():
|
||||
logging.debug("exiftool path = %s" % (exiftool_path))
|
||||
if exiftool_path:
|
||||
|
||||
@@ -29,7 +29,17 @@ class FileUtilABC(ABC):
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def cmp_sig(cls, file1, file2):
|
||||
def utime(cls, path, times):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def cmp(cls, file1, file2, mtime1=None):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def cmp_file_sig(cls, file1, file2):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@@ -104,11 +114,37 @@ class FileUtilMacOS(FileUtilABC):
|
||||
os.unlink(filepath)
|
||||
|
||||
@classmethod
|
||||
def cmp_sig(cls, f1, s2):
|
||||
def utime(cls, path, times):
|
||||
""" Set the access and modified time of path. """
|
||||
os.utime(path, times)
|
||||
|
||||
@classmethod
|
||||
def cmp(cls, f1, f2, mtime1=None):
|
||||
"""Does shallow compare (file signatures) of f1 to file f2.
|
||||
Arguments:
|
||||
f1 -- File name
|
||||
f2 -- File name
|
||||
mtime1 -- optional, pass alternate file modification timestamp for f1; will be converted to int
|
||||
|
||||
Return value:
|
||||
True if the file signatures as returned by stat are the same, False otherwise.
|
||||
Does not do a byte-by-byte comparison.
|
||||
"""
|
||||
|
||||
s1 = cls._sig(os.stat(f1))
|
||||
if mtime1 is not None:
|
||||
s1 = (s1[0], s1[1], int(mtime1))
|
||||
s2 = cls._sig(os.stat(f2))
|
||||
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
|
||||
return False
|
||||
return s1 == s2
|
||||
|
||||
@classmethod
|
||||
def cmp_file_sig(cls, f1, s2):
|
||||
"""Compare file f1 to signature s2.
|
||||
Arguments:
|
||||
f1 -- File name
|
||||
s2 -- stats as returned by sig
|
||||
s2 -- stats as returned by _sig
|
||||
|
||||
Return value:
|
||||
True if the files are the same, False otherwise.
|
||||
@@ -130,7 +166,12 @@ class FileUtilMacOS(FileUtilABC):
|
||||
|
||||
@staticmethod
|
||||
def _sig(st):
|
||||
return (stat.S_IFMT(st.st_mode), st.st_size, st.st_mtime)
|
||||
""" return tuple of (mode, size, mtime) of file based on os.stat
|
||||
Args:
|
||||
st: os.stat signature
|
||||
"""
|
||||
# use int(st.st_mtime) because ditto does not copy fractional portion of mtime
|
||||
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
|
||||
|
||||
|
||||
class FileUtil(FileUtilMacOS):
|
||||
@@ -141,8 +182,8 @@ class FileUtil(FileUtilMacOS):
|
||||
|
||||
class FileUtilNoOp(FileUtil):
|
||||
""" No-Op implementation of FileUtil for testing / dry-run mode
|
||||
all methods with exception of cmp_sig and file_cmp are no-op
|
||||
cmp_sig functions as FileUtil.cmp_sig does
|
||||
all methods with exception of cmp, cmp_file_sig and file_cmp are no-op
|
||||
cmp and cmp_file_sig functions as FileUtil methods do
|
||||
file_cmp returns mock data
|
||||
"""
|
||||
|
||||
@@ -172,6 +213,10 @@ class FileUtilNoOp(FileUtil):
|
||||
def unlink(cls, dest):
|
||||
cls.verbose(f"unlink: {dest}")
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
cls.verbose(f"utime: {path}, {times}")
|
||||
|
||||
@classmethod
|
||||
def file_sig(cls, file1):
|
||||
cls.verbose(f"file_sig: {file1}")
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
# TODO: should this be its own PhotoExporter class?
|
||||
|
||||
import filecmp
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
@@ -37,7 +36,8 @@ from ..fileutil import FileUtil
|
||||
from ..utils import dd_to_dms_str, findfiles
|
||||
|
||||
ExportResults = namedtuple(
|
||||
"ExportResults", ["exported", "new", "updated", "skipped", "exif_updated"]
|
||||
"ExportResults",
|
||||
["exported", "new", "updated", "skipped", "exif_updated", "touched"],
|
||||
)
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ def export2(
|
||||
export_db=None,
|
||||
fileutil=FileUtil,
|
||||
dry_run=False,
|
||||
touch_file=False,
|
||||
):
|
||||
""" export photo, like export but with update and dry_run options
|
||||
dest: must be valid destination path or exception raised
|
||||
@@ -347,6 +348,7 @@ def export2(
|
||||
for getting/setting data related to exported files to compare update state
|
||||
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
|
||||
dry_run: (boolean, default=False); set to True to run in "dry run" mode
|
||||
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
|
||||
|
||||
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
|
||||
where each field is a list of file paths
|
||||
@@ -375,6 +377,9 @@ def export2(
|
||||
# list of all files skipped because they do not need to be updated (for use with update=True)
|
||||
update_skipped_files = []
|
||||
|
||||
# list of all files with utime touched (touch_file = True)
|
||||
touched_files = []
|
||||
|
||||
# check edited and raise exception trying to export edited version of
|
||||
# photo that hasn't been edited
|
||||
if edited and not self.hasadjustments:
|
||||
@@ -482,7 +487,7 @@ def export2(
|
||||
if update and dest.exists():
|
||||
# destination exists, check to see if destination is the right UUID
|
||||
dest_uuid = export_db.get_uuid_for_file(dest)
|
||||
if dest_uuid is None and filecmp.cmp(src, dest):
|
||||
if dest_uuid is None and fileutil.cmp(src, dest):
|
||||
# might be exporting into a pre-ExportDB folder or the DB got deleted
|
||||
logging.debug(
|
||||
f"Found matching file with blank uuid: {self.uuid}, {dest}"
|
||||
@@ -514,7 +519,7 @@ def export2(
|
||||
dest = pathlib.Path(file_)
|
||||
found_match = True
|
||||
break
|
||||
elif dest_uuid is None and filecmp.cmp(src, file_):
|
||||
elif dest_uuid is None and fileutil.cmp(src, file_):
|
||||
# files match, update the UUID
|
||||
logging.debug(
|
||||
f"Found matching file with blank uuid: {self.uuid}, {file_}"
|
||||
@@ -558,12 +563,14 @@ def export2(
|
||||
no_xattr,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
fileutil,
|
||||
)
|
||||
exported_files = results.exported
|
||||
update_new_files = results.new
|
||||
update_updated_files = results.updated
|
||||
update_skipped_files = results.skipped
|
||||
touched_files = results.touched
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
if live_photo and self.live_photo:
|
||||
@@ -583,12 +590,14 @@ def export2(
|
||||
no_xattr,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
fileutil,
|
||||
)
|
||||
exported_files.extend(results.exported)
|
||||
update_new_files.extend(results.new)
|
||||
update_updated_files.extend(results.updated)
|
||||
update_skipped_files.extend(results.skipped)
|
||||
touched_files.extend(results.touched)
|
||||
else:
|
||||
logging.debug(f"Skipping missing live movie for {filename}")
|
||||
|
||||
@@ -608,17 +617,19 @@ def export2(
|
||||
no_xattr,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
fileutil,
|
||||
)
|
||||
exported_files.extend(results.exported)
|
||||
update_new_files.extend(results.new)
|
||||
update_updated_files.extend(results.updated)
|
||||
update_skipped_files.extend(results.skipped)
|
||||
touched_files.extend(results.touched)
|
||||
else:
|
||||
logging.debug(f"Skipping missing RAW photo for {filename}")
|
||||
else:
|
||||
# use_photo_export
|
||||
exported = None
|
||||
exported = []
|
||||
# export live_photo .mov file?
|
||||
live_photo = True if live_photo and self.live_photo else False
|
||||
if edited:
|
||||
@@ -628,7 +639,7 @@ def export2(
|
||||
filestem = dest.stem
|
||||
else:
|
||||
# didn't get passed a filename, add _edited
|
||||
filestem = f"{dest.stem}_edited"
|
||||
filestem = f"{dest.stem}{edited_identifier}"
|
||||
dest = dest.parent / f"{filestem}.jpeg"
|
||||
|
||||
exported = _export_photo_uuid_applescript(
|
||||
@@ -657,8 +668,16 @@ def export2(
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if exported is not None:
|
||||
if exported:
|
||||
if touch_file:
|
||||
for exported_file in exported:
|
||||
touched_files.append(exported_file)
|
||||
ts = int(self.date.timestamp())
|
||||
fileutil.utime(exported_file, (ts, ts))
|
||||
exported_files.extend(exported)
|
||||
if update:
|
||||
update_new_files.extend(exported)
|
||||
|
||||
else:
|
||||
logging.warning(
|
||||
f"Error exporting photo {self.uuid} to {dest} with use_photos_export"
|
||||
@@ -760,6 +779,7 @@ def export2(
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
)
|
||||
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
self._exiftool_json_sidecar(
|
||||
@@ -774,13 +794,23 @@ def export2(
|
||||
)
|
||||
exif_files_updated.append(exported_file)
|
||||
|
||||
return ExportResults(
|
||||
if touch_file:
|
||||
for exif_file in exif_files_updated:
|
||||
touched_files.append(exported_file)
|
||||
ts = int(self.date.timestamp())
|
||||
fileutil.utime(exported_file, (ts, ts))
|
||||
|
||||
touched_files = list(set(touched_files))
|
||||
|
||||
results = ExportResults(
|
||||
exported_files,
|
||||
update_new_files,
|
||||
update_updated_files,
|
||||
update_skipped_files,
|
||||
exif_files_updated,
|
||||
touched_files,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _export_photo(
|
||||
@@ -793,11 +823,12 @@ def _export_photo(
|
||||
no_xattr,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
touch_file,
|
||||
fileutil=FileUtil,
|
||||
):
|
||||
""" Helper function for export()
|
||||
Does the actual copy or hardlink taking the appropriate
|
||||
action depending on update, overwrite
|
||||
action depending on update, overwrite, export_as_hardlink
|
||||
Assumes destination is the right destination (e.g. UUID matches)
|
||||
sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido
|
||||
|
||||
@@ -810,6 +841,7 @@ def _export_photo(
|
||||
no_xattr: don't copy extended attributes
|
||||
export_as_hardlink: bool
|
||||
exiftool: bool
|
||||
touch_file: bool
|
||||
fileutil: FileUtil class that conforms to fileutil.FileUtilABC
|
||||
|
||||
Returns:
|
||||
@@ -820,143 +852,99 @@ def _export_photo(
|
||||
update_updated_files = []
|
||||
update_new_files = []
|
||||
update_skipped_files = []
|
||||
touched_files = []
|
||||
|
||||
dest_str = str(dest)
|
||||
dest_exists = dest.exists()
|
||||
if export_as_hardlink:
|
||||
# use hardlink instead of copy
|
||||
if not update:
|
||||
# not update, do the the hardlink
|
||||
if overwrite and dest.exists():
|
||||
# need to remove the destination first
|
||||
# dest.unlink()
|
||||
fileutil.unlink(dest)
|
||||
logging.debug(f"Not update: export_as_hardlink linking file {src} {dest}")
|
||||
fileutil.hardlink(src, dest)
|
||||
export_db.set_data(
|
||||
dest_str,
|
||||
self.uuid,
|
||||
fileutil.file_sig(dest_str),
|
||||
(None, None, None),
|
||||
self.json(),
|
||||
None,
|
||||
)
|
||||
exported_files.append(dest_str)
|
||||
elif dest_exists and dest.samefile(src):
|
||||
# update, hardlink and it already points to the right file, do nothing
|
||||
logging.debug(
|
||||
f"Update: skipping samefile with export_as_hardlink {src} {dest}"
|
||||
)
|
||||
update_skipped_files.append(dest_str)
|
||||
elif dest_exists:
|
||||
# update, not the same file (e.g. user may not have used export_as_hardlink last time it was run
|
||||
logging.debug(
|
||||
f"Update: removing existing file prior to export_as_hardlink {src} {dest}"
|
||||
)
|
||||
# dest.unlink()
|
||||
fileutil.unlink(dest)
|
||||
fileutil.hardlink(src, dest)
|
||||
export_db.set_data(
|
||||
dest_str,
|
||||
self.uuid,
|
||||
fileutil.file_sig(dest_str),
|
||||
(None, None, None),
|
||||
self.json(),
|
||||
None,
|
||||
)
|
||||
update_updated_files.append(dest_str)
|
||||
exported_files.append(dest_str)
|
||||
else:
|
||||
# update, hardlink, destination doesn't exist (new file)
|
||||
logging.debug(
|
||||
f"Update: exporting new file with export_as_hardlink {src} {dest}"
|
||||
)
|
||||
fileutil.hardlink(src, dest)
|
||||
export_db.set_data(
|
||||
dest_str,
|
||||
self.uuid,
|
||||
fileutil.file_sig(dest_str),
|
||||
(None, None, None),
|
||||
self.json(),
|
||||
None,
|
||||
)
|
||||
exported_files.append(dest_str)
|
||||
update_new_files.append(dest_str)
|
||||
op_desc = "export_as_hardlink"
|
||||
else:
|
||||
if not update:
|
||||
# not update, do the the copy
|
||||
if overwrite and dest.exists():
|
||||
# need to remove the destination first
|
||||
# dest.unlink()
|
||||
fileutil.unlink(dest)
|
||||
logging.debug(f"Not update: copying file {src} {dest}")
|
||||
fileutil.copy(src, dest_str, norsrc=no_xattr)
|
||||
exported_files.append(dest_str)
|
||||
export_db.set_data(
|
||||
dest_str,
|
||||
self.uuid,
|
||||
fileutil.file_sig(dest_str),
|
||||
(None, None, None),
|
||||
self.json(),
|
||||
None,
|
||||
)
|
||||
# elif dest_exists and not exiftool and cmp_file(dest_str, export_db.get_stat_orig_for_file(dest_str)):
|
||||
elif (
|
||||
dest_exists
|
||||
and not exiftool
|
||||
and filecmp.cmp(src, dest)
|
||||
and not dest.samefile(src)
|
||||
):
|
||||
# destination exists but is identical
|
||||
logging.debug(f"Update: skipping identifical original files {src} {dest}")
|
||||
# call set_stat because code can reach this spot if no export DB but exporting a RAW or live photo
|
||||
# potentially re-writes the data in the database but ensures database is complete
|
||||
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
|
||||
update_skipped_files.append(dest_str)
|
||||
elif (
|
||||
dest_exists
|
||||
and exiftool
|
||||
and fileutil.cmp_sig(dest_str, export_db.get_stat_exif_for_file(dest_str))
|
||||
and not dest.samefile(src)
|
||||
):
|
||||
# destination exists but is identical
|
||||
logging.debug(f"Update: skipping identifical exiftool files {src} {dest}")
|
||||
update_skipped_files.append(dest_str)
|
||||
elif dest_exists:
|
||||
# destination exists but is different or is a hardlink
|
||||
logging.debug(f"Update: removing existing file prior to copy {src} {dest}")
|
||||
stat_src = os.stat(src)
|
||||
stat_dest = os.stat(dest)
|
||||
# dest.unlink()
|
||||
fileutil.unlink(dest)
|
||||
fileutil.copy(src, dest_str, norsrc=no_xattr)
|
||||
export_db.set_data(
|
||||
dest_str,
|
||||
self.uuid,
|
||||
fileutil.file_sig(dest_str),
|
||||
(None, None, None),
|
||||
self.json(),
|
||||
None,
|
||||
)
|
||||
exported_files.append(dest_str)
|
||||
update_updated_files.append(dest_str)
|
||||
else:
|
||||
# destination doesn't exist, copy the file
|
||||
logging.debug(f"Update: copying new file {src} {dest}")
|
||||
fileutil.copy(src, dest_str, norsrc=no_xattr)
|
||||
export_db.set_data(
|
||||
dest_str,
|
||||
self.uuid,
|
||||
fileutil.file_sig(dest_str),
|
||||
(None, None, None),
|
||||
self.json(),
|
||||
None,
|
||||
)
|
||||
exported_files.append(dest_str)
|
||||
op_desc = "export_by_copying"
|
||||
|
||||
if not update:
|
||||
# not update, export the file
|
||||
logging.debug(f"Exporting file with {op_desc} {src} {dest}")
|
||||
exported_files.append(dest_str)
|
||||
if touch_file:
|
||||
sig = fileutil.file_sig(src)
|
||||
sig = (sig[0], sig[1], int(self.date.timestamp()))
|
||||
if not fileutil.cmp_file_sig(src, sig):
|
||||
touched_files.append(dest_str)
|
||||
else: # updating
|
||||
if not dest_exists:
|
||||
# update, destination doesn't exist (new file)
|
||||
logging.debug(f"Update: exporting new file with {op_desc} {src} {dest}")
|
||||
update_new_files.append(dest_str)
|
||||
if touch_file:
|
||||
touched_files.append(dest_str)
|
||||
else:
|
||||
# update, destination exists, but we might not need to replace it...
|
||||
if exiftool:
|
||||
sig_exif = export_db.get_stat_exif_for_file(dest_str)
|
||||
cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif)
|
||||
sig_exif = (sig_exif[0], sig_exif[1], int(self.date.timestamp()))
|
||||
cmp_touch = fileutil.cmp_file_sig(dest_str, sig_exif)
|
||||
else:
|
||||
cmp_orig = fileutil.cmp(src, dest)
|
||||
cmp_touch = fileutil.cmp(src, dest, mtime1=int(self.date.timestamp()))
|
||||
|
||||
sig_cmp = cmp_touch if touch_file else cmp_orig
|
||||
|
||||
if (export_as_hardlink and dest.samefile(src)) or (
|
||||
not export_as_hardlink and not dest.samefile(src) and sig_cmp
|
||||
):
|
||||
# destination exists and signatures match, skip it
|
||||
update_skipped_files.append(dest_str)
|
||||
else:
|
||||
# destination exists but signature is different
|
||||
if touch_file and cmp_orig and not cmp_touch:
|
||||
# destination exists, signature matches original but does not match expected touch time
|
||||
# skip exporting but update touch time
|
||||
update_skipped_files.append(dest_str)
|
||||
touched_files.append(dest_str)
|
||||
elif not touch_file and cmp_touch and not cmp_orig:
|
||||
# destination exists, signature matches expected touch but not original
|
||||
# user likely exported with touch_file and is now exporting without touch_file
|
||||
# don't update the file because it's same but leave touch time
|
||||
update_skipped_files.append(dest_str)
|
||||
else:
|
||||
# destination exists but is different
|
||||
update_updated_files.append(dest_str)
|
||||
if touch_file:
|
||||
touched_files.append(dest_str)
|
||||
|
||||
if not update_skipped_files:
|
||||
if dest_exists and (update or overwrite):
|
||||
# need to remove the destination first
|
||||
logging.debug(
|
||||
f"Update: removing existing file prior to {op_desc} {src} {dest}"
|
||||
)
|
||||
fileutil.unlink(dest)
|
||||
if export_as_hardlink:
|
||||
fileutil.hardlink(src, dest)
|
||||
else:
|
||||
fileutil.copy(src, dest_str, norsrc=no_xattr)
|
||||
|
||||
export_db.set_data(
|
||||
dest_str,
|
||||
self.uuid,
|
||||
fileutil.file_sig(dest_str),
|
||||
(None, None, None),
|
||||
self.json(),
|
||||
None,
|
||||
)
|
||||
|
||||
if touched_files:
|
||||
ts = int(self.date.timestamp())
|
||||
fileutil.utime(dest, (ts, ts))
|
||||
|
||||
return ExportResults(
|
||||
exported_files, update_new_files, update_updated_files, update_skipped_files, []
|
||||
exported_files + update_new_files + update_updated_files,
|
||||
update_new_files,
|
||||
update_updated_files,
|
||||
update_skipped_files,
|
||||
[],
|
||||
touched_files,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,16 +5,12 @@ PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import timedelta, timezone
|
||||
from pprint import pformat
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -25,10 +21,11 @@ from .._constants import (
|
||||
_PHOTOS_4_ROOT_FOLDER,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND,
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
)
|
||||
from ..albuminfo import AlbumInfo
|
||||
from ..albuminfo import AlbumInfo, ImportInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
from ..phototemplate import PhotoTemplate
|
||||
from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
@@ -88,7 +85,7 @@ class PhotoInfo:
|
||||
def date(self):
|
||||
""" image creation date as timezone aware datetime object """
|
||||
return self._info["imageDate"]
|
||||
|
||||
|
||||
@property
|
||||
def date_modified(self):
|
||||
""" image modification date as timezone aware datetime object
|
||||
@@ -357,7 +354,7 @@ class PhotoInfo:
|
||||
except AttributeError:
|
||||
try:
|
||||
faces = self._db._db_faceinfo_uuid[self._uuid]
|
||||
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
|
||||
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
|
||||
except KeyError:
|
||||
# no faces
|
||||
self._faceinfo = []
|
||||
@@ -387,6 +384,19 @@ class PhotoInfo:
|
||||
]
|
||||
return self._album_info
|
||||
|
||||
@property
|
||||
def import_info(self):
|
||||
""" ImportInfo object representing import session for the photo or None if no import session """
|
||||
try:
|
||||
return self._import_info
|
||||
except AttributeError:
|
||||
self._import_info = (
|
||||
ImportInfo(db=self._db, uuid=self._info["import_uuid"])
|
||||
if self._info["import_uuid"] is not None
|
||||
else None
|
||||
)
|
||||
return self._import_info
|
||||
|
||||
@property
|
||||
def keywords(self):
|
||||
""" list of keywords for picture """
|
||||
@@ -745,7 +755,7 @@ class PhotoInfo:
|
||||
""" 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
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
|
||||
@@ -4,3 +4,4 @@ Processes a Photos.app library database to extract information about photos
|
||||
"""
|
||||
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb_utils import get_db_version, get_db_model_version, get_model_version
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
import logging
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
|
||||
from ..utils import _db_is_locked, _debug, _open_sql_file
|
||||
|
||||
from .photosdb_utils import get_db_version
|
||||
|
||||
def _process_exifinfo(self):
|
||||
""" load the exif data from the database
|
||||
@@ -35,14 +35,16 @@ def _process_exifinfo_5(photosdb):
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = conn.execute(
|
||||
"""
|
||||
SELECT ZGENERICASSET.ZUUID, ZEXTENDEDATTRIBUTES.*
|
||||
FROM ZGENERICASSET
|
||||
f"""
|
||||
SELECT {asset_table}.ZUUID, ZEXTENDEDATTRIBUTES.*
|
||||
FROM {asset_table}
|
||||
JOIN ZEXTENDEDATTRIBUTES
|
||||
ON ZEXTENDEDATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
|
||||
ON ZEXTENDEDATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
import logging
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
|
||||
from ..utils import _open_sql_file
|
||||
from .photosdb_utils import get_db_version
|
||||
|
||||
|
||||
"""
|
||||
@@ -180,13 +181,15 @@ def _process_faceinfo_5(photosdb):
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = cursor.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT
|
||||
ZDETECTEDFACE.Z_PK,
|
||||
ZGENERICASSET.ZUUID,
|
||||
{asset_table}.ZUUID,
|
||||
ZDETECTEDFACE.ZUUID,
|
||||
ZDETECTEDFACE.ZPERSON,
|
||||
ZPERSON.ZFULLNAME,
|
||||
@@ -225,7 +228,7 @@ def _process_faceinfo_5(photosdb):
|
||||
ZDETECTEDFACE.ZYAW,
|
||||
ZDETECTEDFACE.ZMASTERIDENTIFIER
|
||||
FROM ZDETECTEDFACE
|
||||
JOIN ZGENERICASSET ON ZGENERICASSET.Z_PK = ZDETECTEDFACE.ZASSET
|
||||
JOIN {asset_table} ON {asset_table}.Z_PK = ZDETECTEDFACE.ZASSET
|
||||
JOIN ZPERSON ON ZPERSON.Z_PK = ZDETECTEDFACE.ZPERSON;
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
import logging
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
|
||||
from ..utils import _open_sql_file
|
||||
from .photosdb_utils import get_db_version
|
||||
|
||||
"""
|
||||
This module should be imported in the class defintion of PhotosDB in photosdb.py
|
||||
@@ -45,16 +46,18 @@ def _process_scoreinfo_5(photosdb):
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = cursor.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT
|
||||
ZGENERICASSET.ZUUID,
|
||||
ZGENERICASSET.ZOVERALLAESTHETICSCORE,
|
||||
ZGENERICASSET.ZCURATIONSCORE,
|
||||
ZGENERICASSET.ZPROMOTIONSCORE,
|
||||
ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE,
|
||||
{asset_table}.ZUUID,
|
||||
{asset_table}.ZOVERALLAESTHETICSCORE,
|
||||
{asset_table}.ZCURATIONSCORE,
|
||||
{asset_table}.ZPROMOTIONSCORE,
|
||||
{asset_table}.ZHIGHLIGHTVISIBILITYSCORE,
|
||||
ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE,
|
||||
ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE,
|
||||
ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE,
|
||||
@@ -78,8 +81,8 @@ def _process_scoreinfo_5(photosdb):
|
||||
ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE,
|
||||
ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE,
|
||||
ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE
|
||||
FROM ZGENERICASSET
|
||||
JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
|
||||
FROM {asset_table}
|
||||
JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import platform
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -16,6 +15,7 @@ from pprint import pformat
|
||||
from shutil import copyfile
|
||||
|
||||
from .._constants import (
|
||||
_DB_TABLE_NAMES,
|
||||
_MOVIE_TYPE,
|
||||
_PHOTO_TYPE,
|
||||
_PHOTOS_3_VERSION,
|
||||
@@ -25,15 +25,15 @@ from .._constants import (
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_FOLDER_KIND,
|
||||
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
|
||||
_PHOTOS_5_ROOT_FOLDER_KIND,
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND,
|
||||
_PHOTOS_5_VERSION,
|
||||
_TESTED_DB_VERSIONS,
|
||||
_TESTED_OS_VERSIONS,
|
||||
_UNKNOWN_PERSON,
|
||||
TIME_DELTA,
|
||||
)
|
||||
from .._version import __version__
|
||||
from ..albuminfo import AlbumInfo, FolderInfo
|
||||
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo
|
||||
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
|
||||
from ..personinfo import PersonInfo
|
||||
from ..photoinfo import PhotoInfo
|
||||
@@ -45,6 +45,7 @@ from ..utils import (
|
||||
_open_sql_file,
|
||||
get_last_library_path,
|
||||
)
|
||||
from .photosdb_utils import get_db_model_version, get_db_version
|
||||
|
||||
# TODO: Add test for imageTimeZoneOffsetSeconds = None
|
||||
# TODO: Add test for __str__
|
||||
@@ -267,7 +268,7 @@ class PhotosDB:
|
||||
if _db_is_locked(self._dbfile):
|
||||
self._tmp_db = self._copy_db_file(self._dbfile)
|
||||
|
||||
self._db_version = self._get_db_version(self._tmp_db)
|
||||
self._db_version = get_db_version(self._tmp_db)
|
||||
|
||||
# If Photos >= 5, actual data isn't in photos.db but in Photos.sqlite
|
||||
if int(self._db_version) > int(_PHOTOS_4_VERSION):
|
||||
@@ -483,6 +484,18 @@ class PhotosDB:
|
||||
self._albums_shared = self._get_albums(shared=True)
|
||||
return self._albums_shared
|
||||
|
||||
@property
|
||||
def import_info(self):
|
||||
""" return list of ImportInfo objects for each import session in the database """
|
||||
try:
|
||||
return self._import_info
|
||||
except AttributeError:
|
||||
self._import_info = [
|
||||
ImportInfo(db=self, uuid=album)
|
||||
for album in self._get_album_uuids(import_session=True)
|
||||
]
|
||||
return self._import_info
|
||||
|
||||
@property
|
||||
def db_version(self):
|
||||
""" return the database version as stored in LiGlobals table """
|
||||
@@ -512,6 +525,7 @@ class PhotosDB:
|
||||
""" If sqlite shared memory and write-ahead log files exist, those are copied too """
|
||||
# required because python's sqlite3 implementation can't read a locked file
|
||||
# _, suffix = os.path.splitext(fname)
|
||||
dest_name = dest_path = ""
|
||||
try:
|
||||
dest_name = pathlib.Path(fname).name
|
||||
dest_path = os.path.join(self._tempdir_name, dest_name)
|
||||
@@ -530,41 +544,10 @@ class PhotosDB:
|
||||
|
||||
return dest_path
|
||||
|
||||
def _get_db_version(self, db_file):
|
||||
""" Gets the Photos DB version from LiGlobals table
|
||||
|
||||
Args:
|
||||
db_file: path to database file containing LiGlobals table
|
||||
|
||||
Returns: version as str
|
||||
"""
|
||||
|
||||
version = None
|
||||
|
||||
(conn, c) = _open_sql_file(db_file)
|
||||
|
||||
# get database version
|
||||
c.execute(
|
||||
"SELECT value from LiGlobals where LiGlobals.keyPath is 'libraryVersion'"
|
||||
)
|
||||
version = c.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
if version not in _TESTED_DB_VERSIONS:
|
||||
print(
|
||||
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
|
||||
+ f" You have database version={version} which has not been tested"
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
def _process_database4(self):
|
||||
""" process the Photos database to extract info
|
||||
works on Photos version <= 4.0 """
|
||||
|
||||
# Epoch is Jan 1, 2001
|
||||
td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
|
||||
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# get info to associate persons with photos
|
||||
@@ -711,7 +694,8 @@ class PhotosDB:
|
||||
isInTrash,
|
||||
folderUuid,
|
||||
albumType,
|
||||
albumSubclass
|
||||
albumSubclass,
|
||||
createDate
|
||||
FROM RKAlbum """
|
||||
)
|
||||
|
||||
@@ -724,6 +708,7 @@ class PhotosDB:
|
||||
# 5: folderUuid
|
||||
# 6: albumType
|
||||
# 7: albumSubclass -- if 3, normal user album
|
||||
# 8: createDate
|
||||
|
||||
for album in c:
|
||||
self._dbalbum_details[album[0]] = {
|
||||
@@ -741,6 +726,9 @@ class PhotosDB:
|
||||
"albumSubclass": album[7],
|
||||
# for compatability with Photos 5 where album kind is ZKIND
|
||||
"kind": album[7],
|
||||
"creation_date": album[8],
|
||||
"start_date": None, # Photos 5 only
|
||||
"end_date": None, # Photos 5 only
|
||||
}
|
||||
|
||||
# get details about folders
|
||||
@@ -946,7 +934,7 @@ class PhotosDB:
|
||||
# not accounted for
|
||||
try:
|
||||
self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp(
|
||||
row[4] + td
|
||||
row[4] + TIME_DELTA
|
||||
)
|
||||
except ValueError:
|
||||
self._dbphotos[uuid]["lastmodifieddate"] = None
|
||||
@@ -956,7 +944,7 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
|
||||
|
||||
try:
|
||||
imagedate = datetime.fromtimestamp(row[5] + td)
|
||||
imagedate = datetime.fromtimestamp(row[5] + TIME_DELTA)
|
||||
seconds = self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
@@ -1092,6 +1080,11 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["original_orientation"] = row[38]
|
||||
self._dbphotos[uuid]["original_filesize"] = row[39]
|
||||
|
||||
# import session not yet handled for Photos 4
|
||||
self._dbphotos[uuid]["import_session"] = None
|
||||
self._dbphotos[uuid]["import_uuid"] = None
|
||||
self._dbphotos[uuid]["fok_import_session"] = None
|
||||
|
||||
# get additional details from RKMaster, needed for RAW processing
|
||||
c.execute(
|
||||
""" SELECT
|
||||
@@ -1436,7 +1429,7 @@ class PhotosDB:
|
||||
|
||||
def _process_database5(self):
|
||||
""" process the Photos database to extract info
|
||||
works on Photos version >= 5.0
|
||||
works on Photos version 5 and version 6
|
||||
|
||||
This is a big hairy 700 line function that should probably be refactored
|
||||
but it works so don't touch it.
|
||||
@@ -1445,11 +1438,17 @@ class PhotosDB:
|
||||
if _debug():
|
||||
logging.debug(f"_process_database5")
|
||||
|
||||
# Epoch is Jan 1, 2001
|
||||
td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
|
||||
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# some of the tables/columns have different names in different versions of Photos
|
||||
photos_ver = get_db_model_version(self._tmp_db)
|
||||
self._photos_ver = photos_ver
|
||||
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
|
||||
keyword_join = _DB_TABLE_NAMES[photos_ver]["KEYWORD_JOIN"]
|
||||
album_join = _DB_TABLE_NAMES[photos_ver]["ALBUM_JOIN"]
|
||||
album_sort = _DB_TABLE_NAMES[photos_ver]["ALBUM_SORT_ORDER"]
|
||||
import_fok = _DB_TABLE_NAMES[photos_ver]["IMPORT_FOK"]
|
||||
|
||||
# Look for all combinations of persons and pictures
|
||||
if _debug():
|
||||
logging.debug(f"Getting information about persons")
|
||||
@@ -1496,14 +1495,14 @@ class PhotosDB:
|
||||
# get info on keyface -- some photos have null keyface so can't do a single query
|
||||
# (at least not with my SQL skills)
|
||||
c.execute(
|
||||
""" SELECT
|
||||
f""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
ZPERSON.ZKEYFACE,
|
||||
ZGENERICASSET.ZUUID,
|
||||
{asset_table}.ZUUID,
|
||||
ZDETECTEDFACE.ZUUID
|
||||
FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET
|
||||
FROM ZPERSON, ZDETECTEDFACE, {asset_table}
|
||||
WHERE ZDETECTEDFACE.Z_PK = ZPERSON.ZKEYFACE AND
|
||||
ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK
|
||||
ZDETECTEDFACE.ZASSET = {asset_table}.Z_PK
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1522,12 +1521,12 @@ class PhotosDB:
|
||||
|
||||
# get information on detected faces
|
||||
c.execute(
|
||||
""" SELECT
|
||||
f""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
ZGENERICASSET.ZUUID
|
||||
FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET
|
||||
{asset_table}.ZUUID
|
||||
FROM ZPERSON, ZDETECTEDFACE, {asset_table}
|
||||
WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND
|
||||
ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK
|
||||
ZDETECTEDFACE.ZASSET = {asset_table}.Z_PK
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1556,12 +1555,12 @@ class PhotosDB:
|
||||
|
||||
# get details about albums
|
||||
c.execute(
|
||||
""" SELECT
|
||||
f""" SELECT
|
||||
ZGENERICALBUM.ZUUID,
|
||||
ZGENERICASSET.ZUUID,
|
||||
Z_26ASSETS.Z_FOK_34ASSETS
|
||||
FROM ZGENERICASSET
|
||||
JOIN Z_26ASSETS ON Z_26ASSETS.Z_34ASSETS = ZGENERICASSET.Z_PK
|
||||
{asset_table}.ZUUID,
|
||||
{album_sort}
|
||||
FROM {asset_table}
|
||||
JOIN Z_26ASSETS ON {album_join} = {asset_table}.Z_PK
|
||||
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS
|
||||
"""
|
||||
)
|
||||
@@ -1597,7 +1596,10 @@ class PhotosDB:
|
||||
"ZKIND, " # 6
|
||||
"ZPARENTFOLDER, " # 7
|
||||
"Z_PK, " # 8
|
||||
"ZTRASHEDSTATE " # 9
|
||||
"ZTRASHEDSTATE, " # 9
|
||||
"ZCREATIONDATE, " # 10
|
||||
"ZSTARTDATE, " # 11
|
||||
"ZENDDATE " # 12
|
||||
"FROM ZGENERICALBUM "
|
||||
)
|
||||
for album in c:
|
||||
@@ -1614,6 +1616,9 @@ class PhotosDB:
|
||||
"parentfolder": album[7],
|
||||
"pk": album[8],
|
||||
"intrash": False if album[9] == 0 else True,
|
||||
"creation_date": album[10],
|
||||
"start_date": album[11],
|
||||
"end_date": album[12],
|
||||
}
|
||||
|
||||
# add cross-reference by pk to uuid
|
||||
@@ -1668,11 +1673,11 @@ class PhotosDB:
|
||||
|
||||
# get details on keywords
|
||||
c.execute(
|
||||
"SELECT ZKEYWORD.ZTITLE, ZGENERICASSET.ZUUID "
|
||||
"FROM ZGENERICASSET "
|
||||
"JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK "
|
||||
"JOIN Z_1KEYWORDS ON Z_1KEYWORDS.Z_1ASSETATTRIBUTES = ZADDITIONALASSETATTRIBUTES.Z_PK "
|
||||
"JOIN ZKEYWORD ON ZKEYWORD.Z_PK = Z_1KEYWORDS.Z_37KEYWORDS "
|
||||
f"""SELECT ZKEYWORD.ZTITLE, {asset_table}.ZUUID
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN Z_1KEYWORDS ON Z_1KEYWORDS.Z_1ASSETATTRIBUTES = ZADDITIONALASSETATTRIBUTES.Z_PK
|
||||
JOIN ZKEYWORD ON ZKEYWORD.Z_PK = {keyword_join} """
|
||||
)
|
||||
for keyword in c:
|
||||
if not keyword[1] in self._dbkeywords_uuid:
|
||||
@@ -1699,45 +1704,45 @@ class PhotosDB:
|
||||
# get details about photos
|
||||
logging.debug(f"Getting information about photos")
|
||||
c.execute(
|
||||
"""SELECT ZGENERICASSET.ZUUID,
|
||||
f"""SELECT {asset_table}.ZUUID,
|
||||
ZADDITIONALASSETATTRIBUTES.ZMASTERFINGERPRINT,
|
||||
ZADDITIONALASSETATTRIBUTES.ZTITLE,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALFILENAME,
|
||||
ZGENERICASSET.ZMODIFICATIONDATE,
|
||||
ZGENERICASSET.ZDATECREATED,
|
||||
{asset_table}.ZMODIFICATIONDATE,
|
||||
{asset_table}.ZDATECREATED,
|
||||
ZADDITIONALASSETATTRIBUTES.ZTIMEZONEOFFSET,
|
||||
ZADDITIONALASSETATTRIBUTES.ZINFERREDTIMEZONEOFFSET,
|
||||
ZADDITIONALASSETATTRIBUTES.ZTIMEZONENAME,
|
||||
ZGENERICASSET.ZHIDDEN,
|
||||
ZGENERICASSET.ZFAVORITE,
|
||||
ZGENERICASSET.ZDIRECTORY,
|
||||
ZGENERICASSET.ZFILENAME,
|
||||
ZGENERICASSET.ZLATITUDE,
|
||||
ZGENERICASSET.ZLONGITUDE,
|
||||
ZGENERICASSET.ZHASADJUSTMENTS,
|
||||
ZGENERICASSET.ZCLOUDBATCHPUBLISHDATE,
|
||||
ZGENERICASSET.ZKIND,
|
||||
ZGENERICASSET.ZUNIFORMTYPEIDENTIFIER,
|
||||
ZGENERICASSET.ZAVALANCHEUUID,
|
||||
ZGENERICASSET.ZAVALANCHEPICKTYPE,
|
||||
ZGENERICASSET.ZKINDSUBTYPE,
|
||||
ZGENERICASSET.ZCUSTOMRENDEREDVALUE,
|
||||
{asset_table}.ZHIDDEN,
|
||||
{asset_table}.ZFAVORITE,
|
||||
{asset_table}.ZDIRECTORY,
|
||||
{asset_table}.ZFILENAME,
|
||||
{asset_table}.ZLATITUDE,
|
||||
{asset_table}.ZLONGITUDE,
|
||||
{asset_table}.ZHASADJUSTMENTS,
|
||||
{asset_table}.ZCLOUDBATCHPUBLISHDATE,
|
||||
{asset_table}.ZKIND,
|
||||
{asset_table}.ZUNIFORMTYPEIDENTIFIER,
|
||||
{asset_table}.ZAVALANCHEUUID,
|
||||
{asset_table}.ZAVALANCHEPICKTYPE,
|
||||
{asset_table}.ZKINDSUBTYPE,
|
||||
{asset_table}.ZCUSTOMRENDEREDVALUE,
|
||||
ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE,
|
||||
ZGENERICASSET.ZCLOUDASSETGUID,
|
||||
{asset_table}.ZCLOUDASSETGUID,
|
||||
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
|
||||
ZGENERICASSET.ZMOMENT,
|
||||
{asset_table}.ZMOMENT,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
|
||||
ZGENERICASSET.ZTRASHEDSTATE,
|
||||
ZGENERICASSET.ZHEIGHT,
|
||||
ZGENERICASSET.ZWIDTH,
|
||||
ZGENERICASSET.ZORIENTATION,
|
||||
{asset_table}.ZTRASHEDSTATE,
|
||||
{asset_table}.ZHEIGHT,
|
||||
{asset_table}.ZWIDTH,
|
||||
{asset_table}.ZORIENTATION,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALHEIGHT,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
|
||||
FROM ZGENERICASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
|
||||
ORDER BY ZGENERICASSET.ZUUID """
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
ORDER BY {asset_table}.ZUUID """
|
||||
)
|
||||
# Order of results
|
||||
# 0 SELECT ZGENERICASSET.ZUUID,
|
||||
@@ -1791,7 +1796,7 @@ class PhotosDB:
|
||||
# I don't know what these mean but they will raise exception in datetime if
|
||||
# not accounted for
|
||||
try:
|
||||
info["lastmodifieddate"] = datetime.fromtimestamp(row[4] + td)
|
||||
info["lastmodifieddate"] = datetime.fromtimestamp(row[4] + TIME_DELTA)
|
||||
except ValueError:
|
||||
info["lastmodifieddate"] = None
|
||||
except TypeError:
|
||||
@@ -1800,7 +1805,7 @@ class PhotosDB:
|
||||
info["imageTimeZoneOffsetSeconds"] = row[6]
|
||||
|
||||
try:
|
||||
imagedate = datetime.fromtimestamp(row[5] + td)
|
||||
imagedate = datetime.fromtimestamp(row[5] + TIME_DELTA)
|
||||
seconds = info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
@@ -1893,13 +1898,14 @@ class PhotosDB:
|
||||
|
||||
# Handle HDR photos and portraits
|
||||
# ZGENERICASSET.ZCUSTOMRENDEREDVALUE
|
||||
# 2 = portrait (maybe, see issue #203)
|
||||
# 3 = HDR photo
|
||||
# 4 = non-HDR version of the photo
|
||||
# 6 = panorama
|
||||
# 8 = portrait
|
||||
info["customRenderedValue"] = row[22]
|
||||
info["hdr"] = True if row[22] == 3 else False
|
||||
info["portrait"] = True if row[22] == 8 else False
|
||||
info["portrait"] = True if row[22] == 8 or row[22] == 2 else False
|
||||
|
||||
# Set panorama from either KindSubType or RenderedValue
|
||||
info["panorama"] = True if row[21] == 1 or row[22] == 6 else False
|
||||
@@ -1945,6 +1951,12 @@ class PhotosDB:
|
||||
info["original_orientation"] = row[34]
|
||||
info["original_filesize"] = row[35]
|
||||
|
||||
# initialize import session info which will be filled in later
|
||||
# not every photo has an import session so initialize all records now
|
||||
info["import_session"] = None
|
||||
info["fok_import_session"] = None
|
||||
info["import_uuid"] = None
|
||||
|
||||
# associated RAW image info
|
||||
# will be filled in later
|
||||
info["has_raw"] = False
|
||||
@@ -1971,14 +1983,40 @@ class PhotosDB:
|
||||
# else:
|
||||
# info["burst"] = False
|
||||
|
||||
# get info on import sessions
|
||||
# 0 ZGENERICASSET.ZUUID
|
||||
# 1 ZGENERICASSET.ZIMPORTSESSION
|
||||
# 2 ZGENERICASSET.Z_FOK_IMPORTSESSION
|
||||
# 3 ZGENERICALBUM.ZUUID,
|
||||
c.execute(
|
||||
f"""SELECT
|
||||
{asset_table}.ZUUID,
|
||||
{asset_table}.ZIMPORTSESSION,
|
||||
{import_fok},
|
||||
ZGENERICALBUM.ZUUID
|
||||
FROM
|
||||
{asset_table}
|
||||
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = {asset_table}.ZIMPORTSESSION
|
||||
"""
|
||||
)
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
try:
|
||||
self._dbphotos[uuid]["import_session"] = row[1]
|
||||
self._dbphotos[uuid]["fok_import_session"] = row[2]
|
||||
self._dbphotos[uuid]["import_uuid"] = row[3]
|
||||
except KeyError:
|
||||
logging.debug(f"No info record for uuid {uuid} for import session")
|
||||
|
||||
# Get extended description
|
||||
c.execute(
|
||||
"SELECT ZGENERICASSET.ZUUID, "
|
||||
"ZASSETDESCRIPTION.ZLONGDESCRIPTION "
|
||||
"FROM ZGENERICASSET "
|
||||
"JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK "
|
||||
"JOIN ZASSETDESCRIPTION ON ZASSETDESCRIPTION.Z_PK = ZADDITIONALASSETATTRIBUTES.ZASSETDESCRIPTION "
|
||||
"ORDER BY ZGENERICASSET.ZUUID "
|
||||
f"""SELECT {asset_table}.ZUUID,
|
||||
ZASSETDESCRIPTION.ZLONGDESCRIPTION
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZASSETDESCRIPTION ON ZASSETDESCRIPTION.Z_PK = ZADDITIONALASSETATTRIBUTES.ZASSETDESCRIPTION
|
||||
ORDER BY {asset_table}.ZUUID """
|
||||
)
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -1992,12 +2030,12 @@ class PhotosDB:
|
||||
|
||||
# get information about adjusted/edited photos
|
||||
c.execute(
|
||||
"SELECT ZGENERICASSET.ZUUID, "
|
||||
"ZGENERICASSET.ZHASADJUSTMENTS, "
|
||||
"ZUNMANAGEDADJUSTMENT.ZADJUSTMENTFORMATIDENTIFIER "
|
||||
"FROM ZGENERICASSET, ZUNMANAGEDADJUSTMENT "
|
||||
"JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK "
|
||||
"WHERE ZADDITIONALASSETATTRIBUTES.ZUNMANAGEDADJUSTMENT = ZUNMANAGEDADJUSTMENT.Z_PK "
|
||||
f"""SELECT {asset_table}.ZUUID,
|
||||
{asset_table}.ZHASADJUSTMENTS,
|
||||
ZUNMANAGEDADJUSTMENT.ZADJUSTMENTFORMATIDENTIFIER
|
||||
FROM {asset_table}, ZUNMANAGEDADJUSTMENT
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
WHERE ZADDITIONALASSETATTRIBUTES.ZUNMANAGEDADJUSTMENT = ZUNMANAGEDADJUSTMENT.Z_PK """
|
||||
)
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -2016,12 +2054,12 @@ class PhotosDB:
|
||||
|
||||
# Get info on remote/local availability for photos in shared albums
|
||||
c.execute(
|
||||
""" SELECT
|
||||
ZGENERICASSET.ZUUID,
|
||||
f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
|
||||
ZINTERNALRESOURCE.ZREMOTEAVAILABILITY
|
||||
FROM ZGENERICASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
|
||||
)
|
||||
@@ -2047,11 +2085,11 @@ class PhotosDB:
|
||||
|
||||
# get information on local/remote availability
|
||||
c.execute(
|
||||
""" SELECT ZGENERICASSET.ZUUID,
|
||||
f""" SELECT {asset_table}.ZUUID,
|
||||
ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
|
||||
ZINTERNALRESOURCE.ZREMOTEAVAILABILITY
|
||||
FROM ZGENERICASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZFINGERPRINT = ZADDITIONALASSETATTRIBUTES.ZMASTERFINGERPRINT """
|
||||
)
|
||||
|
||||
@@ -2075,11 +2113,11 @@ class PhotosDB:
|
||||
|
||||
# get information about cloud sync state
|
||||
c.execute(
|
||||
""" SELECT
|
||||
ZGENERICASSET.ZUUID,
|
||||
f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZCLOUDMASTER.ZCLOUDLOCALSTATE
|
||||
FROM ZCLOUDMASTER, ZGENERICASSET
|
||||
WHERE ZGENERICASSET.ZMASTER = ZCLOUDMASTER.Z_PK """
|
||||
FROM ZCLOUDMASTER, {asset_table}
|
||||
WHERE {asset_table}.ZMASTER = ZCLOUDMASTER.Z_PK """
|
||||
)
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -2090,15 +2128,15 @@ class PhotosDB:
|
||||
# get information about associted RAW images
|
||||
# RAW images have ZDATASTORESUBTYPE = 17
|
||||
c.execute(
|
||||
""" SELECT
|
||||
ZGENERICASSET.ZUUID,
|
||||
f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZINTERNALRESOURCE.ZDATALENGTH,
|
||||
ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER,
|
||||
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
|
||||
ZINTERNALRESOURCE.ZRESOURCETYPE
|
||||
FROM ZGENERICASSET
|
||||
FROM {asset_table}
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
|
||||
WHERE ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 17
|
||||
"""
|
||||
@@ -2382,16 +2420,26 @@ class PhotosDB:
|
||||
hierarchy = _recurse_folder_hierarchy(folders)
|
||||
return hierarchy
|
||||
|
||||
def _get_album_uuids(self, shared=False):
|
||||
def _get_album_uuids(self, shared=False, import_session=False):
|
||||
""" Return list of album UUIDs found in photos database
|
||||
|
||||
Filters out albums in the trash and any special album types
|
||||
|
||||
Args:
|
||||
shared: boolean; if True, returns shared albums, else normal albums
|
||||
import_session: boolean, if True, returns import session albums, else normal or shared albums
|
||||
Note: flags (shared, import_session) are mutually exclusive
|
||||
|
||||
Raises:
|
||||
ValueError: raised if mutually exclusive flags passed
|
||||
|
||||
Returns: list of album UUIDs
|
||||
"""
|
||||
if shared and import_session:
|
||||
raise ValueError(
|
||||
"flags are mutually exclusive: pass zero or one of shared, import_session"
|
||||
)
|
||||
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
version4 = True
|
||||
if shared:
|
||||
@@ -2399,11 +2447,21 @@ class PhotosDB:
|
||||
f"Shared albums not implemented for Photos library version {self._db_version}"
|
||||
)
|
||||
return [] # not implemented for _PHOTOS_4_VERSION
|
||||
elif import_session:
|
||||
logging.warning(
|
||||
f"Import sessions not implemented for Photos library version {self._db_version}"
|
||||
)
|
||||
return [] # not implemented for _PHOTOS_4_VERSION
|
||||
else:
|
||||
album_kind = _PHOTOS_4_ALBUM_KIND
|
||||
else:
|
||||
version4 = False
|
||||
album_kind = _PHOTOS_5_SHARED_ALBUM_KIND if shared else _PHOTOS_5_ALBUM_KIND
|
||||
if shared:
|
||||
album_kind = _PHOTOS_5_SHARED_ALBUM_KIND
|
||||
elif import_session:
|
||||
album_kind = _PHOTOS_5_IMPORT_SESSION_ALBUM_KIND
|
||||
else:
|
||||
album_kind = _PHOTOS_5_ALBUM_KIND
|
||||
|
||||
album_list = []
|
||||
# look through _dbalbum_details because _dbalbums_album won't have empty albums it
|
||||
|
||||
84
osxphotos/photosdb/photosdb_utils.py
Normal file
@@ -0,0 +1,84 @@
|
||||
""" utility functions used by PhotosDB """
|
||||
|
||||
import logging
|
||||
import plistlib
|
||||
|
||||
from .._constants import (
|
||||
_PHOTOS_5_MODEL_VERSION,
|
||||
_PHOTOS_6_MODEL_VERSION,
|
||||
_TESTED_DB_VERSIONS,
|
||||
)
|
||||
from ..utils import _open_sql_file
|
||||
|
||||
|
||||
def get_db_version(db_file):
|
||||
""" Gets the Photos DB version from LiGlobals table
|
||||
|
||||
Args:
|
||||
db_file: path to photos.db database file containing LiGlobals table
|
||||
|
||||
Returns: version as str
|
||||
"""
|
||||
|
||||
version = None
|
||||
|
||||
(conn, c) = _open_sql_file(db_file)
|
||||
|
||||
# get database version
|
||||
c.execute("SELECT value from LiGlobals where LiGlobals.keyPath is 'libraryVersion'")
|
||||
version = c.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
if version not in _TESTED_DB_VERSIONS:
|
||||
print(
|
||||
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
|
||||
+ f" You have database version={version} which has not been tested"
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def get_model_version(db_file):
|
||||
""" Returns the database model version from Z_METADATA
|
||||
|
||||
Args:
|
||||
db_file: path to Photos.sqlite database file containing Z_METADATA table
|
||||
|
||||
Returns: model version as str
|
||||
"""
|
||||
|
||||
version = None
|
||||
|
||||
(conn, c) = _open_sql_file(db_file)
|
||||
|
||||
# get database version
|
||||
c.execute("SELECT MAX(Z_VERSION), Z_PLIST FROM Z_METADATA")
|
||||
results = c.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
plist = plistlib.loads(results[1])
|
||||
return plist["PLModelVersion"]
|
||||
|
||||
|
||||
def get_db_model_version(db_file):
|
||||
""" Returns Photos version based on model version found in db_file
|
||||
|
||||
Args:
|
||||
db_file: path to Photos.sqlite file
|
||||
|
||||
Returns: int of major Photos version number (e.g. 5 or 6).
|
||||
If unknown model version found, logs warning and returns most current Photos version.
|
||||
"""
|
||||
|
||||
model_ver = get_model_version(db_file)
|
||||
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
|
||||
db_ver = 5
|
||||
elif _PHOTOS_6_MODEL_VERSION[0] <= model_ver <= _PHOTOS_6_MODEL_VERSION[1]:
|
||||
db_ver = 6
|
||||
else:
|
||||
logging.warning(f"Unknown model version: {model_ver}")
|
||||
# cross our fingers and try latest version
|
||||
db_ver = 6
|
||||
|
||||
return db_ver
|
||||
@@ -17,6 +17,9 @@ Some of the export tests rely on photos in my local library and will look for `O
|
||||
|
||||
One test for locale does not run on GitHub's automated workflow and will look for `OSXPHOTOS_TEST_LOCALE=1` to determine if it should be run. If you want to run this test, set the environment variable.
|
||||
|
||||
## Test Photo Libraries
|
||||
**Important**: The test code uses several test photo libraries created on various version of MacOS. If you need to inspect one of these or modify one for a test, make a copy of the library (for example, copy it to your ~/Pictures folder) then open the copy in Photos. Once done, copy the revised library back to the tests/ folder. If you do not do this, the Photos background process photoanalysisd will forever try to process the library resulting in updates to the database which will cause git to see changes to the file you didn't intend. I'm not aware of any way to disassociate photoanalysisd from the library once you've opened it in Photos.
|
||||
|
||||
## Attribution ##
|
||||
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com) and from my own photo library. All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>5001</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.15.6.photoslibrary/database/Photos.sqlite
Normal file
BIN
tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-shm
Normal file
BIN
tests/Test-10.15.6.photoslibrary/database/Photos.sqlite-wal
Normal file
16
tests/Test-10.15.6.photoslibrary/database/Photos.sqlite.lock
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>hostname</key>
|
||||
<string>Rhets-MacBook-Pro.local</string>
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>1847</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
<integer>501</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.15.6.photoslibrary/database/metaSchema.db
Normal file
BIN
tests/Test-10.15.6.photoslibrary/database/photos.db
Normal file
BIN
tests/Test-10.15.6.photoslibrary/database/search/psi.sqlite
Normal file
BIN
tests/Test-10.15.6.photoslibrary/database/search/psi.sqlite-shm
Normal file
@@ -0,0 +1,188 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BlacklistedMeaningsByMeaning</key>
|
||||
<dict/>
|
||||
<key>MePersonUUID</key>
|
||||
<string>39488755-78C0-40B2-B378-EDA280E1823C</string>
|
||||
<key>SceneWhitelist</key>
|
||||
<array>
|
||||
<string>Graduation</string>
|
||||
<string>Aquarium</string>
|
||||
<string>Food</string>
|
||||
<string>Ice Skating</string>
|
||||
<string>Mountain</string>
|
||||
<string>Cliff</string>
|
||||
<string>Basketball</string>
|
||||
<string>Tennis</string>
|
||||
<string>Jewelry</string>
|
||||
<string>Cheese</string>
|
||||
<string>Softball</string>
|
||||
<string>Football</string>
|
||||
<string>Circus</string>
|
||||
<string>Jet Ski</string>
|
||||
<string>Playground</string>
|
||||
<string>Carousel</string>
|
||||
<string>Paint Ball</string>
|
||||
<string>Windsurfing</string>
|
||||
<string>Sailboat</string>
|
||||
<string>Sunbathing</string>
|
||||
<string>Dam</string>
|
||||
<string>Fireplace</string>
|
||||
<string>Flower</string>
|
||||
<string>Scuba</string>
|
||||
<string>Hiking</string>
|
||||
<string>Cetacean</string>
|
||||
<string>Pier</string>
|
||||
<string>Bowling</string>
|
||||
<string>Snowboarding</string>
|
||||
<string>Zoo</string>
|
||||
<string>Snowmobile</string>
|
||||
<string>Theater</string>
|
||||
<string>Boat</string>
|
||||
<string>Casino</string>
|
||||
<string>Car</string>
|
||||
<string>Diving</string>
|
||||
<string>Cycling</string>
|
||||
<string>Musical Instrument</string>
|
||||
<string>Board Game</string>
|
||||
<string>Castle</string>
|
||||
<string>Sunset Sunrise</string>
|
||||
<string>Martial Arts</string>
|
||||
<string>Motocross</string>
|
||||
<string>Submarine</string>
|
||||
<string>Cat</string>
|
||||
<string>Snow</string>
|
||||
<string>Kiteboarding</string>
|
||||
<string>Squash</string>
|
||||
<string>Geyser</string>
|
||||
<string>Music</string>
|
||||
<string>Archery</string>
|
||||
<string>Desert</string>
|
||||
<string>Blackjack</string>
|
||||
<string>Fireworks</string>
|
||||
<string>Sportscar</string>
|
||||
<string>Feline</string>
|
||||
<string>Soccer</string>
|
||||
<string>Museum</string>
|
||||
<string>Baby</string>
|
||||
<string>Fencing</string>
|
||||
<string>Railroad</string>
|
||||
<string>Nascar</string>
|
||||
<string>Sky Surfing</string>
|
||||
<string>Bird</string>
|
||||
<string>Games</string>
|
||||
<string>Baseball</string>
|
||||
<string>Dressage</string>
|
||||
<string>Snorkeling</string>
|
||||
<string>Pyramid</string>
|
||||
<string>Kite</string>
|
||||
<string>Rowboat</string>
|
||||
<string>Golf</string>
|
||||
<string>Watersports</string>
|
||||
<string>Lightning</string>
|
||||
<string>Canyon</string>
|
||||
<string>Auditorium</string>
|
||||
<string>Night Sky</string>
|
||||
<string>Karaoke</string>
|
||||
<string>Skiing</string>
|
||||
<string>Parade</string>
|
||||
<string>Forest</string>
|
||||
<string>Hot Air Balloon</string>
|
||||
<string>Dragon Parade</string>
|
||||
<string>Easter Egg</string>
|
||||
<string>Monument</string>
|
||||
<string>Jungle</string>
|
||||
<string>Thanksgiving</string>
|
||||
<string>Jockey Horse</string>
|
||||
<string>Stadium</string>
|
||||
<string>Airplane</string>
|
||||
<string>Ballet</string>
|
||||
<string>Yoga</string>
|
||||
<string>Coral Reef</string>
|
||||
<string>Skating</string>
|
||||
<string>Wrestling</string>
|
||||
<string>Bicycle</string>
|
||||
<string>Tattoo</string>
|
||||
<string>Amusement Park</string>
|
||||
<string>Canoe</string>
|
||||
<string>Cheerleading</string>
|
||||
<string>Ping Pong</string>
|
||||
<string>Fishing</string>
|
||||
<string>Magic</string>
|
||||
<string>Reptile</string>
|
||||
<string>Winter Sport</string>
|
||||
<string>Waterfall</string>
|
||||
<string>Train</string>
|
||||
<string>Bonsai</string>
|
||||
<string>Surfing</string>
|
||||
<string>Dog</string>
|
||||
<string>Cake</string>
|
||||
<string>Sledding</string>
|
||||
<string>Sandcastle</string>
|
||||
<string>Glacier</string>
|
||||
<string>Lighthouse</string>
|
||||
<string>Equestrian</string>
|
||||
<string>Rafting</string>
|
||||
<string>Shore</string>
|
||||
<string>Hockey</string>
|
||||
<string>Santa Claus</string>
|
||||
<string>Formula One Car</string>
|
||||
<string>Sport</string>
|
||||
<string>Vehicle</string>
|
||||
<string>Boxing</string>
|
||||
<string>Rollerskating</string>
|
||||
<string>Underwater</string>
|
||||
<string>Orchestra</string>
|
||||
<string>Carnival</string>
|
||||
<string>Rocket</string>
|
||||
<string>Skateboarding</string>
|
||||
<string>Helicopter</string>
|
||||
<string>Performance</string>
|
||||
<string>Oktoberfest</string>
|
||||
<string>Water Polo</string>
|
||||
<string>Skate Park</string>
|
||||
<string>Animal</string>
|
||||
<string>Nightclub</string>
|
||||
<string>String Instrument</string>
|
||||
<string>Dinosaur</string>
|
||||
<string>Gymnastics</string>
|
||||
<string>Cricket</string>
|
||||
<string>Volcano</string>
|
||||
<string>Lake</string>
|
||||
<string>Aurora</string>
|
||||
<string>Dancing</string>
|
||||
<string>Concert</string>
|
||||
<string>Rock Climbing</string>
|
||||
<string>Hang Glider</string>
|
||||
<string>Rodeo</string>
|
||||
<string>Fish</string>
|
||||
<string>Art</string>
|
||||
<string>Motorcycle</string>
|
||||
<string>Volleyball</string>
|
||||
<string>Wake Boarding</string>
|
||||
<string>Badminton</string>
|
||||
<string>Motor Sport</string>
|
||||
<string>Sumo</string>
|
||||
<string>Parasailing</string>
|
||||
<string>Skydiving</string>
|
||||
<string>Kickboxing</string>
|
||||
<string>Pinata</string>
|
||||
<string>Foosball</string>
|
||||
<string>Go Kart</string>
|
||||
<string>Poker</string>
|
||||
<string>Kayak</string>
|
||||
<string>Swimming</string>
|
||||
<string>Atv</string>
|
||||
<string>Beach</string>
|
||||
<string>Dartboard</string>
|
||||
<string>Athletics</string>
|
||||
<string>Camping</string>
|
||||
<string>Tornado</string>
|
||||
<string>Billiards</string>
|
||||
<string>Rugby</string>
|
||||
<string>Airshow</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>insertAlbum</key>
|
||||
<array/>
|
||||
<key>insertAsset</key>
|
||||
<array/>
|
||||
<key>insertHighlight</key>
|
||||
<array/>
|
||||
<key>insertMemory</key>
|
||||
<array/>
|
||||
<key>insertMoment</key>
|
||||
<array/>
|
||||
<key>removeAlbum</key>
|
||||
<array/>
|
||||
<key>removeAsset</key>
|
||||
<array/>
|
||||
<key>removeHighlight</key>
|
||||
<array/>
|
||||
<key>removeMemory</key>
|
||||
<array/>
|
||||
<key>removeMoment</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>embeddingVersion</key>
|
||||
<string>1</string>
|
||||
<key>localeIdentifier</key>
|
||||
<string>en_US</string>
|
||||
<key>sceneTaxonomySHA</key>
|
||||
<string>87914a047c69fbe8013fad2c70fa70c6c03b08b56190fe4054c880e6b9f57cc3</string>
|
||||
<key>searchIndexVersion</key>
|
||||
<string>10</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 541 KiB |
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>MigrationService</key>
|
||||
<dict>
|
||||
<key>State</key>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
<key>MigrationService.LastCompletedTask</key>
|
||||
<integer>12</integer>
|
||||
<key>MigrationService.ValidationCounts</key>
|
||||
<dict>
|
||||
<key>MigrationDetectedFaceprint</key>
|
||||
<integer>6</integer>
|
||||
<key>MigrationManagedAsset</key>
|
||||
<integer>0</integer>
|
||||
<key>MigrationSceneClassification</key>
|
||||
<integer>44</integer>
|
||||
<key>MigrationUnmanagedAdjustment</key>
|
||||
<integer>0</integer>
|
||||
<key>RDVersion.cloudLocalState.CPLIsNotPushed</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>92D68107-B6C7-453B-96D2-97B0F26D5B8B/L0/020</string>
|
||||
<string>88A5F8B8-5B9A-43C7-BB85-3952B81580EB/L0/020</string>
|
||||
<string>29EF7A97-7E76-4D5F-A5E0-CC0A93E8524C/L0/020</string>
|
||||
<string>2C2AF115-BD1D-4434-A747-D1C8BD8E2045/L0/020</string>
|
||||
<string>CB051A4C-2CB7-4B90-B59B-08CC4D0C2823/L0/020</string>
|
||||
</array>
|
||||
<key>Photos</key>
|
||||
<dict>
|
||||
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>TopLevelAlbums</string>
|
||||
<string>TopLevelSlideshows</string>
|
||||
</array>
|
||||
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||
<dict>
|
||||
<key>kZoomLevelIdentifierAlbums</key>
|
||||
<integer>7</integer>
|
||||
<key>kZoomLevelIdentifierVersions</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
<key>lastAddToDestination</key>
|
||||
<dict>
|
||||
<key>key</key>
|
||||
<integer>1</integer>
|
||||
<key>lastKnownDisplayName</key>
|
||||
<string>September 28, 2018</string>
|
||||
<key>type</key>
|
||||
<string>album</string>
|
||||
<key>uuid</key>
|
||||
<string>DFFKmHt3Tk+AGzZLe2Xq+g</string>
|
||||
</dict>
|
||||
<key>lastKnownItemCounts</key>
|
||||
<dict>
|
||||
<key>other</key>
|
||||
<integer>0</integer>
|
||||
<key>photos</key>
|
||||
<integer>7</integer>
|
||||
<key>videos</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BackgroundHighlightCollection</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2020-06-24T04:02:12Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2020-06-24T04:02:12Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-05-30T02:16:06Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-05-29T04:31:37Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>revgeoprovider</key>
|
||||
<string>7618</string>
|
||||
</dict>
|
||||
</plist>
|
||||