Add photo_rename.py.
This commit is contained in:
parent
15c1beb0a8
commit
0c001841b3
1 changed files with 223 additions and 0 deletions
223
photo_rename.py
Normal file
223
photo_rename.py
Normal file
|
@ -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:]))
|
Loading…
Reference in a new issue