Skip to content

Instantly share code, notes, and snippets.

@arkarkark
Created September 22, 2022 00:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save arkarkark/0484af118c7416db883410709ee4b030 to your computer and use it in GitHub Desktop.
Save arkarkark/0484af118c7416db883410709ee4b030 to your computer and use it in GitHub Desktop.
select photos in Photos.app and then run this to find duplicate heic and jpeg photos.
#!/usr/bin/env python3
# Copyright 2020 Alex K (wtwf.com)
# PYTHON_ARGCOMPLETE_OK
"""Remove duplicate heic/jpg photos from Photos.app
Keep the HEIC versions."""
__author__ = "wtwf.com (Alex K)"
import argparse
import atexit
import collections
import datetime
import logging
import os
import sys
import appscript
import argcomplete
import humanize
import tqdm
class LogRuntime:
RUNTIMES = collections.defaultdict(list)
def __init__(self, name=None, args=None, kwargs=None):
self.args = args or []
self.kwargs = kwargs or []
self.name = name
def __call__(self, func):
def wrapped_f(*args, **kwargs):
start = datetime.datetime.now()
reply = func(*args, **kwargs)
delta = start - datetime.datetime.now()
description = self.name or func.__name__
description += "("
description += ", ".join(
[args[x] for x in self.args] + [f"{x}={kwargs[x]}" for x in self.kwargs]
)
description += ")"
LogRuntime.RUNTIMES[description].append(delta)
return reply
return wrapped_f
@staticmethod
def show_runtimes():
print("\nRuntimes:\n", file=sys.stdout)
for desc, deltas in LogRuntime.RUNTIMES.items():
duration = ", ".join([humanize.naturaldelta(delta) for delta in deltas])
print(f"{desc}: {duration}", file=sys.stdout)
atexit.register(LogRuntime.show_runtimes)
class ShutdownHandler(logging.Handler):
def emit(self, record):
logging.shutdown()
sys.exit(1)
class HeicOnly:
def __init__(self):
self.photos = appscript.app("Photos")
self.dupe_album = self.get_or_make_album("Dupes")
self.both_dupe_album = self.get_or_make_album("BOTH Dupes")
self.remove_dupes()
def get_or_make_album(self, name):
a = self.photos.albums[appscript.its.name == name]()
if a and a[0] and a[0].name() == name:
logging.debug("Found album: %r", name)
return a[0]
logging.debug("Making album: %r", name)
a = self.photos.make(new=appscript.k.album)
a.name.set(name)
return a
def remove_dupes(self):
previous_item = None
for item in tqdm.tqdm(self.photos.selection()):
logging.debug("\n%r\n", item.filename())
if self.same_item(item, previous_item):
logging.debug("Same item!")
self.remove_dupe_item(item, previous_item)
else:
logging.debug("different item!")
previous_item = item
logging.debug("")
@staticmethod
def same_item(a, b):
return (
a
and b
# and a.locaton() == b.location()
and os.path.splitext(a.filename())[0] == os.path.splitext(b.filename())[0]
)
def remove_dupe_item(self, a, b):
if os.path.splitext(a.filename())[1].lower() == ".heic":
heic = a
jpeg = b
else:
heic = b
jpeg = a
logging.debug("HEIC: %r\tJPEG: %r", heic.filename(), jpeg.filename())
# copy over if it's a favorite
if jpeg.favorite():
logging.debug("It's a favorite!")
heic.favorite.set(True)
# add heic to albums that jpeg is part of
for album in self.albums_with(jpeg):
if album.id() != self.dupe_album.id():
logging.debug("Duplicating to %r", album.name())
try:
self.photos.add([heic], to=album)
except:
pass
# add this to the dupes album
self.photos.add([heic], to=self.both_dupe_album)
self.photos.add([jpeg], to=self.both_dupe_album)
self.photos.add([jpeg], to=self.dupe_album)
def albums_with(self, jpeg):
albums = self.photos.albums()
album_items = self.photos.albums.media_items[appscript.its.id == jpeg.id()]()
matches = []
for index, items in enumerate(album_items):
if items:
matches.append(albums[index])
logging.debug(
"albums_with %r is %r", jpeg.filename(), [x.name() for x in matches]
)
return matches
@LogRuntime()
def main():
"""Parse args and do the thing."""
logging.basicConfig()
logging.getLogger().addHandler(ShutdownHandler(level=50))
parser = argparse.ArgumentParser(description="Program to do the thing.")
parser.add_argument("-p", "--password", help="Password")
parser.add_argument("-size", "--size", help="Size", type=int)
parser.add_argument("-v", "--verbose", help="Log verbosely", action="store_true")
parser.add_argument("-d", "--debug", help="Log debug messages", action="store_true")
argcomplete.autocomplete(parser)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.INFO)
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logging.info("Log")
HeicOnly()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment