#!/usr/bin/env python3 import argparse import datetime import logging import os import re import typing import coloredlogs import exifread log = logging.getLogger(__name__) coloredlogs.install(level="DEBUG", fmt="%(levelname)s %(message)s", logger=log) EXTENSION_PATTERN = re.compile(r"\.(JPE?G|DNG)", re.I) COMMON_PATTERN = re.compile(r"(IMG|DSC[NF]?|100|P10|f|t)_?\d+", re.I) EXIF_TAG_ID = 0x9003 # DateTimeOriginal EXIF_DATE_FORMAT = "%Y:%m:%d %H:%M:%S" def get_pictures(directory: str = ".", skip_renamed: bool = True) -> typing.Generator: for root, _, files in os.walk(directory): for filename in files: filename_trunk, extension = os.path.splitext(filename) if not re.match(EXTENSION_PATTERN, extension): continue if skip_renamed: if not re.match(COMMON_PATTERN, filename_trunk): continue full_path = os.path.join(root, filename) yield full_path def main(args: argparse.Namespace) -> None: log.warning("Counting files...") kwargs = {"directory": args.dir, "skip_renamed": args.skip_renamed} log.warning("Processing files...") for full_path in get_pictures(**kwargs): # Find date with open(full_path, 'rb') as fd: exif_data = exifread.process_file(fd) if not exif_data: log.warning(f"{full_path} does not have EXIF data") for ifd_tag in exif_data.values(): if ifd_tag.tag == EXIF_TAG_ID: date_raw = ifd_tag.values break else: log.warning(f"{full_path} does not have required EXIF tag") continue date = datetime.datetime.strptime(date_raw, EXIF_DATE_FORMAT) # Determine new filename ext = os.path.splitext(full_path)[1].lower() if ext == '.jpeg': ext = '.jpg' new_name = date.isoformat().replace(":", "-").replace("T", "_") + args.suffix # First substitution is to allow images being sent to a NTFS filesystem # Second substitution is for esthetics new_path = os.path.join(args.dir, f"{new_name}{ext}") # TODO Allow keeping image in same folder i = 0 while os.path.exists(new_path): if full_path == new_path: break log.debug(f"{new_path} already exists, incrementing") i += 1 new_path = os.path.join(args.dir, f"{new_name}_{i}{ext}") # Rename file if full_path == new_path: log.debug(f"{full_path} already at required filename") continue log.info(f"{full_path} →\t{new_path}") if os.path.exists(new_path): raise FileExistsError(f"Won't overwrite {new_path}") if not args.dry: os.rename(full_path, new_path) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Rename images based on their dates") parser.add_argument( "dir", metavar="DIRECTORY", type=str, default=".", nargs="?", help="Directory containing the pictures", ) parser.add_argument( "-d", "--dry", action="store_true", help="Do not actually rename, just show old and new path", ) parser.add_argument( "-r", "--skip-renamed", action="store_true", help="Skip images whose filename doesn't match usual camera output filenames.", ) parser.add_argument( "-s", "--suffix", default="", help="Text to add before the extension", ) args = parser.parse_args() main(args)