Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

osxphotos sync: add support for location #1642

Merged
merged 6 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion osxphotos/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"title",
"description",
"favorite",
"location",
]
SYNC_IMPORT_TYPES_ALL = ["all"] + SYNC_IMPORT_TYPES

Expand Down Expand Up @@ -374,11 +375,87 @@ def import_metadata_for_photo(
# add photo to any new albums but do not remove from existing albums
results += _update_albums_for_photo(photo, metadata, dry_run, verbose)

if "location" in set_:
# If --set use origin location in the destination photo
results += _set_location_for_photo(photo, metadata, dry_run, verbose)
elif "location" in merge:
# if --merge
# and property is set in the destination then no action is taken;
# if property is not set in the destination but is set in the source,
# then the value is copied to destination.
results += _merge_location_for_photo(photo, metadata, dry_run, verbose)

results += _set_metadata_for_photo(photo, metadata, set_, dry_run, verbose)
results += _merge_metadata_for_photo(photo, metadata, merge, dry_run, verbose)

return results

def _process_location_for_photo(
photo: PhotoInfo,
metadata: dict[str, Any],
dry_run: bool,
verbose: Callable[..., None],
merge: bool = False,
) -> SyncResults:
"""Set or merge location metadata for a photo."""
field = "location"
updated = False
results = SyncResults()
photo_ = photoscript.Photo(photo.uuid)

value = tuple(metadata.get(field, (None, None)))
before = getattr(photo, field, (None, None))

if merge:
if all(coord is None for coord in before):
if value != before:
updated = True
verbose(f"\tMerging {field} to {value} from {before}")
if not dry_run:
set_photo_property(photo_, field, value)
else:
verbose(f"\tNothing to do for {field}", level=2)
else:
verbose(f"\tLocation already set. Nothing done for {field}", level=2)
else:
if value != before:
updated = True
verbose(f"\tSetting {field} to {value} from {before}")
if not dry_run:
set_photo_property(photo_, field, value)
else:
verbose(f"\tNothing to do for {field}", level=2)

results.add_result(
photo.uuid,
photo.original_filename,
photo.fingerprint,
field,
updated,
before,
value)
return results


def _set_location_for_photo(
photo: PhotoInfo,
metadata: dict[str, Any],
dry_run: bool,
verbose: Callable[..., None],
) -> SyncResults:
"""Set location metadata 9even if (None, None) for a photo, if different."""
return _process_location_for_photo(photo, metadata, dry_run, verbose, merge=False)


def _merge_location_for_photo(
photo: PhotoInfo,
metadata: dict[str, Any],
dry_run: bool,
verbose: Callable[..., None],
) -> SyncResults:
"""Merge location metadata for a photo if not already set."""
return _process_location_for_photo(photo, metadata, dry_run, verbose, merge=True)


def _update_albums_for_photo(
photo: PhotoInfo,
Expand Down Expand Up @@ -438,9 +515,12 @@ def _set_metadata_for_photo(
if field == "albums":
continue

if field == "location":
continue

value = metadata[field]
before = getattr(photo, field)

if isinstance(value, list):
value = sorted(value)
if isinstance(before, list):
Expand Down Expand Up @@ -480,6 +560,9 @@ def _merge_metadata_for_photo(
if field == "albums":
continue

if field == "location":
continue

value = metadata[field]
before = getattr(photo, field)

Expand Down Expand Up @@ -547,6 +630,8 @@ def set_photo_property(photo: photoscript.Photo, property: str, value: Any):
raise ValueError(f"{property} must be a str, not {type(value)}")
elif property == "favorite":
value = bool(value)
elif property == "location":
value = (value[0], value[1])
elif property not in {"title", "description", "favorite", "keywords"}:
raise ValueError(f"Unknown property: {property}")
setattr(photo, property, value)
Expand Down
1 change: 1 addition & 0 deletions osxphotos/cli/sync_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"favorite",
"keywords",
"title",
"location",
]


Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def get_os_version():


OS_VER = get_os_version() if is_macos else [None, None]
if OS_VER[0] == "10" and OS_VER[1] == "15":
if OS_VER[0] == "10" and OS_VER[1] in ("15", "16"):
# Catalina
TEST_LIBRARY = "tests/Test-10.15.7.photoslibrary"
TEST_LIBRARY_IMPORT = TEST_LIBRARY
Expand Down
89 changes: 89 additions & 0 deletions tests/test_cli_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@

TEST_ALBUM_NAME = "SyncTestAlbum"

UUID_TEST_PHOTO_3 = "D1D4040D-D141-44E8-93EA-E403D9F63E07" # Frítest.jpg, No Location
UUID_TEST_PHOTO_4 = "D1359D09-1373-4F3B-B0E3-1A4DE573E4A3" # Jellyfish1.mp4, Location
UUID_TEST_PHOTO_5 = "7783E8E6-9CAC-40F3-BE22-81FB7051C266" # IMG_3092.heic, Location

TEST_ALBUM_NAME_LOCATION = "SyncTestAlbumLocation"


@pytest.mark.test_sync
def test_sync_export():
Expand Down Expand Up @@ -114,3 +120,86 @@ def test_sync_export_import():
assert report_data[uuid]["updated"]
assert report_data[uuid]["albums"]["updated"]
assert not report_data[uuid]["error"]

@pytest.mark.test_sync
def test_sync_export_import_location():
"""Test --export and --import location"""

photoslib = photoscript.PhotosLibrary()

# create a new album and initialize metadata
test_album = photoslib.create_album(TEST_ALBUM_NAME_LOCATION)
for uuid in [UUID_TEST_PHOTO_3, UUID_TEST_PHOTO_4, UUID_TEST_PHOTO_5]:
photo = photoscript.Photo(uuid)
photo.favorite = True
test_album.add([photo])

# export data
with CliRunner().isolated_filesystem():
result = CliRunner().invoke(
sync,
[
"--export",
"test_location.db",
],
)
assert result.exit_code == 0

# preserve metadata for comparison and clear metadata
metadata_before = {}
for uuid in [UUID_TEST_PHOTO_3, UUID_TEST_PHOTO_4, UUID_TEST_PHOTO_5]:
photo = photoscript.Photo(uuid)
metadata_before[uuid] = {
"title": photo.title,
"description": photo.description,
"keywords": photo.keywords,
"favorites": photo.favorite,
"location": photo.location,
}
photo.title = ""
photo.description = ""
photo.keywords = ["NewKeyword"]
photo.favorite = False
photo.location = (24.681666439037876, 32.88630618597232)

# delete the test album
photoslib.delete_album(test_album)

# import metadata
result = CliRunner().invoke(
sync,
[
"--import",
"test_location.db",
"--set",
"title,description,favorite,albums,location",
"--merge",
"keywords",
"--report",
"test_report_location.json",
],
)
assert result.exit_code == 0
assert os.path.exists("test_report_location.json")

# check metadata
for uuid in [UUID_TEST_PHOTO_3, UUID_TEST_PHOTO_4, UUID_TEST_PHOTO_5]:
photo = photoscript.Photo(uuid)
assert photo.title == metadata_before[uuid]["title"]
assert photo.description == metadata_before[uuid]["description"]
assert sorted(photo.keywords) == sorted(
["NewKeyword", *metadata_before[uuid]["keywords"]]
)
assert photo.favorite == metadata_before[uuid]["favorites"]
assert photo.location == metadata_before[uuid]["location"]
assert TEST_ALBUM_NAME_LOCATION in [album.title for album in photo.albums]

# check report
with open("test_report_location.json", "r") as f:
report = json.load(f)
report_data = {record["uuid"]: record for record in report}
for uuid in [UUID_TEST_PHOTO_3, UUID_TEST_PHOTO_4, UUID_TEST_PHOTO_5]:
assert report_data[uuid]["updated"]
assert report_data[uuid]["albums"]["updated"]
assert report_data[uuid]["location"]["updated"]
assert not report_data[uuid]["error"]
Loading