From 0c001841b3789a51befef53936da78bd488b2d0b Mon Sep 17 00:00:00 2001 From: Ethan Dalool Date: Sat, 12 Nov 2022 20:50:41 -0800 Subject: [PATCH] Add photo_rename.py. --- photo_rename.py | 223 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 photo_rename.py diff --git a/photo_rename.py b/photo_rename.py new file mode 100644 index 0000000..36fe38f --- /dev/null +++ b/photo_rename.py @@ -0,0 +1,223 @@ +import argparse +import datetime +import os +import re +import sys + +from voussoirkit import imagetools +from voussoirkit import interactive +from voussoirkit import pathclass +from voussoirkit import pipeable +from voussoirkit import spinal +from voussoirkit import vlogging + +log = vlogging.getLogger(__name__) + +def makename(file, read_exif=False): + old = file.replace_extension('').basename + new = old + + # Already optimized filenames need not apply + # This is also important when the filename and the exif disagree + if re.match(r'^(\d\d\d\d)-(\d\d)-(\d\d)_(\d\d)-(\d\d)-(\d\d)(?:x\d+)?$', old) and not read_exif: + return file + + # Microsoft ICE + new = re.sub( + r'^(\d\d\d\d)-(\d\d)-(\d\d)_(\d\d)-(\d\d)-(\d\d)_stitch$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + new = re.sub( + r'^(\d\d\d\d)(\d\d)(\d\d)_(\d\d)(\d\d)(\d\d)_stitch$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + # Android screenshots + new = re.sub( + r'^Screenshot_(\d\d\d\d)(\d\d)(\d\d)-(\d\d)(\d\d)(\d\d)(?:_cropped)?$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + new = re.sub( + r'^Recording_(\d\d\d\d)-(\d\d)-(\d\d)-(\d\d)-(\d\d)-(\d\d)(?:_cropped)?$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + # Terraria screenshots + new = re.sub( + r'^Capture (\d\d\d\d)-(\d\d)-(\d\d) (\d\d)_(\d\d)_(\d\d)$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + # OBS screenshots + new = re.sub( + r'^Screenshot (\d\d\d\d)-(\d\d)-(\d\d)_(\d\d)-(\d\d)-(\d\d)$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + new = re.sub( + r'^(\d\d\d\d)(\d\d)(\d\d)-(\d\d)(\d\d)(\d\d)$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + new = re.sub( + r'^(\d\d\d\d)-(\d\d)-(\d\d)[_-](\d\d)(\d\d)(\d\d)$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + new = re.sub( + r'^(\d\d\d\d)(\d\d)(\d\d)-(\d\d)_(\d\d)_(\d\d)$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + # LG Android camera + new = re.sub( + r'^(\d\d\d\d)(\d\d)(\d\d)_(\d\d)(\d\d)(\d\d)(?:_HDR)?$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + # Unihertz Jelly 2 camera + new = re.sub( + r'^IMG_(\d\d\d\d)(\d\d)(\d\d)_(\d\d)(\d\d)(\d\d)_\d+$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + # Jelly videos + new = re.sub( + r'^VID_(\d\d\d\d)(\d\d)(\d\d)_(\d\d)(\d\d)(\d\d)+$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + # Jelly screen recordings + new = re.sub( + r'^screen-(\d\d\d\d)(\d\d)(\d\d)-(\d\d)(\d\d)(\d\d)$', + r'\1-\2-\3_\4-\5-\6', + new, + ) + + new = re.sub( + r'^(\d\d\d\d)-(\d\d)-(\d\d)_(\d\d)-(\d\d)-(\d\d)[_-](\d+)$', + r'\1-\2-\3_\4-\5-\6x\7', + new, + ) + + new = re.sub( + r'^(\d\d\d\d)(\d\d)(\d\d)[_-](\d+)$', + r'\1-\2-\3x\4', + new, + ) + + new = re.sub( + r'^(\d\d\d\d)(\d\d)(\d\d) (\w+.*)$', + r'\1-\2-\3 \4', + new, + ) + + new = re.sub( + r'^(\d\d\d\d)(\d\d)(\d\d)$', + r'\1-\2-\3', + new, + ) + + # Kakaotalk downloaded photos + # Unix timestamps, followed by an index if downloaded as bundle + unix = re.match(r'^(?:kakaotalk_)?(\d{13})(?:[_-](\d))?', new) + if unix: + date = datetime.datetime.fromtimestamp(int(unix.group(1)) / 1000) + f = date.strftime('%Y-%m-%d_%H-%M-%S') + if unix.group(2): + f += 'x' + unix.group(2) + new = re.sub(unix.group(0), f, new) + + # exif comes last because I feel the filename is most important. + # Especially in cases where the user has edited a photo with software that + # reset the exif but the filename refers to the original date. + # I'm sure cases could be made either way but I'm starting here. + if new == old and read_exif and file.extension in {'jpg', 'jpeg'}: + new = makename_exif(file, old) + + new = file.parent.with_child(new).add_extension(file.extension) + return new + +def makename_exif(file, fallback): + dt = imagetools.get_exif_datetime(file) + if not dt: + return fallback + return dt.strftime('%Y-%m-%d_%H-%M-%S') + +def makenames(files, read_exif=False): + pairs = {} + new_duplicates = {} + for file in files: + newname = makename(file, read_exif=read_exif) + new_duplicates.setdefault(newname, []).append(file) + if file.basename == newname.basename: + continue + pairs[file] = newname + + if not pairs: + return pairs + + for (new, olds) in list(new_duplicates.items()): + count = len(olds) + if count > 1: + olds.sort() + new_duplicates.pop(new) + wanted = new + zeroes = len(str(len(olds))) + for (index, old) in enumerate(olds): + new_base = wanted.replace_extension('').basename + f'x{index+1:0{zeroes}d}' + new = wanted.parent.with_child(new_base).add_extension(wanted.extension) + if old.basename == new.basename: + del pairs[old] + continue + pairs[old] = new + + return pairs + +def photo_rename_argparse(args): + patterns = pipeable.input_many(args.patterns, skip_blank=True, strip=True) + if args.recurse: + files = spinal.walk('.', glob_filenames=patterns, yield_directories=False) + else: + files = pathclass.glob_many_files(patterns) + + pairs = makenames(files, read_exif=args.read_exif) + if not pairs: + return 0 + + for (old, new) in pairs.items(): + print(f'{old.relative_path} -> {new.relative_path}') + + if not (args.autoyes or interactive.getpermission('Okay?', must_pick=True)): + return 1 + + for (old, new) in pairs.items(): + os.rename(old.absolute_path, new.absolute_path) + +@vlogging.main_decorator +def main(argv): + parser = argparse.ArgumentParser(description=__doc__) + + parser.add_argument('patterns', nargs='+') + parser.add_argument('--recurse', action='store_true') + parser.add_argument('--exif', dest='read_exif', action='store_true') + parser.add_argument('--yes', dest='autoyes', action='store_true') + parser.set_defaults(func=photo_rename_argparse) + + args = parser.parse_args(argv) + return args.func(args) + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:]))