Last active
August 6, 2022 19:44
-
-
Save RhetTbull/62a36a29377c3fcd1ed888b8ad8e6230 to your computer and use it in GitHub Desktop.
Update favorites in Photos based on values in a JSON file produced by this iOS Shortcut: https://www.icloud.com/shortcuts/2057bcae53b146b3847260dc0cced1b6
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Update favorites in Photos based on values in a JSON file produced by this iOS Shortcut: https://www.icloud.com/shortcuts/2057bcae53b146b3847260dc0cced1b6 | |
First install osxphotos: https://github.com/RhetTbull/osxphotos | |
Run with `osxphotos run update_favorites.py --help` to see help | |
The input JSON file has form: | |
{"28":{"date":"2021-08-05 13:18:37.232-0700","name":"IMG_5702"}} | |
Where "28" is the index (not used), date is photo creation date and name is stem of photo name. | |
""" | |
import datetime | |
import json | |
from os.path import splitext | |
from typing import Dict, List, Optional, Tuple | |
import click | |
from photoscript import Photo | |
from rich import print | |
from rich.progress import Progress | |
from osxphotos import PhotoInfo, PhotosDB | |
from osxphotos.photosalbum import PhotosAlbum | |
_DEBUG_MODE = False | |
def debug(*args): | |
"""Debug print""" | |
global _DEBUG_MODE | |
if _DEBUG_MODE: | |
print(*args) | |
def datetime_to_key(dt: datetime.datetime): | |
"""Convert a datetime.datetime to timestamp key used for comparing photos""" | |
# return round(dt.timestamp(), 1) | |
return int(dt.timestamp()) | |
def load_favorites(favorites_file: str) -> List: | |
"""Load favorites data from JSON file""" | |
with open(favorites_file, "r") as fd: | |
data_dict = json.load(fd) | |
if not isinstance(data_dict, dict): | |
raise ValueError(f"Could not parse file {favorites_file}") | |
return list(data_dict.values()) | |
def load_photos_from_photosdb(photosdb: PhotosDB) -> Dict: | |
"""Load photos from Photos library and return dict where | |
key is modified ISO 8601 formatted creation date and value is PhotoInfo object | |
date is in format "2019-01-19 10:45:05.041-0800" | |
""" | |
photos = photosdb.photos() | |
all_photos = {} | |
for photo in photos: | |
date = datetime_to_key(photo.date) | |
try: | |
all_photos[date].append(photo) | |
except KeyError: | |
all_photos[date] = [photo] | |
return all_photos | |
def find_matching_photo( | |
photos: Dict[str, PhotoInfo], name: str, dt: str | |
) -> Tuple[Optional[PhotoInfo], List[PhotoInfo]]: | |
"""Give a list of photos, find the photo that matches name and creation date or return list of possible matches""" | |
# Cannot compare ISO 8601 dates because iOS Shortcuts uses a different format than datetime.datetime and | |
# even when coercing them to same format, I noticed different results, | |
# e.g. "2010-04-01T11:32:35.000-07:00" on iOS but "2010-04-01T10:32:35.000-08:00" on Mac | |
# These are identical in value but ISO format string doesn't match | |
# convert all datetimes to seconds for comparison | |
global _DEBUG_MODE | |
date = datetime.datetime.strptime(dt, "%Y-%m-%d %H:%M:%S.%f%z") | |
date = datetime_to_key(date) | |
if date in photos: | |
debug(f"Found matching date for {name=} {dt=} {date=}:") | |
debug(f"{[(p.original_filename, p.date.isoformat()) for p in photos[date]]}") | |
for photo in photos[date]: | |
photo_name = splitext(photo.original_filename)[0] | |
if name == photo_name: | |
debug(f"Found match: {photo.uuid}") | |
return photo, [] | |
else: | |
return None, photos[date] | |
debug(f"Did not find match for {name=} {dt=} {date=}") | |
return None, [] | |
def add_photos_to_album(photos: List[PhotoInfo], album_name: str, dry_run: bool): | |
"""Add photos from a list to album album_name; if dry_run, don't actually add the photos""" | |
album = PhotosAlbum(album_name) | |
with Progress() as progress: | |
task = progress.add_task(f"Adding photo(s) to album '{album_name}'") | |
for photo in photos: | |
if not dry_run: | |
album.add(photo) | |
progress.advance(task) | |
def clear_favorite_status(photos: List[PhotoInfo], dry_run: bool): | |
"""Given a list of photos, clear the favorite status of each photo""" | |
with Progress() as progress: | |
task = progress.add_task( | |
f"Clearing Favorite status for {len(photos)} photo(s)", total=len(photos) | |
) | |
for photo in photos: | |
photo_ = Photo(photo.uuid) | |
if not dry_run: | |
photo_.favorite = False | |
progress.advance(task) | |
def set_favorite_status(photos: List[PhotoInfo], clear: bool, dry_run: bool): | |
"""Set favorite status for list of photos | |
if photo in current_favorites, don't re-set the status (unless clear=True which means the favorite status had been cleared) | |
skip setting if dry_run | |
""" | |
existing_favorites = 0 | |
new_favorites = 0 | |
with Progress() as progress: | |
task = progress.add_task(f"Setting Favorite status for {len(photos)} photo(s)") | |
for photo in photos: | |
if not clear and photo.favorite: | |
existing_favorites += 1 | |
continue | |
else: | |
photo_ = Photo(photo.uuid) | |
if not dry_run: | |
photo_.favorite = True | |
new_favorites += 1 | |
progress.advance(task) | |
print( | |
f"Done: updated Favorite status for {new_favorites} photo(s), {existing_favorites} photo(s) were already Favorite" | |
) | |
@click.command() | |
@click.option( | |
"--library", type=click.Path(), required=False, help="Path to Photos library" | |
) | |
@click.option( | |
"--possibles", | |
metavar="ALBUM_NAME", | |
help="Add possible matches to album ALBUM_NAME.", | |
) | |
@click.option( | |
"--clear", | |
is_flag=True, | |
help="Clear Favorites in Photos before applying new favorites from file.", | |
) | |
@click.option( | |
"--backup", | |
metavar="ALBUM_NAME", | |
help="Backup all Favorite photos to album ALBUM_NAME before setting new Favorites.", | |
) | |
@click.option("--dry-run", is_flag=True, help="Dry run only; do not update photos.") | |
@click.option("--debug-mode", is_flag=True, help="Enable debug mode.") | |
@click.argument("favorites_file") | |
def update_favorites( | |
library, possibles, clear, backup, dry_run, debug_mode, favorites_file | |
): | |
"""Update Favorite photos in Photos from a JSON file produced by the | |
iOS Shortcut at: https://www.icloud.com/shortcuts/2057bcae53b146b3847260dc0cced1b6 | |
The JSON file should have format "{'id': {'photo_name': 'photo_creation_date'},...}" | |
""" | |
if debug_mode: | |
global _DEBUG_MODE | |
_DEBUG_MODE = True | |
# Load the JSON file | |
favorites = load_favorites(favorites_file) | |
print(f"Loaded {len(favorites)} favorites from {favorites_file}") | |
# Get photos from the Photos library | |
print(f"Loading photos from Photos library") | |
photosdb = PhotosDB(dbfile=library) | |
all_photos = load_photos_from_photosdb(photosdb) | |
print(f"Found {len(all_photos)} in Photos library") | |
# find all matching photos | |
favorite_photos = [] | |
unmatched_photos = [] | |
possible_matched_photos = [] | |
for fav in favorites: | |
# each entry in the favorites dict has form {name: creation_date} | |
# don't know the key (name) so iterate over the dict to get the first (only) key | |
filename = fav["name"] | |
creation_date = fav["date"] | |
matching_photo, possible_matches = find_matching_photo( | |
all_photos, filename, creation_date | |
) | |
if matching_photo: | |
# print(f"Found match for {filename}, {creation_date}") | |
favorite_photos.append(matching_photo) | |
elif possible_matches: | |
print( | |
f"Found possible matching photos for {filename} {creation_date}: " | |
f"{[p.original_filename for p in possible_matches]}" | |
) | |
possible_matched_photos.extend(possible_matches) | |
else: | |
print( | |
f"[red]Could not find matching photo for {filename} {creation_date}[/]" | |
) | |
unmatched_photos.append(fav) | |
print(f"Found {len(favorite_photos)} matching photo(s) in Photos library") | |
print(f"Found {len(possible_matched_photos)} possible match(es)") | |
print(f"Did not find matches for {len(unmatched_photos)} photo(s)") | |
if possible_matched_photos and possibles: | |
print( | |
f"Adding {len(possible_matched_photos)} possible matches to album '{possibles}'" | |
) | |
add_photos_to_album(possible_matched_photos, possibles, dry_run) | |
current_favorites = [p for p in photosdb.photos() if p.favorite] | |
if backup: | |
print( | |
f"Backing up {len(current_favorites)} current Favorite(s) to album '{backup}'" | |
) | |
add_photos_to_album(current_favorites, backup, dry_run) | |
if clear: | |
print("[yellow]:warning: Clearing all current favorites[/yellow]") | |
# use photoscript to clear favorite status | |
clear_favorite_status(current_favorites, dry_run) | |
# finally, set the favorite status | |
set_favorite_status(favorite_photos, clear, dry_run) | |
if __name__ == "__main__": | |
update_favorites() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment