cmd/photo_rename.py

224 lines
6.1 KiB
Python

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:]))