Skip to content

Instantly share code, notes, and snippets.

@RhetTbull
Last active August 6, 2022 19:44
Show Gist options
  • Save RhetTbull/62a36a29377c3fcd1ed888b8ad8e6230 to your computer and use it in GitHub Desktop.
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
"""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